feat(v2): wire Slack send branch + Schedule Trigger into the template, generalize transitive-dep test

n8n/workflow.template.json:
- Added the 10 new nodes for the real Slack send branch (Data Table -
  Buscar Envios Recentes, Code - Guard de Envio Slack, IF - Deve Enviar
  Slack?, Slack - Enviar Mensagem, Code - Pular Envio Slack, Code -
  Registrar Envio Sucesso/Falha, the two Data Table - Registrar Envio
  Digest nodes, Code - Lancar Erro Envio Slack) and wired every
  connection -- Code - Montar Digest Coordenacao now fans out to both
  the existing shadow-diff branch (unchanged, always runs) and the new
  send branch in parallel, so a Slack failure never loses the shadow
  audit trail.
- Renamed "Schedule Trigger - Dias uteis 08h (desligado em DEV)" ->
  "Schedule Trigger - Dias uteis 08h": it was already wired to Config
  and already had the correct cron (0 8 * * 1-5) from earlier in the
  project -- this cycle didn't need to build it, just stop calling it
  "desligado" now that it's genuinely production-track (activation is
  controlled by the workflow's own active flag, not the node's name).
- Added settings.timezone: "America/Sao_Paulo" explicitly (previously
  implicit/unset) -- matches the timezone already hardcoded into
  buildTopMessage's dateStr computation elsewhere in this codebase, not
  a new assumption.
- Config node: AI_MAX_CANDIDATES 30->60, added ENVIRONMENT/SEND_ENABLED.

