Adiciona V2 do relatorio GLPI: camada deterministica + IA com fallback

Evolui a automacao (V1 preservada, intocada) para uma V2 com:

- Camada deterministica (src/v2): calendario operacional (porta fiel da
  logica da V1), queries SQL somente-leitura (indicadores, ranking com
  IDs, contexto de chamados, baseline historico), deteccao de anomalia
  (spike de categoria/cliente, concentracao, tendencia de backlog),
  memoria/fingerprint de insights com transicao de estado (new ->
  persisting -> normalizing -> resolved), mensagens curtas (fallback V1 e
  V2). 97 testes automatizados (node --test).

- Camada de IA (structured output via OpenAI, schema JSON validado em
  codigo, nunca confia em numeros vindos da IA): prompt de "Analista
  Operacional", fallback automatico e seguro quando a IA falha/expira/
  retorna JSON invalido.

- Workflow n8n "[DEV] GLPI - Relatorio Operacional IA v2" (id
  3x5kb58TeBCvirm6, INATIVO) criado e validado ao vivo no DEV
  (devn8n.gtigrupo.com.br) via webhook temporario (removido apos os
  testes) com SHADOW_MODE/DEV_MODE ligados — nenhuma mensagem real
  enviada, nenhuma escrita no GLPI.

- tools/build_v2_workflow.js + build_v2_workflow_assemble.js: geram o
  workflow n8n a partir de src/v2/*.js (Code nodes remotos nao importam
  arquivos locais), mantendo uma unica fonte de verdade testada.

Bugs reais encontrados e corrigidos durante a validacao ao vivo:
- glpi_tickets nao tem coluna "solution" nesta versao do schema.
- Nodes MySQL/DataTable sem "Execute Once" executavam 1x por item
  recebido do node anterior; a cadeia amplificava ate ~76 mil execucoes
  na query de baseline por cliente, causando o timeout real observado.
  Corrigido com executeOnce=true nos 8 nodes afetados e reescrita da
  query de baseline para nao depender de JOIN com tabela de dimensao.
- IF "Pode Enviar" usava o operador booleano invertido (checava "e
  true" em vez de "e false"), o que quase permitia envio real ao Slack
  mesmo com SHADOW_MODE/DEV_MODE ligados.
- gpt-5.5 exige max_completion_tokens (nao max_tokens) e gasta o
  orcamento em tokens de raciocinio; ajustado para 3000 tokens +
  reasoning_effort=low.

Validado ao vivo contra dados reais do GLPI DEV (20/08/2026): IA gerou
insights reais com evidencia auditavel, memoria persistiu entre
execucoes (occurrences incrementando, normalizacao detectada),
resultado 200 confirmando envio suprimido (shadow mode).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Automacao GLPI
2026-08-25 08:49:45 -03:00
co-authored by Claude Sonnet 5
parent b2aefcd2d7
commit bdfa675d89
23 changed files with 4671 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);
});
+318
View File
@@ -0,0 +1,318 @@
'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);
});
+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)));
}
}
});