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 " 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('Slack node nao anexa o rodape automatico "Automated with this n8n workflow" (bug real: vazou na primeira mensagem real de teste)', () => { // O node Slack do n8n inclui esse link por padrao (includeLinkToWorkflow default: true) -- // vazou pra dentro do canal real no primeiro teste de envio. A missao pede mensagem minima, // sem nada alem do que buildTopMessage gera. const wf = buildWorkflow(); const slackNode = wf.nodes.find((n) => n.type === 'n8n-nodes-base.slack'); assert.equal(slackNode.parameters.otherOptions.includeLinkToWorkflow, false); }); 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'); });