const fs = require('fs'); const path = require('path'); const { PrismaClient } = require('@prisma/client'); const { PrismaPg } = require('@prisma/adapter-pg'); const { Pool } = require('pg'); require('dotenv').config(); // Base configurations - Target the dev server directly via localhost port const BASE_URL = "http://127.0.0.1:3001"; let prisma; let pool; const deployedWorkflowIds = []; let n8nUrl; let n8nApiKey; // Helper to run query with admin credentials function getPrisma() { if (!prisma) { const dbUrl = new URL(process.env.DATABASE_URL_DEV || process.env.DATABASE_URL); pool = new Pool({ host: dbUrl.hostname, port: dbUrl.port ? parseInt(dbUrl.port) : 5432, user: decodeURIComponent(dbUrl.username), password: decodeURIComponent(dbUrl.password), database: dbUrl.pathname.substring(1).split('?')[0], ssl: false }); const adapter = new PrismaPg(pool); prisma = new PrismaClient({ adapter }); } return prisma; } async function runAsAdmin(queryFn) { const db = getPrisma(); return db.$transaction(async (tx) => { await tx.$executeRawUnsafe(`SET LOCAL app.current_user_role = 'admin';`); return queryFn(tx); }); } async function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // Custom simple assertion let passed = 0; let failed = 0; function assert(condition, message) { if (condition) { console.log(` ✓ PASS: ${message}`); passed++; } else { console.error(` ✗ FAIL: ${message}`); failed++; } } async function cleanupDb() { console.log("Cleaning up integration test records in test database..."); try { await runAsAdmin(async (tx) => { // Clean sales results await tx.salesResult.deleteMany({ where: { OR: [ { idempotencyKey: { startsWith: 'key-integration' } }, { period: '2026-07' } ] } }); // Clean import jobs if (tx.salesImportJob) { await tx.salesImportJob.deleteMany({ where: { idempotencyKey: { startsWith: 'key-integration' } } }); } // Clean settlements await tx.settlement.deleteMany({ where: { period: '2026-07' } }); // Clean goals await tx.goal.deleteMany({ where: { period: '2026-07' } }); // Clean test plans await tx.calculationRule.deleteMany({ where: { plan: { code: 'PLAN-INT-P5' } } }); await tx.compensationPlan.deleteMany({ where: { code: 'PLAN-INT-P5' } }); // Clean audit logs await tx.auditLog.deleteMany({ where: { action: 'CREATE', targetTable: 'sales_results' } }); }); } catch (err) { console.error("Error during DB cleanup:", err); } } async function runTests() { console.log("=== STARTING REAL N8N E2E INTEGRATION TEST SUITE ==="); // 1. Clean DB await cleanupDb(); // 2. Fetch n8n API configuration const mcpConfigPath = path.join(__dirname, '../.agents/mcp_config.json'); if (!fs.existsSync(mcpConfigPath)) { console.error("Error: .agents/mcp_config.json not found."); process.exit(1); } const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8')); const n8nEnv = mcpConfig.mcpServers.n8n.env; n8nUrl = n8nEnv.N8N_API_URL || "https://n8n.gaboggamer.online"; n8nApiKey = n8nEnv.N8N_API_KEY; if (!n8nApiKey) { console.error("Error: N8N_API_KEY is missing in mcp_config.json."); process.exit(1); } // 3. Spawn n8n bootstrap to ensure permanent workflows are deployed and active console.log("Running n8n bootstrap/kickstarter to deploy permanent workflows..."); const { execSync } = require('child_process'); try { execSync('node scripts/n8n-bootstrap.js', { stdio: 'inherit' }); } catch (err) { console.error("Failed to run n8n bootstrap:", err.message); } // 4. Fetch existing workflows to retrieve the project ID dynamically and clean up old test workflows console.log("Fetching workflows from n8n to clean up old test workflows and retrieve project ID..."); let projectId; try { const listRes = await fetch(`${n8nUrl}/api/v1/workflows`, { method: 'GET', headers: { 'X-N8N-API-KEY': n8nApiKey } }); if (listRes.ok) { const listData = await listRes.json(); const firstWf = listData.data.find(w => w.shared && w.shared.length > 0); if (firstWf) { projectId = firstWf.shared[0].projectId; console.log(`Resolved project ID dynamically: ${projectId}`); } // Cleanup any leftover old test workflows for (const wf of listData.data) { if (wf.name.startsWith('Semillero E2E Integration:')) { console.log(`Deleting leftover workflow: ${wf.name} (${wf.id})`); await fetch(`${n8nUrl}/api/v1/workflows/${wf.id}`, { method: 'DELETE', headers: { 'X-N8N-API-KEY': n8nApiKey } }); } } } } catch (err) { console.warn("Warning: Could not fetch project ID or clean up workflows:", err.message); } // 5. Verify the external dev server is running console.log(`Verifying target dev server at ${BASE_URL}...`); try { const res = await fetch(`${BASE_URL}/api/auth/me`); if (!res.ok) { console.warn(`Warning: Target server returned status ${res.status}`); } } catch (e) { console.error(`Error: Dev server is unreachable at ${BASE_URL}. Error:`, e.message); await cleanup(); process.exit(1); } // ========================================== // TEST CASE 0: WORKFLOW CREATION AND DELETION API // ========================================== console.log("\n[Test 0] Verifying workflow creation and deletion via n8n API..."); let tempWorkflowId; try { const createRes = await fetch(`${n8nUrl}/api/v1/workflows`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-N8N-API-KEY': n8nApiKey }, body: JSON.stringify({ name: `Semillero E2E Integration: API Test Temp - ${Date.now()}`, nodes: [], connections: {}, settings: {}, projectId }) }); if (!createRes.ok) { console.error(`Workflow creation failed. Status: ${createRes.status}`, await createRes.text()); } assert(createRes.ok, "API: Successfully created a temporary workflow in n8n"); if (createRes.ok) { const createData = await createRes.json(); tempWorkflowId = createData.id; const deleteRes = await fetch(`${n8nUrl}/api/v1/workflows/${tempWorkflowId}`, { method: 'DELETE', headers: { 'X-N8N-API-KEY': n8nApiKey } }); assert(deleteRes.ok, "API: Successfully deleted the temporary workflow in n8n"); } } catch (err) { console.error("API test failed:", err.message); failed++; } // ========================================== // TEST CASE 1: SALES IMPORT WORKFLOW // ========================================== console.log("\n[Test 1] Triggering Sales Import Workflow..."); const importIdempotencyKey = `key-integration-import-${Date.now()}`; const uploadRes = await fetch(`${n8nUrl}/webhook/calculate-commissions`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-n8n-signature': process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=', 'x-semillero-env': 'dev' }, body: JSON.stringify({ idempotencyKey: importIdempotencyKey, uploaderId: 1, sales: [ { username: 'colaborador_mde', hotelCode: 'EST-MDE', period: '2026-06', amount: 2000000, salesCount: 5, transactionId: `TX-INT-COMM-${Date.now()}` } ] }) }); assert(uploadRes.status === 200 || uploadRes.status === 202, `Import webhook response code: ${uploadRes.status}`); const uploadData = await uploadRes.json(); assert(uploadData.success && uploadData.code === 'IMPORT_ACCEPTED', "Import accepted by n8n"); let importCallbackCompleted = false; for (let i = 0; i < 20; i++) { await sleep(2000); const dbSales = await runAsAdmin(tx => tx.salesResult.findMany({ where: { idempotencyKey: { startsWith: importIdempotencyKey } } })); if (dbSales.length > 0) { assert(dbSales.length === 1, "Sales result saved to dev database by n8n callback"); assert(parseFloat(dbSales[0].amount) === 2000000.00, "Imported amount verified"); assert(dbSales[0].isAnomaly === true, "isAnomaly flag correctly set to true"); assert(dbSales[0].flaggedReason && typeof dbSales[0].flaggedReason === 'object', "flaggedReason is stored as a JSON object"); assert(dbSales[0].flaggedReason.en.includes("exceeds normal threshold limits"), "flaggedReason English matches expected text"); assert(dbSales[0].flaggedReason.es.includes("excede") || dbSales[0].flaggedReason.es.includes("umbral"), "flaggedReason Spanish contains translation"); importCallbackCompleted = true; break; } } assert(importCallbackCompleted, "Sales import integration completed successfully"); // ========================================== // TEST CASE 2: SETTLEMENT CALCULATION WORKFLOW // ========================================== console.log("\n[Test 2] Triggering Settlement Calculation Workflow..."); // Seed DB records for the calculation E2E test console.log("Seeding test database with plan, rules, goal, and sales results for calculation E2E test..."); const adminUser = (await runAsAdmin(tx => tx.user.findMany())).find(u => u.username === 'admin'); const colaboradorMde = (await runAsAdmin(tx => tx.user.findMany())).find(u => u.username === 'colaborador_mde'); const testPlan = await runAsAdmin(async (tx) => { // Delete existing PLAN-INT-P5 if any await tx.calculationRule.deleteMany({ where: { plan: { code: 'PLAN-INT-P5' } } }); await tx.compensationPlan.deleteMany({ where: { code: 'PLAN-INT-P5' } }); const plan = await tx.compensationPlan.create({ data: { name: 'Integration Test Plan Phase 5', code: 'PLAN-INT-P5', validityStart: new Date('2026-01-01'), type: 'SCALE', status: 'ACTIVE', version: 1, createdBy: adminUser.id } }); await tx.calculationRule.create({ data: { planId: plan.id, type: 'TIER', minAchievement: 0.0, maxAchievement: 2.0, rate: 0.02, payoutAmount: 0.0 } }); return plan; }); // Seed Goal and SalesResult for period 2026-07 await runAsAdmin(async (tx) => { await tx.goal.deleteMany({ where: { period: '2026-07', targetId: colaboradorMde.id } }); await tx.goal.create({ data: { targetType: 'INDIVIDUAL', targetId: colaboradorMde.id, period: '2026-07', amount: 500000.00 } }); await tx.salesResult.deleteMany({ where: { period: '2026-07', userId: colaboradorMde.id } }); await tx.salesResult.create({ data: { source: 'API', hotelId: colaboradorMde.hotelId, userId: colaboradorMde.id, period: '2026-07', amount: 600000.00, salesCount: 2, idempotencyKey: 'key-integration-sales-p5', uploadedBy: adminUser.id, status: 'PENDING' } }); }); // Trigger calculation webhook (commit mode) console.log(`Triggering settlement calculation webhook directly at ${n8nUrl}/webhook/calculate-settlements...`); const calcRes = await fetch(`${n8nUrl}/webhook/calculate-settlements`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-n8n-signature': process.env.N8N_WEBHOOK_SECRET_DEV || 'qUHuPqPjA65psdtwQU7zgp/DVkvd1xXk2WP/vzbnEdc=', 'x-semillero-env': 'dev' }, body: JSON.stringify({ period: '2026-07', simulateOnly: false, uploaderId: adminUser.id }) }); assert(calcRes.status === 200 || calcRes.status === 201, `Calculation webhook response code: ${calcRes.status}`); const calcData = await calcRes.json(); assert(calcData.success && calcData.code === 'SETTLEMENTS_CALCULATED', "Calculation execution successful"); // Verify settlement created in database const finalSettlement = await runAsAdmin(tx => tx.settlement.findFirst({ where: { userId: colaboradorMde.id, period: '2026-07', planId: testPlan.id } })); assert(finalSettlement !== null, "Settlement record created in database for colaborador_mde in 2026-07"); if (finalSettlement) { assert(finalSettlement.status === 'PENDING', "Settlement status is set to PENDING"); assert(parseFloat(finalSettlement.calculatedCommission) === 12000.00, "Calculated commission is correctly 12,000.00 (600,000 * 2%)"); assert(parseFloat(finalSettlement.totalPayout) === 12000.00, "Total payout is correctly 12,000.00"); assert(finalSettlement.aiAudited === true, "aiAudited flag correctly set to true"); assert(finalSettlement.aiAuditNotes && typeof finalSettlement.aiAuditNotes === 'object', "aiAuditNotes is stored as a JSON object"); assert(finalSettlement.aiAuditNotes.en.includes("High commission payout"), "aiAuditNotes English contains warning text"); assert(finalSettlement.aiAuditNotes.es.includes("comisión alto") || finalSettlement.aiAuditNotes.es.includes("umbral"), "aiAuditNotes Spanish contains translation"); } await cleanup(); console.log(`\n=== REAL N8N INTEGRATION TEST RESULTS: ${passed} PASSED, ${failed} FAILED ===`); if (failed > 0) { process.exit(1); } else { process.exit(0); } } async function cleanup() { console.log("\nCleaning up integration test processes..."); // 1. Delete all deployed workflows from n8n starting with 'Semillero E2E Integration:' try { const listRes = await fetch(`${n8nUrl}/api/v1/workflows`, { method: 'GET', headers: { 'X-N8N-API-KEY': n8nApiKey } }); if (listRes.ok) { const listData = await listRes.json(); for (const wf of listData.data) { if (wf.name.startsWith('Semillero E2E Integration:')) { console.log(`Cleaning up workflow: ${wf.name} (${wf.id})`); await fetch(`${n8nUrl}/api/v1/workflows/${wf.id}`, { method: 'DELETE', headers: { 'X-N8N-API-KEY': n8nApiKey } }); } } } } catch (err) { console.warn("Warning during cleanup: Could not clean up workflows:", err.message); } // 2. Clean DB records await cleanupDb(); // 3. Disconnect Prisma try { if (prisma) { await prisma.$disconnect(); } if (pool) { await pool.end(); } } catch (e) {} } process.on('SIGINT', async () => { await cleanup(); process.exit(1); }); runTests().catch(async err => { console.error("Fatal E2E integration test runner error:", err); failed++; await cleanup(); process.exit(1); });