Files
SLACK_COORD_RELATORIO/tools/build-workflow.test.js
T
GustavoandClaude Sonnet 5 86039ecfe4 fix(v2): Data Table - Buscar Envios Recentes needs alwaysOutputData (real bug found in DEV validation)
Found running the real validation execution against DEV: the envios
digest table starts empty (the normal state for every day until the
first successful send), the "get" operation returned 0 rows, and n8n
does not execute downstream nodes when a node outputs 0 items -- so
Code - Guard de Envio Slack, the IF, the real Slack node, and everything
else in the send branch silently never ran. This would have broken the
happy path on every single day in production, not just an edge case.

Fixed with alwaysOutputData: true on that node (forces at least one
output item even with 0 matches; the resulting empty {} item doesn't
false-positive in alreadySentSuccessfully since none of its fields match
a real record's shape). Re-ran the validation execution after the fix:
Code - Guard de Envio Slack now runs correctly (shouldSend: false,
reason: 'send_disabled' with SEND_ENABLED=false as expected), and the
IF node's true branch (Slack - Enviar Mensagem) is confirmed to never
fire while disabled.

Added a regression test asserting alwaysOutputData on this node.

157/157 tests passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 20:56:02 -03:00

251 lines
13 KiB
JavaScript

const { test } = require('node:test');
const assert = require('node:assert/strict');
const { buildWorkflow } = require('./build-workflow');
test('workflow montado tem nome e nodes esperados', () => {
const wf = buildWorkflow();
assert.equal(wf.name, '[DEV] Coordenador GLPI - Chamados Abertos v2 (Shadow)');
const names = wf.nodes.map((n) => n.name);
assert.ok(names.includes('Code - Montar SQL Candidatos'));
});
test('Code - Montar SQL Candidatos tem a logica de config e o SQL inlined (sem marcador de build)', () => {
const wf = buildWorkflow();
const node = wf.nodes.find((n) => n.name === 'Code - Montar SQL Candidatos');
assert.ok(node.parameters.jsCode.includes('function parseConfig'));
assert.ok(node.parameters.jsCode.includes('WITH ultima_interacao'));
assert.ok(!node.parameters.jsCode.includes('__BUILD__'));
assert.ok(!node.parameters.jsCode.includes('__SQL_CANDIDATOS_V2__'));
});
test('nenhum node de Code sobra com marcador nao resolvido', () => {
const wf = buildWorkflow();
for (const node of wf.nodes) {
if (node.type === 'n8n-nodes-base.code') {
assert.ok(!node.parameters.jsCode.includes('__BUILD__'), `node ${node.name} tem marcador nao resolvido`);
}
}
});
test('todo jsCode gerado e sintaticamente valido (pega identificador duplicado, chaves erradas, etc.)', () => {
const wf = buildWorkflow();
for (const node of wf.nodes) {
if (node.type === 'n8n-nodes-base.code') {
assert.doesNotThrow(() => {
// eslint-disable-next-line no-new-func
new Function('$input', '$env', '$', node.parameters.jsCode);
}, `node ${node.name} tem jsCode com erro de sintaxe`);
}
}
});
test('nenhum jsCode gerado contem "require(" sobrando (modulos devem estar totalmente inlined)', () => {
const wf = buildWorkflow();
for (const node of wf.nodes) {
if (node.type === 'n8n-nodes-base.code') {
assert.ok(!/\brequire\(/.test(node.parameters.jsCode), `node ${node.name} ainda tem require() nao resolvido`);
}
}
});
test('workflow gerado NAO tem nenhum node de webhook de debug', () => {
const wf = buildWorkflow();
const names = wf.nodes.map((n) => n.name);
assert.ok(!names.includes('Debug - Ver Painel'));
assert.ok(!names.includes('Debug - Responder'));
assert.ok(!names.includes('Code - Montar Painel Debug'));
const webhookNodes = wf.nodes.filter((n) => n.type === 'n8n-nodes-base.webhook');
assert.equal(webhookNodes.length, 0, 'nenhum node webhook deveria sobrar no workflow gerado');
});
test('glpi_base_url no Config nao e mais o placeholder do V1', () => {
const wf = buildWorkflow();
const configNode = wf.nodes.find((n) => n.name === 'Config - Coordenador GLPI v2');
const glpiField = configNode.parameters.assignments.assignments.find((a) => a.name === 'glpi_base_url');
assert.notEqual(glpiField.value, 'https://glpi.suaempresa.com.br');
assert.equal(glpiField.value, 'https://glpi.gtigrupo.com.br');
});
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;
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('Data Table - Buscar Envios Recentes tem alwaysOutputData=true (bug real: 0 linhas -- caso normal do dia a dia -- parava o branch de envio inteiro)', () => {
// Regressao: rodou de verdade contra o DEV -- a tabela de envios estava vazia (nenhum envio
// ja aconteceu, o caso NORMAL de todo dia), o node "get" devolveu 0 itens, e o n8n nao
// executa nenhum node downstream quando o node anterior produz 0 itens. Isso significa que
// Code - Guard de Envio Slack, o IF, o Slack real, tudo -- nunca rodava em um dia comum.
// alwaysOutputData:true garante pelo menos 1 item de saida mesmo com 0 linhas encontradas.
const wf = buildWorkflow();
const node = wf.nodes.find((n) => n.name === 'Data Table - Buscar Envios Recentes');
assert.equal(node.alwaysOutputData, true, 'sem isso, um dia sem envios anteriores nunca chega a avaliar se deve enviar');
});
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`);
}
}
});
test('link_glpi na query de candidatos usa a variavel de config, nao um dominio fixo no SQL', () => {
const fs = require('node:fs');
const path = require('node:path');
const sql = fs.readFileSync(path.join(__dirname, '..', 'sql', 'candidatos_v2_glpi.sql'), 'utf8');
assert.ok(sql.includes("CONCAT('{GLPI_BASE_URL}'"), 'o SQL deve continuar usando o placeholder substituido pelo node, nunca um dominio hardcoded');
assert.ok(!sql.includes('suaempresa.com.br'));
assert.ok(!sql.includes('gtigrupo.com.br'), 'o dominio real deve vir so da config, nunca hardcoded no SQL');
});