@snow-tzu/type-config
Version:
Core configuration management system with Spring Boot-like features
1,012 lines (846 loc) • 31.3 kB
text/typescript
import 'reflect-metadata';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import {
ConfigManager,
ConfigProperty,
ConfigurationProperties,
DefaultValue,
InMemoryConfigSource,
Required,
Validate,
} from '../src';
import { IsEmail, IsNumber, IsString, IsUrl, Max, Min, MinLength } from 'class-validator';
describe('ConfigManager', () => {
let tempDir: string;
const createdManagers: ConfigManager[] = [];
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'config-test-'));
});
afterEach(async () => {
fs.rmSync(tempDir, { recursive: true, force: true });
for (const manager of createdManagers) {
await manager.dispose();
}
createdManagers.length = 0;
});
describe('initialization', () => {
it('should initialize with default options', async () => {
const manager = new ConfigManager();
createdManagers.push(manager);
await manager.initialize();
expect(manager.getProfile()).toBe(process.env.NODE_ENV || 'development');
});
it('should use provided profile', async () => {
const manager = new ConfigManager({ profile: 'production' });
createdManagers.push(manager);
await manager.initialize();
expect(manager.getProfile()).toBe('production');
});
it('should load config from JSON file', async () => {
const configPath = path.join(tempDir, 'application.json');
fs.writeFileSync(configPath, JSON.stringify({ database: { host: 'localhost' } }));
const manager = new ConfigManager({ configDir: tempDir });
createdManagers.push(manager);
await manager.initialize();
expect(manager.get('database.host')).toBe('localhost');
});
it('should load config from YAML file', async () => {
const configPath = path.join(tempDir, 'application.yml');
fs.writeFileSync(configPath, 'server:\n port: 3000');
const manager = new ConfigManager({ configDir: tempDir });
createdManagers.push(manager);
await manager.initialize();
expect(manager.get('server.port')).toBe(3000);
});
it('should load profile-specific config', async () => {
const basePath = path.join(tempDir, 'application.json');
const prodPath = path.join(tempDir, 'application-production.json');
fs.writeFileSync(basePath, JSON.stringify({ database: { host: 'localhost' } }));
fs.writeFileSync(prodPath, JSON.stringify({ database: { host: 'prod-server' } }));
const manager = new ConfigManager({ configDir: tempDir, profile: 'production' });
createdManagers.push(manager);
await manager.initialize();
expect(manager.get('database.host')).toBe('prod-server');
});
it('should load additional sources', async () => {
const additionalSource = new InMemoryConfigSource({ custom: { value: 'test' } }, 500);
const manager = new ConfigManager({ additionalSources: [additionalSource] });
createdManagers.push(manager);
await manager.initialize();
expect(manager.get('custom.value')).toBe('test');
});
it('should not initialize twice', async () => {
const manager = new ConfigManager();
createdManagers.push(manager);
await manager.initialize();
await manager.initialize();
expect(manager.getProfile()).toBeDefined();
});
});
describe('get', () => {
it('should retrieve nested config value', async () => {
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource({ a: { b: { c: 'value' } } }, 100)],
});
createdManagers.push(manager);
await manager.initialize();
const result = manager.get('a.b.c');
expect(result).toBe('value');
});
it('should return default value when path not found', async () => {
const manager = new ConfigManager();
createdManagers.push(manager);
await manager.initialize();
const result = manager.get('non.existent.path', 'default');
expect(result).toBe('default');
});
it('should return undefined when path not found and no default', async () => {
const manager = new ConfigManager();
createdManagers.push(manager);
await manager.initialize();
const result = manager.get('non.existent.path');
expect(result).toBeUndefined();
});
it('should handle arrays', async () => {
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource({ items: ['a', 'b', 'c'] }, 100)],
});
createdManagers.push(manager);
await manager.initialize();
const result = manager.get('items');
expect(result).toEqual(['a', 'b', 'c']);
});
});
describe('getAll', () => {
it('should return all configuration', async () => {
const config = { database: { host: 'localhost' }, server: { port: 3000 } };
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
const result = manager.getAll();
expect(result).toMatchObject(config);
});
it('should return copy of config', async () => {
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource({ test: 'value' }, 100)],
});
createdManagers.push(manager);
await manager.initialize();
const result = manager.getAll();
result.test = 'modified';
expect(manager.get('test')).toBe('value');
});
});
describe('bind', () => {
it('should bind configuration to class instance', async () => {
class DatabaseConfig {
host!: string;
port!: number;
}
const config = { database: { host: 'localhost', port: 5432 } };
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
const instance = manager.bind(DatabaseConfig);
expect(instance.host).toBe('localhost');
expect(instance.port).toBe(5432);
});
it('should use default values', async () => {
class ServerConfig {
port!: number;
host!: string;
}
const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource({}, 100)] });
createdManagers.push(manager);
await manager.initialize();
const instance = manager.bind(ServerConfig);
expect(instance.port).toBe(3000);
expect(instance.host).toBe('localhost');
});
it('should throw error for missing required property', async () => {
class DatabaseConfig {
host!: string;
}
const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource({}, 100)] });
createdManagers.push(manager);
await manager.initialize();
expect(() => manager.bind(DatabaseConfig)).toThrow(
"Required configuration property 'database.host' is missing"
);
});
it('should return cached instance', async () => {
class TestConfig {
value!: string;
}
const config = { test: { value: 'original' } };
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
const instance1 = manager.bind(TestConfig);
const instance2 = manager.bind(TestConfig);
expect(instance1).toBe(instance2);
});
it('should throw error for class without ConfigurationProperties decorator', async () => {
class PlainClass {}
const manager = new ConfigManager();
createdManagers.push(manager);
await manager.initialize();
expect(() => manager.bind(PlainClass)).toThrow(
'must be decorated with @ConfigurationProperties'
);
});
it('should convert types correctly', async () => {
class TestConfig {
stringValue!: string;
numberValue!: number;
booleanValue!: boolean;
arrayValue!: string[];
}
const config = {
test: { stringValue: 123, numberValue: '456', booleanValue: 'true', arrayValue: 'single' },
};
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
const instance = manager.bind(TestConfig);
expect(instance.stringValue).toBe('123');
expect(instance.numberValue).toBe(456);
expect(instance.booleanValue).toBe(true);
expect(instance.arrayValue).toEqual(['single']);
});
});
describe('dispose', () => {
it('should cleanup resources', async () => {
const manager = new ConfigManager();
createdManagers.push(manager);
await manager.initialize();
await manager.dispose();
});
});
describe('encryption', () => {
it('should initialize with encryption key', async () => {
const encryptionKey = '12345678901234567890123456789012';
const manager = new ConfigManager({
encryptionKey,
additionalSources: [new InMemoryConfigSource({ test: 'plain-value' }, 100)],
});
createdManagers.push(manager);
await manager.initialize();
expect(manager.get('test')).toBe('plain-value');
});
});
describe('Map binding', () => {
it('should bind configuration to Map property', async () => {
class DatabasesConfig {
connections!: Map<string, any>;
}
const config = {
databases: {
connections: {
db1: { host: 'localhost', port: 5432 },
db2: { host: 'remote', port: 5433 },
},
},
};
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
const instance = manager.bind(DatabasesConfig);
expect(instance.connections).toBeInstanceOf(Map);
expect(instance.connections.size).toBe(2);
expect(instance.connections.get('db1')).toEqual({ host: 'localhost', port: 5432 });
expect(instance.connections.get('db2')).toEqual({ host: 'remote', port: 5433 });
});
it('should preserve nested structures in map values', async () => {
class TestConfig {
items!: Map<string, any>;
}
const config = {
config: {
items: {
item1: {
name: 'Test',
nested: {
deep: {
value: 'nested-value',
},
},
},
},
},
};
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
const instance = manager.bind(TestConfig);
expect(instance.items.get('item1')).toEqual({
name: 'Test',
nested: {
deep: {
value: 'nested-value',
},
},
});
});
it('should throw error when binding non-object to Map', async () => {
class TestConfig {
map!: Map<string, any>;
}
const config = { test: { map: 'not-an-object' } };
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
expect(() => manager.bind(TestConfig)).toThrow(
"Cannot bind primitive value to Map<string, T> for property 'map'"
);
});
it('should throw error when binding array to Map', async () => {
class TestConfig {
map!: Map<string, any>;
}
const config = { test: { map: ['array', 'values'] } };
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
expect(() => manager.bind(TestConfig)).toThrow(
"Cannot bind primitive value to Map<string, T> for property 'map'"
);
});
it('should handle empty map', async () => {
class TestConfig {
map!: Map<string, any>;
}
const config = { test: { map: {} } };
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
const instance = manager.bind(TestConfig);
expect(instance.map).toBeInstanceOf(Map);
expect(instance.map.size).toBe(0);
});
it('should validate required Map property', async () => {
class TestConfig {
map!: Map<string, any>;
}
const config = { test: {} };
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
expect(() => manager.bind(TestConfig)).toThrow(
"Required configuration property 'test.map' is missing"
);
});
it('should work with Map and default values', async () => {
class TestConfig {
map!: Map<string, any>;
}
const config = { test: {} };
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
const instance = manager.bind(TestConfig);
expect(instance.map).toBeInstanceOf(Map);
expect(instance.map.size).toBe(0);
});
});
describe('Map path access', () => {
it('should access deep path into maps', async () => {
const config = {
databases: {
connections: {
'serhafen-us': {
host: 'localhost',
port: 5432,
username: 'postgres',
password: 'secret',
database: 'serhafen_common',
schema: 'us',
},
'serhafen-ag': {
host: 'remote-host',
port: 5433,
username: 'admin',
password: 'admin-secret',
database: 'serhafen_ag',
schema: 'ag',
},
},
},
};
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
// Test deep path access to specific nested values
expect(manager.get('databases.connections.serhafen-us.host')).toBe('localhost');
expect(manager.get('databases.connections.serhafen-us.port')).toBe(5432);
expect(manager.get('databases.connections.serhafen-us.username')).toBe('postgres');
expect(manager.get('databases.connections.serhafen-ag.host')).toBe('remote-host');
expect(manager.get('databases.connections.serhafen-ag.schema')).toBe('ag');
});
it('should access partial path to return entire map entry', async () => {
const config = {
databases: {
connections: {
'serhafen-us': {
host: 'localhost',
port: 5432,
username: 'postgres',
},
'serhafen-ag': {
host: 'remote-host',
port: 5433,
username: 'admin',
},
},
},
};
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
// Test partial path access to get entire map entry
const usEntry = manager.get('databases.connections.serhafen-us');
expect(usEntry).toEqual({
host: 'localhost',
port: 5432,
username: 'postgres',
});
const agEntry = manager.get('databases.connections.serhafen-ag');
expect(agEntry).toEqual({
host: 'remote-host',
port: 5433,
username: 'admin',
});
});
it('should access top-level map to return entire map as object', async () => {
const config = {
databases: {
connections: {
db1: { host: 'host1', port: 5432 },
db2: { host: 'host2', port: 5433 },
db3: { host: 'host3', port: 5434 },
},
pool: {
min: 2,
max: 10,
},
},
};
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
// Test top-level map access
const connections = manager.get('databases.connections');
expect(connections).toEqual({
db1: { host: 'host1', port: 5432 },
db2: { host: 'host2', port: 5433 },
db3: { host: 'host3', port: 5434 },
});
// Verify it's an object with all keys
expect(Object.keys(connections)).toEqual(['db1', 'db2', 'db3']);
});
it('should return default value for missing map key', async () => {
const config = {
databases: {
connections: {
db1: { host: 'host1', port: 5432 },
},
},
};
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
// Test default value for non-existent map key
const result = manager.get('databases.connections.non-existent', {
host: 'default-host',
port: 9999,
});
expect(result).toEqual({ host: 'default-host', port: 9999 });
// Test default value for non-existent nested property
const nestedResult = manager.get('databases.connections.non-existent.host', 'fallback-host');
expect(nestedResult).toBe('fallback-host');
});
it('should handle kebab-case keys in map paths', async () => {
const config = {
services: {
endpoints: {
'user-service': { url: 'http://user-service:8080' },
'order-service': { url: 'http://order-service:8081' },
'payment-service': { url: 'http://payment-service:8082' },
},
},
};
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
// Test kebab-case keys work correctly
expect(manager.get('services.endpoints.user-service.url')).toBe('http://user-service:8080');
expect(manager.get('services.endpoints.order-service.url')).toBe('http://order-service:8081');
expect(manager.get('services.endpoints.payment-service.url')).toBe(
'http://payment-service:8082'
);
});
it('should handle deeply nested structures within map values', async () => {
const config = {
applications: {
configs: {
app1: {
server: {
host: 'localhost',
port: 3000,
ssl: {
enabled: true,
cert: '/path/to/cert',
key: '/path/to/key',
},
},
},
},
},
};
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
// Test deeply nested path access
expect(manager.get('applications.configs.app1.server.host')).toBe('localhost');
expect(manager.get('applications.configs.app1.server.ssl.enabled')).toBe(true);
expect(manager.get('applications.configs.app1.server.ssl.cert')).toBe('/path/to/cert');
// Test partial access to nested object
const ssl = manager.get('applications.configs.app1.server.ssl');
expect(ssl).toEqual({
enabled: true,
cert: '/path/to/cert',
key: '/path/to/key',
});
});
it('should return undefined for missing nested map keys without default', async () => {
const config = {
databases: {
connections: {
db1: { host: 'host1' },
},
},
};
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
// Test undefined for missing keys
expect(manager.get('databases.connections.missing-db')).toBeUndefined();
expect(manager.get('databases.connections.missing-db.host')).toBeUndefined();
expect(manager.get('databases.missing-section.db1')).toBeUndefined();
});
it('should handle empty map structures', async () => {
const config = {
databases: {
connections: {},
},
};
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
// Test empty map access
const connections = manager.get('databases.connections');
expect(connections).toEqual({});
expect(Object.keys(connections)).toHaveLength(0);
// Test accessing key in empty map
expect(manager.get('databases.connections.any-key')).toBeUndefined();
expect(manager.get('databases.connections.any-key', 'default')).toBe('default');
});
it('should work with map paths across profile merging', async () => {
const basePath = path.join(tempDir, 'application.json');
const prodPath = path.join(tempDir, 'application-production.json');
fs.writeFileSync(
basePath,
JSON.stringify({
databases: {
connections: {
db1: { host: 'localhost', port: 5432 },
db2: { host: 'localhost', port: 5433 },
},
},
})
);
fs.writeFileSync(
prodPath,
JSON.stringify({
databases: {
connections: {
db1: { host: 'prod-host', port: 5432 },
},
},
})
);
const manager = new ConfigManager({ configDir: tempDir, profile: 'production' });
createdManagers.push(manager);
await manager.initialize();
// Test that profile-specific values override base
expect(manager.get('databases.connections.db1.host')).toBe('prod-host');
expect(manager.get('databases.connections.db1.port')).toBe(5432);
// Test that base values remain for non-overridden entries
expect(manager.get('databases.connections.db2.host')).toBe('localhost');
expect(manager.get('databases.connections.db2.port')).toBe(5433);
});
});
describe('validation', () => {
it('should validate config with @Validate decorator when validation passes', async () => {
class ServerConfig {
host!: string;
port!: number;
}
const config = { server: { host: 'localhost', port: 3000 } };
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
const instance = manager.bind(ServerConfig);
expect(instance.host).toBe('localhost');
expect(instance.port).toBe(3000);
});
it('should throw error when validation fails for string type', async () => {
class ServerConfig {
email!: string;
}
const config = { server: { email: 'not-an-email' } }; // Invalid: not an email format
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
expect(() => manager.bind(ServerConfig)).toThrow('Validation failed for ServerConfig');
expect(() => manager.bind(ServerConfig)).toThrow('email');
});
it('should throw error when validation fails for number type', async () => {
class ServerConfig {
port!: number;
}
const config = { server: { port: 100 } }; // Invalid: below minimum
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
expect(() => manager.bind(ServerConfig)).toThrow('Validation failed for ServerConfig');
expect(() => manager.bind(ServerConfig)).toThrow('port');
});
it('should throw error with multiple validation failures', async () => {
class DatabaseConfig {
host!: string;
port!: number;
username!: string;
}
const config = { database: { host: 'not-a-url', port: 100, username: 'ab' } }; // All invalid
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
expect(() => manager.bind(DatabaseConfig)).toThrow('Validation failed for DatabaseConfig');
});
it('should not validate when validateOnBind is false', async () => {
class ServerConfig {
email!: string;
}
const config = { server: { email: 'not-an-email' } }; // Invalid but validation disabled
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
validateOnBind: false,
});
createdManagers.push(manager);
await manager.initialize();
// Should not throw even with invalid data
const instance = manager.bind(ServerConfig);
expect(instance.email).toBe('not-an-email');
});
it('should not validate when @Validate decorator is not present', async () => {
class ServerConfig {
email!: string;
}
const config = { server: { email: 'not-an-email' } }; // Invalid but no @Validate decorator
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
// Should not throw even with invalid data
const instance = manager.bind(ServerConfig);
expect(instance.email).toBe('not-an-email');
});
it('should validate with default values when @Validate is present', async () => {
class ServerConfig {
host!: string;
port!: number;
}
const config = {}; // Empty config, will use defaults
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
const instance = manager.bind(ServerConfig);
expect(instance.host).toBe('localhost');
expect(instance.port).toBe(3000);
});
it('should fail validation when default values are invalid', async () => {
class ServerConfig {
// Invalid default (below minimum)
port!: number;
}
const config = {}; // Empty config, will use invalid default
const manager = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config, 100)],
});
createdManagers.push(manager);
await manager.initialize();
expect(() => manager.bind(ServerConfig)).toThrow('Validation failed for ServerConfig');
expect(() => manager.bind(ServerConfig)).toThrow('port');
});
it('should validate combined with required properties', async () => {
class DatabaseConfig {
host!: string;
port!: number;
}
// Test missing required property
const config1 = { database: { port: 5432 } }; // Missing required host
const manager1 = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config1, 100)],
});
createdManagers.push(manager1);
await manager1.initialize();
expect(() => manager1.bind(DatabaseConfig)).toThrow(
"Required configuration property 'database.host' is missing"
);
// Test invalid type for required property
const config2 = { database: { host: 'not-a-url', port: 5432 } }; // Invalid URL
const manager2 = new ConfigManager({
additionalSources: [new InMemoryConfigSource(config2, 100)],
});
createdManagers.push(manager2);
await manager2.initialize();
expect(() => manager2.bind(DatabaseConfig)).toThrow('Validation failed for DatabaseConfig');
});
});
});