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(); }); }); });