'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); const { buildReport, computeEvidenceHash } = require('../src/v2/pipeline'); const { resolvePeriod } = require('../src/v2/period'); const { loadConfig } = require('../src/v2/config'); const config = loadConfig(); function baseIndicators(overrides) { return { tipo_periodo: 'diario', periodo_label: '2026-08-20', data_inicio: '2026-08-20', data_fim: '2026-08-20', abertos: 12, fechados: 11, saldo: -1, pendentes: 22, alta_prioridade_pendentes: 3, categorias_recorrentes: 'Software / SO: 4\nBug / Falha do Sistema: 2', clientes_volume: 'IMDI: 2 chamados\nLaboratorio da Cidade: 2 chamados', ...overrides, }; } function baseInput(overrides) { const period = resolvePeriod('2026-08-20', new Date(2026, 7, 21), config); return { period, indicators: baseIndicators(), rankingCategorias: [{ id: 10, nome: 'Software / SO', total: 4 }, { id: 11, nome: 'Bug / Falha do Sistema', total: 2 }], rankingClientes: [{ id: 20, nome: 'IMDI', total: 2 }, { id: 21, nome: 'Laboratorio da Cidade', total: 2 }], baselineRowsCategoria: [], baselineRowsCliente: [], ticketsContexto: [ { id: 1, title: 'Erro impressora', category: 'Hardware', client: 'IMDI', priority: 3, status: 5, opened_at: '2026-08-20' }, { id: 2, title: 'Duvida acesso', category: 'Acesso', client: 'CTO', priority: 2, status: 5, opened_at: '2026-08-20' }, ], aiCall: { attempted: false, error: null, content: null }, previousMemoryRows: [], previousPendentes: null, altaPrioridadeAnterior: null, todayIso: '2026-08-21', ...overrides, }; } function aiContentWith(insights, assessment) { return JSON.stringify({ period_assessment: assessment || { status: 'stable', summary: 'ok' }, insights, }); } test('IA desligada -> fallback determinista: sem insights de IA, mas destaques deterministas (ex.: categoria lider) continuam funcionando', () => { const cfg = loadConfig({ AI_ENABLED: false }); const r = buildReport(baseInput(), cfg); assert.equal(r.observabilidade.fallback_ia, true); assert.equal(r.observabilidade.fallback_motivo, 'ia_desativada_por_configuracao'); assert.equal(r.insights_aceitos.length, 0); // destaque deterministico (nao depende de IA) continua aparecendo: assert.match(r.mensagem_v2, /Software \/ SO liderou no dia com 4 chamados\./); }); test('IA desligada e sem nenhum destaque deterministico -> mensagem cai na frase padrao (nao inventa observacao)', () => { const cfg = loadConfig({ AI_ENABLED: false }); const input = baseInput({ rankingCategorias: [], rankingClientes: [] }); const r = buildReport(input, cfg); assert.match(r.mensagem_v2, /Sem anomalias relevantes no periodo\./); }); test('IA respondeu JSON valido com insight bem formado -> aceito e aparece na mensagem', () => { const content = aiContentWith([ { type: 'CATEGORY_SPIKE', severity: 'medium', title: 'Hardware acima do padrao', summary: 'Volume de hardware ficou acima do usual.', evidence_ticket_ids: [1], confidence: 0.9, reportable: true, subject_key: 'categoria:hardware', }, ]); const input = baseInput({ aiCall: { attempted: true, error: null, content } }); const r = buildReport(input, config); assert.equal(r.observabilidade.ia_usada, true); assert.equal(r.insights_aceitos.length, 1); assert.match(r.mensagem_v2, /Hardware acima do padrao/); }); test('JSON da IA invalido -> fallback, automacao nao quebra', () => { const input = baseInput({ aiCall: { attempted: true, error: null, content: '{ json quebrado' } }); assert.doesNotThrow(() => { const r = buildReport(input, config); assert.equal(r.observabilidade.fallback_ia, true); assert.match(r.observabilidade.fallback_motivo, /json_ia_invalido/); }); }); test('timeout/erro HTTP da IA -> fallback, automacao nao quebra', () => { const input = baseInput({ aiCall: { attempted: true, error: 'ETIMEDOUT', content: null } }); const r = buildReport(input, config); assert.equal(r.observabilidade.fallback_ia, true); assert.match(r.observabilidade.fallback_motivo, /falha_http_ia:ETIMEDOUT/); }); test('insight com confianca baixa nao aparece na mensagem final', () => { const content = aiContentWith([ { type: 'CATEGORY_SPIKE', severity: 'low', title: 'Suspeita fraca', summary: 'x', evidence_ticket_ids: [1], confidence: 0.1, reportable: true, subject_key: 'categoria:x', }, ]); const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config); assert.equal(r.insights_aceitos.length, 0); assert.doesNotMatch(r.mensagem_v2, /Suspeita fraca/); }); test('insight sem evidencia (ticket id fora do contexto enviado) nao aparece', () => { const content = aiContentWith([ { type: 'CATEGORY_SPIKE', severity: 'high', title: 'Nao auditavel', summary: 'x', evidence_ticket_ids: [999], // nao esta em ticketsContexto confidence: 0.95, reportable: true, subject_key: 'categoria:x', }, ]); const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config); assert.equal(r.insights_aceitos.length, 0); assert.equal(r.insights_suprimidos[0].motivo, 'evidencia_fora_do_contexto_enviado'); }); test('numeros oficiais da mensagem SEMPRE vem do SQL, nunca da IA (mesmo se a IA tentar sugerir outro numero no texto)', () => { const content = aiContentWith( [], { status: 'critical', summary: 'Na verdade foram 999 chamados abertos, nao 12 (numero fabricado pela IA).' } ); const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config); assert.match(r.mensagem_v2, /12 abertos \| 11 fechados \| saldo -1/); assert.doesNotMatch(r.mensagem_v2, /999/); }); test('maximo de insights respeitado tambem no nivel do pipeline', () => { const insights = Array.from({ length: 8 }, (_, i) => ({ type: 'CATEGORY_SPIKE', severity: 'medium', title: `Insight ${i}`, summary: 'x', evidence_ticket_ids: [1], confidence: 0.9, reportable: true, subject_key: `categoria:tema_${i}`, })); const r = buildReport(baseInput({ aiCall: { attempted: true, error: null, content: aiContentWith(insights) } }), config); assert.ok(r.insights_aceitos.length <= config.MAX_REPORT_INSIGHTS); }); test('insight novo hoje fica com status new; no dia seguinte com o mesmo fingerprint fica persisting', () => { const content = aiContentWith([ { type: 'CATEGORY_SPIKE', severity: 'medium', title: 'Hardware acima do padrao', summary: 'x', evidence_ticket_ids: [1], confidence: 0.9, reportable: true, subject_key: 'categoria:hardware', }, ]); const dia1 = buildReport( baseInput({ aiCall: { attempted: true, error: null, content }, todayIso: '2026-08-20' }), config ); assert.equal(dia1.insights_aceitos[0].memoryRow.current_status, 'new'); const memoriaAposDia1 = dia1.memoria_atualizacoes; const dia2 = buildReport( baseInput({ aiCall: { attempted: true, error: null, content }, previousMemoryRows: memoriaAposDia1, todayIso: '2026-08-21', }), config ); assert.equal(dia2.insights_aceitos[0].memoryRow.current_status, 'persisting'); assert.equal(dia2.insights_aceitos[0].memoryRow.occurrences, 2); // as frases nao devem ser identicas entre os dois dias (evita spam) const bullet1 = dia1.mensagem_v2.split('\n').find((l) => l.includes('Hardware acima do padrao')); const bullet2 = dia2.mensagem_v2.split('\n').find((l) => l.includes('Hardware acima do padrao')); assert.notEqual(bullet1, bullet2); }); test('insight que existia ontem e nao aparece hoje gera aviso de normalizacao uma vez, depois fica silencioso', () => { const memoriaAtiva = [ { fingerprint: 'CATEGORY_SPIKE:categoria:hardware', insight_type: 'CATEGORY_SPIKE', subject_key: 'categoria:hardware', first_seen_at: '2026-08-18', last_seen_at: '2026-08-19', occurrences: 2, severity: 'medium', last_reported_at: '2026-08-19', current_status: 'persisting', }, ]; // dia sem deteccao de IA (nenhum insight novo) -> registro antigo deve normalizar const diaSemRecorrencia = buildReport( baseInput({ aiCall: { attempted: true, error: null, content: aiContentWith([]) }, previousMemoryRows: memoriaAtiva, todayIso: '2026-08-20' }), config ); assert.match(diaSemRecorrencia.mensagem_v2, /voltou ao padrao/); const registroNormalizando = diaSemRecorrencia.memoria_atualizacoes.find((r) => r.fingerprint === memoriaAtiva[0].fingerprint); assert.equal(registroNormalizando.current_status, 'normalizing'); // no dia seguinte, com o registro ja 'normalizing', deve virar 'resolved' e SILENCIAR const diaSeguinte = buildReport( baseInput({ aiCall: { attempted: true, error: null, content: aiContentWith([]) }, previousMemoryRows: [registroNormalizando], todayIso: '2026-08-21', }), config ); assert.doesNotMatch(diaSeguinte.mensagem_v2, /voltou ao padrao/); const registroResolvido = diaSeguinte.memoria_atualizacoes.find((r) => r.fingerprint === memoriaAtiva[0].fingerprint); assert.equal(registroResolvido.current_status, 'resolved'); }); test('mensagem diaria e semanal permanecem curtas mesmo com varios insights aceitos', () => { const insights = Array.from({ length: 5 }, (_, i) => ({ type: 'CATEGORY_SPIKE', severity: 'medium', title: `Tema relevante numero ${i} com titulo moderadamente descritivo`, summary: 'x'.repeat(300), evidence_ticket_ids: [1], confidence: 0.9, reportable: true, subject_key: `categoria:tema_${i}`, })); const content = aiContentWith(insights); const diario = buildReport(baseInput({ aiCall: { attempted: true, error: null, content } }), config); assert.ok(diario.mensagem_v2.length < 1000, `mensagem diaria tinha ${diario.mensagem_v2.length} chars`); const semanal = buildReport( baseInput({ period: resolvePeriod('', new Date(2026, 7, 24), config), indicators: baseIndicators({ tipo_periodo: 'semanal', periodo_label: '2026-08-17 a 2026-08-23', data_inicio: '2026-08-17', data_fim: '2026-08-23', abertos: 51, fechados: 52, saldo: 1, pendentes: 21 }), aiCall: { attempted: true, error: null, content }, }), config ); assert.ok(semanal.mensagem_v2.length < 1000, `mensagem semanal tinha ${semanal.mensagem_v2.length} chars`); }); test('computeEvidenceHash e estavel independente da ordem dos ids', () => { assert.equal(computeEvidenceHash([3, 1, 2]), computeEvidenceHash([1, 2, 3])); }); test('anomalias deterministicas (spike de categoria) sao calculadas mesmo sem IA', () => { const input = baseInput({ rankingCategorias: [{ id: 99, nome: 'Hardware', total: 7 }], baselineRowsCategoria: [ { dia: '2026-08-13', id: 99, total: 2 }, { dia: '2026-08-14', id: 99, total: 2 }, { dia: '2026-08-17', id: 99, total: 2 }, { dia: '2026-08-18', id: 99, total: 3 }, { dia: '2026-08-19', id: 99, total: 1 }, ], }); const r = buildReport(input, config); assert.ok(r.anomalias_deterministicas.spikesCategoria.some((s) => s.chave === 'Hardware')); }); test('spikesCategoria casa baseline e periodo atual por ID, nao por nome (evita bug de nomes duplicados/mudando)', () => { const cfg = loadConfig({ HISTORICAL_BASELINE_DAYS: 5 }); const periodoComJanelaCurta = resolvePeriod('2026-08-20', new Date(2026, 7, 21), cfg); const input = baseInput({ period: periodoComJanelaCurta, rankingCategorias: [{ id: 5, nome: 'Nome Mudou Hoje', total: 7 }], baselineRowsCategoria: [ { dia: '2026-08-13', id: 5, total: 2 }, { dia: '2026-08-14', id: 5, total: 2 }, { dia: '2026-08-17', id: 5, total: 2 }, { dia: '2026-08-18', id: 5, total: 3 }, { dia: '2026-08-19', id: 5, total: 1 }, // linha de outra categoria com o MESMO nome que a atual tinha antes - // nao deve ser confundida, pois o casamento e por id { dia: '2026-08-13', id: 999, total: 50 }, ], }); const r = buildReport(input, cfg); const spike = r.anomalias_deterministicas.spikesCategoria.find((s) => s.chave === 'Nome Mudou Hoje'); assert.ok(spike); // janela de 5 dias uteis (13 a 19/08) cobre 7 dias corridos (inclui fim de // semana zero-preenchido): soma 10 (2+2+2+3+1) / 7 dias = 1.43. O ponto do // teste e a media NAO ser contaminada pela linha do id=999 (que sozinha // daria media >= 7). assert.equal(spike.baseline_media, 1.43); }); // --- envio (guarda de seguranca + idempotencia, secoes 12/13 ligadas ao pipeline) --- test('envio.pode_enviar reflete a guarda de seguranca (scheduled + producao = true)', () => { const cfg = loadConfig({ SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, SLACK_DESTINATION: 'C0BJNBTPFT5' }); const r = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg); assert.equal(r.envio.pode_enviar, true); assert.equal(r.envio.duplicata_bloqueada, false); }); test('envio.pode_enviar false quando SHADOW_MODE=true, mesmo scheduled+producao', () => { const cfg = loadConfig({ SHADOW_MODE: true, DEV_MODE: false, SEND_ENABLED: true, SLACK_DESTINATION: 'C0BJNBTPFT5' }); const r = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg); assert.equal(r.envio.pode_enviar, false); assert.match(r.envio.motivo_guarda, /SHADOW_MODE/); }); test('envio.pode_enviar false em execucao manual sem FORCE_SEND, mesmo com guards de producao ok', () => { const cfg = loadConfig({ SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, FORCE_SEND: false, SLACK_DESTINATION: 'C0BJNBTPFT5' }); const r = buildReport(baseInput({ triggerType: 'manual', sendLogRows: [] }), cfg); assert.equal(r.envio.pode_enviar, false); }); test('envio.duplicata_bloqueada=true e pode_enviar=false quando ja existe log de sucesso com a MESMA chave logica', () => { const cfg = loadConfig({ SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, SLACK_DESTINATION: 'C0BJNBTPFT5' }); const input = baseInput({ triggerType: 'scheduled', sendLogRows: [] }); const primeira = buildReport(input, cfg); assert.equal(primeira.envio.pode_enviar, true); const segunda = buildReport( baseInput({ triggerType: 'scheduled', sendLogRows: [{ send_key: primeira.envio.send_key, status: 'success' }] }), cfg ); assert.equal(segunda.envio.pode_enviar, false); assert.equal(segunda.envio.duplicata_bloqueada, true); }); test('envio.send_key permanece estavel para o MESMO periodo/mensagem em duas chamadas distintas', () => { const cfg = loadConfig({ SLACK_DESTINATION: 'C0BJNBTPFT5' }); const a = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg); const b = buildReport(baseInput({ triggerType: 'scheduled', sendLogRows: [] }), cfg); assert.equal(a.envio.send_key, b.envio.send_key); });