- Add English/Spanish translation system for UI pages - Add bilingual AI audit notes using translation LLM chains in n8n - Support zero-trust Docker routing to dev stack via internal hostnames - Quiet down Next.js database query and middleware logging in test runs
448 lines
15 KiB
JavaScript
448 lines
15 KiB
JavaScript
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);
|
|
});
|