chore: import relatorio (subtree)

git-subtree-dir: relatorio
git-subtree-mainline: e322cec92b
git-subtree-split: 34bcb132f3
This commit is contained in:
Gustavo Santos
2026-10-01 14:26:02 -03:00
35 changed files with 6220 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
redact,
truncate,
buildTicketContext,
buildJsonSchema,
parseAiResponse,
} = require('../src/v2/aiSchema');
const { loadConfig } = require('../src/v2/config');
const config = loadConfig();
test('redact remove padroes comuns de segredo antes de mandar para a IA', () => {
const texto = 'acesso com senha: abc123 e token=xyz999';
const limpo = redact(texto);
assert.doesNotMatch(limpo, /abc123/);
assert.doesNotMatch(limpo, /xyz999/);
assert.match(limpo, /\[REDACTED\]/);
});
test('truncate corta texto longo e mantem texto curto intacto', () => {
assert.equal(truncate('abc', 10), 'abc');
assert.equal(truncate('a'.repeat(20), 5), 'aaaaa…');
});
test('buildTicketContext respeita AI_MAX_TICKETS', () => {
const cfg = loadConfig({ AI_MAX_TICKETS: 3 });
const tickets = Array.from({ length: 10 }, (_, i) => ({
id: i + 1,
title: `Chamado ${i}`,
priority: 3,
opened_at: '2026-08-20',
}));
const ctx = buildTicketContext(tickets, cfg);
assert.equal(ctx.tickets.length, 3);
assert.equal(ctx.total_disponivel, 10);
assert.equal(ctx.truncado, true);
});
test('buildTicketContext prioriza prioridade mais alta quando precisa cortar', () => {
const cfg = loadConfig({ AI_MAX_TICKETS: 1 });
const tickets = [
{ id: 1, title: 'baixa', priority: 1, opened_at: '2026-08-20' },
{ id: 2, title: 'alta', priority: 5, opened_at: '2026-08-19' },
];
const ctx = buildTicketContext(tickets, cfg);
assert.equal(ctx.tickets[0].id, 2);
});
test('buildTicketContext nunca ultrapassa AI_MAX_CONTEXT_CHARS mesmo com muitos tickets pequenos', () => {
const cfg = loadConfig({ AI_MAX_TICKETS: 1000, AI_MAX_CONTEXT_CHARS: 500 });
const tickets = Array.from({ length: 200 }, (_, i) => ({
id: i + 1,
title: `Chamado numero ${i} com um titulo mediano para ocupar espaco`,
description: 'descricao razoavelmente longa para ajudar a estourar o limite de contexto configurado',
priority: 3,
opened_at: '2026-08-20',
}));
const ctx = buildTicketContext(tickets, cfg);
assert.ok(JSON.stringify(ctx.tickets).length <= cfg.AI_MAX_CONTEXT_CHARS);
assert.ok(ctx.tickets.length < 200);
});
test('buildTicketContext nao envia campos alem do minimo necessario (minimizacao de contexto)', () => {
const ctx = buildTicketContext(
[{ id: 1, title: 'x', priority: 1, opened_at: '2026-08-20', extraCampoSensivel: 'nao deveria ir' }],
config
);
assert.equal(ctx.tickets[0].extraCampoSensivel, undefined);
});
test('buildJsonSchema define os tipos de insight da missao e exige evidencia', () => {
const schema = buildJsonSchema();
assert.equal(schema.strict, true);
assert.ok(schema.schema.properties.insights.items.required.includes('evidence_ticket_ids'));
assert.ok(schema.schema.properties.insights.items.properties.type.enum.includes('CATEGORY_SPIKE'));
});
test('parseAiResponse aceita JSON valido', () => {
const r = parseAiResponse(JSON.stringify({ period_assessment: { status: 'stable', summary: 'ok' }, insights: [] }));
assert.equal(r.ok, true);
assert.equal(r.data.period_assessment.status, 'stable');
});
test('parseAiResponse tolera cercas de markdown ```json ... ```', () => {
const bruto = '```json\n{"period_assessment":{"status":"stable","summary":"ok"},"insights":[]}\n```';
const r = parseAiResponse(bruto);
assert.equal(r.ok, true);
});
test('parseAiResponse nunca lanca excecao com JSON quebrado, so retorna ok:false', () => {
assert.doesNotThrow(() => {
const r = parseAiResponse('{ isso nao é json valido');
assert.equal(r.ok, false);
assert.match(r.error, /json_invalido/);
});
});
test('parseAiResponse rejeita resposta vazia/undefined sem lancar excecao', () => {
assert.doesNotThrow(() => {
assert.equal(parseAiResponse(undefined).ok, false);
assert.equal(parseAiResponse('').ok, false);
assert.equal(parseAiResponse(null).ok, false);
});
});
test('parseAiResponse rejeita objeto sem o formato esperado (falta insights[])', () => {
const r = parseAiResponse(JSON.stringify({ foo: 'bar' }));
assert.equal(r.ok, false);
assert.equal(r.error, 'formato_inesperado');
});
+75
View File
@@ -0,0 +1,75 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
mean,
median,
isAnomaly,
detectarSpikes,
detectarConcentracaoCliente,
calcularTendenciaBacklog,
} = require('../src/v2/anomaly');
const { loadConfig } = require('../src/v2/config');
const config = loadConfig();
test('median calcula corretamente para lista par e impar', () => {
assert.equal(median([1, 2, 3]), 2);
assert.equal(median([1, 2, 3, 4]), 2.5);
assert.equal(median([]), 0);
});
test('mean calcula a media simples', () => {
assert.equal(mean([1, 2, 3]), 2);
assert.equal(mean([]), 0);
});
test('isAnomaly: exige multiplicador E delta minimo (evita falso positivo trivial 0 -> 1)', () => {
// baseline 0, atual 1: multiplicador ok (sem historico usa MIN_DELTA), mas delta(1) < MIN_DELTA(3)
assert.equal(isAnomaly(1, 0, config), false);
// baseline 2.1, atual 7 (exemplo da missao: hardware hoje 7, media 20du 2.1)
assert.equal(isAnomaly(7, 2.1, config), true);
// baseline 5, atual 6: nao dobra o baseline
assert.equal(isAnomaly(6, 5, config), false);
});
test('detectarSpikes retorna somente categorias/clientes que estouraram o baseline, com numeros auditaveis', () => {
const atuais = [
{ chave: 'Hardware', atual: 7 },
{ chave: 'Software', atual: 4 },
];
const baseline = new Map([
['Hardware', [2, 2, 3, 1, 2]], // media 2.0
['Software', [4, 3, 4, 5, 4]], // media 4.0 (estavel)
]);
const spikes = detectarSpikes(atuais, baseline, config);
assert.equal(spikes.length, 1);
assert.equal(spikes[0].chave, 'Hardware');
assert.equal(spikes[0].baseline_media, 2);
});
test('detectarConcentracaoCliente exige contagem minima E participacao minima', () => {
const clientes = [
{ nome: 'IMDI', total: 5 },
{ nome: 'CTO', total: 2 },
];
// total periodo 10: IMDI = 50% (>=40%) e >=3 chamados -> concentracao
const r = detectarConcentracaoCliente(clientes, 10, config);
assert.equal(r.length, 1);
assert.equal(r[0].cliente, 'IMDI');
assert.equal(r[0].share, 0.5);
});
test('detectarConcentracaoCliente nao aponta cliente com poucos chamados mesmo com share alto', () => {
const clientes = [{ nome: 'Cliente Pequeno', total: 2 }];
const r = detectarConcentracaoCliente(clientes, 2, config); // 100% mas so 2 chamados
assert.equal(r.length, 0);
});
test('calcularTendenciaBacklog classifica direcao corretamente', () => {
assert.equal(calcularTendenciaBacklog(22, 20).direcao, 'aumentando');
assert.equal(calcularTendenciaBacklog(18, 20).direcao, 'diminuindo');
assert.equal(calcularTendenciaBacklog(20, 20).direcao, 'estavel');
assert.equal(calcularTendenciaBacklog(20, null).direcao, 'indisponivel');
});
+47
View File
@@ -0,0 +1,47 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
listCalendarDays,
pivotDailyRows,
bucketIntoChunks,
bucketMapIntoChunks,
} = require('../src/v2/baseline');
test('listCalendarDays inclui inicio e fim, sem pular nenhum dia', () => {
const dias = listCalendarDays('2026-08-01', '2026-08-05');
assert.deepEqual(dias, ['2026-08-01', '2026-08-02', '2026-08-03', '2026-08-04', '2026-08-05']);
});
test('pivotDailyRows preenche com zero os dias sem chamados (nao enviesa a media)', () => {
const allDayKeys = ['2026-08-01', '2026-08-02', '2026-08-03'];
const rows = [
{ dia: '2026-08-01', nome: 'Hardware', total: 2 },
{ dia: '2026-08-03', nome: 'Hardware', total: 4 },
];
const pivot = pivotDailyRows(rows, allDayKeys);
assert.deepEqual(pivot.get('Hardware'), [2, 0, 4]);
});
test('pivotDailyRows aceita objetos Date do driver MySQL, nao so string', () => {
const allDayKeys = ['2026-08-01', '2026-08-02'];
const rows = [{ dia: new Date(2026, 7, 1), nome: 'Software', total: 5 }];
const pivot = pivotDailyRows(rows, allDayKeys);
assert.deepEqual(pivot.get('Software'), [5, 0]);
});
test('bucketIntoChunks agrupa dias em blocos de 7 (semana)', () => {
const serie = [1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2];
assert.deepEqual(bucketIntoChunks(serie, 7), [7, 14]);
});
test('bucketMapIntoChunks aplica o bucket em todas as chaves do mapa', () => {
const mapa = new Map([
['A', [1, 1, 1, 1, 1, 1, 1]],
['B', [2, 0, 0, 0, 0, 0, 0]],
]);
const bucketed = bucketMapIntoChunks(mapa, 7);
assert.deepEqual(bucketed.get('A'), [7]);
assert.deepEqual(bucketed.get('B'), [2]);
});
+169
View File
@@ -0,0 +1,169 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
formatFallbackMessage,
computeOperationalStatus,
buildDestaques,
formatShortMessage,
} = require('../src/v2/format');
const { loadConfig } = require('../src/v2/config');
const config = loadConfig();
test('formatFallbackMessage reproduz o formato atual da V1 (numeros do exemplo da missao, 20/08)', () => {
const row = {
tipo_periodo: 'diario',
periodo_label: '2026-08-20',
abertos: 12,
fechados: 11,
pendentes: 22,
alta_prioridade_pendentes: 3,
saldo: -1,
categorias_recorrentes: 'Software / SO: 4\nBug / Falha do Sistema: 2',
clientes_volume: 'IMDI: 2 chamados\nLaboratorio da Cidade: 2 chamados',
};
const texto = formatFallbackMessage(row);
assert.match(texto, /Resumo diario do suporte GLPI/);
assert.match(texto, /Data do relatorio: 2026-08-20/);
assert.match(texto, /Chamados abertos no dia: 12/);
assert.match(texto, /Chamados fechados no dia: 11/);
assert.match(texto, /Pendentes atuais: 22/);
assert.match(texto, /Alta prioridade ainda pendentes: 3/);
assert.match(texto, /Saldo fechados - abertos: -1/);
assert.match(texto, /Software \/ SO: 4/);
assert.match(texto, /IMDI: 2 chamados/);
});
test('formatFallbackMessage nunca lanca excecao com dados vazios (fallback tem que ser sempre seguro)', () => {
assert.doesNotThrow(() => {
formatFallbackMessage({
tipo_periodo: 'diario',
periodo_label: '2026-08-20',
abertos: 0,
fechados: 0,
pendentes: 0,
alta_prioridade_pendentes: 0,
saldo: 0,
categorias_recorrentes: '',
clientes_volume: '',
});
});
});
test('computeOperationalStatus: sem insights e backlog estavel -> NORMAL', () => {
const s = computeOperationalStatus({ insightsAceitos: [], tendenciaBacklog: { direcao: 'estavel' } });
assert.equal(s, 'NORMAL');
});
test('computeOperationalStatus: insight severity high -> CRITICAL mesmo com backlog estavel', () => {
const s = computeOperationalStatus({
insightsAceitos: [{ severity: 'high' }],
tendenciaBacklog: { direcao: 'estavel' },
});
assert.equal(s, 'CRITICAL');
});
test('computeOperationalStatus: backlog aumentando sem insights -> ATTENTION', () => {
const s = computeOperationalStatus({ insightsAceitos: [], tendenciaBacklog: { direcao: 'aumentando' } });
assert.equal(s, 'ATTENTION');
});
test('buildDestaques nao produz mais itens que MAX_REPORT_INSIGHTS', () => {
const insightsAceitos = Array.from({ length: 10 }, (_, i) => ({ title: `Insight ${i}`, occurrenceText: null }));
const destaques = buildDestaques(
{ topCategoria: null, concentracoesCliente: [], altaPrioridadeDelta: null, insightsAceitos, unidade: 'no dia' },
config
);
assert.ok(destaques.length <= config.MAX_REPORT_INSIGHTS);
});
test('buildDestaques nao inventa destaque de alta prioridade quando o valor nao mudou', () => {
const destaques = buildDestaques(
{
topCategoria: null,
concentracoesCliente: [],
altaPrioridadeDelta: { anterior: 3, atual: 3 },
insightsAceitos: [],
unidade: 'no dia',
},
config
);
assert.equal(destaques.length, 0);
});
test('formatShortMessage: mensagem diaria contem os numeros oficiais e fica curta (sem paragrafos longos)', () => {
const { texto, status } = formatShortMessage(
{
tipo_periodo: 'diario',
data_inicio: '2026-08-20',
data_fim: '2026-08-20',
abertos: 12,
fechados: 11,
saldo: -1,
pendentes: 22,
alta_prioridade_pendentes: 3,
topCategoria: { nome: 'Software / SO', total: 4 },
concentracoesCliente: [],
altaPrioridadeDelta: { anterior: 2, atual: 3 },
insightsAceitos: [],
tendenciaBacklog: { direcao: 'aumentando', delta: 2 },
},
config
);
assert.match(texto, /Resumo do suporte — 20\/08/);
assert.match(texto, /12 abertos \| 11 fechados \| saldo -1/);
assert.match(texto, /22 pendentes \| 3 alta prioridade/);
assert.match(texto, /Software \/ SO liderou no dia com 4 chamados\./);
assert.match(texto, /subiram de 2 para 3/);
assert.equal(status, 'ATTENTION');
assert.ok(texto.length < 700, `mensagem deveria ser curta, tinha ${texto.length} chars`);
});
test('formatShortMessage: sem nenhum destaque real, mostra a frase padrao (nao inventa observacao)', () => {
const { texto } = formatShortMessage(
{
tipo_periodo: 'diario',
data_inicio: '2026-08-20',
data_fim: '2026-08-20',
abertos: 5,
fechados: 5,
saldo: 0,
pendentes: 10,
alta_prioridade_pendentes: 1,
topCategoria: null,
concentracoesCliente: [],
altaPrioridadeDelta: null,
insightsAceitos: [],
tendenciaBacklog: { direcao: 'estavel', delta: 0 },
},
config
);
assert.match(texto, /Sem anomalias relevantes no periodo\./);
});
test('formatShortMessage: mensagem semanal usa titulo e secao "Leitura"', () => {
const { texto } = formatShortMessage(
{
tipo_periodo: 'semanal',
data_inicio: '2026-08-17',
data_fim: '2026-08-23',
abertos: 51,
fechados: 52,
saldo: 1,
pendentes: 21,
alta_prioridade_pendentes: 3,
topCategoria: { nome: 'Bug / Falha do Sistema', total: 9 },
concentracoesCliente: [],
altaPrioridadeDelta: null,
insightsAceitos: [],
tendenciaBacklog: { direcao: 'estavel', delta: 0 },
},
config
);
assert.match(texto, /Resumo semanal do suporte — 17\/08 a 23\/08/);
assert.match(texto, /51 abertos \| 52 fechados \| saldo \+1/);
assert.match(texto, /Leitura/);
assert.match(texto, /→ Semana equilibrada/);
});
+188
View File
@@ -0,0 +1,188 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
slugify,
normalizeSubjectKey,
computeFingerprint,
validateInsightShape,
sanitizeInsights,
nextMemoryState,
nextStateForAbsentRecord,
describeOccurrence,
} = require('../src/v2/insights');
const { loadConfig } = require('../src/v2/config');
const config = loadConfig();
test('slugify remove acentos e caracteres especiais', () => {
assert.equal(slugify('Emissão de NF-e'), 'emissao_de_nf_e');
assert.equal(slugify(''), 'geral');
assert.equal(slugify(undefined), 'geral');
});
test('normalizeSubjectKey aceita namespace explicito da IA', () => {
assert.equal(normalizeSubjectKey('cliente:IMDI', 'CLIENT_CONCENTRATION'), 'cliente:imdi');
});
test('normalizeSubjectKey infere namespace pelo type quando IA nao manda um valido', () => {
assert.equal(normalizeSubjectKey('Impressao', 'CATEGORY_SPIKE'), 'categoria:impressao');
assert.equal(normalizeSubjectKey('erro nota fiscal', 'RECURRENCE'), 'tema:erro_nota_fiscal');
});
test('computeFingerprint e deterministico e estavel', () => {
const f1 = computeFingerprint('CATEGORY_SPIKE', 'categoria:impressao');
const f2 = computeFingerprint('CATEGORY_SPIKE', 'categoria:impressao');
assert.equal(f1, f2);
assert.equal(f1, 'CATEGORY_SPIKE:categoria:impressao');
});
function insightValido(overrides) {
return {
type: 'CATEGORY_SPIKE',
severity: 'medium',
title: 'Hardware acima do padrao',
summary: 'Volume de hardware ficou acima da referencia recente.',
evidence_ticket_ids: [1, 2, 3],
confidence: 0.9,
reportable: true,
subject_key: 'categoria:hardware',
...overrides,
};
}
test('validateInsightShape aceita um insight bem formado', () => {
const r = validateInsightShape(insightValido());
assert.equal(r.valido, true);
});
test('validateInsightShape rejeita type fora do enum', () => {
const r = validateInsightShape(insightValido({ type: 'INVENTADO' }));
assert.equal(r.valido, false);
assert.ok(r.erros.includes('type_invalido'));
});
test('validateInsightShape rejeita evidence_ticket_ids vazio', () => {
const r = validateInsightShape(insightValido({ evidence_ticket_ids: [] }));
assert.equal(r.valido, false);
assert.ok(r.erros.includes('evidence_ticket_ids_invalido'));
});
test('validateInsightShape rejeita confidence fora de 0..1', () => {
const r = validateInsightShape(insightValido({ confidence: 1.5 }));
assert.equal(r.valido, false);
});
test('sanitizeInsights: JSON valido passa e recebe fingerprint', () => {
const ctx = { validTicketIds: new Set([1, 2, 3]) };
const { accepted, suppressed } = sanitizeInsights([insightValido()], ctx, config);
assert.equal(accepted.length, 1);
assert.equal(suppressed.length, 0);
assert.equal(accepted[0].fingerprint, 'CATEGORY_SPIKE:categoria:hardware');
});
test('sanitizeInsights: JSON invalido (schema quebrado) nunca quebra a automacao, so e suprimido', () => {
const ctx = { validTicketIds: new Set([1]) };
assert.doesNotThrow(() => {
const { accepted, suppressed } = sanitizeInsights(
[{ nonsense: true }, 'string solta', null, 42],
ctx,
config
);
assert.equal(accepted.length, 0);
assert.equal(suppressed.length, 4);
});
});
test('sanitizeInsights: insight com confianca abaixo do minimo nao aparece', () => {
const ctx = { validTicketIds: new Set([1, 2, 3]) };
const { accepted, suppressed } = sanitizeInsights(
[insightValido({ confidence: 0.2 })],
ctx,
config
);
assert.equal(accepted.length, 0);
assert.equal(suppressed[0].motivo, 'confianca_abaixo_do_minimo');
});
test('sanitizeInsights: evidencia com ticket id fora do contexto enviado a IA e suprimida (numero nao auditavel)', () => {
const ctx = { validTicketIds: new Set([1, 2]) }; // ticket 3 nao foi enviado a IA
const { accepted, suppressed } = sanitizeInsights([insightValido()], ctx, config);
assert.equal(accepted.length, 0);
assert.equal(suppressed[0].motivo, 'evidencia_fora_do_contexto_enviado');
});
test('sanitizeInsights: reportable=false fica suprimido mas auditavel', () => {
const ctx = { validTicketIds: new Set([1, 2, 3]) };
const { accepted, suppressed } = sanitizeInsights(
[insightValido({ reportable: false })],
ctx,
config
);
assert.equal(accepted.length, 0);
assert.equal(suppressed[0].motivo, 'nao_marcado_como_reportable');
});
test('sanitizeInsights respeita MAX_REPORT_INSIGHTS', () => {
const ctx = { validTicketIds: new Set([1, 2, 3]) };
const cfg = loadConfig({ MAX_REPORT_INSIGHTS: 2 });
const seis = Array.from({ length: 6 }, (_, i) =>
insightValido({ subject_key: `categoria:tema_${i}`, confidence: 0.9 - i * 0.01 })
);
const { accepted, suppressed } = sanitizeInsights(seis, ctx, cfg);
assert.equal(accepted.length, 2);
assert.equal(suppressed.length, 4);
assert.ok(suppressed.every((s) => s.motivo === 'limite_maximo_de_insights_atingido'));
});
test('sanitizeInsights descarta duplicata de fingerprint na mesma execucao (evita spam no mesmo relatorio)', () => {
const ctx = { validTicketIds: new Set([1, 2, 3]) };
const duplicado = [insightValido(), insightValido({ title: 'Outro titulo, mesmo tema' })];
const { accepted, suppressed } = sanitizeInsights(duplicado, ctx, config);
assert.equal(accepted.length, 1);
assert.equal(suppressed[0].motivo, 'fingerprint_duplicado_na_mesma_execucao');
});
test('nextMemoryState: primeira ocorrencia gera status new', () => {
const s = nextMemoryState(null, '2026-08-18');
assert.equal(s.current_status, 'new');
assert.equal(s.occurrences, 1);
assert.equal(s.first_seen_at, '2026-08-18');
});
test('nextMemoryState: ocorrencia repetida incrementa occurrences e vira persisting', () => {
const anterior = { first_seen_at: '2026-08-18', occurrences: 1, current_status: 'new' };
const s = nextMemoryState(anterior, '2026-08-19');
assert.equal(s.current_status, 'persisting');
assert.equal(s.occurrences, 2);
assert.equal(s.first_seen_at, '2026-08-18');
});
test('describeOccurrence varia a frase conforme o estado (nao repete a mesma frase todo dia)', () => {
assert.equal(describeOccurrence({ current_status: 'new' }), 'identificado hoje');
assert.equal(
describeOccurrence({ current_status: 'persisting', occurrences: 3 }),
'segue acima do padrao pelo 3o dia'
);
assert.equal(describeOccurrence({ current_status: 'normalizing' }), 'voltou ao padrao');
assert.equal(describeOccurrence({ current_status: 'resolved' }), null);
});
test('nextStateForAbsentRecord: insight que sumiu vira normalizing (reporta 1x)', () => {
const anterior = { current_status: 'persisting', occurrences: 3, first_seen_at: '2026-08-17', last_seen_at: '2026-08-19' };
const s = nextStateForAbsentRecord(anterior, '2026-08-20');
assert.equal(s.current_status, 'normalizing');
});
test('nextStateForAbsentRecord: normalizing que continua ausente vira resolved (silencioso)', () => {
const anterior = { current_status: 'normalizing', occurrences: 3, first_seen_at: '2026-08-17', last_seen_at: '2026-08-19' };
const s = nextStateForAbsentRecord(anterior, '2026-08-21');
assert.equal(s.current_status, 'resolved');
});
test('nextStateForAbsentRecord: resolved permanece resolved (nao volta a falar sozinho)', () => {
const anterior = { current_status: 'resolved', occurrences: 3, first_seen_at: '2026-08-17', last_seen_at: '2026-08-19' };
const s = nextStateForAbsentRecord(anterior, '2026-08-25');
assert.equal(s.current_status, 'resolved');
});
+113
View File
@@ -0,0 +1,113 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const { resolvePeriod, businessDaysWindowBefore, parseDate, formatDate } = require('../src/v2/period');
const { loadConfig } = require('../src/v2/config');
const config = loadConfig();
// Ancoras reais usadas na missao (agosto/2026):
// 17/08 = segunda, 18/08 = terca, 19/08 = quarta, 20/08 = quinta,
// 21/08 = sexta, 22/08 = sabado, 23/08 = domingo, 24/08 = segunda.
function d(dia) {
return new Date(2026, 7, dia);
}
test('terca (18/08) cobre o relatorio diario de segunda (17/08)', () => {
const r = resolvePeriod('', d(18), config);
assert.equal(r.tipo_periodo, 'diario');
assert.equal(r.data_inicio, '2026-08-17');
assert.equal(r.data_fim, '2026-08-17');
});
test('quarta (19/08) cobre o relatorio diario de terca (18/08)', () => {
const r = resolvePeriod('', d(19), config);
assert.equal(r.tipo_periodo, 'diario');
assert.equal(r.data_inicio, '2026-08-18');
assert.equal(r.data_fim, '2026-08-18');
});
test('quinta (20/08) cobre o relatorio diario de quarta (19/08)', () => {
const r = resolvePeriod('', d(20), config);
assert.equal(r.tipo_periodo, 'diario');
assert.equal(r.data_inicio, '2026-08-19');
assert.equal(r.data_fim, '2026-08-19');
});
test('sexta (21/08) cobre o relatorio diario de quinta (20/08)', () => {
const r = resolvePeriod('', d(21), config);
assert.equal(r.tipo_periodo, 'diario');
assert.equal(r.data_inicio, '2026-08-20');
assert.equal(r.data_fim, '2026-08-20');
});
test('segunda (24/08) cobre o relatorio semanal consolidado de 17/08 a 23/08', () => {
const r = resolvePeriod('', d(24), config);
assert.equal(r.tipo_periodo, 'semanal');
assert.equal(r.data_inicio, '2026-08-17');
assert.equal(r.data_fim, '2026-08-23');
assert.equal(r.periodo_label, '2026-08-17 a 2026-08-23');
});
test('sabado/domingo nao fazem parte do calendario operacional (nao ha schedule nesses dias, mas a funcao deve continuar pura)', () => {
// Sabado (22/08): getDay() = 6, cai no "senao" (diario) porque so segunda e tratada
// como especial. Isso e esperado: o Schedule Trigger real (cron 1-5) nunca dispara
// aos sabados/domingos, entao este caso nunca ocorre em producao/DEV.
const r = resolvePeriod('', d(22), config);
assert.equal(r.tipo_periodo, 'diario');
assert.equal(r.data_inicio, '2026-08-21');
});
test('data_alvo manual (YYYY-MM-DD) forca modo diario manual, independente do dia da semana atual', () => {
const r = resolvePeriod('2026-08-20', d(24), config);
assert.equal(r.tipo_periodo, 'manual_diario');
assert.equal(r.data_inicio, '2026-08-20');
assert.equal(r.data_fim, '2026-08-20');
});
test('data_alvo manual aceita formato dd/mm/aa', () => {
const r = resolvePeriod('20/08/26', d(24), config);
assert.equal(r.data_inicio, '2026-08-20');
});
test('data_alvo manual aceita formato dd/mm/aaaa', () => {
const r = resolvePeriod('20/08/2026', d(24), config);
assert.equal(r.data_inicio, '2026-08-20');
});
test('data_alvo invalida (texto livre) cai no modo automatico', () => {
const r = resolvePeriod('data invalida', d(19), config);
assert.equal(r.tipo_periodo, 'diario');
assert.equal(r.data_inicio, '2026-08-18');
});
test('parseDate rejeita string vazia', () => {
assert.equal(parseDate(''), null);
assert.equal(parseDate(undefined), null);
});
test('businessDaysWindowBefore conta somente dias uteis (pula fim de semana)', () => {
// antes de 24/08 (segunda): 5 dias uteis = 21,20,19,18,17 (sex,qui,qua,ter,seg)
const janela = businessDaysWindowBefore(d(24), 5);
assert.equal(formatDate(janela.start), '2026-08-17');
assert.equal(formatDate(janela.end), '2026-08-21');
assert.equal(janela.businessDays, 5);
});
test('baseline diario expoe janela rolling e last5BusinessDays sem usar datas incorretas', () => {
const r = resolvePeriod('', d(19), config); // periodo = 18/08 (terca)
assert.equal(r.baseline.immediatePrevious.start, r.baseline.immediatePrevious.end);
assert.ok(r.baseline.rolling.start < r.baseline.rolling.end || r.baseline.rolling.start <= r.baseline.rolling.end);
assert.ok(r.baseline.last5BusinessDays.end < r.data_inicio);
assert.ok(r.baseline.rolling.end < r.data_inicio);
});
test('baseline semanal expoe janela de 4 semanas anteriores sem sobrepor o periodo atual', () => {
const r = resolvePeriod('', d(24), config); // periodo = 17 a 23/08
assert.equal(r.baseline.immediatePrevious.start, '2026-08-10');
assert.equal(r.baseline.immediatePrevious.end, '2026-08-16');
assert.equal(r.baseline.last4Weeks.start, '2026-07-20');
assert.equal(r.baseline.last4Weeks.end, '2026-08-16');
assert.ok(r.baseline.last4Weeks.end < r.data_inicio);
});
+400
View File
@@ -0,0 +1,400 @@
'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);
});
test('regressao: retry do MESMO periodo com IA nao-deterministica (texto/hash diferentes) ainda e bloqueado como duplicata (bug real de 2026-08-25: 2 mensagens reais enviadas ao Slack antes desta correcao — execucoes 16744 e 16746, mesmo periodo 2026-08-24, hashes diferentes porque a IA mudou os insights entre as duas chamadas)', () => {
const cfg = loadConfig({ SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, SLACK_DESTINATION: 'C0BJNBTPFT5' });
// 1a chamada: IA retorna um insight
const primeira = buildReport(
baseInput({
triggerType: 'scheduled',
sendLogRows: [],
aiCall: { attempted: true, error: null, content: aiContentWith([
{ type: 'CATEGORY_SPIKE', severity: 'medium', title: 'Titulo A', summary: 'Resumo A',
evidence_ticket_ids: [1], confidence: 0.9, reportable: true, subject_key: 'categoria:a' },
]) },
}),
cfg
);
assert.equal(primeira.envio.pode_enviar, true);
// 2a chamada (retry do MESMO periodo): IA retorna um insight DIFERENTE
// (titulo/summary diferentes -> mensagem_v2 diferente -> message_hash
// diferente). O send_key (identidade do relatorio) tem que continuar
// sendo o MESMO, pois e o MESMO periodo/destino.
const segunda = buildReport(
baseInput({
triggerType: 'scheduled',
sendLogRows: [{ send_key: primeira.envio.send_key, status: 'success' }],
aiCall: { attempted: true, error: null, content: aiContentWith([
{ type: 'CATEGORY_SPIKE', severity: 'medium', title: 'Titulo B totalmente diferente', summary: 'Resumo B',
evidence_ticket_ids: [1], confidence: 0.9, reportable: true, subject_key: 'categoria:a' },
]) },
}),
cfg
);
assert.notEqual(primeira.envio.message_hash, segunda.envio.message_hash, 'pre-condicao do teste: os textos precisam ser diferentes');
assert.equal(segunda.envio.send_key, primeira.envio.send_key, 'send_key deve ser o mesmo apesar do texto diferente');
assert.equal(segunda.envio.pode_enviar, false);
assert.equal(segunda.envio.duplicata_bloqueada, true);
});
+121
View File
@@ -0,0 +1,121 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildCategoryFilterClause,
buildIndicatorsQuery,
buildRankingCompletoQuery,
buildTicketContextQuery,
buildBaselineDailyQuery,
buildExtendedMetricsQuery,
} = require('../src/v2/queries');
const { assertReadOnlySql, FORBIDDEN_KEYWORDS } = require('../src/v2/sqlGuard');
const { resolvePeriod } = require('../src/v2/period');
const { loadConfig } = require('../src/v2/config');
const config = loadConfig();
const period = resolvePeriod('2026-08-20', new Date(2026, 7, 24), config);
test('assertReadOnlySql aceita SELECT e devolve a mesma string', () => {
const sql = 'SELECT * FROM glpi_tickets';
assert.equal(assertReadOnlySql(sql), sql);
});
test('assertReadOnlySql bloqueia qualquer verbo de escrita', () => {
for (const verbo of FORBIDDEN_KEYWORDS) {
assert.throws(() => assertReadOnlySql(`${verbo} INTO glpi_tickets ...`), /Query bloqueada/);
}
});
test('assertReadOnlySql nao gera falso positivo em nomes de coluna legitimos do GLPI (date_creation, updater...)', () => {
assert.doesNotThrow(() =>
assertReadOnlySql('SELECT date_creation, users_id_lastupdater FROM glpi_tickets')
);
});
test('buildCategoryFilterClause usa os IDs configurados (7,16,17,18) e nao verbo de escrita', () => {
const clause = buildCategoryFilterClause([7, 16, 17, 18]);
assert.match(clause, /id = 7/);
assert.match(clause, /id = 16/);
assert.match(clause, /id = 17/);
assert.match(clause, /id = 18/);
assert.doesNotThrow(() => assertReadOnlySql(clause));
});
test('buildCategoryFilterClause rejeita id invalido (defesa contra injecao via config)', () => {
assert.throws(() => buildCategoryFilterClause([7, 'DROP TABLE x']));
});
test('buildIndicatorsQuery e somente leitura e preserva a logica da V1 (abertos por date, fechados por closedate)', () => {
const sql = buildIndicatorsQuery(period, config);
assert.doesNotThrow(() => assertReadOnlySql(sql));
assert.match(sql, /is_deleted = false/);
assert.match(sql, /date >= '2026-08-20'/);
assert.match(sql, /closedate >= '2026-08-20'/);
assert.match(sql, /status NOT IN \(5, 6\)/);
assert.match(sql, /priority >= 4/);
});
test('buildIndicatorsQuery rejeita data em formato invalido (protecao contra interpolacao livre)', () => {
const periodoRuim = { ...period, data_inicio: "2026-08-20'; DROP TABLE glpi_tickets; --", data_fim: period.data_fim };
assert.throws(() => buildIndicatorsQuery(periodoRuim, config), /Data invalida/);
});
test('buildRankingCompletoQuery gera SQL somente leitura para categoria e cliente, e expoe o id (nao so o nome)', () => {
const sqlCategoria = buildRankingCompletoQuery(period, config, 'categoria');
const sqlCliente = buildRankingCompletoQuery(period, config, 'cliente');
assert.doesNotThrow(() => assertReadOnlySql(sqlCategoria));
assert.doesNotThrow(() => assertReadOnlySql(sqlCliente));
assert.match(sqlCategoria, /glpi_itilcategories/);
assert.match(sqlCliente, /glpi_entities/);
assert.match(sqlCategoria, /SELECT t\.itilcategories_id AS id,/);
assert.match(sqlCliente, /SELECT t\.entities_id AS id,/);
});
test('buildTicketContextQuery limita linhas via LIMIT e nao seleciona tabelas de escrita', () => {
const sql = buildTicketContextQuery(period, config);
assert.doesNotThrow(() => assertReadOnlySql(sql));
assert.match(sql, new RegExp(`LIMIT ${config.TICKET_CONTEXT_SQL_LIMIT}`));
assert.match(sql, /LEFT\(t\.content, 600\) AS description/);
});
test('buildBaselineDailyQuery agrupa por dia e por dimensao (categoria/cliente) sem verbo de escrita, e nao faz JOIN com tabela de dimensao (so glpi_tickets)', () => {
const janela = period.baseline.rolling;
const sql = buildBaselineDailyQuery(janela, config, 'categoria', period);
assert.doesNotThrow(() => assertReadOnlySql(sql));
assert.match(sql, /GROUP BY dia,/);
assert.doesNotMatch(sql, /LEFT JOIN glpi_itilcategories/);
assert.doesNotMatch(sql, /LEFT JOIN glpi_entities/);
});
test('buildBaselineDailyQuery restringe o agrupamento aos IDs ativos no periodo atual (evita agrupar a base inteira de entidades/categorias)', () => {
const janela = period.baseline.rolling;
const sqlCliente = buildBaselineDailyQuery(janela, config, 'cliente', period);
assert.doesNotThrow(() => assertReadOnlySql(sqlCliente));
// a subquery de IDs ativos usa o periodo ATUAL (data_inicio do relatorio), nao a janela historica
assert.match(sqlCliente, new RegExp(`t2\\.date >= '${period.data_inicio}'`));
assert.match(sqlCliente, /AND t\.entities_id IN \(/);
});
test('buildExtendedMetricsQuery e somente leitura mesmo usando campos ainda nao validados no schema real', () => {
const sql = buildExtendedMetricsQuery(period, config);
assert.doesNotThrow(() => assertReadOnlySql(sql));
assert.match(sql, /time_to_resolve/);
});
test('nenhuma query gerada para nenhum tipo de periodo conhecido contem verbo de escrita (varredura ampla)', () => {
const referencias = [new Date(2026, 7, 18), new Date(2026, 7, 19), new Date(2026, 7, 20), new Date(2026, 7, 21), new Date(2026, 7, 24)];
for (const ref of referencias) {
const p = resolvePeriod('', ref, config);
assert.doesNotThrow(() => assertReadOnlySql(buildIndicatorsQuery(p, config)));
assert.doesNotThrow(() => assertReadOnlySql(buildTicketContextQuery(p, config)));
assert.doesNotThrow(() => assertReadOnlySql(buildRankingCompletoQuery(p, config, 'categoria')));
assert.doesNotThrow(() => assertReadOnlySql(buildRankingCompletoQuery(p, config, 'cliente')));
assert.doesNotThrow(() => assertReadOnlySql(buildExtendedMetricsQuery(p, config)));
if (p.baseline.rolling) {
assert.doesNotThrow(() => assertReadOnlySql(buildBaselineDailyQuery(p.baseline.rolling, config, 'categoria', p)));
assert.doesNotThrow(() => assertReadOnlySql(buildBaselineDailyQuery(p.baseline.rolling, config, 'cliente', p)));
}
}
});
+123
View File
@@ -0,0 +1,123 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { canSend, computeMessageHash, buildSendKey, isDuplicate } = require('../src/v2/sendGuard');
const BASE = { SHADOW_MODE: false, DEV_MODE: false, SEND_ENABLED: true, FORCE_SEND: false };
// --- Matriz de guarda de seguranca completa (secao 15 do ciclo final) ---
test('SHADOW_MODE=true nunca envia, mesmo scheduled com tudo mais ligado', () => {
const r = canSend({ triggerType: 'scheduled', config: { ...BASE, SHADOW_MODE: true } });
assert.equal(r.podeEnviar, false);
});
test('SHADOW_MODE=true nunca envia, mesmo manual com FORCE_SEND=true', () => {
const r = canSend({ triggerType: 'manual', config: { ...BASE, SHADOW_MODE: true, FORCE_SEND: true } });
assert.equal(r.podeEnviar, false);
});
test('DEV_MODE=true sem override nunca envia para producao (scheduled)', () => {
const r = canSend({ triggerType: 'scheduled', config: { ...BASE, DEV_MODE: true } });
assert.equal(r.podeEnviar, false);
});
test('DEV_MODE=true bloqueia mesmo manual+FORCE_SEND=true (sem override possivel)', () => {
const r = canSend({ triggerType: 'manual', config: { ...BASE, DEV_MODE: true, FORCE_SEND: true } });
assert.equal(r.podeEnviar, false);
});
test('SEND_ENABLED=false nunca envia (scheduled)', () => {
const r = canSend({ triggerType: 'scheduled', config: { ...BASE, SEND_ENABLED: false } });
assert.equal(r.podeEnviar, false);
});
test('SEND_ENABLED=false nunca envia (manual + FORCE_SEND=true)', () => {
const r = canSend({ triggerType: 'manual', config: { ...BASE, SEND_ENABLED: false, FORCE_SEND: true } });
assert.equal(r.podeEnviar, false);
});
test('scheduled + producao (SEND_ENABLED=true, SHADOW_MODE=false, DEV_MODE=false) pode enviar', () => {
const r = canSend({ triggerType: 'scheduled', config: { ...BASE } });
assert.equal(r.podeEnviar, true);
});
test('manual + FORCE_SEND=false nunca envia', () => {
const r = canSend({ triggerType: 'manual', config: { ...BASE, FORCE_SEND: false } });
assert.equal(r.podeEnviar, false);
});
test('manual + FORCE_SEND=true pode enviar (sujeito a idempotencia a parte)', () => {
const r = canSend({ triggerType: 'manual', config: { ...BASE, FORCE_SEND: true } });
assert.equal(r.podeEnviar, true);
});
test('motivo sempre presente e legivel, mesmo quando pode enviar', () => {
const r = canSend({ triggerType: 'scheduled', config: { ...BASE } });
assert.equal(typeof r.motivo, 'string');
assert.ok(r.motivo.length > 0);
});
// --- Hash / chave / duplicata (secao 13) ---
test('computeMessageHash e deterministico para o mesmo texto', () => {
const a = computeMessageHash('Resumo diario: 11 abertos, 11 fechados');
const b = computeMessageHash('Resumo diario: 11 abertos, 11 fechados');
assert.equal(a, b);
});
test('computeMessageHash muda quando o texto muda', () => {
const a = computeMessageHash('Resumo diario: 11 abertos');
const b = computeMessageHash('Resumo diario: 12 abertos');
assert.notEqual(a, b);
});
test('computeMessageHash lida com texto vazio/nulo sem lancar erro', () => {
assert.doesNotThrow(() => computeMessageHash(null));
assert.doesNotThrow(() => computeMessageHash(undefined));
assert.doesNotThrow(() => computeMessageHash(''));
});
test('buildSendKey combina report_type + periodo + destino (SEM message_hash)', () => {
const key = buildSendKey({
reportType: 'diario',
periodStart: '2026-08-24',
periodEnd: '2026-08-24',
destination: 'C0BJNBTPFT5',
});
assert.equal(key, 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5');
});
test('regressao: send_key IGUAL mesmo com message_hash diferente (bug real de 2026-08-25 — 2 mensagens reais enviadas ao Slack porque a IA nao e deterministica e mudou o texto entre duas chamadas do MESMO periodo, gerando hashes diferentes e escapando da deteccao de duplicata)', () => {
const base = { reportType: 'diario', periodStart: '2026-08-24', periodEnd: '2026-08-24', destination: 'C0BJNBTPFT5' };
const keyTentativa1 = buildSendKey(base);
const keyTentativa2 = buildSendKey(base);
assert.equal(keyTentativa1, keyTentativa2);
const rows = [{ send_key: keyTentativa1, status: 'success' }];
assert.equal(isDuplicate(rows, keyTentativa2), true);
});
test('isDuplicate detecta uma chave ja registrada com status=success', () => {
const key = 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5|abc123';
const rows = [{ send_key: key, status: 'success' }];
assert.equal(isDuplicate(rows, key), true);
});
test('isDuplicate ignora registros com status diferente de success', () => {
const key = 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5|abc123';
const rows = [{ send_key: key, status: 'failed' }, { send_key: key, status: 'blocked_duplicate' }];
assert.equal(isDuplicate(rows, key), false);
});
test('isDuplicate retorna false quando a lista de logs esta vazia ou ausente', () => {
assert.equal(isDuplicate([], 'qualquer'), false);
assert.equal(isDuplicate(null, 'qualquer'), false);
assert.equal(isDuplicate(undefined, 'qualquer'), false);
});
test('isDuplicate nao confunde chaves de periodos diferentes', () => {
const rows = [{ send_key: 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5|abc123', status: 'success' }];
assert.equal(isDuplicate(rows, 'diario|2026-08-25|2026-08-25|C0BJNBTPFT5|abc123'), false);
});
+98
View File
@@ -0,0 +1,98 @@
'use strict';
/**
* Teste de regressao estrutural (secao 16 do ciclo final).
*
* Contexto: a causa raiz do timeout que travou "MySQL - Baseline Diario
* Cliente" foi a ausencia de `executeOnce: true` nesse (e em outros) node
* MySQL/Data Table — sem ele, o n8n reexecuta o node UMA VEZ POR ITEM de
* entrada (76.440 execucoes observadas), em vez de uma unica vez. Este
* teste garante que essa regressao especifica nao pode voltar a acontecer
* silenciosamente: ele le o workflow gerado (o mesmo arquivo que e
* reimportado no n8n) e falha se qualquer um dos nodes criticos perder a
* flag.
*
* Roda contra o ARQUIVO GERADO (nao contra tools/build_v2_workflow_assemble.js
* diretamente) porque e o arquivo gerado que de fato e importado no n8n —
* e o que importa e o resultado final, nao a implementacao do gerador.
* Rode `node tools/build_v2_workflow.js` antes de `npm test` sempre que
* alterar o assemble script (o proprio README/uso do script ja pede isso).
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const WORKFLOW_PATH = path.join(__dirname, '..', 'Resumo Operacional GLPI v2 - IA.n8n.json');
// Os nodes que buscam dados em lote (uma query cobrindo todos os itens) e
// que, se rodarem "por item" em vez de uma unica vez, causam o mesmo tipo
// de explosao combinatoria que gerou o timeout original.
const NODES_QUE_DEVEM_EXECUTAR_UMA_VEZ = [
'MySQL - Indicadores do Periodo',
'MySQL - Ranking Categorias',
'MySQL - Ranking Clientes',
'MySQL - Contexto de Chamados',
'MySQL - Baseline Diario Categoria',
'MySQL - Baseline Diario Cliente',
'Data Table - Buscar Memoria de Insights',
'Data Table - Buscar Historico Anterior',
];
function loadWorkflow() {
if (!fs.existsSync(WORKFLOW_PATH)) {
throw new Error(
`Workflow gerado nao encontrado em ${WORKFLOW_PATH}. Rode "node tools/build_v2_workflow.js" antes de rodar os testes.`
);
}
return JSON.parse(fs.readFileSync(WORKFLOW_PATH, 'utf8'));
}
test('workflow gerado existe e tem a lista de nodes esperada', () => {
const wf = loadWorkflow();
assert.ok(Array.isArray(wf.nodes));
assert.ok(wf.nodes.length > 0);
});
for (const nodeName of NODES_QUE_DEVEM_EXECUTAR_UMA_VEZ) {
test(`node "${nodeName}" mantem executeOnce=true (regressao do timeout de 76.440 execucoes)`, () => {
const wf = loadWorkflow();
const node = wf.nodes.find((n) => n.name === nodeName);
assert.ok(node, `node "${nodeName}" nao existe mais no workflow gerado — renomeado? atualize este teste.`);
assert.equal(
node.executeOnce,
true,
`node "${nodeName}" perdeu executeOnce=true — isso reintroduz o bug do timeout (76.440 execucoes).`
);
});
}
test('Code - Motor V2 inclui as funcoes de sendGuard.js (regressao: modulo requerido mas nao concatenado)', () => {
// Bug real encontrado e corrigido em 2026-08-25: pipeline.js passou a
// `require('./sendGuard')`, mas tools/build_v2_workflow.js remove TODA
// linha de require ao montar o Code node (para o n8n nao tentar dar
// require de um arquivo que nao existe no ambiente remoto) — sem incluir
// sendGuard.js na lista de modulos concatenados, canSend/computeMessageHash/
// buildSendKey ficariam undefined em runtime, quebrando o node inteiro.
const wf = loadWorkflow();
const node = wf.nodes.find((n) => n.name === 'Code - Motor V2: Relatorio Completo');
assert.ok(node, 'node "Code - Motor V2: Relatorio Completo" nao encontrado');
const codigo = node.parameters && node.parameters.jsCode;
assert.ok(codigo, 'node sem parameters.jsCode');
for (const nomeFuncao of ['function canSend', 'function computeMessageHash', 'function buildSendKey']) {
assert.ok(
codigo.includes(nomeFuncao),
`Code - Motor V2 nao contem "${nomeFuncao}" — sendGuard.js nao esta sendo concatenado (ver MOD.sendGuard em tools/build_v2_workflow.js)`
);
}
});
test('nenhum node MySQL do workflow roda sem executeOnce=true (varredura ampla, protege contra novo node MySQL futuro)', () => {
const wf = loadWorkflow();
const mysqlNodes = wf.nodes.filter((n) => n.type === 'n8n-nodes-base.mySql');
assert.ok(mysqlNodes.length > 0, 'esperava pelo menos um node MySQL no workflow');
for (const node of mysqlNodes) {
assert.equal(node.executeOnce, true, `node MySQL "${node.name}" sem executeOnce=true`);
}
});