diff --git a/tests/core/config/env-interpolator.test.js b/tests/core/config/env-interpolator.test.js new file mode 100644 index 000000000..895cc1b4c --- /dev/null +++ b/tests/core/config/env-interpolator.test.js @@ -0,0 +1,216 @@ +/** + * Testes unitários para env-interpolator + * + * Cobre interpolateString, interpolateEnvVars e lintEnvPatterns. + * + * @see .aiox-core/core/config/env-interpolator.js + * @issue #52 + */ + +'use strict'; + +const { + interpolateString, + interpolateEnvVars, + lintEnvPatterns, + ENV_VAR_PATTERN, +} = require('../../../.aiox-core/core/config/env-interpolator'); + +// ============================================================================ +// interpolateString +// ============================================================================ + +describe('interpolateString', () => { + const ORIGINAL_ENV = process.env; + + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + afterAll(() => { + process.env = ORIGINAL_ENV; + }); + + it('deve resolver ${VAR} existente', () => { + process.env.MY_VAR = 'hello'; + expect(interpolateString('${MY_VAR}')).toBe('hello'); + }); + + it('deve resolver ${VAR:-default} quando VAR existe', () => { + process.env.MY_VAR = 'real'; + expect(interpolateString('${MY_VAR:-fallback}')).toBe('real'); + }); + + it('deve usar default quando VAR não existe', () => { + delete process.env.MISSING_VAR; + expect(interpolateString('${MISSING_VAR:-fallback}')).toBe('fallback'); + }); + + it('deve retornar string vazia e gerar warning quando VAR não existe sem default', () => { + delete process.env.MISSING_VAR; + const warnings = []; + const result = interpolateString('${MISSING_VAR}', { warnings }); + + expect(result).toBe(''); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('MISSING_VAR'); + }); + + it('deve resolver múltiplas variáveis na mesma string', () => { + process.env.HOST = 'localhost'; + process.env.PORT = '3000'; + expect(interpolateString('${HOST}:${PORT}')).toBe('localhost:3000'); + }); + + it('deve preservar texto sem padrão ${...}', () => { + expect(interpolateString('no variables here')).toBe('no variables here'); + }); + + it('deve resolver default vazio ${VAR:-}', () => { + delete process.env.EMPTY_DEFAULT; + expect(interpolateString('${EMPTY_DEFAULT:-}')).toBe(''); + }); +}); + +// ============================================================================ +// interpolateEnvVars +// ============================================================================ + +describe('interpolateEnvVars', () => { + const ORIGINAL_ENV = process.env; + + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + }); + + afterAll(() => { + process.env = ORIGINAL_ENV; + }); + + it('deve interpolar strings em objetos aninhados', () => { + process.env.DB_HOST = 'pg.example.com'; + const config = { + database: { + host: '${DB_HOST}', + port: 5432, + }, + }; + + const result = interpolateEnvVars(config); + expect(result.database.host).toBe('pg.example.com'); + expect(result.database.port).toBe(5432); + }); + + it('deve interpolar strings em arrays', () => { + process.env.ITEM = 'resolved'; + const config = ['${ITEM}', 'static']; + + const result = interpolateEnvVars(config); + expect(result).toEqual(['resolved', 'static']); + }); + + it('deve preservar números, booleanos e null', () => { + expect(interpolateEnvVars(42)).toBe(42); + expect(interpolateEnvVars(true)).toBe(true); + expect(interpolateEnvVars(null)).toBeNull(); + }); + + it('deve processar objetos profundamente aninhados', () => { + process.env.SECRET = 'top-secret'; + const config = { + l1: { l2: { l3: { key: '${SECRET}' } } }, + }; + + const result = interpolateEnvVars(config); + expect(result.l1.l2.l3.key).toBe('top-secret'); + }); + + it('deve coletar warnings de variáveis ausentes', () => { + delete process.env.UNKNOWN; + const warnings = []; + interpolateEnvVars({ key: '${UNKNOWN}' }, { warnings }); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('UNKNOWN'); + }); + + it('não deve mutar o config original', () => { + process.env.VAL = 'new'; + const config = { a: '${VAL}' }; + const configCopy = JSON.parse(JSON.stringify(config)); + + interpolateEnvVars(config); + expect(config).toEqual(configCopy); + }); +}); + +// ============================================================================ +// lintEnvPatterns +// ============================================================================ + +describe('lintEnvPatterns', () => { + it('deve detectar padrões ${...} em strings', () => { + const config = { api: { key: '${API_KEY}' } }; + const findings = lintEnvPatterns(config, 'config.yaml'); + + expect(findings).toHaveLength(1); + expect(findings[0]).toContain('config.yaml'); + expect(findings[0]).toContain('api.key'); + expect(findings[0]).toContain('${API_KEY}'); + }); + + it('deve detectar padrões em arrays', () => { + const config = { hosts: ['static', '${DYNAMIC_HOST}'] }; + const findings = lintEnvPatterns(config, 'test.yaml'); + + expect(findings).toHaveLength(1); + expect(findings[0]).toContain('hosts[1]'); + }); + + it('deve retornar array vazio quando não há padrões', () => { + const config = { name: 'static', port: 3000 }; + const findings = lintEnvPatterns(config, 'clean.yaml'); + + expect(findings).toEqual([]); + }); + + it('deve detectar múltiplos padrões', () => { + const config = { + db: { host: '${DB_HOST}', pass: '${DB_PASS}' }, + api: '${API_URL}', + }; + const findings = lintEnvPatterns(config, 'app.yaml'); + + expect(findings).toHaveLength(3); + }); + + it('deve funcionar com objetos profundamente aninhados', () => { + const config = { a: { b: { c: { d: '${DEEP}' } } } }; + const findings = lintEnvPatterns(config, 'deep.yaml'); + + expect(findings).toHaveLength(1); + expect(findings[0]).toContain('a.b.c.d'); + }); +}); + +// ============================================================================ +// ENV_VAR_PATTERN +// ============================================================================ + +describe('ENV_VAR_PATTERN', () => { + it('deve ser uma regex global', () => { + expect(ENV_VAR_PATTERN).toBeInstanceOf(RegExp); + expect(ENV_VAR_PATTERN.global).toBe(true); + }); + + it('deve capturar nome da variável', () => { + const match = '${MY_VAR}'.match(new RegExp(ENV_VAR_PATTERN.source)); + expect(match[1]).toBe('MY_VAR'); + }); + + it('deve capturar valor default', () => { + const match = '${MY_VAR:-default}'.match(new RegExp(ENV_VAR_PATTERN.source)); + expect(match[1]).toBe('MY_VAR'); + expect(match[2]).toBe('default'); + }); +}); diff --git a/tests/core/config/merge-utils.test.js b/tests/core/config/merge-utils.test.js new file mode 100644 index 000000000..528695707 --- /dev/null +++ b/tests/core/config/merge-utils.test.js @@ -0,0 +1,171 @@ +/** + * Testes unitários para merge-utils + * + * Cobre deepMerge, mergeAll e isPlainObject conforme ADR-PRO-002. + * + * @see .aiox-core/core/config/merge-utils.js + * @issue #52 + */ + +'use strict'; + +const { deepMerge, mergeAll, isPlainObject } = require('../../../.aiox-core/core/config/merge-utils'); + +// ============================================================================ +// isPlainObject +// ============================================================================ + +describe('isPlainObject', () => { + it('deve retornar true para objetos literais', () => { + expect(isPlainObject({})).toBe(true); + expect(isPlainObject({ a: 1 })).toBe(true); + }); + + it('deve retornar true para Object.create(null)', () => { + expect(isPlainObject(Object.create(null))).toBe(true); + }); + + it('deve retornar false para arrays', () => { + expect(isPlainObject([])).toBe(false); + expect(isPlainObject([1, 2])).toBe(false); + }); + + it('deve retornar false para null e undefined', () => { + expect(isPlainObject(null)).toBe(false); + expect(isPlainObject(undefined)).toBe(false); + }); + + it('deve retornar false para primitivos', () => { + expect(isPlainObject(42)).toBe(false); + expect(isPlainObject('string')).toBe(false); + expect(isPlainObject(true)).toBe(false); + }); + + it('deve retornar false para Date e RegExp', () => { + expect(isPlainObject(new Date())).toBe(false); + expect(isPlainObject(/regex/)).toBe(false); + }); +}); + +// ============================================================================ +// deepMerge +// ============================================================================ + +describe('deepMerge', () => { + it('deve fazer last-wins para escalares', () => { + const result = deepMerge({ a: 1 }, { a: 2 }); + expect(result.a).toBe(2); + }); + + it('deve preservar chaves do target ausentes no source', () => { + const result = deepMerge({ a: 1, b: 2 }, { a: 10 }); + expect(result).toEqual({ a: 10, b: 2 }); + }); + + it('deve adicionar chaves novas do source', () => { + const result = deepMerge({ a: 1 }, { b: 2 }); + expect(result).toEqual({ a: 1, b: 2 }); + }); + + it('deve fazer deep merge de objetos aninhados', () => { + const target = { db: { host: 'localhost', port: 5432 } }; + const source = { db: { port: 3306, name: 'mydb' } }; + const result = deepMerge(target, source); + + expect(result.db).toEqual({ host: 'localhost', port: 3306, name: 'mydb' }); + }); + + it('deve substituir arrays (não concatenar) por padrão', () => { + const target = { tags: ['a', 'b'] }; + const source = { tags: ['c'] }; + const result = deepMerge(target, source); + + expect(result.tags).toEqual(['c']); + }); + + it('deve concatenar arrays com +append', () => { + const target = { plugins: ['core', 'auth'] }; + const source = { 'plugins+append': ['analytics'] }; + const result = deepMerge(target, source); + + expect(result.plugins).toEqual(['core', 'auth', 'analytics']); + }); + + it('deve criar array quando +append mas target não tem o campo', () => { + const target = {}; + const source = { 'items+append': [1, 2] }; + const result = deepMerge(target, source); + + expect(result.items).toEqual([1, 2]); + }); + + it('deve deletar chave quando value é null', () => { + const target = { a: 1, b: 2, c: 3 }; + const source = { b: null }; + const result = deepMerge(target, source); + + expect(result).toEqual({ a: 1, c: 3 }); + expect('b' in result).toBe(false); + }); + + it('não deve mutar os inputs', () => { + const target = { a: { x: 1 } }; + const source = { a: { y: 2 } }; + const targetCopy = JSON.parse(JSON.stringify(target)); + const sourceCopy = JSON.parse(JSON.stringify(source)); + + deepMerge(target, source); + + expect(target).toEqual(targetCopy); + expect(source).toEqual(sourceCopy); + }); + + it('deve retornar source quando target não é objeto', () => { + expect(deepMerge('string', { a: 1 })).toEqual({ a: 1 }); + expect(deepMerge(null, { a: 1 })).toEqual({ a: 1 }); + }); + + it('deve retornar target quando source é undefined', () => { + expect(deepMerge({ a: 1 }, undefined)).toEqual({ a: 1 }); + }); + + it('deve fazer merge profundo em 3+ níveis', () => { + const target = { l1: { l2: { l3: { a: 1, b: 2 } } } }; + const source = { l1: { l2: { l3: { b: 20, c: 30 } } } }; + const result = deepMerge(target, source); + + expect(result.l1.l2.l3).toEqual({ a: 1, b: 20, c: 30 }); + }); +}); + +// ============================================================================ +// mergeAll +// ============================================================================ + +describe('mergeAll', () => { + it('deve fazer merge de múltiplas camadas em ordem', () => { + const base = { a: 1, b: 2 }; + const override = { b: 20, c: 30 }; + const final = { c: 300 }; + const result = mergeAll(base, override, final); + + expect(result).toEqual({ a: 1, b: 20, c: 300 }); + }); + + it('deve ignorar camadas null/undefined', () => { + const result = mergeAll({ a: 1 }, null, undefined, { b: 2 }); + expect(result).toEqual({ a: 1, b: 2 }); + }); + + it('deve retornar objeto vazio quando sem argumentos', () => { + expect(mergeAll()).toEqual({}); + }); + + it('deve retornar cópia quando apenas uma camada', () => { + const layer = { a: 1 }; + const result = mergeAll(layer); + + expect(result).toEqual({ a: 1 }); + expect(result).not.toBe(layer); // Deve ser cópia + }); +});