added cli-covarage tool and fixed more tests
This commit is contained in:
parent
b63e58784c
commit
b845a8eade
57 changed files with 11917 additions and 295 deletions
633
libs/core/config/test/env.loader.test.ts
Normal file
633
libs/core/config/test/env.loader.test.ts
Normal file
|
|
@ -0,0 +1,633 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test';
|
||||
import { readFileSync } from 'fs';
|
||||
import { EnvLoader } from '../src/loaders/env.loader';
|
||||
import { ConfigLoaderError } from '../src/errors';
|
||||
|
||||
// Mock fs module
|
||||
mock.module('fs', () => ({
|
||||
readFileSync: mock(() => '')
|
||||
}));
|
||||
|
||||
describe('EnvLoader', () => {
|
||||
let loader: EnvLoader;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear environment
|
||||
for (const key in process.env) {
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original environment
|
||||
for (const key in process.env) {
|
||||
delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, originalEnv);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should have highest priority', () => {
|
||||
loader = new EnvLoader();
|
||||
expect(loader.priority).toBe(100);
|
||||
});
|
||||
|
||||
it('should accept prefix and options', () => {
|
||||
loader = new EnvLoader('APP_', {
|
||||
convertCase: true,
|
||||
parseJson: false,
|
||||
});
|
||||
expect(loader).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('load', () => {
|
||||
it('should load environment variables without prefix', () => {
|
||||
process.env.TEST_VAR = 'test_value';
|
||||
process.env.ANOTHER_VAR = 'another_value';
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
// Environment variables with underscores are converted to nested structure
|
||||
interface ExpectedConfig {
|
||||
test?: { var: string };
|
||||
another?: { var: string };
|
||||
}
|
||||
expect((config as ExpectedConfig).test?.var).toBe('test_value');
|
||||
expect((config as ExpectedConfig).another?.var).toBe('another_value');
|
||||
});
|
||||
|
||||
it('should filter by prefix', () => {
|
||||
process.env.APP_NAME = 'myapp';
|
||||
process.env.APP_VERSION = '1.0.0';
|
||||
process.env.OTHER_VAR = 'ignored';
|
||||
|
||||
loader = new EnvLoader('APP_');
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.NAME).toBe('myapp');
|
||||
expect(config.VERSION).toBe('1.0.0');
|
||||
expect(config.OTHER_VAR).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should parse values by default', () => {
|
||||
process.env.BOOL_TRUE = 'true';
|
||||
process.env.BOOL_FALSE = 'false';
|
||||
process.env.NUMBER = '42';
|
||||
process.env.STRING = 'hello';
|
||||
process.env.NULL_VAL = 'null';
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
// Values are nested based on underscores
|
||||
expect((config as any).bool?.true).toBe(true);
|
||||
expect((config as any).bool?.false).toBe(false);
|
||||
expect((config as any).NUMBER).toBe(42); // No underscore, keeps original case
|
||||
expect((config as any).STRING).toBe('hello'); // No underscore, keeps original case
|
||||
expect((config as any).null?.val).toBeNull();
|
||||
});
|
||||
|
||||
it('should parse JSON values', () => {
|
||||
process.env.JSON_ARRAY = '["a","b","c"]';
|
||||
process.env.JSON_OBJECT = '{"key":"value","num":123}';
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
// JSON values are parsed and nested
|
||||
expect((config as any).json?.array).toEqual(['a', 'b', 'c']);
|
||||
expect((config as any).json?.object).toEqual({ key: 'value', num: 123 });
|
||||
});
|
||||
|
||||
it('should disable parsing when parseValues is false', () => {
|
||||
process.env.VALUE = 'true';
|
||||
|
||||
loader = new EnvLoader('', { parseValues: false, parseJson: false });
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.VALUE).toBe('true'); // String, not boolean
|
||||
});
|
||||
|
||||
it('should convert to camelCase when enabled', () => {
|
||||
process.env.MY_VAR_NAME = 'value';
|
||||
process.env.ANOTHER_TEST_VAR = 'test';
|
||||
|
||||
loader = new EnvLoader('', { convertCase: true });
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.myVarName).toBe('value');
|
||||
expect(config.anotherTestVar).toBe('test');
|
||||
});
|
||||
|
||||
it('should handle nested delimiter', () => {
|
||||
process.env.APP__NAME = 'myapp';
|
||||
process.env.APP__CONFIG__PORT = '3000';
|
||||
|
||||
loader = new EnvLoader('', { nestedDelimiter: '__' });
|
||||
const config = loader.load();
|
||||
|
||||
expect(config).toEqual({
|
||||
APP: {
|
||||
NAME: 'myapp',
|
||||
CONFIG: {
|
||||
PORT: 3000
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert underscores to nested structure by default', () => {
|
||||
process.env.DATABASE_HOST = 'localhost';
|
||||
process.env.DATABASE_PORT = '5432';
|
||||
process.env.DATABASE_CREDENTIALS_USER = 'admin';
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect(config).toEqual({
|
||||
database: {
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
credentials: {
|
||||
user: 'admin'
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle single keys without underscores', () => {
|
||||
process.env.PORT = '3000';
|
||||
process.env.NAME = 'app';
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
// Single keys without underscores keep their original case
|
||||
expect((config as any).PORT).toBe(3000);
|
||||
// NAME has a special mapping to 'name'
|
||||
expect((config as any).name).toBe('app');
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider mappings', () => {
|
||||
it('should map WebShare environment variables', () => {
|
||||
process.env.WEBSHARE_API_KEY = 'secret-key';
|
||||
process.env.WEBSHARE_ENABLED = 'true';
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.webshare).toEqual({
|
||||
apiKey: 'secret-key',
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should map EOD provider variables', () => {
|
||||
process.env.EOD_API_KEY = 'eod-key';
|
||||
process.env.EOD_BASE_URL = 'https://api.eod.com';
|
||||
process.env.EOD_TIER = 'premium';
|
||||
process.env.EOD_ENABLED = 'true';
|
||||
process.env.EOD_PRIORITY = '1';
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.providers).toEqual({
|
||||
eod: {
|
||||
apiKey: 'eod-key',
|
||||
baseUrl: 'https://api.eod.com',
|
||||
tier: 'premium',
|
||||
enabled: true,
|
||||
priority: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should map Interactive Brokers variables', () => {
|
||||
process.env.IB_GATEWAY_HOST = 'localhost';
|
||||
process.env.IB_GATEWAY_PORT = '7497';
|
||||
process.env.IB_CLIENT_ID = '1';
|
||||
process.env.IB_ENABLED = 'false';
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.providers).toEqual({
|
||||
ib: {
|
||||
gateway: {
|
||||
host: 'localhost',
|
||||
port: 7497,
|
||||
clientId: 1,
|
||||
},
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should map log configuration', () => {
|
||||
process.env.LOG_LEVEL = 'debug';
|
||||
process.env.LOG_FORMAT = 'json';
|
||||
process.env.LOG_HIDE_OBJECT = 'true';
|
||||
process.env.LOG_LOKI_ENABLED = 'true';
|
||||
process.env.LOG_LOKI_HOST = 'loki.example.com';
|
||||
process.env.LOG_LOKI_PORT = '3100';
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.log).toEqual({
|
||||
level: 'debug',
|
||||
format: 'json',
|
||||
hideObject: true,
|
||||
loki: {
|
||||
enabled: true,
|
||||
host: 'loki.example.com',
|
||||
port: 3100,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not apply provider mappings when prefix is set', () => {
|
||||
process.env.APP_WEBSHARE_API_KEY = 'key';
|
||||
|
||||
loader = new EnvLoader('APP_');
|
||||
const config = loader.load();
|
||||
|
||||
// Should not map to webshare.apiKey, but still converts underscores to nested
|
||||
expect((config as any).webshare?.api?.key).toBe('key');
|
||||
expect((config as any).webshare?.apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not apply provider mappings when convertCase is true', () => {
|
||||
process.env.WEBSHARE_API_KEY = 'key';
|
||||
|
||||
loader = new EnvLoader('', { convertCase: true });
|
||||
const config = loader.load();
|
||||
|
||||
// Should convert to camelCase instead of mapping
|
||||
expect(config.webshareApiKey).toBe('key');
|
||||
expect(config.webshare).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadEnvFile', () => {
|
||||
it('should load .env file', () => {
|
||||
const envContent = `
|
||||
# Comment line
|
||||
TEST_VAR=value1
|
||||
ANOTHER_VAR="quoted value"
|
||||
NUMBER_VAR=42
|
||||
|
||||
# Another comment
|
||||
BOOL_VAR=true
|
||||
`;
|
||||
|
||||
(readFileSync as any).mockReturnValue(envContent);
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect(process.env.TEST_VAR).toBe('value1');
|
||||
expect(process.env.ANOTHER_VAR).toBe('quoted value');
|
||||
expect((config as any).test?.var).toBe('value1');
|
||||
expect((config as any).another?.var).toBe('quoted value');
|
||||
expect((config as any).number?.var).toBe(42);
|
||||
expect((config as any).bool?.var).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle single quoted values', () => {
|
||||
const envContent = `VAR='single quoted'`;
|
||||
(readFileSync as any).mockReturnValue(envContent);
|
||||
|
||||
loader = new EnvLoader();
|
||||
loader.load();
|
||||
|
||||
expect(process.env.VAR).toBe('single quoted');
|
||||
});
|
||||
|
||||
it('should skip invalid lines', () => {
|
||||
const envContent = `
|
||||
VALID=value
|
||||
INVALID_LINE_WITHOUT_EQUALS
|
||||
ANOTHER_VALID=value2
|
||||
=NO_KEY
|
||||
KEY_WITHOUT_VALUE=
|
||||
`;
|
||||
|
||||
(readFileSync as any).mockReturnValue(envContent);
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect((config as any).VALID).toBe('value');
|
||||
expect((config as any).another?.valid).toBe('value2');
|
||||
expect((config as any).key?.without?.value).toBe(''); // Empty string
|
||||
});
|
||||
|
||||
it('should not override existing environment variables', () => {
|
||||
process.env.EXISTING = 'original';
|
||||
|
||||
const envContent = `EXISTING=from_file`;
|
||||
(readFileSync as any).mockReturnValue(envContent);
|
||||
|
||||
loader = new EnvLoader();
|
||||
loader.load();
|
||||
|
||||
expect(process.env.EXISTING).toBe('original');
|
||||
});
|
||||
|
||||
it('should handle file not found gracefully', () => {
|
||||
(readFileSync as any).mockImplementation(() => {
|
||||
const error: any = new Error('File not found');
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
});
|
||||
|
||||
loader = new EnvLoader();
|
||||
// Should not throw
|
||||
expect(() => loader.load()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should warn on other file errors', () => {
|
||||
const consoleWarnSpy = spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
(readFileSync as any).mockImplementation(() => {
|
||||
const error: any = new Error('Permission denied');
|
||||
error.code = 'EACCES';
|
||||
throw error;
|
||||
});
|
||||
|
||||
loader = new EnvLoader();
|
||||
loader.load();
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should try multiple env file paths', () => {
|
||||
const readFileSpy = readFileSync as any;
|
||||
readFileSpy.mockImplementation((path: string) => {
|
||||
if (path === '../../.env') {
|
||||
return 'FOUND=true';
|
||||
}
|
||||
const error: any = new Error('Not found');
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
});
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect(readFileSpy).toHaveBeenCalledWith('./.env', 'utf-8');
|
||||
expect(readFileSpy).toHaveBeenCalledWith('../.env', 'utf-8');
|
||||
expect(readFileSpy).toHaveBeenCalledWith('../../.env', 'utf-8');
|
||||
expect((config as any).FOUND).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty values', () => {
|
||||
process.env.EMPTY = '';
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect((config as any).EMPTY).toBe('');
|
||||
});
|
||||
|
||||
it('should handle very long values', () => {
|
||||
const longValue = 'a'.repeat(10000);
|
||||
process.env.LONG = longValue;
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect((config as any).LONG).toBe(longValue);
|
||||
});
|
||||
|
||||
it('should handle special characters in values', () => {
|
||||
process.env.SPECIAL = '!@#$%^&*()_+-=[]{}|;:,.<>?';
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect((config as any).SPECIAL).toBe('!@#$%^&*()_+-=[]{}|;:,.<>?');
|
||||
});
|
||||
|
||||
it('should handle readonly properties gracefully', () => {
|
||||
// Simulate readonly property scenario
|
||||
const config = { readonly: 'original' };
|
||||
Object.defineProperty(config, 'readonly', {
|
||||
writable: false,
|
||||
configurable: false
|
||||
});
|
||||
|
||||
process.env.READONLY = 'new_value';
|
||||
|
||||
loader = new EnvLoader();
|
||||
// Should not throw when trying to set readonly properties
|
||||
expect(() => loader.load()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should parse undefined string as undefined', () => {
|
||||
process.env.UNDEF = 'undefined';
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect((config as any).UNDEF).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle number-like strings that should remain strings', () => {
|
||||
process.env.ZIP_CODE = '00123'; // Leading zeros
|
||||
process.env.PHONE = '+1234567890';
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect((config as any).zip?.code).toBe('00123'); // Should remain string
|
||||
expect((config as any).PHONE).toBe('+1234567890'); // Should remain string
|
||||
});
|
||||
|
||||
it('should handle deeply nested structures', () => {
|
||||
process.env.A_B_C_D_E_F = 'deep';
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.a).toEqual({
|
||||
b: {
|
||||
c: {
|
||||
d: {
|
||||
e: {
|
||||
f: 'deep'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw ConfigLoaderError on unexpected error', () => {
|
||||
// Mock an error during load
|
||||
const originalEntries = Object.entries;
|
||||
Object.entries = () => {
|
||||
throw new Error('Unexpected error');
|
||||
};
|
||||
|
||||
loader = new EnvLoader();
|
||||
|
||||
try {
|
||||
expect(() => loader.load()).toThrow(ConfigLoaderError);
|
||||
expect(() => loader.load()).toThrow('Failed to load environment variables');
|
||||
} finally {
|
||||
Object.entries = originalEntries;
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle empty path in setNestedValue', () => {
|
||||
loader = new EnvLoader();
|
||||
const config = {};
|
||||
|
||||
// Test private method indirectly by setting an env var with special key
|
||||
process.env.EMPTY_PATH_TEST = 'value';
|
||||
|
||||
// Force an empty path scenario through provider mapping
|
||||
const privateLoader = loader as any;
|
||||
const result = privateLoader.setNestedValue(config, [], 'value');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle QuoteMedia provider mappings', () => {
|
||||
process.env.QM_USERNAME = 'testuser';
|
||||
process.env.QM_PASSWORD = 'testpass';
|
||||
process.env.QM_BASE_URL = 'https://api.quotemedia.com';
|
||||
process.env.QM_WEBMASTER_ID = '12345';
|
||||
process.env.QM_ENABLED = 'true';
|
||||
process.env.QM_PRIORITY = '5';
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.providers).toEqual(expect.objectContaining({
|
||||
qm: {
|
||||
username: 'testuser',
|
||||
password: 'testpass',
|
||||
baseUrl: 'https://api.quotemedia.com',
|
||||
webmasterId: '12345',
|
||||
enabled: true,
|
||||
priority: 5,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it('should handle Yahoo Finance provider mappings', () => {
|
||||
process.env.YAHOO_BASE_URL = 'https://finance.yahoo.com';
|
||||
process.env.YAHOO_COOKIE_JAR = '/path/to/cookies';
|
||||
process.env.YAHOO_CRUMB = 'abc123';
|
||||
process.env.YAHOO_ENABLED = 'false';
|
||||
process.env.YAHOO_PRIORITY = '10';
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.providers).toEqual(expect.objectContaining({
|
||||
yahoo: {
|
||||
baseUrl: 'https://finance.yahoo.com',
|
||||
cookieJar: '/path/to/cookies',
|
||||
crumb: 'abc123',
|
||||
enabled: false,
|
||||
priority: 10,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
it('should handle additional provider mappings', () => {
|
||||
process.env.WEBSHARE_API_URL = 'https://api.webshare.io';
|
||||
process.env.IB_ACCOUNT = 'DU123456';
|
||||
process.env.IB_MARKET_DATA_TYPE = '1';
|
||||
process.env.IB_PRIORITY = '3';
|
||||
process.env.VERSION = '1.2.3';
|
||||
process.env.DEBUG_MODE = 'true';
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
expect(config.webshare).toEqual(expect.objectContaining({
|
||||
apiUrl: 'https://api.webshare.io',
|
||||
}));
|
||||
expect(config.providers?.ib).toEqual(expect.objectContaining({
|
||||
account: 'DU123456',
|
||||
marketDataType: '1',
|
||||
priority: 3,
|
||||
}));
|
||||
expect(config.version).toBe('1.2.3');
|
||||
expect(config.debug).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle all .env file paths exhausted', () => {
|
||||
const readFileSpy = readFileSync as any;
|
||||
readFileSpy.mockImplementation((path: string) => {
|
||||
const error: any = new Error('Not found');
|
||||
error.code = 'ENOENT';
|
||||
throw error;
|
||||
});
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
// Should try all paths
|
||||
expect(readFileSpy).toHaveBeenCalledWith('./.env', 'utf-8');
|
||||
expect(readFileSpy).toHaveBeenCalledWith('../.env', 'utf-8');
|
||||
expect(readFileSpy).toHaveBeenCalledWith('../../.env', 'utf-8');
|
||||
expect(readFileSpy).toHaveBeenCalledWith('../../../.env', 'utf-8');
|
||||
|
||||
// Should return empty config when no env files found
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle key without equals in env file', () => {
|
||||
const envContent = `KEY_WITHOUT_EQUALS`;
|
||||
(readFileSync as any).mockReturnValue(envContent);
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
// Should skip lines without equals
|
||||
expect(Object.keys(config).length).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle nested structure with existing non-object value', () => {
|
||||
process.env.CONFIG = 'string_value';
|
||||
process.env.CONFIG_NESTED = 'nested_value';
|
||||
|
||||
loader = new EnvLoader();
|
||||
const config = loader.load();
|
||||
|
||||
// CONFIG should be an object with nested value
|
||||
expect((config as any).config).toEqual({
|
||||
nested: 'nested_value'
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip setNestedValue when path reduction fails', () => {
|
||||
// Create a scenario where the reduce operation would fail
|
||||
const testConfig: any = {};
|
||||
Object.defineProperty(testConfig, 'protected', {
|
||||
value: 'immutable',
|
||||
writable: false,
|
||||
configurable: false
|
||||
});
|
||||
|
||||
process.env.PROTECTED_NESTED_VALUE = 'test';
|
||||
|
||||
loader = new EnvLoader();
|
||||
// Should not throw, but skip the problematic variable
|
||||
expect(() => loader.load()).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue