import { beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; import { z } from 'zod'; import { ConfigManager } from '../src/config-manager'; import { ConfigError, ConfigValidationError } from '../src/errors'; import type { ConfigLoader, Environment } from '../src/types'; // Mock the logger mock.module('@stock-bot/logger', () => ({ getLogger: () => ({ info: mock(() => {}), error: mock(() => {}), warn: mock(() => {}), debug: mock(() => {}), }), })); // Mock loader class class MockLoader implements ConfigLoader { constructor( private data: Record, public priority: number = 0 ) {} load(): Record { return this.data; } } describe('ConfigManager', () => { let manager: ConfigManager; beforeEach(() => { // Reset environment delete process.env.NODE_ENV; }); describe('constructor', () => { it('should initialize with default loaders', () => { manager = new ConfigManager(); expect(manager).toBeDefined(); expect(manager.getEnvironment()).toBe('development'); }); it('should detect environment from NODE_ENV', () => { process.env.NODE_ENV = 'production'; manager = new ConfigManager(); expect(manager.getEnvironment()).toBe('production'); }); it('should handle various environment values', () => { const envMap: Record = { production: 'production', prod: 'production', test: 'test', development: 'development', dev: 'development', unknown: 'development', }; for (const [input, expected] of Object.entries(envMap)) { process.env.NODE_ENV = input; manager = new ConfigManager(); expect(manager.getEnvironment()).toBe(expected); } }); it('should use custom loaders when provided', () => { const customLoader = new MockLoader({ custom: 'data' }); manager = new ConfigManager({ loaders: [customLoader], }); manager.initialize(); expect(manager.get()).toEqual({ custom: 'data', environment: 'development' }); }); it('should use custom environment when provided', () => { manager = new ConfigManager({ environment: 'test', }); expect(manager.getEnvironment()).toBe('test'); }); }); describe('initialize', () => { it('should load and merge configurations', () => { const loader1 = new MockLoader({ a: 1, b: { c: 2 } }, 1); const loader2 = new MockLoader({ b: { d: 3 }, e: 4 }, 2); manager = new ConfigManager({ loaders: [loader1, loader2], }); const config = manager.initialize(); expect(config).toEqual({ a: 1, b: { c: 2, d: 3 }, e: 4, environment: 'development', }); }); it('should return cached config on subsequent calls', () => { const loader = new MockLoader({ test: 'data' }); const loadSpy = spyOn(loader, 'load'); manager = new ConfigManager({ loaders: [loader], }); const config1 = manager.initialize(); const config2 = manager.initialize(); expect(config1).toBe(config2); expect(loadSpy).toHaveBeenCalledTimes(1); }); it('should validate config with schema', () => { const schema = z.object({ name: z.string(), port: z.number(), environment: z.string(), }); const loader = new MockLoader({ name: 'test-app', port: 3000, }); manager = new ConfigManager({ loaders: [loader], }); const config = manager.initialize(schema); expect(config).toEqual({ name: 'test-app', port: 3000, environment: 'development', }); }); it('should throw validation error for invalid config', () => { const schema = z.object({ name: z.string(), port: z.number(), }); const loader = new MockLoader({ name: 'test-app', port: 'invalid', // Should be number }); manager = new ConfigManager({ loaders: [loader], }); expect(() => manager.initialize(schema)).toThrow(ConfigValidationError); }); it('should handle empty loaders', () => { manager = new ConfigManager({ loaders: [], }); const config = manager.initialize(); expect(config).toEqual({ environment: 'development' }); }); it('should ignore loaders that return empty config', () => { const loader1 = new MockLoader({}); const loader2 = new MockLoader({ data: 'value' }); manager = new ConfigManager({ loaders: [loader1, loader2], }); const config = manager.initialize(); expect(config).toEqual({ data: 'value', environment: 'development' }); }); it('should respect loader priority order', () => { const loader1 = new MockLoader({ value: 'first' }, 1); const loader2 = new MockLoader({ value: 'second' }, 2); const loader3 = new MockLoader({ value: 'third' }, 0); manager = new ConfigManager({ loaders: [loader1, loader2, loader3], }); const config = manager.initialize(); // Priority order: 0, 1, 2 (lowest to highest) // So 'second' should win expect(config.value).toBe('second'); }); it('should handle validation errors with detailed error info', () => { const schema = z.object({ name: z.string(), port: z.number().min(1).max(65535), features: z.object({ enabled: z.boolean(), }), }); const loader = new MockLoader({ name: 123, // Should be string port: 99999, // Out of range features: { enabled: 'yes', // Should be boolean }, }); manager = new ConfigManager({ loaders: [loader], }); try { manager.initialize(schema); expect(true).toBe(false); // Should not reach here } catch (error) { expect(error).toBeInstanceOf(ConfigValidationError); const validationError = error as ConfigValidationError; expect(validationError.errors).toBeDefined(); expect(validationError.errors.length).toBeGreaterThan(0); } }); }); describe('get', () => { it('should return config after initialization', () => { const loader = new MockLoader({ test: 'data' }); manager = new ConfigManager({ loaders: [loader] }); manager.initialize(); expect(manager.get()).toEqual({ test: 'data', environment: 'development' }); }); it('should throw error if not initialized', () => { manager = new ConfigManager(); expect(() => manager.get()).toThrow(ConfigError); expect(() => manager.get()).toThrow('Configuration not initialized'); }); }); describe('getValue', () => { beforeEach(() => { const loader = new MockLoader({ database: { host: 'localhost', port: 5432, credentials: { username: 'admin', password: 'secret', }, }, cache: { enabled: true, ttl: 3600, }, }); manager = new ConfigManager({ loaders: [loader] }); manager.initialize(); }); it('should get value by path', () => { expect(manager.getValue('database.host')).toBe('localhost'); expect(manager.getValue('database.port')).toBe(5432); expect(manager.getValue('cache.enabled')).toBe(true); }); it('should get nested values', () => { expect(manager.getValue('database.credentials.username')).toBe('admin'); expect(manager.getValue('database.credentials.password')).toBe('secret'); }); it('should throw error for non-existent path', () => { expect(() => manager.getValue('nonexistent.path')).toThrow(ConfigError); expect(() => manager.getValue('nonexistent.path')).toThrow('Configuration key not found'); }); it('should handle top-level values', () => { expect(manager.getValue('database')).toEqual({ host: 'localhost', port: 5432, credentials: { username: 'admin', password: 'secret', }, }); }); }); describe('has', () => { beforeEach(() => { const loader = new MockLoader({ database: { host: 'localhost' }, cache: { enabled: true }, }); manager = new ConfigManager({ loaders: [loader] }); manager.initialize(); }); it('should return true for existing paths', () => { expect(manager.has('database')).toBe(true); expect(manager.has('database.host')).toBe(true); expect(manager.has('cache.enabled')).toBe(true); }); it('should return false for non-existent paths', () => { expect(manager.has('nonexistent')).toBe(false); expect(manager.has('database.port')).toBe(false); expect(manager.has('cache.ttl')).toBe(false); }); }); describe('set', () => { beforeEach(() => { const loader = new MockLoader({ app: { name: 'test', version: '1.0.0' }, port: 3000, }); manager = new ConfigManager({ loaders: [loader] }); manager.initialize(); }); it('should update configuration values', () => { manager.set({ port: 4000 }); expect(manager.get().port).toBe(4000); manager.set({ app: { version: '2.0.0' } }); expect(manager.get().app.version).toBe('2.0.0'); expect(manager.get().app.name).toBe('test'); // Unchanged }); it('should validate updates when schema is present', () => { const schema = z.object({ app: z.object({ name: z.string(), version: z.string(), }), port: z.number().min(1000).max(9999), environment: z.string(), }); manager = new ConfigManager({ loaders: [new MockLoader({ app: { name: 'test', version: '1.0.0' }, port: 3000 })], }); manager.initialize(schema); // Valid update manager.set({ port: 4000 }); expect(manager.get().port).toBe(4000); // Invalid update expect(() => manager.set({ port: 99999 })).toThrow(ConfigValidationError); }); it('should throw error if not initialized', () => { const newManager = new ConfigManager(); expect(() => newManager.set({ test: 'value' })).toThrow(ConfigError); }); }); describe('reset', () => { it('should clear configuration', () => { const loader = new MockLoader({ test: 'data' }); manager = new ConfigManager({ loaders: [loader] }); manager.initialize(); expect(manager.get()).toBeDefined(); manager.reset(); expect(() => manager.get()).toThrow(ConfigError); }); }); describe('validate', () => { it('should validate current config against schema', () => { const loader = new MockLoader({ name: 'test-app', port: 3000, }); manager = new ConfigManager({ loaders: [loader] }); manager.initialize(); const schema = z.object({ name: z.string(), port: z.number(), environment: z.string(), }); const validated = manager.validate(schema); expect(validated).toEqual({ name: 'test-app', port: 3000, environment: 'development', }); }); it('should throw if validation fails', () => { const loader = new MockLoader({ name: 'test-app', port: 'invalid', }); manager = new ConfigManager({ loaders: [loader] }); manager.initialize(); const schema = z.object({ name: z.string(), port: z.number(), }); expect(() => manager.validate(schema)).toThrow(); }); }); describe('deepMerge', () => { it('should handle circular references', () => { const obj1: any = { a: 1 }; const obj2: any = { b: 2 }; obj1.circular = obj1; // Create circular reference obj2.ref = obj1; const loader1 = new MockLoader(obj1); const loader2 = new MockLoader(obj2); manager = new ConfigManager({ loaders: [loader1, loader2] }); // Should not throw on circular reference const config = manager.initialize(); expect(config.a).toBe(1); expect(config.b).toBe(2); }); it('should handle null and undefined values', () => { const loader1 = new MockLoader({ a: null, b: 'value' }); const loader2 = new MockLoader({ a: 'overridden', c: undefined }); manager = new ConfigManager({ loaders: [loader1, loader2] }); const config = manager.initialize(); expect(config.a).toBe('overridden'); expect(config.b).toBe('value'); expect(config.c).toBeUndefined(); }); it('should handle Date and RegExp objects', () => { const date = new Date('2024-01-01'); const regex = /test/gi; const loader = new MockLoader({ date: date, pattern: regex, nested: { date: date, pattern: regex, }, }); manager = new ConfigManager({ loaders: [loader] }); const config = manager.initialize(); expect(config.date).toBe(date); expect(config.pattern).toBe(regex); expect(config.nested.date).toBe(date); expect(config.nested.pattern).toBe(regex); }); it('should handle arrays without merging', () => { const loader1 = new MockLoader({ items: [1, 2, 3] }); const loader2 = new MockLoader({ items: [4, 5, 6] }); manager = new ConfigManager({ loaders: [loader1, loader2] }); const config = manager.initialize(); // Arrays should be replaced, not merged expect(config.items).toEqual([4, 5, 6]); }); }); });