import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'; import { existsSync, readFileSync } from 'fs'; import { FileLoader } from '../src/loaders/file.loader'; import { ConfigLoaderError } from '../src/errors'; // Mock fs module mock.module('fs', () => ({ existsSync: mock(() => false), readFileSync: mock(() => '') })); describe('FileLoader', () => { let loader: FileLoader; const configPath = '/app/config'; const environment = 'development'; beforeEach(() => { // Reset mocks (existsSync as any).mockReset(); (readFileSync as any).mockReset(); }); describe('constructor', () => { it('should have medium priority', () => { loader = new FileLoader(configPath, environment); expect(loader.priority).toBe(50); }); it('should store config path and environment', () => { loader = new FileLoader('/custom/path', 'production'); expect(loader).toBeDefined(); }); }); describe('load', () => { it('should load only default.json when environment file does not exist', () => { const defaultConfig = { name: 'app', port: 3000, features: ['auth', 'cache'], }; (existsSync as any).mockImplementation((path: string) => { return path.endsWith('default.json'); }); (readFileSync as any).mockImplementation((path: string) => { if (path.endsWith('default.json')) { return JSON.stringify(defaultConfig); } return '{}'; }); loader = new FileLoader(configPath, environment); const config = loader.load(); expect(existsSync).toHaveBeenCalledWith('/app/config/default.json'); expect(existsSync).toHaveBeenCalledWith('/app/config/development.json'); expect(readFileSync).toHaveBeenCalledWith('/app/config/default.json', 'utf-8'); expect(config).toEqual(defaultConfig); }); it('should load and merge default and environment configs', () => { const defaultConfig = { name: 'app', port: 3000, database: { host: 'localhost', port: 5432, }, }; const devConfig = { port: 3001, database: { host: 'dev-db', }, debug: true, }; (existsSync as any).mockReturnValue(true); (readFileSync as any).mockImplementation((path: string) => { if (path.endsWith('default.json')) { return JSON.stringify(defaultConfig); } if (path.endsWith('development.json')) { return JSON.stringify(devConfig); } return '{}'; }); loader = new FileLoader(configPath, environment); const config = loader.load(); expect(config).toEqual({ name: 'app', port: 3001, // Overridden by dev config database: { host: 'dev-db', // Overridden by dev config port: 5432, // Preserved from default }, debug: true, // Added by dev config }); }); it('should handle production environment', () => { const defaultConfig = { name: 'app', debug: true }; const prodConfig = { debug: false, secure: true }; (existsSync as any).mockReturnValue(true); (readFileSync as any).mockImplementation((path: string) => { if (path.endsWith('default.json')) { return JSON.stringify(defaultConfig); } if (path.endsWith('production.json')) { return JSON.stringify(prodConfig); } return '{}'; }); loader = new FileLoader(configPath, 'production'); const config = loader.load(); expect(existsSync).toHaveBeenCalledWith('/app/config/production.json'); expect(config).toEqual({ name: 'app', debug: false, secure: true, }); }); it('should return empty object when no config files exist', () => { (existsSync as any).mockReturnValue(false); loader = new FileLoader(configPath, environment); const config = loader.load(); expect(config).toEqual({}); expect(readFileSync).not.toHaveBeenCalled(); }); it('should throw ConfigLoaderError on JSON parse error', () => { (existsSync as any).mockReturnValue(true); (readFileSync as any).mockReturnValue('{ invalid json'); loader = new FileLoader(configPath, environment); expect(() => loader.load()).toThrow(ConfigLoaderError); expect(() => loader.load()).toThrow('Failed to load configuration files'); }); it('should throw ConfigLoaderError on file read error', () => { (existsSync as any).mockReturnValue(true); (readFileSync as any).mockImplementation(() => { throw new Error('Permission denied'); }); loader = new FileLoader(configPath, environment); expect(() => loader.load()).toThrow(ConfigLoaderError); expect(() => loader.load()).toThrow('Failed to load configuration files'); }); it('should handle different config paths', () => { const customPath = '/custom/config/dir'; const config = { custom: true }; (existsSync as any).mockImplementation((path: string) => { return path.startsWith(customPath); }); (readFileSync as any).mockReturnValue(JSON.stringify(config)); loader = new FileLoader(customPath, environment); loader.load(); expect(existsSync).toHaveBeenCalledWith(`${customPath}/default.json`); expect(existsSync).toHaveBeenCalledWith(`${customPath}/development.json`); }); }); describe('deepMerge', () => { it('should handle null and undefined values', () => { const defaultConfig = { a: 'value', b: null, c: 'default', }; const envConfig = { a: null, b: 'updated', // Note: undefined values are not preserved in JSON }; (existsSync as any).mockReturnValue(true); (readFileSync as any).mockImplementation((path: string) => { if (path.endsWith('default.json')) { return JSON.stringify(defaultConfig); } if (path.endsWith('development.json')) { return JSON.stringify(envConfig); } return '{}'; }); loader = new FileLoader(configPath, environment); const config = loader.load(); expect(config).toEqual({ a: null, b: 'updated', c: 'default', // Preserved from default since envConfig doesn't have 'c' }); }); it('should handle arrays correctly', () => { const defaultConfig = { items: [1, 2, 3], features: ['auth', 'cache'], }; const envConfig = { items: [4, 5], features: ['auth', 'cache', 'search'], }; (existsSync as any).mockReturnValue(true); (readFileSync as any).mockImplementation((path: string) => { if (path.endsWith('default.json')) { return JSON.stringify(defaultConfig); } if (path.endsWith('development.json')) { return JSON.stringify(envConfig); } return '{}'; }); loader = new FileLoader(configPath, environment); const config = loader.load(); // Arrays should be replaced, not merged expect(config).toEqual({ items: [4, 5], features: ['auth', 'cache', 'search'], }); }); it('should handle deeply nested objects', () => { const defaultConfig = { level1: { level2: { level3: { a: 1, b: 2, }, c: 3, }, d: 4, }, }; const envConfig = { level1: { level2: { level3: { b: 22, e: 5, }, f: 6, }, }, }; (existsSync as any).mockReturnValue(true); (readFileSync as any).mockImplementation((path: string) => { if (path.endsWith('default.json')) { return JSON.stringify(defaultConfig); } if (path.endsWith('development.json')) { return JSON.stringify(envConfig); } return '{}'; }); loader = new FileLoader(configPath, environment); const config = loader.load(); expect(config).toEqual({ level1: { level2: { level3: { a: 1, b: 22, e: 5, }, c: 3, f: 6, }, d: 4, }, }); }); it('should handle Date and RegExp objects', () => { // Dates and RegExps in JSON are serialized as strings const defaultConfig = { createdAt: '2023-01-01T00:00:00.000Z', pattern: '/test/gi', }; const envConfig = { updatedAt: '2023-06-01T00:00:00.000Z', }; (existsSync as any).mockReturnValue(true); (readFileSync as any).mockImplementation((path: string) => { if (path.endsWith('default.json')) { return JSON.stringify(defaultConfig); } if (path.endsWith('development.json')) { return JSON.stringify(envConfig); } return '{}'; }); loader = new FileLoader(configPath, environment); const config = loader.load(); expect(config).toEqual({ createdAt: '2023-01-01T00:00:00.000Z', pattern: '/test/gi', updatedAt: '2023-06-01T00:00:00.000Z', }); }); }); describe('edge cases', () => { it('should handle empty JSON files', () => { (existsSync as any).mockReturnValue(true); (readFileSync as any).mockReturnValue('{}'); loader = new FileLoader(configPath, environment); const config = loader.load(); expect(config).toEqual({}); }); it('should handle whitespace in JSON files', () => { const config = { test: 'value' }; (existsSync as any).mockReturnValue(true); (readFileSync as any).mockReturnValue(` \n\t${JSON.stringify(config)}\n `); loader = new FileLoader(configPath, environment); const result = loader.load(); expect(result).toEqual(config); }); it('should handle very large config files', () => { const largeConfig: Record = {}; for (let i = 0; i < 1000; i++) { largeConfig[`key_${i}`] = { value: i, nested: { data: `data_${i}` }, }; } (existsSync as any).mockReturnValue(true); (readFileSync as any).mockReturnValue(JSON.stringify(largeConfig)); loader = new FileLoader(configPath, environment); const config = loader.load(); expect(Object.keys(config)).toHaveLength(1000); expect(config.key_500).toEqual({ value: 500, nested: { data: 'data_500' }, }); }); it('should handle unicode in config values', () => { const config = { emoji: '🚀', chinese: '你好', arabic: 'مرحبا', }; (existsSync as any).mockReturnValue(true); (readFileSync as any).mockReturnValue(JSON.stringify(config)); loader = new FileLoader(configPath, environment); const result = loader.load(); expect(result).toEqual(config); }); it('should handle config with circular reference patterns', () => { // JSON doesn't support circular references, but we can have // patterns that look circular const config = { parent: { child: { ref: 'parent', }, }, }; (existsSync as any).mockReturnValue(true); (readFileSync as any).mockReturnValue(JSON.stringify(config)); loader = new FileLoader(configPath, environment); const result = loader.load(); expect(result).toEqual(config); }); it('should handle numeric string keys', () => { const config = { '123': 'numeric key', '456': { nested: 'value' }, }; (existsSync as any).mockReturnValue(true); (readFileSync as any).mockReturnValue(JSON.stringify(config)); loader = new FileLoader(configPath, environment); const result = loader.load(); expect(result).toEqual(config); }); }); });