tools/build-workflow.test.js: 10 new tests (connections integrity after
a node rename -- would have caught a real bug this cycle where renaming
the Schedule Trigger orphaned its connection key; Schedule Trigger wired
+ correct cron/timezone; Manual Trigger still present; real Slack node
exists with the right credential/onError; guard runs before Slack;
per-ticket envio registration only fires after the success Data Table
write; Slack failure always registers before throwing; AI_MAX_CANDIDATES
== CANDIDATE_POOL_SIZE; SEND_ENABLED/ENVIRONMENT present; GLPI SQL/query
scan for INSERT/UPDATE/DELETE/ALTER/DROP/CREATE/TRUNCATE; dataTable node
operation values are valid enum members -- caught a real bug, "getRows"
[the MCP tool's action name] isn't a valid node parameter value, it's
"get"). Replaced the narrow normalize.js-specific transitive-dependency
test with a general one that scans every logic/*.js top-level function
and asserts any node calling it also has it inlined -- this caught a
second real instance of the same bug class (slack.js's use of hash.js's
djb2Hash, missing from 3 new node headers) before it reached a live run.

n8n/deployed/coordenador-glpi-v2.n8n.json regenerated (43 nodes).

156/156 tests passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Gustavo
2026-08-24 20:51:15 -03:00
co-authored by Claude Sonnet 5
parent abc690ddb7
commit d64b1132b9
3 changed files with 1463 additions and 170 deletions
+156 -11
View File
@@ -66,21 +66,166 @@ test('glpi_base_url no Config nao e mais o placeholder do V1', () => {
assert.equal(glpiField.value, 'https://glpi.gtigrupo.com.br');
});
test('todo node que chama normalizeGlpiText/normalizeTicket/buildAiContextText tem a definicao dessas funcoes inlined (deps transitivas de context.js -> normalize.js)', () => {
// Regressao: context.js passou a fazer require('./normalize') nesta correcao, mas
// readLogicSource() so inlina o que o header @requires do node lista -- nao resolve
// deps transitivas. Um node com "@requires context.js" e sem "normalize.js" no header
// compila (sintaxe valida), mas explode em runtime com "normalizeGlpiText is not defined".
test('nenhum node chama uma funcao de logic/*.js sem ter a definicao dela inlined (deps transitivas resolvidas em todo @requires)', () => {
// Regressao GENERICA -- ja pegou dois bugs reais nesta missao (normalize.js nao listado
// junto de context.js; hash.js nao listado junto de slack.js). readLogicSource() so inlina
// o que o header @requires de CADA node lista -- nao resolve deps transitivas entre
// logic/*.js sozinho. Um node com a chamada mas sem a definicao compila (sintaxe valida) e
// so explode em runtime com "<funcao> is not defined". Este teste escaneia TODAS as funcoes
// top-level de logic/*.js e garante que, se um node CHAMA uma delas, a DEFINICAO tambem
// esta no mesmo jsCode gerado -- sem precisar listar caso a caso feito antes.
const fs = require('node:fs');
const path = require('node:path');
const logicDir = path.join(__dirname, '..', 'logic');
const functionNames = [];
for (const file of fs.readdirSync(logicDir)) {
if (!file.endsWith('.js') || file.endsWith('.test.js')) continue;
const src = fs.readFileSync(path.join(logicDir, file), 'utf8');
const matches = src.matchAll(/^function\s+(\w+)\s*\(/gm);
for (const m of matches) functionNames.push(m[1]);
}
assert.ok(functionNames.length > 10, 'sanity: deveria ter encontrado varias funcoes em logic/*.js');
const wf = buildWorkflow();
for (const node of wf.nodes) {
if (node.type !== 'n8n-nodes-base.code') continue;
const code = node.parameters.jsCode;
const callsHelper = /\bnormalizeGlpiText\(|\bnormalizeTicket\(|\bbuildAiContextText\(/.test(code);
if (!callsHelper) continue;
assert.ok(
code.includes('function normalizeGlpiText'),
`node ${node.name} chama uma funcao de normalize.js mas nao tem normalizeGlpiText inlined (adicionar normalize.js ao @requires)`,
);
for (const fnName of functionNames) {
const callsIt = new RegExp(`[^.\\w]${fnName}\\(`).test(code);
if (!callsIt) continue;
const definesIt = code.includes(`function ${fnName}`);
assert.ok(
definesIt,
`node "${node.name}" chama ${fnName}(...) mas nao tem "function ${fnName}" inlined -- falta um arquivo de logic/*.js no @requires desse node driver`,
);
}
}
});
test('nenhuma chave de connections fica orfa (nome renomeado sem atualizar connections) -- pegaria o bug real do Schedule Trigger', () => {
const wf = buildWorkflow();
const names = new Set(wf.nodes.map((n) => n.name));
for (const sourceKey of Object.keys(wf.connections)) {
assert.ok(names.has(sourceKey), `connections tem uma chave orfa: "${sourceKey}" nao corresponde a nenhum node`);
}
for (const conn of Object.values(wf.connections)) {
for (const branch of conn.main || []) {
for (const edge of branch || []) {
assert.ok(names.has(edge.node), `connections aponta para um node inexistente: "${edge.node}"`);
}
}
}
});
test('Schedule Trigger esta conectado de verdade ao pipeline (Config) -- teste obrigatorio #17', () => {
const wf = buildWorkflow();
const scheduleNode = wf.nodes.find((n) => n.type === 'n8n-nodes-base.scheduleTrigger');
assert.ok(scheduleNode, 'workflow deveria ter um Schedule Trigger');
const conn = wf.connections[scheduleNode.name];
assert.ok(conn && conn.main && conn.main[0] && conn.main[0].length > 0, 'Schedule Trigger precisa estar conectado a algum node');
assert.equal(conn.main[0][0].node, 'Config - Coordenador GLPI v2');
});
test('Schedule Trigger roda segunda-sexta as 08h, timezone America/Sao_Paulo -- teste obrigatorio #18', () => {
const wf = buildWorkflow();
const scheduleNode = wf.nodes.find((n) => n.type === 'n8n-nodes-base.scheduleTrigger');
const cron = scheduleNode.parameters.rule.interval[0].expression;
assert.equal(cron, '0 8 * * 1-5');
assert.equal(wf.settings.timezone, 'America/Sao_Paulo');
});
test('Manual Trigger continua presente para testes/manutencao', () => {
const wf = buildWorkflow();
assert.ok(wf.nodes.some((n) => n.type === 'n8n-nodes-base.manualTrigger'));
});
test('node real de envio Slack existe (nao e so simulado/comentado)', () => {
const wf = buildWorkflow();
const slackNode = wf.nodes.find((n) => n.type === 'n8n-nodes-base.slack');
assert.ok(slackNode, 'deveria existir um node n8n-nodes-base.slack real');
assert.equal(slackNode.parameters.text, '={{ $json.mensagem }}');
assert.ok(slackNode.credentials && slackNode.credentials.slackApi, 'Slack node deveria usar uma credencial slackApi (nao webhook cru hardcoded)');
assert.equal(slackNode.onError, 'continueErrorOutput', 'precisa separar sucesso/falha pra idempotencia e nao-silenciar-erro');
});
test('guard de envio roda antes do Slack e existe IF de decisao', () => {
const wf = buildWorkflow();
const guardConn = wf.connections['Code - Guard de Envio Slack'];
assert.equal(guardConn.main[0][0].node, 'IF - Deve Enviar Slack?');
const ifConn = wf.connections['IF - Deve Enviar Slack?'];
assert.equal(ifConn.main[0][0].node, 'Slack - Enviar Mensagem');
assert.equal(ifConn.main[1][0].node, 'Code - Pular Envio Slack');
});
test('nodes n8n-nodes-base.dataTable usam valores de operation validos (bug real: "getRows" nao existe, e "get")', () => {
// Regressao: n8n_manage_datatable (a ferramenta MCP) usa a acao "getRows", mas o node
// nativo n8n-nodes-base.dataTable usa o valor de parametro "get" -- nomes parecidos,
// APIs diferentes. Pegou um erro real de validacao ao sincronizar com o DEV live.
const VALID_ROW_OPERATIONS = new Set(['deleteRows', 'get', 'rowExists', 'rowNotExists', 'insert', 'update', 'upsert']);
const wf = buildWorkflow();
for (const node of wf.nodes) {
if (node.type !== 'n8n-nodes-base.dataTable') continue;
const op = node.parameters.operation;
assert.ok(VALID_ROW_OPERATIONS.has(op), `node "${node.name}" tem operation="${op}", que nao e um valor valido do node dataTable`);
}
});
test('registro de envio (per-ticket) so acontece depois do Data Table de sucesso do Slack -- nunca antes da confirmacao', () => {
const wf = buildWorkflow();
// "Code - Selecionar Top 10" nao pode mais alimentar "Code - Preparar Registro de Envio" direto.
const top10Conn = wf.connections['Code - Selecionar Top 10'];
const directTargets = top10Conn.main[0].map((e) => e.node);
assert.ok(!directTargets.includes('Code - Preparar Registro de Envio'), 'registro de envio nao pode mais sair direto do Selecionar Top 10');
// A cadeia real: Sucesso -> Data Table Sucesso -> Preparar Registro de Envio.
const sucessoDbConn = wf.connections['Data Table - Registrar Envio Digest (Sucesso)'];
assert.equal(sucessoDbConn.main[0][0].node, 'Code - Preparar Registro de Envio');
});
test('falha do Slack sempre grava status=failed antes de lancar o erro (execucao visivelmente falha)', () => {
const wf = buildWorkflow();
const falhaConn = wf.connections['Code - Registrar Envio Falha'];
assert.equal(falhaConn.main[0][0].node, 'Data Table - Registrar Envio Digest (Falha)');
const falhaDbConn = wf.connections['Data Table - Registrar Envio Digest (Falha)'];
assert.equal(falhaDbConn.main[0][0].node, 'Code - Lancar Erro Envio Slack');
const lancarErroNode = wf.nodes.find((n) => n.name === 'Code - Lancar Erro Envio Slack');
assert.ok(lancarErroNode.parameters.jsCode.includes('throw new Error'));
});
test('AI_MAX_CANDIDATES no Config e igual a CANDIDATE_POOL_SIZE (nenhum candidato fica sem analise por cap artificial) -- teste obrigatorio #12', () => {
const wf = buildWorkflow();
const configNode = wf.nodes.find((n) => n.name === 'Config - Coordenador GLPI v2');
const assignments = configNode.parameters.assignments.assignments;
const poolSize = assignments.find((a) => a.name === 'CANDIDATE_POOL_SIZE').value;
const maxCandidates = assignments.find((a) => a.name === 'AI_MAX_CANDIDATES').value;
assert.equal(maxCandidates, poolSize);
});
test('Config expoe SEND_ENABLED (default false) e ENVIRONMENT -- teste obrigatorio #13/#22', () => {
const wf = buildWorkflow();
const configNode = wf.nodes.find((n) => n.name === 'Config - Coordenador GLPI v2');
const assignments = configNode.parameters.assignments.assignments;
const sendEnabled = assignments.find((a) => a.name === 'SEND_ENABLED');
const environment = assignments.find((a) => a.name === 'ENVIRONMENT');
assert.ok(sendEnabled, 'Config precisa expor SEND_ENABLED');
assert.equal(sendEnabled.value, false, 'DEV deve comecar com SEND_ENABLED=false');
assert.ok(environment, 'Config precisa expor ENVIRONMENT');
});
test('GLPI continua exclusivamente read-only -- nenhum SQL gerado ou arquivo .sql contem INSERT/UPDATE/DELETE/ALTER/DROP/CREATE/TRUNCATE -- teste obrigatorio #20', () => {
const fs = require('node:fs');
const path = require('node:path');
const forbidden = /\b(INSERT|UPDATE|DELETE|ALTER|DROP|CREATE|TRUNCATE)\b/i;
const sqlDir = path.join(__dirname, '..', 'sql');
for (const file of fs.readdirSync(sqlDir)) {
if (!file.endsWith('.sql')) continue;
const content = fs.readFileSync(path.join(sqlDir, file), 'utf8');
assert.ok(!forbidden.test(content), `${file} contem um comando de escrita SQL -- GLPI precisa ser read-only`);
}
const wf = buildWorkflow();
for (const node of wf.nodes) {
if (node.type === 'n8n-nodes-base.mySql' && node.parameters && typeof node.parameters.query === 'string') {
assert.ok(!forbidden.test(node.parameters.query), `node MySQL "${node.name}" tem um comando de escrita`);
}
}
});