517 lines
14 KiB
TypeScript
517 lines
14 KiB
TypeScript
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<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]);
|
|
});
|
|
});
|
|
});
|