Files
SLACK_COORD_RELATORIO/test/pipeline.test.js
T
Automacao GLPIandClaude Sonnet 5 64ddfb9b7b V2: guarda de envio real (SEND_ENABLED/FORCE_SEND), idempotencia por send_key e Slack nativo
- src/v2/sendGuard.js (novo): canSend() reduz SHADOW_MODE/DEV_MODE/
  SEND_ENABLED/FORCE_SEND a uma formula unica testada exaustivamente
  (secao 15 do ciclo final: matriz completa de guarda de seguranca).
  computeMessageHash/buildSendKey/isDuplicate implementam a chave
  logica de idempotencia da secao 13 (report_type+periodo+destino+hash).
- src/v2/config.js: SEND_ENABLED, FORCE_SEND, SLACK_DESTINATION.
- src/v2/pipeline.js: buildReport agora calcula envio.pode_enviar/
  motivo_guarda/duplicata_bloqueada/send_key a partir de triggerType
  (.mode) e sendLogRows (Data Table de log de envios).
- tools/build_v2_workflow*.js: novo Data Table 'Buscar Log de Envios',
  troca do HTTP Slack com webhook placeholder por node Slack nativo
  (credencial real lNqwBBrtLfkRqw2X, reaproveitada do projeto irmao
  Coordenador GLPI), branches de sucesso/falha gravando o log de envio
  so apos confirmacao real do Slack.
- test/sendGuard.test.js, test/workflowStructure.test.js (novos):
  cobrem a matriz de guarda de seguranca e travam regressao do bug de
  timeout (executeOnce=true nos 8 nodes de leitura em lote) e do bug
  de sendGuard.js nao concatenado no Code node gerado.
- Corrigido bug real encontrado nesta sessao: sendGuard.js nao estava
  na lista de modulos concatenados pelo gerador, deixando canSend/
  computeMessageHash/buildSendKey undefined em runtime.
- Deployado e validado ao vivo no DEV (workflow 3x5kb58TeBCvirm6):
  n8n_validate_workflow -> valid:true, 0 erros, executeOnce preservado
  nos 9 nodes de leitura em lote.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 09:22:42 -03:00

362 lines
15 KiB
JavaScript

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