diff --git a/tests/core/config/env-interpolator.test.js b/tests/core/config/env-interpolator.test.js new file mode 100644 index 0000000000..7272a6c845 --- /dev/null +++ b/tests/core/config/env-interpolator.test.js @@ -0,0 +1,220 @@ +/** + * Testes unitários para env-interpolator + * + * Cobre interpolateString, interpolateEnvVars e lintEnvPatterns. + * + * @see .aiox-core/core/config/env-interpolator.js + * @issue #52 + */ + +'use strict'; + +const path = require('path'); + +const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..'); + +const { + interpolateString, + interpolateEnvVars, + lintEnvPatterns, + ENV_VAR_PATTERN, +} = require(path.join(PROJECT_ROOT, '.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 0000000000..182f079b1c --- /dev/null +++ b/tests/core/config/merge-utils.test.js @@ -0,0 +1,181 @@ +/** + * 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 path = require('path'); + +const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..'); + +const { deepMerge, mergeAll, isPlainObject } = require(path.join( + PROJECT_ROOT, + '.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 + }); +}); diff --git a/tests/core/execution/rate-limit-manager.test.js b/tests/core/execution/rate-limit-manager.test.js new file mode 100644 index 0000000000..e9638524a0 --- /dev/null +++ b/tests/core/execution/rate-limit-manager.test.js @@ -0,0 +1,329 @@ +/** + * Testes unitários para RateLimitManager + * + * Cobre executeWithRetry, calculateDelay, isRateLimitError, + * preemptiveThrottle, metrics, events e withRateLimit wrapper. + * + * @see .aiox-core/core/execution/rate-limit-manager.js + * @issue #52 + */ + +'use strict'; + +const path = require('path'); + +const PROJECT_ROOT = path.resolve(__dirname, '..', '..', '..'); + +const RateLimitManager = require(path.join( + PROJECT_ROOT, + '.aiox-core', + 'core', + 'execution', + 'rate-limit-manager', +)); +const { withRateLimit, getGlobalManager } = RateLimitManager; + +// ============================================================================ +// Constructor +// ============================================================================ + +describe('RateLimitManager — constructor', () => { + it('deve usar defaults quando sem config', () => { + const mgr = new RateLimitManager(); + expect(mgr.maxRetries).toBe(5); + expect(mgr.baseDelay).toBe(1000); + expect(mgr.maxDelay).toBe(30000); + expect(mgr.requestsPerMinute).toBe(50); + }); + + it('deve aceitar config custom', () => { + const mgr = new RateLimitManager({ + maxRetries: 3, + baseDelay: 500, + maxDelay: 10000, + requestsPerMinute: 20, + }); + expect(mgr.maxRetries).toBe(3); + expect(mgr.baseDelay).toBe(500); + expect(mgr.maxDelay).toBe(10000); + expect(mgr.requestsPerMinute).toBe(20); + }); + + it('deve inicializar metrics zerados', () => { + const mgr = new RateLimitManager(); + expect(mgr.metrics.rateLimitHits).toBe(0); + expect(mgr.metrics.totalRetries).toBe(0); + expect(mgr.metrics.totalRequests).toBe(0); + }); + + it('deve ser EventEmitter', () => { + const mgr = new RateLimitManager(); + expect(typeof mgr.on).toBe('function'); + expect(typeof mgr.emit).toBe('function'); + }); +}); + +// ============================================================================ +// isRateLimitError +// ============================================================================ + +describe('RateLimitManager — isRateLimitError', () => { + let mgr; + beforeEach(() => { mgr = new RateLimitManager(); }); + + it('deve detectar HTTP 429 via status', () => { + expect(mgr.isRateLimitError({ status: 429, message: '' })).toBe(true); + }); + + it('deve detectar HTTP 429 via statusCode', () => { + expect(mgr.isRateLimitError({ statusCode: 429, message: '' })).toBe(true); + }); + + it('deve detectar "rate limit" na mensagem', () => { + expect(mgr.isRateLimitError(new Error('rate limit exceeded'))).toBe(true); + }); + + it('deve detectar "too many requests" na mensagem', () => { + expect(mgr.isRateLimitError(new Error('too many requests'))).toBe(true); + }); + + it('deve detectar "throttl" na mensagem', () => { + expect(mgr.isRateLimitError(new Error('request throttled'))).toBe(true); + }); + + it('deve detectar "quota exceeded" na mensagem', () => { + expect(mgr.isRateLimitError(new Error('API quota exceeded'))).toBe(true); + }); + + it('deve detectar "overloaded" (Anthropic)', () => { + expect(mgr.isRateLimitError(new Error('API is overloaded'))).toBe(true); + }); + + it('deve detectar code RATE_LIMITED', () => { + const err = new Error('fail'); + err.code = 'RATE_LIMITED'; + expect(mgr.isRateLimitError(err)).toBe(true); + }); + + it('deve detectar code TOO_MANY_REQUESTS', () => { + const err = new Error('fail'); + err.code = 'TOO_MANY_REQUESTS'; + expect(mgr.isRateLimitError(err)).toBe(true); + }); + + it('deve retornar false para erros normais', () => { + expect(mgr.isRateLimitError(new Error('connection refused'))).toBe(false); + expect(mgr.isRateLimitError(new Error('timeout'))).toBe(false); + expect(mgr.isRateLimitError({ status: 500, message: 'server error' })).toBe(false); + }); +}); + +// ============================================================================ +// calculateDelay +// ============================================================================ + +describe('RateLimitManager — calculateDelay', () => { + let mgr; + beforeEach(() => { mgr = new RateLimitManager({ baseDelay: 1000, maxDelay: 30000 }); }); + + it('deve usar retryAfter do erro quando disponível', () => { + const err = new Error('rate limited'); + err.retryAfter = 5; + const delay = mgr.calculateDelay(1, err); + expect(delay).toBe(5000); + }); + + it('deve limitar retryAfter ao maxDelay', () => { + const err = new Error('rate limited'); + err.retryAfter = 60; + const delay = mgr.calculateDelay(1, err); + expect(delay).toBe(30000); + }); + + it('deve extrair retry-after da mensagem de erro', () => { + const err = new Error('Retry-After: 3'); + const delay = mgr.calculateDelay(1, err); + expect(delay).toBe(3000); + }); + + it('deve usar backoff exponencial sem retryAfter', () => { + const err = new Error('rate limited'); + // attempt 1: 1000 * 2^0 = 1000 + jitter(0-1000) ≈ 1000-2000 + const delay1 = mgr.calculateDelay(1, err); + expect(delay1).toBeGreaterThanOrEqual(1000); + expect(delay1).toBeLessThanOrEqual(2001); + + // attempt 3: 1000 * 2^2 = 4000 + jitter(0-1000) ≈ 4000-5000 + const delay3 = mgr.calculateDelay(3, err); + expect(delay3).toBeGreaterThanOrEqual(4000); + expect(delay3).toBeLessThanOrEqual(5001); + }); + + it('deve limitar ao maxDelay', () => { + const err = new Error('rate limited'); + // attempt 10: 1000 * 2^9 = 512000 → capped at 30000 + const delay = mgr.calculateDelay(10, err); + expect(delay).toBeLessThanOrEqual(30000); + }); +}); + +// ============================================================================ +// executeWithRetry +// ============================================================================ + +describe('RateLimitManager — executeWithRetry', () => { + let mgr; + beforeEach(() => { + mgr = new RateLimitManager({ maxRetries: 3, baseDelay: 1 }); + mgr.sleep = jest.fn().mockResolvedValue(undefined); // Skip delays + }); + + it('deve retornar resultado quando fn sucede na primeira tentativa', async () => { + const result = await mgr.executeWithRetry(() => Promise.resolve('ok')); + expect(result).toBe('ok'); + expect(mgr.metrics.totalRequests).toBe(1); + expect(mgr.metrics.rateLimitHits).toBe(0); + }); + + it('deve fazer retry em rate limit e suceder na segunda', async () => { + let attempt = 0; + const fn = () => { + attempt++; + if (attempt === 1) { + const err = new Error('rate limit'); + throw err; + } + return Promise.resolve('recovered'); + }; + + const result = await mgr.executeWithRetry(fn); + expect(result).toBe('recovered'); + expect(mgr.metrics.rateLimitHits).toBe(1); + expect(mgr.metrics.successAfterRetry).toBe(1); + expect(mgr.metrics.totalRetries).toBe(1); + }); + + it('deve lançar após maxRetries em rate limit', async () => { + const fn = () => { throw new Error('rate limit exceeded'); }; + + await expect(mgr.executeWithRetry(fn)).rejects.toThrow('Rate limit exceeded after 3 retries'); + expect(mgr.metrics.rateLimitHits).toBe(3); + }); + + it('deve lançar imediatamente para erros não-rate-limit', async () => { + const fn = () => { throw new Error('connection refused'); }; + + await expect(mgr.executeWithRetry(fn)).rejects.toThrow('connection refused'); + expect(mgr.metrics.rateLimitHits).toBe(0); + expect(mgr.metrics.totalRetries).toBe(0); + }); + + it('deve emitir eventos rate_limit_hit e waiting', async () => { + const events = []; + mgr.on('rate_limit_hit', (data) => events.push({ type: 'hit', ...data })); + mgr.on('waiting', (data) => events.push({ type: 'wait', ...data })); + + let attempt = 0; + const fn = () => { + attempt++; + if (attempt <= 2) throw new Error('rate limit'); + return Promise.resolve('ok'); + }; + + await mgr.executeWithRetry(fn); + expect(events.filter((e) => e.type === 'hit')).toHaveLength(2); + expect(events.filter((e) => e.type === 'wait')).toHaveLength(2); + }); +}); + +// ============================================================================ +// Metrics & Events +// ============================================================================ + +describe('RateLimitManager — metrics & events', () => { + let mgr; + beforeEach(() => { mgr = new RateLimitManager(); }); + + it('getMetrics deve calcular averageWaitTime e successRate', () => { + mgr.metrics.totalRequests = 10; + mgr.metrics.rateLimitHits = 2; + mgr.metrics.totalRetries = 3; + mgr.metrics.totalWaitTime = 9000; + + const m = mgr.getMetrics(); + expect(m.averageWaitTime).toBe(3000); + expect(m.successRate).toBe(80); + }); + + it('getMetrics deve retornar 100% successRate sem requests', () => { + const m = mgr.getMetrics(); + expect(m.successRate).toBe(100); + expect(m.averageWaitTime).toBe(0); + }); + + it('logEvent deve respeitar maxEventLog', () => { + for (let i = 0; i < 120; i++) { + mgr.logEvent('test', { i }); + } + expect(mgr.eventLog.length).toBe(100); + expect(mgr.eventLog[0].i).toBe(20); // Primeiros 20 descartados + }); + + it('getRecentEvents deve retornar últimos N eventos', () => { + for (let i = 0; i < 10; i++) { + mgr.logEvent('test', { i }); + } + const recent = mgr.getRecentEvents(3); + expect(recent).toHaveLength(3); + expect(recent[0].i).toBe(7); + }); + + it('resetMetrics deve zerar tudo', () => { + mgr.metrics.totalRequests = 10; + mgr.logEvent('test', {}); + + mgr.resetMetrics(); + expect(mgr.metrics.totalRequests).toBe(0); + expect(mgr.eventLog).toHaveLength(0); + }); + + it('formatStatus deve retornar string formatada', () => { + mgr.metrics.totalRequests = 5; + mgr.metrics.rateLimitHits = 1; + const status = mgr.formatStatus(); + + expect(status).toContain('Rate Limit Manager Status'); + expect(status).toContain('Total Requests: 5'); + expect(status).toContain('Rate Limit Hits: 1'); + }); +}); + +// ============================================================================ +// withRateLimit wrapper +// ============================================================================ + +describe('withRateLimit', () => { + it('deve wrappear função com retry automático', async () => { + const mgr = new RateLimitManager({ maxRetries: 2, baseDelay: 1 }); + mgr.sleep = jest.fn().mockResolvedValue(undefined); + + const original = jest.fn().mockResolvedValue('result'); + const wrapped = withRateLimit(original, mgr, { label: 'test' }); + + const result = await wrapped('arg1', 'arg2'); + expect(result).toBe('result'); + expect(original).toHaveBeenCalledWith('arg1', 'arg2'); + }); +}); + +// ============================================================================ +// getGlobalManager singleton +// ============================================================================ + +describe('getGlobalManager', () => { + it('deve retornar instância singleton', () => { + const mgr1 = getGlobalManager({ maxRetries: 10 }); + const mgr2 = getGlobalManager({ maxRetries: 99 }); + expect(mgr1).toBe(mgr2); // Mesmo objeto + }); +});