Found running the real Slack test send (with the user's explicit
confirmation to try SEND_ENABLED=true once): the Slack node itself fired
correctly and got a real response back from Slack's API ("invalid_arguments"
-- confirms the pipeline mechanics, credential and node config work end
to end up to that point). Separately, once the failure branch tried to
record it, "Data Table - Registrar Envio Digest (Falha)" crashed with
"Cannot convert undefined or null to object" inside n8n's own insert
operation -- both new envio-digest insert nodes had operation:"insert"
and dataTableId but no "columns" mapping, which is a required property
for insert/update/upsert (confirmed via get_node nodes-base.dataTable).
Fixed both nodes with the same columns.mappingMode:"defineBelow" shape
already used by the existing "Data Table - Registrar Shadow Run" node.
Added a regression test scanning every dataTable node with an insert/
update/upsert operation for a columns.mappingMode.
158/158 tests passing.
Separately (not fixed here, needs the user): the actual Slack
"invalid_arguments" error suggests $vars.SLACK_CHANNEL_TECNICO isn't
resolving to a valid channel on this n8n instance -- no tool available
to this agent can list/inspect n8n instance Variables to confirm. Left
SEND_ENABLED=false and the workflow inactive pending the user's input on
the correct channel.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
268 lines
14 KiB
JavaScript
268 lines
14 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('todo node dataTable com operation insert/update/upsert tem columns.mappingMode definido (bug real: insert sem columns crasha no n8n com "Cannot convert undefined or null to object")', () => {
|
|
// Regressao: rodou de verdade contra o DEV -- os dois nodes novos "Data Table - Registrar
|
|
// Envio Digest (Sucesso/Falha)" tinham operation:"insert" e dataTableId, mas nenhum
|
|
// "columns" -- que e um campo OBRIGATORIO pra insert/update/upsert (ver get_node
|
|
// nodes-base.dataTable). Sem ele, o proprio node do n8n crasha dentro do proprio insert.
|
|
const wf = buildWorkflow();
|
|
const OPERATIONS_REQUIRING_COLUMNS = new Set(['insert', 'update', 'upsert']);
|
|
for (const node of wf.nodes) {
|
|
if (node.type !== 'n8n-nodes-base.dataTable') continue;
|
|
if (!OPERATIONS_REQUIRING_COLUMNS.has(node.parameters.operation)) continue;
|
|
assert.ok(
|
|
node.parameters.columns && node.parameters.columns.mappingMode,
|
|
`node "${node.name}" tem operation="${node.parameters.operation}" mas nao tem columns.mappingMode -- vai crashar em runtime`,
|
|
);
|
|
}
|
|
});
|
|
|
|
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');
|
|
});
|