stock-bot/libs/core/config/test/file.loader.test.ts
2025-06-26 16:12:27 -04:00

436 lines
12 KiB
TypeScript

import { existsSync, readFileSync } from 'fs';
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import { ConfigLoaderError } from '../src/errors';
import { FileLoader } from '../src/loaders/file.loader';
// 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<string, unknown> = {};
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);
});
});
});