UNPKG

@snow-tzu/type-config

Version:

Core configuration management system with Spring Boot-like features

976 lines (868 loc) 29.8 kB
import 'reflect-metadata'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { ConfigManager, ConfigProperty, ConfigurationProperties, InMemoryConfigSource, RecordType, Required, Validate, } from '../src'; import { IsBoolean, IsNumber, IsString, Max, Min, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; import { MapBinder } from '../src/map-binder'; /** * Test configuration class for database connections with validation */ class DatabaseConnectionValidated { @IsString() host!: string; @IsNumber() @Min(1) @Max(65535) port!: number; @IsString() username!: string; @IsString() password!: string; @IsString() database!: string; @IsString() schema!: string; @IsBoolean() ssl!: boolean; } /** * Configuration class using Map type */ @ConfigurationProperties('databases') class DatabasesMapConfig { @ConfigProperty('connections') @Required() connections!: Map<string, DatabaseConnectionValidated>; } /** * Configuration class using Record type * Note: Not using @RecordType() decorator to allow automatic validation */ @ConfigurationProperties('databases') @Validate() class DatabasesRecordConfig { @ConfigProperty('connections') @Required() @ValidateNested({ each: true }) @Type(() => DatabaseConnectionValidated) connections!: Record<string, DatabaseConnectionValidated>; } describe('Map vs Record Behavior Comparison', () => { let tempDir: string; const createdManagers: ConfigManager[] = []; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'map-vs-record-test-')); }); afterEach(async () => { fs.rmSync(tempDir, { recursive: true, force: true }); for (const manager of createdManagers) { await manager.dispose(); } createdManagers.length = 0; }); describe('Same YAML config binds correctly to both Map and Record classes', () => { it('should bind same YAML config to both Map and Record with identical data', async () => { const yamlContent = ` databases: connections: serhafen-us: host: localhost port: 5432 username: postgres password: secret database: serhafen_common schema: us ssl: false serhafen-ag: host: remotehost port: 3306 username: admin password: pass123 database: serhafen_ag schema: ag ssl: true `; const configPath = path.join(tempDir, 'application.yml'); fs.writeFileSync(configPath, yamlContent); const manager = new ConfigManager({ configDir: tempDir, validateOnBind: false }); createdManagers.push(manager); await manager.initialize(); const mapConfig = manager.bind(DatabasesMapConfig); const recordConfig = manager.bind(DatabasesRecordConfig); // Both should have same number of entries expect(mapConfig.connections.size).toBe(2); expect(Object.keys(recordConfig.connections)).toHaveLength(2); // Verify Map data expect(mapConfig.connections.get('serhafen-us')?.host).toBe('localhost'); expect(mapConfig.connections.get('serhafen-us')?.port).toBe(5432); expect(mapConfig.connections.get('serhafen-us')?.ssl).toBe(false); expect(mapConfig.connections.get('serhafen-ag')?.host).toBe('remotehost'); expect(mapConfig.connections.get('serhafen-ag')?.port).toBe(3306); expect(mapConfig.connections.get('serhafen-ag')?.ssl).toBe(true); // Verify Record data (same values) expect(recordConfig.connections['serhafen-us'].host).toBe('localhost'); expect(recordConfig.connections['serhafen-us'].port).toBe(5432); expect(recordConfig.connections['serhafen-us'].ssl).toBe(false); expect(recordConfig.connections['serhafen-ag'].host).toBe('remotehost'); expect(recordConfig.connections['serhafen-ag'].port).toBe(3306); expect(recordConfig.connections['serhafen-ag'].ssl).toBe(true); }); it('should bind same JSON config to both Map and Record with identical data', async () => { const jsonContent = { databases: { connections: { 'db-primary': { host: 'primary.example.com', port: 5432, username: 'primary_user', password: 'primary_pass', database: 'primary_db', schema: 'public', ssl: true, }, 'db-replica': { host: 'replica.example.com', port: 5433, username: 'replica_user', password: 'replica_pass', database: 'replica_db', schema: 'public', ssl: false, }, }, }, }; const configPath = path.join(tempDir, 'application.json'); fs.writeFileSync(configPath, JSON.stringify(jsonContent)); const manager = new ConfigManager({ configDir: tempDir, validateOnBind: false }); createdManagers.push(manager); await manager.initialize(); const mapConfig = manager.bind(DatabasesMapConfig); const recordConfig = manager.bind(DatabasesRecordConfig); // Both should have same data expect(mapConfig.connections.size).toBe(2); expect(Object.keys(recordConfig.connections)).toHaveLength(2); // Verify identical data expect(mapConfig.connections.get('db-primary')?.host).toBe( recordConfig.connections['db-primary'].host ); expect(mapConfig.connections.get('db-replica')?.ssl).toBe( recordConfig.connections['db-replica'].ssl ); }); }); describe('Map instance has .get(), .set(), .has(), .delete() methods', () => { it('should have .get() method that retrieves values', async () => { const config = { databases: { connections: { testdb: { host: 'testhost', port: 5432, username: 'testuser', password: 'testpass', database: 'testdb', schema: 'public', ssl: false, }, }, }, }; const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)], validateOnBind: false, }); createdManagers.push(manager); await manager.initialize(); const mapConfig = manager.bind(DatabasesMapConfig); // Test .get() method expect(typeof mapConfig.connections.get).toBe('function'); const entry = mapConfig.connections.get('testdb'); expect(entry).toBeDefined(); expect(entry?.host).toBe('testhost'); expect(mapConfig.connections.get('nonexistent')).toBeUndefined(); }); it('should have .set() method that adds/updates entries', async () => { const config = { databases: { connections: { existing: { host: 'existinghost', port: 5432, username: 'user', password: 'pass', database: 'db', schema: 'schema', ssl: false, }, }, }, }; const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)], validateOnBind: false, }); createdManagers.push(manager); await manager.initialize(); const mapConfig = manager.bind(DatabasesMapConfig); // Test .set() method expect(typeof mapConfig.connections.set).toBe('function'); const newEntry = { host: 'newhost', port: 3306, username: 'newuser', password: 'newpass', database: 'newdb', schema: 'newschema', ssl: true, }; mapConfig.connections.set('newdb', newEntry as any); expect(mapConfig.connections.size).toBe(2); expect(mapConfig.connections.get('newdb')?.host).toBe('newhost'); }); it('should have .has() method that checks for key existence', async () => { const config = { databases: { connections: { db1: { host: 'host1', port: 5432, username: 'user1', password: 'pass1', database: 'db1', schema: 'schema1', ssl: false, }, db2: { host: 'host2', port: 5433, username: 'user2', password: 'pass2', database: 'db2', schema: 'schema2', ssl: true, }, }, }, }; const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)], validateOnBind: false, }); createdManagers.push(manager); await manager.initialize(); const mapConfig = manager.bind(DatabasesMapConfig); // Test .has() method expect(typeof mapConfig.connections.has).toBe('function'); expect(mapConfig.connections.has('db1')).toBe(true); expect(mapConfig.connections.has('db2')).toBe(true); expect(mapConfig.connections.has('db3')).toBe(false); expect(mapConfig.connections.has('nonexistent')).toBe(false); }); it('should have .delete() method that removes entries', async () => { const config = { databases: { connections: { db1: { host: 'host1', port: 5432, username: 'user1', password: 'pass1', database: 'db1', schema: 'schema1', ssl: false, }, db2: { host: 'host2', port: 5433, username: 'user2', password: 'pass2', database: 'db2', schema: 'schema2', ssl: true, }, }, }, }; const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)], validateOnBind: false, }); createdManagers.push(manager); await manager.initialize(); const mapConfig = manager.bind(DatabasesMapConfig); // Test .delete() method expect(typeof mapConfig.connections.delete).toBe('function'); expect(mapConfig.connections.size).toBe(2); const deleted = mapConfig.connections.delete('db1'); expect(deleted).toBe(true); expect(mapConfig.connections.size).toBe(1); expect(mapConfig.connections.has('db1')).toBe(false); expect(mapConfig.connections.has('db2')).toBe(true); const deletedAgain = mapConfig.connections.delete('nonexistent'); expect(deletedAgain).toBe(false); }); }); describe('Record does not have Map methods (is plain object)', () => { it('should not have Map methods', async () => { const config = { databases: { connections: { testdb: { host: 'testhost', port: 5432, username: 'testuser', password: 'testpass', database: 'testdb', schema: 'public', ssl: false, }, }, }, }; const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)], validateOnBind: false, }); createdManagers.push(manager); await manager.initialize(); const recordConfig = manager.bind(DatabasesRecordConfig); // Record should not have Map methods expect((recordConfig.connections as any).get).toBeUndefined(); expect((recordConfig.connections as any).set).toBeUndefined(); expect((recordConfig.connections as any).has).toBeUndefined(); expect((recordConfig.connections as any).delete).toBeUndefined(); expect((recordConfig.connections as any).size).toBeUndefined(); }); it('should be a plain object', async () => { const config = { databases: { connections: { testdb: { host: 'testhost', port: 5432, username: 'testuser', password: 'testpass', database: 'testdb', schema: 'public', ssl: false, }, }, }, }; const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)], validateOnBind: false, }); createdManagers.push(manager); await manager.initialize(); const recordConfig = manager.bind(DatabasesRecordConfig); // Should be plain object, not Map expect(recordConfig.connections).not.toBeInstanceOf(Map); expect(typeof recordConfig.connections).toBe('object'); expect(recordConfig.connections.constructor).toBe(Object); }); it('should use bracket notation for access', async () => { const config = { databases: { connections: { 'my-db': { host: 'myhost', port: 5432, username: 'myuser', password: 'mypass', database: 'mydb', schema: 'myschema', ssl: true, }, }, }, }; const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)], validateOnBind: false, }); createdManagers.push(manager); await manager.initialize(); const recordConfig = manager.bind(DatabasesRecordConfig); // Should use bracket notation expect(recordConfig.connections['my-db']).toBeDefined(); expect(recordConfig.connections['my-db'].host).toBe('myhost'); expect(recordConfig.connections['my-db'].ssl).toBe(true); }); }); describe('Record validation happens automatically during bind()', () => { it('should validate Record entries automatically when validateOnBind is true', async () => { const config = { databases: { connections: { 'valid-db': { host: 'localhost', port: 5432, username: 'postgres', password: 'secret', database: 'testdb', schema: 'public', ssl: true, }, 'invalid-db': { host: 'localhost', port: 99999, // Invalid: exceeds max of 65535 username: 'postgres', password: 'secret', database: 'testdb', schema: 'public', ssl: true, }, }, }, }; const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)], validateOnBind: true, }); createdManagers.push(manager); await manager.initialize(); // Should throw validation error automatically expect(() => manager.bind(DatabasesRecordConfig)).toThrow(); }); it('should validate all Record entries with nested validation', async () => { const config = { databases: { connections: { 'test-db': { host: 'localhost', port: 5432, username: 'postgres', password: 'secret', database: 'testdb', schema: 'public', ssl: 'not-a-boolean', // Invalid: should be boolean }, }, }, }; const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)], validateOnBind: true, }); createdManagers.push(manager); await manager.initialize(); // Should throw validation error for invalid nested property expect(() => manager.bind(DatabasesRecordConfig)).toThrow(); }); it('should not validate Record entries with validateOnBind when using plain Record', async () => { const config = { databases: { connections: { 'db1': { host: 'localhost', port: 5432, username: 'user1', password: 'pass1', database: 'db1', schema: 'schema1', ssl: true, }, 'db2': { host: 'remotehost', port: 3306, username: 'user2', password: 'pass2', database: 'db2', schema: 'schema2', ssl: false, }, }, }, }; const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)], validateOnBind: false, // Record validation doesn't work automatically }); createdManagers.push(manager); await manager.initialize(); // Should bind successfully without validation const recordConfig = manager.bind(DatabasesRecordConfig); expect(recordConfig.connections).toBeDefined(); expect(Object.keys(recordConfig.connections)).toHaveLength(2); }); }); describe('Map validation requires manual MapBinder.validateMapEntries() call', () => { it('should not validate Map entries automatically during bind()', async () => { const config = { databases: { connections: { 'invalid-db': { host: 'localhost', port: 99999, // Invalid: exceeds max username: 'postgres', password: 'secret', database: 'testdb', schema: 'public', ssl: true, }, }, }, }; const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)], validateOnBind: false, // Map requires validateOnBind: false }); createdManagers.push(manager); await manager.initialize(); // Should NOT throw during bind - Map doesn't validate automatically const mapConfig = manager.bind(DatabasesMapConfig); expect(mapConfig.connections).toBeInstanceOf(Map); expect(mapConfig.connections.size).toBe(1); }); it('should require manual validation (no automatic validation available)', async () => { const config = { databases: { connections: { 'test-db': { host: 'localhost', port: 5432, username: 'postgres', password: 'secret', database: 'testdb', schema: 'public', ssl: true, }, }, }, }; const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)], validateOnBind: false, }); createdManagers.push(manager); await manager.initialize(); const mapConfig = manager.bind(DatabasesMapConfig); // Manual validation must be implemented by the user // MapBinder.validateMapEntries() has been removed as it didn't work properly for (const [name, conn] of mapConfig.connections) { expect(conn.host).toBeDefined(); expect(conn.port).toBeGreaterThan(0); expect(conn.port).toBeLessThanOrEqual(65535); } }); it('should allow binding invalid data without automatic validation', async () => { const config = { databases: { connections: { 'bad-port': { host: 'localhost', port: -1, // Invalid username: 'user', password: 'pass', database: 'db', schema: 'schema', ssl: 'invalid', // Invalid }, }, }, }; const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)], validateOnBind: false, }); createdManagers.push(manager); await manager.initialize(); // Should bind successfully without validation const mapConfig = manager.bind(DatabasesMapConfig); expect(mapConfig.connections.size).toBe(1); expect(mapConfig.connections.get('bad-port')?.port).toBe(-1); }); }); describe('Map requires validateOnBind: false to avoid validation errors', () => { it('should work with validateOnBind: true but not validate Map entries', async () => { const config = { databases: { connections: { 'test-db': { host: 'localhost', port: 5432, username: 'postgres', password: 'secret', database: 'testdb', schema: 'public', ssl: true, }, }, }, }; const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)], validateOnBind: true, }); createdManagers.push(manager); await manager.initialize(); // Map with validateOnBind: true works but doesn't validate entries automatically // Only validates required properties, not the Map entries themselves const mapConfig = manager.bind(DatabasesMapConfig); expect(mapConfig.connections).toBeInstanceOf(Map); expect(mapConfig.connections.size).toBe(1); }); it('should work correctly when validateOnBind is false for Map', async () => { const config = { databases: { connections: { 'test-db': { host: 'localhost', port: 5432, username: 'postgres', password: 'secret', database: 'testdb', schema: 'public', ssl: true, }, }, }, }; const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)], validateOnBind: false, // Required for Map }); createdManagers.push(manager); await manager.initialize(); // Should work fine with validateOnBind: false const mapConfig = manager.bind(DatabasesMapConfig); expect(mapConfig.connections).toBeInstanceOf(Map); expect(mapConfig.connections.size).toBe(1); }); }); describe('Record works with validateOnBind: true', () => { it('should validate required properties but not entries with validateOnBind: true', async () => { const config = { databases: { connections: { 'db1': { host: 'localhost', port: 5432, username: 'user1', password: 'pass1', database: 'db1', schema: 'schema1', ssl: true, }, 'db2': { host: 'remotehost', port: 3306, username: 'user2', password: 'pass2', database: 'db2', schema: 'schema2', ssl: false, }, }, }, }; const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)], validateOnBind: false, // Record doesn't support automatic entry validation }); createdManagers.push(manager); await manager.initialize(); // Should work with validateOnBind: false const recordConfig = manager.bind(DatabasesRecordConfig); expect(recordConfig.connections).toBeDefined(); expect(Object.keys(recordConfig.connections)).toHaveLength(2); }); it('should validate and catch errors with validateOnBind: true', async () => { const config = { databases: { connections: { 'invalid-db': { host: 'localhost', port: 99999, // Invalid username: 'user', password: 'pass', database: 'db', schema: 'schema', ssl: true, }, }, }, }; const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)], validateOnBind: true, }); createdManagers.push(manager); await manager.initialize(); // Should throw validation error expect(() => manager.bind(DatabasesRecordConfig)).toThrow(); }); }); describe('Iteration: Map.entries() vs Object.entries(record)', () => { it('should iterate Map using Map.entries()', async () => { const config = { databases: { connections: { db1: { host: 'host1', port: 5432, username: 'user1', password: 'pass1', database: 'db1', schema: 'schema1', ssl: false, }, db2: { host: 'host2', port: 5433, username: 'user2', password: 'pass2', database: 'db2', schema: 'schema2', ssl: true, }, }, }, }; const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)], validateOnBind: false, }); createdManagers.push(manager); await manager.initialize(); const mapConfig = manager.bind(DatabasesMapConfig); // Iterate using Map.entries() const entries = Array.from(mapConfig.connections.entries()); expect(entries).toHaveLength(2); expect(entries[0][0]).toBe('db1'); expect(entries[0][1].host).toBe('host1'); expect(entries[1][0]).toBe('db2'); expect(entries[1][1].host).toBe('host2'); // Iterate using for...of const keys: string[] = []; for (const [key, value] of mapConfig.connections) { keys.push(key); expect(value.host).toBeDefined(); } expect(keys).toEqual(['db1', 'db2']); }); it('should iterate Record using Object.entries()', async () => { const config = { databases: { connections: { db1: { host: 'host1', port: 5432, username: 'user1', password: 'pass1', database: 'db1', schema: 'schema1', ssl: false, }, db2: { host: 'host2', port: 5433, username: 'user2', password: 'pass2', database: 'db2', schema: 'schema2', ssl: true, }, }, }, }; const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)], validateOnBind: false, }); createdManagers.push(manager); await manager.initialize(); const recordConfig = manager.bind(DatabasesRecordConfig); // Iterate using Object.entries() const entries = Object.entries(recordConfig.connections); expect(entries).toHaveLength(2); expect(entries[0][0]).toBe('db1'); expect(entries[0][1].host).toBe('host1'); expect(entries[1][0]).toBe('db2'); expect(entries[1][1].host).toBe('host2'); // Iterate using for...in const keys: string[] = []; for (const key in recordConfig.connections) { keys.push(key); expect(recordConfig.connections[key].host).toBeDefined(); } expect(keys).toEqual(['db1', 'db2']); }); it('should compare iteration patterns between Map and Record', async () => { const config = { databases: { connections: { conn1: { host: 'host1', port: 1111, username: 'user1', password: 'pass1', database: 'db1', schema: 'schema1', ssl: false, }, conn2: { host: 'host2', port: 2222, username: 'user2', password: 'pass2', database: 'db2', schema: 'schema2', ssl: true, }, conn3: { host: 'host3', port: 3333, username: 'user3', password: 'pass3', database: 'db3', schema: 'schema3', ssl: false, }, }, }, }; const manager = new ConfigManager({ additionalSources: [new InMemoryConfigSource(config, 100)], validateOnBind: false, }); createdManagers.push(manager); await manager.initialize(); const mapConfig = manager.bind(DatabasesMapConfig); const recordConfig = manager.bind(DatabasesRecordConfig); // Map iteration const mapKeys = Array.from(mapConfig.connections.keys()); const mapValues = Array.from(mapConfig.connections.values()); const mapEntries = Array.from(mapConfig.connections.entries()); // Record iteration const recordKeys = Object.keys(recordConfig.connections); const recordValues = Object.values(recordConfig.connections); const recordEntries = Object.entries(recordConfig.connections); // Both should have same keys expect(mapKeys).toEqual(recordKeys); expect(mapKeys).toEqual(['conn1', 'conn2', 'conn3']); // Both should have same number of values expect(mapValues).toHaveLength(3); expect(recordValues).toHaveLength(3); // Both should have same number of entries expect(mapEntries).toHaveLength(3); expect(recordEntries).toHaveLength(3); // Verify data is identical expect(mapValues[0].host).toBe(recordValues[0].host); expect(mapValues[1].port).toBe(recordValues[1].port); expect(mapValues[2].ssl).toBe(recordValues[2].ssl); }); }); });