V2: guarda de envio real (SEND_ENABLED/FORCE_SEND), idempotencia por send_key e Slack nativo
- src/v2/sendGuard.js (novo): canSend() reduz SHADOW_MODE/DEV_MODE/ SEND_ENABLED/FORCE_SEND a uma formula unica testada exaustivamente (secao 15 do ciclo final: matriz completa de guarda de seguranca). computeMessageHash/buildSendKey/isDuplicate implementam a chave logica de idempotencia da secao 13 (report_type+periodo+destino+hash). - src/v2/config.js: SEND_ENABLED, FORCE_SEND, SLACK_DESTINATION. - src/v2/pipeline.js: buildReport agora calcula envio.pode_enviar/ motivo_guarda/duplicata_bloqueada/send_key a partir de triggerType (.mode) e sendLogRows (Data Table de log de envios). - tools/build_v2_workflow*.js: novo Data Table 'Buscar Log de Envios', troca do HTTP Slack com webhook placeholder por node Slack nativo (credencial real lNqwBBrtLfkRqw2X, reaproveitada do projeto irmao Coordenador GLPI), branches de sucesso/falha gravando o log de envio so apos confirmacao real do Slack. - test/sendGuard.test.js, test/workflowStructure.test.js (novos): cobrem a matriz de guarda de seguranca e travam regressao do bug de timeout (executeOnce=true nos 8 nodes de leitura em lote) e do bug de sendGuard.js nao concatenado no Code node gerado. - Corrigido bug real encontrado nesta sessao: sendGuard.js nao estava na lista de modulos concatenados pelo gerador, deixando canSend/ computeMessageHash/buildSendKey undefined em runtime. - Deployado e validado ao vivo no DEV (workflow 3x5kb58TeBCvirm6): n8n_validate_workflow -> valid:true, 0 erros, executeOnce preservado nos 9 nodes de leitura em lote. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
bdfa675d89
commit
64ddfb9b7b
@@ -316,3 +316,46 @@ test('spikesCategoria casa baseline e periodo atual por ID, nao por nome (evita
|
||||
// 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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
'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 os 5 campos da chave logica de idempotencia', () => {
|
||||
const key = buildSendKey({
|
||||
reportType: 'diario',
|
||||
periodStart: '2026-08-24',
|
||||
periodEnd: '2026-08-24',
|
||||
destination: 'C0BJNBTPFT5',
|
||||
messageHash: 'abc123',
|
||||
});
|
||||
assert.equal(key, 'diario|2026-08-24|2026-08-24|C0BJNBTPFT5|abc123');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user