stock-bot/libs/core/config/test/config-manager.test.ts

515 lines
No EOL
15 KiB
TypeScript

import { describe, it, expect, beforeEach, 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<string, unknown>,
public priority: number = 0
) {}
load(): Record<string, unknown> {
return this.data;
}
}
describe('ConfigManager', () => {
let manager: ConfigManager<any>;
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<string, Environment> = {
'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('createTypedGetter', () => {
it('should create a typed getter function', () => {
const loader = new MockLoader({
database: {
host: 'localhost',
port: 5432,
},
});
manager = new ConfigManager({ loaders: [loader] });
manager.initialize();
const schema = z.object({
database: z.object({
host: z.string(),
port: z.number(),
}),
environment: z.string(),
});
const getConfig = manager.createTypedGetter(schema);
const config = getConfig();
expect(config.database.host).toBe('localhost');
expect(config.database.port).toBe(5432);
expect(config.environment).toBe('development');
});
});
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]);
});
});
});