refactor progress
This commit is contained in:
236
packages/config/src/__tests__/ConfigLoader.test.tsx
Normal file
236
packages/config/src/__tests__/ConfigLoader.test.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {mkdtempSync, rmSync, writeFileSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {getConfig, loadConfig, resetConfig} from '@fluxer/config/src/ConfigLoader';
|
||||
import {type ConfigObject, deepMerge} from '@fluxer/config/src/config_loader/ConfigObjectMerge';
|
||||
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
function createTempConfig(config: Record<string, unknown>): string {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'fluxer-config-test-'));
|
||||
const configPath = path.join(dir, 'config.json');
|
||||
writeFileSync(configPath, JSON.stringify(config));
|
||||
return configPath;
|
||||
}
|
||||
|
||||
function makeMinimalConfig(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
const base: ConfigObject = {
|
||||
env: 'test',
|
||||
domain: {
|
||||
base_domain: 'localhost',
|
||||
public_port: 8080,
|
||||
},
|
||||
database: {
|
||||
backend: 'sqlite',
|
||||
sqlite_path: ':memory:',
|
||||
},
|
||||
s3: {
|
||||
access_key_id: 'test-key',
|
||||
secret_access_key: 'test-secret',
|
||||
endpoint: 'http://localhost:9000',
|
||||
},
|
||||
services: {
|
||||
server: {port: 8772, host: '0.0.0.0'},
|
||||
media_proxy: {secret_key: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'},
|
||||
admin: {
|
||||
secret_key_base: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
|
||||
oauth_client_secret: 'fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210',
|
||||
},
|
||||
app_proxy: {port: 8773, sentry_report_host: 'sentry.io', sentry_dsn: 'https://test@sentry.io/1'},
|
||||
marketing: {
|
||||
enabled: false,
|
||||
port: 8774,
|
||||
host: '0.0.0.0',
|
||||
secret_key_base: 'marketing0123456789abcdef0123456789abcdef0123456789abcdef01234567',
|
||||
},
|
||||
gateway: {
|
||||
port: 8771,
|
||||
api_host: 'http://localhost:8772/api',
|
||||
admin_reload_secret: 'deadbeef0123456789abcdef0123456789abcdef0123456789abcdef01234567',
|
||||
media_proxy_endpoint: 'http://localhost:8772/media',
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
rpc_secret: 'rpc-test-secret',
|
||||
},
|
||||
auth: {
|
||||
sudo_mode_secret: 'sudo-test-secret',
|
||||
connection_initiation_secret: 'connection-initiation-test-secret',
|
||||
vapid: {
|
||||
public_key: 'test-vapid-public-key',
|
||||
private_key: 'test-vapid-private-key',
|
||||
},
|
||||
},
|
||||
integrations: {
|
||||
search: {
|
||||
url: 'http://127.0.0.1:7700',
|
||||
api_key: 'test-search-key',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return deepMerge(base, overrides as ConfigObject);
|
||||
}
|
||||
|
||||
describe('ConfigLoader', () => {
|
||||
let tempPaths: Array<string> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
resetConfig();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetConfig();
|
||||
for (const p of tempPaths) {
|
||||
try {
|
||||
rmSync(path.dirname(p), {recursive: true, force: true});
|
||||
} catch {}
|
||||
}
|
||||
tempPaths = [];
|
||||
});
|
||||
|
||||
test('loadConfig loads and caches config', async () => {
|
||||
const configPath = createTempConfig(makeMinimalConfig());
|
||||
tempPaths.push(configPath);
|
||||
|
||||
const config = await loadConfig([configPath]);
|
||||
expect(config.env).toBe('test');
|
||||
expect(config.domain.base_domain).toBe('localhost');
|
||||
});
|
||||
|
||||
test('getConfig throws when config is not loaded', () => {
|
||||
expect(() => getConfig()).toThrow('Config not loaded');
|
||||
});
|
||||
|
||||
test('resetConfig clears the cache', async () => {
|
||||
const configPath = createTempConfig(makeMinimalConfig());
|
||||
tempPaths.push(configPath);
|
||||
|
||||
await loadConfig([configPath]);
|
||||
expect(() => getConfig()).not.toThrow();
|
||||
|
||||
resetConfig();
|
||||
expect(() => getConfig()).toThrow('Config not loaded');
|
||||
});
|
||||
|
||||
test('throws when no config file is found', async () => {
|
||||
await expect(loadConfig(['/nonexistent/path.json'])).rejects.toThrow('No config file found');
|
||||
});
|
||||
|
||||
test('throws when config paths array is empty', async () => {
|
||||
await expect(loadConfig([])).rejects.toThrow('FLUXER_CONFIG must be set');
|
||||
});
|
||||
|
||||
test('derives endpoints from domain config', async () => {
|
||||
const configPath = createTempConfig(makeMinimalConfig());
|
||||
tempPaths.push(configPath);
|
||||
|
||||
const config = await loadConfig([configPath]);
|
||||
expect(config.endpoints.api).toContain('localhost');
|
||||
expect(config.endpoints.api).toContain('/api');
|
||||
expect(config.endpoints.gateway).toContain('ws');
|
||||
});
|
||||
|
||||
test('endpoint_overrides take precedence over derived endpoints', async () => {
|
||||
const configPath = createTempConfig(
|
||||
makeMinimalConfig({
|
||||
endpoint_overrides: {
|
||||
api: 'https://custom-api.example.com',
|
||||
api_client: 'https://custom-api-client.example.com',
|
||||
gateway: 'wss://custom-gw.example.com',
|
||||
},
|
||||
}),
|
||||
);
|
||||
tempPaths.push(configPath);
|
||||
|
||||
const config = await loadConfig([configPath]);
|
||||
expect(config.endpoints.api).toBe('https://custom-api.example.com');
|
||||
expect(config.endpoints.api_client).toBe('https://custom-api-client.example.com');
|
||||
expect(config.endpoints.gateway).toBe('wss://custom-gw.example.com');
|
||||
expect(config.endpoints.app).toContain('localhost');
|
||||
});
|
||||
|
||||
test('allows unknown properties (but warns)', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const configPath = createTempConfig(
|
||||
makeMinimalConfig({
|
||||
extra_root: true,
|
||||
auth: {
|
||||
sudo_mode_secret: 'sudo-test-secret',
|
||||
connection_initiation_secret: 'connection-initiation-test-secret',
|
||||
vapid: {
|
||||
public_key: 'test-vapid-public-key',
|
||||
private_key: 'test-vapid-private-key',
|
||||
},
|
||||
extra_auth: 'oops',
|
||||
},
|
||||
}),
|
||||
);
|
||||
tempPaths.push(configPath);
|
||||
|
||||
await expect(loadConfig([configPath])).resolves.toBeDefined();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"extra_root"'));
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('"extra_auth"'));
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('allows voice enabled without global credentials when default_region is omitted', async () => {
|
||||
const configPath = createTempConfig(
|
||||
makeMinimalConfig({
|
||||
integrations: {
|
||||
voice: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
tempPaths.push(configPath);
|
||||
|
||||
const config = await loadConfig([configPath]);
|
||||
expect(config.integrations.voice.enabled).toBe(true);
|
||||
expect(config.integrations.voice.api_key).toBeUndefined();
|
||||
expect(config.integrations.voice.api_secret).toBeUndefined();
|
||||
});
|
||||
|
||||
test('requires voice credentials when default_region bootstrap is configured', async () => {
|
||||
const configPath = createTempConfig(
|
||||
makeMinimalConfig({
|
||||
integrations: {
|
||||
voice: {
|
||||
enabled: true,
|
||||
default_region: {
|
||||
id: 'default',
|
||||
name: 'Default',
|
||||
emoji: ':earth_africa:',
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
tempPaths.push(configPath);
|
||||
|
||||
await expect(loadConfig([configPath])).rejects.toThrow('api_key');
|
||||
await expect(loadConfig([configPath])).rejects.toThrow('api_secret');
|
||||
});
|
||||
});
|
||||
82
packages/config/src/__tests__/ConfigObjectMerge.test.tsx
Normal file
82
packages/config/src/__tests__/ConfigObjectMerge.test.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {deepMerge, isPlainObject} from '@fluxer/config/src/config_loader/ConfigObjectMerge';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
describe('isPlainObject', () => {
|
||||
test('returns true for plain objects', () => {
|
||||
expect(isPlainObject({})).toBe(true);
|
||||
expect(isPlainObject({a: 1})).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for arrays', () => {
|
||||
expect(isPlainObject([])).toBe(false);
|
||||
expect(isPlainObject([1, 2])).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for null and primitives', () => {
|
||||
expect(isPlainObject(null)).toBe(false);
|
||||
expect(isPlainObject(undefined)).toBe(false);
|
||||
expect(isPlainObject(42)).toBe(false);
|
||||
expect(isPlainObject('string')).toBe(false);
|
||||
expect(isPlainObject(true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deepMerge', () => {
|
||||
test('merges flat objects', () => {
|
||||
const result = deepMerge({a: 1, b: 2}, {b: 3, c: 4});
|
||||
expect(result).toEqual({a: 1, b: 3, c: 4});
|
||||
});
|
||||
|
||||
test('merges nested objects recursively', () => {
|
||||
const target = {database: {host: 'localhost', port: 5432}};
|
||||
const source = {database: {port: 3306, name: 'test'}};
|
||||
const result = deepMerge(target, source);
|
||||
expect(result).toEqual({database: {host: 'localhost', port: 3306, name: 'test'}});
|
||||
});
|
||||
|
||||
test('replaces arrays instead of merging them', () => {
|
||||
const target = {tags: ['a', 'b']};
|
||||
const source = {tags: ['c']};
|
||||
const result = deepMerge(target, source);
|
||||
expect(result).toEqual({tags: ['c']});
|
||||
});
|
||||
|
||||
test('source overrides target for non-object values', () => {
|
||||
const target = {a: 'old', b: {nested: true}};
|
||||
const source = {a: 'new', b: 'replaced'};
|
||||
const result = deepMerge(target, source);
|
||||
expect(result).toEqual({a: 'new', b: 'replaced'});
|
||||
});
|
||||
|
||||
test('does not mutate the target', () => {
|
||||
const target = {a: 1, nested: {b: 2}};
|
||||
const source = {a: 99, nested: {c: 3}};
|
||||
deepMerge(target, source);
|
||||
expect(target).toEqual({a: 1, nested: {b: 2}});
|
||||
});
|
||||
|
||||
test('handles empty objects', () => {
|
||||
expect(deepMerge({}, {a: 1})).toEqual({a: 1});
|
||||
expect(deepMerge({a: 1}, {})).toEqual({a: 1});
|
||||
expect(deepMerge({}, {})).toEqual({});
|
||||
});
|
||||
});
|
||||
388
packages/config/src/__tests__/EndpointDerivation.test.tsx
Normal file
388
packages/config/src/__tests__/EndpointDerivation.test.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
buildUrl,
|
||||
type DomainConfig,
|
||||
deriveDomain,
|
||||
deriveEndpointsFromDomain,
|
||||
} from '@fluxer/config/src/EndpointDerivation';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
describe('buildUrl', () => {
|
||||
test('omits standard HTTP port (80)', () => {
|
||||
expect(buildUrl('http', 'example.com', 80, '/path')).toBe('http://example.com/path');
|
||||
});
|
||||
|
||||
test('omits standard HTTPS port (443)', () => {
|
||||
expect(buildUrl('https', 'example.com', 443, '/path')).toBe('https://example.com/path');
|
||||
});
|
||||
|
||||
test('omits standard WebSocket port (80)', () => {
|
||||
expect(buildUrl('ws', 'example.com', 80, '/gateway')).toBe('ws://example.com/gateway');
|
||||
});
|
||||
|
||||
test('omits standard secure WebSocket port (443)', () => {
|
||||
expect(buildUrl('wss', 'example.com', 443, '/gateway')).toBe('wss://example.com/gateway');
|
||||
});
|
||||
|
||||
test('includes non-standard port', () => {
|
||||
expect(buildUrl('http', 'localhost', 8088, '/api')).toBe('http://localhost:8088/api');
|
||||
});
|
||||
|
||||
test('includes non-standard HTTPS port', () => {
|
||||
expect(buildUrl('https', 'example.com', 8443, '/api')).toBe('https://example.com:8443/api');
|
||||
});
|
||||
|
||||
test('handles missing port', () => {
|
||||
expect(buildUrl('https', 'example.com', undefined, '/api')).toBe('https://example.com/api');
|
||||
});
|
||||
|
||||
test('handles missing path', () => {
|
||||
expect(buildUrl('https', 'example.com', 443)).toBe('https://example.com');
|
||||
});
|
||||
|
||||
test('handles empty path', () => {
|
||||
expect(buildUrl('https', 'example.com', 443, '')).toBe('https://example.com');
|
||||
});
|
||||
|
||||
test('handles root path', () => {
|
||||
expect(buildUrl('https', 'example.com', 443, '/')).toBe('https://example.com/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveDomain', () => {
|
||||
const baseConfig: DomainConfig = {
|
||||
base_domain: 'fluxer.dev',
|
||||
public_scheme: 'https',
|
||||
internal_scheme: 'http',
|
||||
};
|
||||
|
||||
test('uses base domain for api endpoint', () => {
|
||||
expect(deriveDomain('api', baseConfig)).toBe('fluxer.dev');
|
||||
});
|
||||
|
||||
test('uses base domain for app endpoint', () => {
|
||||
expect(deriveDomain('app', baseConfig)).toBe('fluxer.dev');
|
||||
});
|
||||
|
||||
test('uses base domain for gateway endpoint', () => {
|
||||
expect(deriveDomain('gateway', baseConfig)).toBe('fluxer.dev');
|
||||
});
|
||||
|
||||
test('uses base domain for media endpoint', () => {
|
||||
expect(deriveDomain('media', baseConfig)).toBe('fluxer.dev');
|
||||
});
|
||||
|
||||
test('uses custom static CDN domain when specified', () => {
|
||||
const config = {...baseConfig, static_cdn_domain: 'cdn.fluxer.dev'};
|
||||
expect(deriveDomain('static_cdn', config)).toBe('cdn.fluxer.dev');
|
||||
});
|
||||
|
||||
test('uses base domain for static CDN when custom domain not specified', () => {
|
||||
expect(deriveDomain('static_cdn', baseConfig)).toBe('fluxerstatic.com');
|
||||
});
|
||||
|
||||
test('uses custom invite domain when specified', () => {
|
||||
const config = {...baseConfig, invite_domain: 'fluxer.gg'};
|
||||
expect(deriveDomain('invite', config)).toBe('fluxer.gg');
|
||||
});
|
||||
|
||||
test('uses base domain for invite when custom domain not specified', () => {
|
||||
expect(deriveDomain('invite', baseConfig)).toBe('fluxer.dev');
|
||||
});
|
||||
|
||||
test('uses custom gift domain when specified', () => {
|
||||
const config = {...baseConfig, gift_domain: 'fluxer.gift'};
|
||||
expect(deriveDomain('gift', config)).toBe('fluxer.gift');
|
||||
});
|
||||
|
||||
test('uses base domain for gift when custom domain not specified', () => {
|
||||
expect(deriveDomain('gift', baseConfig)).toBe('fluxer.dev');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveEndpointsFromDomain', () => {
|
||||
describe('development environment (localhost)', () => {
|
||||
const devConfig: DomainConfig = {
|
||||
base_domain: 'localhost',
|
||||
public_scheme: 'http',
|
||||
internal_scheme: 'http',
|
||||
public_port: 8088,
|
||||
internal_port: 8088,
|
||||
};
|
||||
|
||||
const endpoints = deriveEndpointsFromDomain(devConfig);
|
||||
|
||||
test('derives api endpoint with port', () => {
|
||||
expect(endpoints.api).toBe('http://localhost:8088/api');
|
||||
});
|
||||
|
||||
test('derives api client endpoint with port', () => {
|
||||
expect(endpoints.api_client).toBe('http://localhost:8088/api');
|
||||
});
|
||||
|
||||
test('derives app endpoint with port', () => {
|
||||
expect(endpoints.app).toBe('http://localhost:8088');
|
||||
});
|
||||
|
||||
test('derives gateway endpoint with ws scheme', () => {
|
||||
expect(endpoints.gateway).toBe('ws://localhost:8088/gateway');
|
||||
});
|
||||
|
||||
test('derives media endpoint with port', () => {
|
||||
expect(endpoints.media).toBe('http://localhost:8088/media');
|
||||
});
|
||||
|
||||
test('derives static CDN endpoint via CDN host', () => {
|
||||
expect(endpoints.static_cdn).toBe('https://fluxerstatic.com');
|
||||
});
|
||||
|
||||
test('derives admin endpoint with port', () => {
|
||||
expect(endpoints.admin).toBe('http://localhost:8088/admin');
|
||||
});
|
||||
|
||||
test('derives marketing endpoint with port', () => {
|
||||
expect(endpoints.marketing).toBe('http://localhost:8088/marketing');
|
||||
});
|
||||
|
||||
test('derives invite endpoint with port', () => {
|
||||
expect(endpoints.invite).toBe('http://localhost:8088/invite');
|
||||
});
|
||||
|
||||
test('derives gift endpoint with port', () => {
|
||||
expect(endpoints.gift).toBe('http://localhost:8088/gift');
|
||||
});
|
||||
});
|
||||
|
||||
describe('production environment (standard HTTPS port)', () => {
|
||||
const prodConfig: DomainConfig = {
|
||||
base_domain: 'fluxer.app',
|
||||
public_scheme: 'https',
|
||||
internal_scheme: 'http',
|
||||
public_port: 443,
|
||||
internal_port: 8080,
|
||||
};
|
||||
|
||||
const endpoints = deriveEndpointsFromDomain(prodConfig);
|
||||
|
||||
test('derives api endpoint without port', () => {
|
||||
expect(endpoints.api).toBe('https://fluxer.app/api');
|
||||
});
|
||||
|
||||
test('derives api client endpoint without port', () => {
|
||||
expect(endpoints.api_client).toBe('https://fluxer.app/api');
|
||||
});
|
||||
|
||||
test('derives app endpoint without port', () => {
|
||||
expect(endpoints.app).toBe('https://fluxer.app');
|
||||
});
|
||||
|
||||
test('derives gateway endpoint with wss scheme without port', () => {
|
||||
expect(endpoints.gateway).toBe('wss://fluxer.app/gateway');
|
||||
});
|
||||
|
||||
test('derives media endpoint without port', () => {
|
||||
expect(endpoints.media).toBe('https://fluxer.app/media');
|
||||
});
|
||||
|
||||
test('derives static CDN endpoint without port', () => {
|
||||
expect(endpoints.static_cdn).toBe('https://fluxerstatic.com');
|
||||
});
|
||||
|
||||
test('derives admin endpoint without port', () => {
|
||||
expect(endpoints.admin).toBe('https://fluxer.app/admin');
|
||||
});
|
||||
|
||||
test('derives marketing endpoint without port', () => {
|
||||
expect(endpoints.marketing).toBe('https://fluxer.app/marketing');
|
||||
});
|
||||
|
||||
test('derives invite endpoint without port', () => {
|
||||
expect(endpoints.invite).toBe('https://fluxer.app/invite');
|
||||
});
|
||||
|
||||
test('derives gift endpoint without port', () => {
|
||||
expect(endpoints.gift).toBe('https://fluxer.app/gift');
|
||||
});
|
||||
});
|
||||
|
||||
describe('staging environment (custom port)', () => {
|
||||
const stagingConfig: DomainConfig = {
|
||||
base_domain: 'staging.fluxer.dev',
|
||||
public_scheme: 'https',
|
||||
internal_scheme: 'http',
|
||||
public_port: 8443,
|
||||
internal_port: 8080,
|
||||
};
|
||||
|
||||
const endpoints = deriveEndpointsFromDomain(stagingConfig);
|
||||
|
||||
test('derives api endpoint with custom port', () => {
|
||||
expect(endpoints.api).toBe('https://staging.fluxer.dev:8443/api');
|
||||
});
|
||||
|
||||
test('derives api client endpoint with custom port', () => {
|
||||
expect(endpoints.api_client).toBe('https://staging.fluxer.dev:8443/api');
|
||||
});
|
||||
|
||||
test('derives app endpoint with custom port', () => {
|
||||
expect(endpoints.app).toBe('https://staging.fluxer.dev:8443');
|
||||
});
|
||||
|
||||
test('derives gateway endpoint with wss and custom port', () => {
|
||||
expect(endpoints.gateway).toBe('wss://staging.fluxer.dev:8443/gateway');
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom CDN domain', () => {
|
||||
const staticCdnConfig: DomainConfig = {
|
||||
base_domain: 'fluxer.app',
|
||||
public_scheme: 'https',
|
||||
internal_scheme: 'http',
|
||||
public_port: 443,
|
||||
static_cdn_domain: 'cdn.fluxer.app',
|
||||
};
|
||||
|
||||
const endpoints = deriveEndpointsFromDomain(staticCdnConfig);
|
||||
|
||||
test('uses custom CDN domain', () => {
|
||||
expect(endpoints.static_cdn).toBe('https://cdn.fluxer.app');
|
||||
});
|
||||
|
||||
test('other endpoints use base domain', () => {
|
||||
expect(endpoints.api).toBe('https://fluxer.app/api');
|
||||
expect(endpoints.app).toBe('https://fluxer.app');
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom invite and gift domains', () => {
|
||||
const customConfig: DomainConfig = {
|
||||
base_domain: 'fluxer.app',
|
||||
public_scheme: 'https',
|
||||
internal_scheme: 'http',
|
||||
public_port: 443,
|
||||
invite_domain: 'fluxer.gg',
|
||||
gift_domain: 'fluxer.gift',
|
||||
};
|
||||
|
||||
const endpoints = deriveEndpointsFromDomain(customConfig);
|
||||
|
||||
test('uses custom invite domain', () => {
|
||||
expect(endpoints.invite).toBe('https://fluxer.gg/invite');
|
||||
});
|
||||
|
||||
test('uses custom gift domain', () => {
|
||||
expect(endpoints.gift).toBe('https://fluxer.gift/gift');
|
||||
});
|
||||
|
||||
test('other endpoints use base domain', () => {
|
||||
expect(endpoints.api).toBe('https://fluxer.app/api');
|
||||
expect(endpoints.app).toBe('https://fluxer.app');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WebSocket scheme derivation', () => {
|
||||
test('derives ws from http', () => {
|
||||
const config: DomainConfig = {
|
||||
base_domain: 'localhost',
|
||||
public_scheme: 'http',
|
||||
internal_scheme: 'http',
|
||||
public_port: 8088,
|
||||
};
|
||||
const endpoints = deriveEndpointsFromDomain(config);
|
||||
expect(endpoints.gateway).toBe('ws://localhost:8088/gateway');
|
||||
});
|
||||
|
||||
test('derives wss from https', () => {
|
||||
const config: DomainConfig = {
|
||||
base_domain: 'fluxer.app',
|
||||
public_scheme: 'https',
|
||||
internal_scheme: 'http',
|
||||
public_port: 443,
|
||||
};
|
||||
const endpoints = deriveEndpointsFromDomain(config);
|
||||
expect(endpoints.gateway).toBe('wss://fluxer.app/gateway');
|
||||
});
|
||||
});
|
||||
|
||||
describe('canary environment', () => {
|
||||
const canaryConfig: DomainConfig = {
|
||||
base_domain: 'canary.fluxer.app',
|
||||
public_scheme: 'https',
|
||||
internal_scheme: 'http',
|
||||
public_port: 443,
|
||||
static_cdn_domain: 'cdn-canary.fluxer.app',
|
||||
};
|
||||
|
||||
const endpoints = deriveEndpointsFromDomain(canaryConfig);
|
||||
|
||||
test('derives api endpoint for canary', () => {
|
||||
expect(endpoints.api).toBe('https://canary.fluxer.app/api');
|
||||
});
|
||||
|
||||
test('derives app endpoint for canary', () => {
|
||||
expect(endpoints.app).toBe('https://canary.fluxer.app');
|
||||
});
|
||||
|
||||
test('derives gateway endpoint for canary', () => {
|
||||
expect(endpoints.gateway).toBe('wss://canary.fluxer.app/gateway');
|
||||
});
|
||||
|
||||
test('uses custom CDN domain for canary', () => {
|
||||
expect(endpoints.static_cdn).toBe('https://cdn-canary.fluxer.app');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
test('handles standard HTTP port (80)', () => {
|
||||
const config: DomainConfig = {
|
||||
base_domain: 'example.com',
|
||||
public_scheme: 'http',
|
||||
internal_scheme: 'http',
|
||||
public_port: 80,
|
||||
};
|
||||
const endpoints = deriveEndpointsFromDomain(config);
|
||||
expect(endpoints.api).toBe('http://example.com/api');
|
||||
expect(endpoints.gateway).toBe('ws://example.com/gateway');
|
||||
});
|
||||
|
||||
test('handles ports when undefined', () => {
|
||||
const config: DomainConfig = {
|
||||
base_domain: 'example.com',
|
||||
public_scheme: 'https',
|
||||
internal_scheme: 'http',
|
||||
};
|
||||
const endpoints = deriveEndpointsFromDomain(config);
|
||||
expect(endpoints.api).toBe('https://example.com/api');
|
||||
expect(endpoints.app).toBe('https://example.com');
|
||||
});
|
||||
|
||||
test('handles IPv4 addresses', () => {
|
||||
const config: DomainConfig = {
|
||||
base_domain: '127.0.0.1',
|
||||
public_scheme: 'http',
|
||||
internal_scheme: 'http',
|
||||
public_port: 8088,
|
||||
};
|
||||
const endpoints = deriveEndpointsFromDomain(config);
|
||||
expect(endpoints.api).toBe('http://127.0.0.1:8088/api');
|
||||
});
|
||||
});
|
||||
});
|
||||
154
packages/config/src/__tests__/EnvironmentOverrides.test.tsx
Normal file
154
packages/config/src/__tests__/EnvironmentOverrides.test.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {buildEnvOverrides, parseEnvValue, setNestedValue} from '@fluxer/config/src/config_loader/EnvironmentOverrides';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
describe('parseEnvValue', () => {
|
||||
test('parses boolean true', () => {
|
||||
expect(parseEnvValue('true')).toBe(true);
|
||||
expect(parseEnvValue(' true ')).toBe(true);
|
||||
});
|
||||
|
||||
test('parses boolean false', () => {
|
||||
expect(parseEnvValue('false')).toBe(false);
|
||||
expect(parseEnvValue(' false ')).toBe(false);
|
||||
});
|
||||
|
||||
test('parses integers', () => {
|
||||
expect(parseEnvValue('42')).toBe(42);
|
||||
expect(parseEnvValue('-7')).toBe(-7);
|
||||
expect(parseEnvValue('0')).toBe(0);
|
||||
});
|
||||
|
||||
test('parses floats', () => {
|
||||
expect(parseEnvValue('3.14')).toBe(3.14);
|
||||
expect(parseEnvValue('-0.5')).toBe(-0.5);
|
||||
});
|
||||
|
||||
test('parses JSON objects', () => {
|
||||
expect(parseEnvValue('{"key": "value"}')).toEqual({key: 'value'});
|
||||
});
|
||||
|
||||
test('parses JSON arrays', () => {
|
||||
expect(parseEnvValue('[1, 2, 3]')).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test('returns raw string for invalid JSON-like values', () => {
|
||||
expect(parseEnvValue('{not json}')).toBe('{not json}');
|
||||
});
|
||||
|
||||
test('returns raw string for plain strings', () => {
|
||||
expect(parseEnvValue('hello')).toBe('hello');
|
||||
expect(parseEnvValue('localhost')).toBe('localhost');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setNestedValue', () => {
|
||||
test('sets a top-level key', () => {
|
||||
const target: Record<string, unknown> = {};
|
||||
setNestedValue(target, ['port'], 8080);
|
||||
expect(target).toEqual({port: 8080});
|
||||
});
|
||||
|
||||
test('sets a nested key', () => {
|
||||
const target: Record<string, unknown> = {};
|
||||
setNestedValue(target, ['database', 'host'], 'localhost');
|
||||
expect(target).toEqual({database: {host: 'localhost'}});
|
||||
});
|
||||
|
||||
test('sets a deeply nested key', () => {
|
||||
const target: Record<string, unknown> = {};
|
||||
setNestedValue(target, ['a', 'b', 'c'], 'deep');
|
||||
expect(target).toEqual({a: {b: {c: 'deep'}}});
|
||||
});
|
||||
|
||||
test('does nothing for empty keys', () => {
|
||||
const target: Record<string, unknown> = {existing: true};
|
||||
setNestedValue(target, [], 'value');
|
||||
expect(target).toEqual({existing: true});
|
||||
});
|
||||
|
||||
test('overwrites non-object intermediate values', () => {
|
||||
const target: Record<string, unknown> = {a: 'string'};
|
||||
setNestedValue(target, ['a', 'b'], 'nested');
|
||||
expect(target).toEqual({a: {b: 'nested'}});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEnvOverrides', () => {
|
||||
test('extracts env vars with the given prefix', () => {
|
||||
const env = {
|
||||
FLUXER_CONFIG__ENV: 'production',
|
||||
FLUXER_CONFIG__DATABASE__HOST: 'db.example.com',
|
||||
UNRELATED_VAR: 'ignored',
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const result = buildEnvOverrides(env, 'FLUXER_CONFIG__');
|
||||
expect(result).toEqual({
|
||||
env: 'production',
|
||||
database: {host: 'db.example.com'},
|
||||
});
|
||||
});
|
||||
|
||||
test('lowercases key segments', () => {
|
||||
const env = {
|
||||
FLUXER_CONFIG__DATABASE__PORT: '5432',
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const result = buildEnvOverrides(env, 'FLUXER_CONFIG__');
|
||||
expect(result).toEqual({database: {port: 5432}});
|
||||
});
|
||||
|
||||
test('skips keys that are exactly the prefix with no remainder', () => {
|
||||
const env = {
|
||||
FLUXER_CONFIG__: 'ignored',
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const result = buildEnvOverrides(env, 'FLUXER_CONFIG__');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test('skips undefined values', () => {
|
||||
const env = {
|
||||
FLUXER_CONFIG__MISSING: undefined,
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const result = buildEnvOverrides(env, 'FLUXER_CONFIG__');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
test('supports custom prefix', () => {
|
||||
const env = {
|
||||
MYAPP__PORT: '3000',
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const result = buildEnvOverrides(env, 'MYAPP__');
|
||||
expect(result).toEqual({port: 3000});
|
||||
});
|
||||
|
||||
test('returns empty object when no matching vars exist', () => {
|
||||
const env = {
|
||||
UNRELATED: 'value',
|
||||
} as NodeJS.ProcessEnv;
|
||||
|
||||
const result = buildEnvOverrides(env, 'FLUXER_CONFIG__');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
102
packages/config/src/__tests__/ServiceConfigSlices.test.tsx
Normal file
102
packages/config/src/__tests__/ServiceConfigSlices.test.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {MasterConfig} from '@fluxer/config/src/MasterZodSchema.generated';
|
||||
import {
|
||||
extractBaseServiceConfig,
|
||||
extractBuildInfoConfig,
|
||||
extractKVClientConfig,
|
||||
extractRateLimit,
|
||||
} from '@fluxer/config/src/ServiceConfigSlices';
|
||||
import {describe, expect, test} from 'vitest';
|
||||
|
||||
function createMasterStub(overrides: Partial<MasterConfig> = {}): MasterConfig {
|
||||
return {
|
||||
env: 'development',
|
||||
telemetry: {enabled: false, otlp_endpoint: 'http://localhost:4318', api_key: '', trace_sampling_ratio: 1},
|
||||
sentry: {enabled: false, dsn: ''},
|
||||
internal: {
|
||||
kv: 'redis://127.0.0.1:6379/0',
|
||||
media_proxy: 'http://localhost:8088/media',
|
||||
},
|
||||
services: {} as MasterConfig['services'],
|
||||
...overrides,
|
||||
} as MasterConfig;
|
||||
}
|
||||
|
||||
describe('extractBaseServiceConfig', () => {
|
||||
test('returns env, telemetry, and sentry from master config', () => {
|
||||
const master = createMasterStub({env: 'production'});
|
||||
const result = extractBaseServiceConfig(master);
|
||||
expect(result).toEqual({
|
||||
env: 'production',
|
||||
telemetry: master.telemetry,
|
||||
sentry: master.sentry,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractKVClientConfig', () => {
|
||||
test('returns kvUrl', () => {
|
||||
const master = createMasterStub();
|
||||
const result = extractKVClientConfig(master);
|
||||
expect(result).toEqual({
|
||||
kvUrl: 'redis://127.0.0.1:6379/0',
|
||||
});
|
||||
});
|
||||
|
||||
test('throws when internal is missing', () => {
|
||||
const master = createMasterStub();
|
||||
(master as Record<string, unknown>).internal = undefined;
|
||||
expect(() => extractKVClientConfig(master)).toThrow('internal configuration is required');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBuildInfoConfig', () => {
|
||||
test('returns releaseChannel and buildTimestamp', () => {
|
||||
const result = extractBuildInfoConfig();
|
||||
expect(result).toHaveProperty('releaseChannel');
|
||||
expect(result).toHaveProperty('buildTimestamp');
|
||||
expect(typeof result.releaseChannel).toBe('string');
|
||||
expect(typeof result.buildTimestamp).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractRateLimit', () => {
|
||||
test('returns undefined for null input', () => {
|
||||
expect(extractRateLimit(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns undefined for undefined input', () => {
|
||||
expect(extractRateLimit(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns undefined when limit is missing', () => {
|
||||
expect(extractRateLimit({window_ms: 60000})).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns undefined when window_ms is missing', () => {
|
||||
expect(extractRateLimit({limit: 100})).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns normalised object for valid input', () => {
|
||||
const result = extractRateLimit({limit: 100, window_ms: 60000});
|
||||
expect(result).toEqual({limit: 100, windowMs: 60000});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user