const { spawn } = require('child_process'); const { PrismaClient } = require('@prisma/client'); const { PrismaPg } = require('@prisma/adapter-pg'); const { Pool } = require('pg'); const puppeteer = require('puppeteer'); const fs = require('fs'); const path = require('path'); require('dotenv').config(); const PORT = 3015; const BASE_URL = `http://localhost:${PORT}`; const SCREENSHOT_DIR = path.join(__dirname, 'screenshots-phase5'); let nextProcess; let prisma; let pool; let browser; // Ensure screenshot dir exists if (!fs.existsSync(SCREENSHOT_DIR)) { fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); } function getPrisma() { if (!prisma) { const dbUrl = new URL(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)); } 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 database records for Phase 5 tests..."); await runAsAdmin(async (tx) => { // Delete settlements created during E2E tests await tx.settlement.deleteMany({ where: { OR: [ { period: '2026-06' }, { period: '2026-05' } ] } }); // Delete goals created during E2E tests await tx.goal.deleteMany({ where: { OR: [ { period: '2026-06' }, { period: '2026-05' } ] } }); // Delete sales results created during E2E tests await tx.salesResult.deleteMany({ where: { OR: [ { period: '2026-06' }, { period: '2026-05' } ] } }); // Delete test plans await tx.calculationRule.deleteMany({ where: { plan: { code: 'PLAN-E2E-P5' } } }); await tx.compensationPlan.deleteMany({ where: { code: 'PLAN-E2E-P5' } }); }); } async function runTests() { console.log("=== STARTING PHASE 5 SETTLEMENT ENGINE & APPROVALS E2E PUPPETEER TEST SUITE ==="); // 1. Clean database await cleanupDb(); // 2. Seed active plan & rules for the tests console.log("Seeding test plan and rules..."); const users = await runAsAdmin(tx => tx.user.findMany()); const adminUser = users.find(u => u.username === 'admin'); const colaboradorMde = users.find(u => u.username === 'colaborador_mde'); const liderCtg = users.find(u => u.username === 'lider_ctg'); if (!colaboradorMde || !liderCtg) { console.error("Error: Collaborator or Leader user not found in database."); process.exit(1); } const testPlan = await runAsAdmin(async (tx) => { const plan = await tx.compensationPlan.create({ data: { name: 'Plan Test Phase 5', code: 'PLAN-E2E-P5', validityStart: new Date('2026-01-01'), type: 'SCALE', status: 'ACTIVE', version: 1, createdBy: adminUser.id } }); await tx.calculationRule.createMany({ data: [ { planId: plan.id, type: 'TIER', minAchievement: 0.0, maxAchievement: 0.8, rate: 0.01, payoutAmount: 0.0 }, { planId: plan.id, type: 'TIER', minAchievement: 0.8, maxAchievement: 1.2, rate: 0.02, payoutAmount: 0.0 }, { planId: plan.id, type: 'BONUS', minAchievement: 1.0, maxAchievement: 2.0, rate: 0.0, payoutAmount: 500.00 } ] }); return plan; }); // 3. Start Next.js production server on port 3015 console.log(`Starting Next.js production server on port ${PORT}...`); nextProcess = spawn('node', ['node_modules/next/dist/bin/next', 'start', '--port', String(PORT)], { env: { ...process.env, PORT: String(PORT), IS_E2E_TEST: 'true' } }); nextProcess.stdout.on('data', (data) => { console.log(`[Next.js] ${data.toString().trim()}`); }); nextProcess.stderr.on('data', (data) => { console.error(`[Next.js ERR] ${data.toString().trim()}`); }); // Wait for server to start up let serverReady = false; for (let i = 0; i < 15; i++) { await sleep(2000); try { const res = await fetch(`${BASE_URL}/api/auth/me`); serverReady = true; break; } catch (e) { // Server not ready yet } } if (!serverReady) { console.error("Error: Next.js server failed to start within timeout."); await cleanup(); process.exit(1); } console.log("Next.js server is ready!"); // 4. Launch Puppeteer browser console.log("Launching Puppeteer browser..."); browser = await puppeteer.launch({ headless: 'shell', args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'] }); const page = await browser.newPage(); await page.setViewport({ width: 1280, height: 800 }); await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]); // Helper to set value of React-controlled inputs natively const setReactInput = async (selector, value) => { await page.$eval(selector, (el, val) => { const nativeSetter = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, "value" ).set; nativeSetter.call(el, val); el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); }, value); }; // --- TEST 1: ENGINE ACCURACY --- console.log("\n[Test 1] Seeding sales/goals and running calculation..."); // Seed goal and sales results for 2026-06 await runAsAdmin(async (tx) => { await tx.goal.create({ data: { targetType: 'INDIVIDUAL', targetId: colaboradorMde.id, period: '2026-06', amount: 10000.00 } }); await tx.salesResult.create({ data: { source: 'EXCEL', hotelId: colaboradorMde.hotelId, userId: colaboradorMde.id, period: '2026-06', amount: 9000.00, salesCount: 5, idempotencyKey: 'key-p5-test-1', uploadedBy: adminUser.id } }); }); // Login as admin await page.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle2' }); await page.type('#username', 'admin'); await page.type('#password', 'password123'); await page.click('button[type="submit"]'); await page.waitForSelector('#nav-simulation'); // Go to simulation page await page.click('#nav-simulation'); await page.waitForSelector('#sim-period'); // Run simulation in commit mode (check off dry-run) await setReactInput('#sim-period', '2026-06'); // Uncheck dry-run checkbox to write to DB const isChecked1 = await page.$eval('#sim-dry-run', el => el.checked); if (isChecked1) { await page.click('#sim-dry-run'); } await page.screenshot({ path: path.join(SCREENSHOT_DIR, '01_simulation_ready.png') }); await page.click('#btn-run-simulation'); await page.waitForSelector('#simulation-success-msg'); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '02_simulation_success.png') }); // Verify in database const dbSettlement = await runAsAdmin(tx => tx.settlement.findFirst({ where: { userId: colaboradorMde.id, period: '2026-06', originalSettlementId: null } })); assert(dbSettlement !== null, "Settlement record created in database for colaborador_mde in 2026-06"); assert(dbSettlement.status === 'PENDING', "Settlement status is set to PENDING"); assert(parseFloat(dbSettlement.salesAmount) === 9000.00, "Sales amount is correctly 9,000.00"); assert(parseFloat(dbSettlement.calculatedCommission) === 180.00, "Proposed commission is 180.00 (9,000 * 2%)"); assert(parseFloat(dbSettlement.totalPayout) === 180.00, "Total payout is 180.00"); // --- TEST 2: LEADER ISOLATION & REJECTION CONSTRAINT --- console.log("\n[Test 2] Verifying leader isolation and rejection constraint..."); // Logout admin await page.goto(`${BASE_URL}/plans`, { waitUntil: 'networkidle2' }); await page.evaluate(() => { const buttons = Array.from(document.querySelectorAll('button')); const logoutBtn = buttons.find(b => b.textContent.includes('Cerrar Sesión')); if (logoutBtn) logoutBtn.click(); }); await page.waitForSelector('#username'); // Login as lider_ctg (Caribe region leader) await page.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle2' }); await page.type('#username', 'lider_ctg'); await page.type('#password', 'password123'); await page.click('button[type="submit"]'); await page.waitForSelector('#nav-approvals'); // Navigate to Approvals await page.click('#nav-approvals'); await page.waitForSelector('#approvals-table'); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '03_lider_ctg_approvals_view.png') }); // Leader of Caribe should not see colaborador_mde's settlement in the table (which is in Antioquia region) const hasColabRow = await page.evaluate((username) => { return document.body.innerHTML.includes(username); }, colaboradorMde.username); assert(!hasColabRow, "Leader of Caribe region cannot see collaborator of Antioquia region in the segregated list"); const directApproveRes = await page.evaluate(async (settlementId) => { const res = await fetch(`/api/settlements/${settlementId}/approve`, { method: 'POST' }); return { status: res.status }; }, dbSettlement.id); assert(directApproveRes.status === 403, "Direct API call to approve another region's settlement returns 403 Forbidden, got " + directApproveRes.status); const directRejectRes = await page.evaluate(async (settlementId) => { const res = await fetch(`/api/settlements/${settlementId}/reject`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: 'Malicioso' }) }); return { status: res.status }; }, dbSettlement.id); assert(directRejectRes.status === 403, "Direct API call to reject another region's settlement returns 403 Forbidden, got " + directRejectRes.status); // Logout lider_ctg await page.goto(`${BASE_URL}/plans`, { waitUntil: 'networkidle2' }); await page.evaluate(() => { const buttons = Array.from(document.querySelectorAll('button')); const logoutBtn = buttons.find(b => b.textContent.includes('Cerrar Sesión')); if (logoutBtn) logoutBtn.click(); }); await page.waitForSelector('#username'); // Login as admin to test rejection constraint await page.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle2' }); await page.type('#username', 'admin'); await page.type('#password', 'password123'); await page.click('button[type="submit"]'); await page.waitForSelector('#nav-approvals'); // Navigate to Approvals await page.click('#nav-approvals'); await page.waitForSelector('#approvals-table'); // Verify direct reject call with empty reason returns 400 const emptyReasonRes = await page.evaluate(async (settlementId) => { const res = await fetch(`/api/settlements/${settlementId}/reject`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: '' }) }); return { status: res.status }; }, dbSettlement.id); assert(emptyReasonRes.status === 400, "Direct API call to reject settlement with empty reason returns 400 Bad Request"); // Reject the settlement using UI Modal await page.click(`#btn-reject-${dbSettlement.id}`); await page.waitForSelector('#reject-reason-input'); await page.type('#reject-reason-input', 'Venta reportada del colaborador no coincide con el cierre del hotel'); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '04_rejection_modal.png') }); await page.click('#btn-confirm-reject'); await page.waitForSelector('#approvals-success-msg'); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '05_rejection_done.png') }); // Verify status in DB const rejectedSettlement = await runAsAdmin(tx => tx.settlement.findUnique({ where: { id: dbSettlement.id } })); assert(rejectedSettlement.status === 'REJECTED', "Settlement status updated to REJECTED in database"); assert(rejectedSettlement.rejectionReason === 'Venta reportada del colaborador no coincide con el cierre del hotel', "Rejection reason successfully stored in database"); // --- TEST 3: CLAWBACK DELTA LOGIC --- console.log("\n[Test 3] Testing clawback delta adjustments..."); // Reset database state: // Let's create an approved settlement for Month M-1 (2026-05) and simulate refund await runAsAdmin(async (tx) => { // Delete any existing settlement for 2026-05 await tx.settlement.deleteMany({ where: { userId: colaboradorMde.id, period: '2026-05' } }); // Create Goal for 2026-05 await tx.goal.create({ data: { targetType: 'INDIVIDUAL', targetId: colaboradorMde.id, period: '2026-05', amount: 10000.00 } }); // Create SalesResult for 2026-05 (Original sales amount = 10,000.00) await tx.salesResult.create({ data: { source: 'EXCEL', hotelId: colaboradorMde.hotelId, userId: colaboradorMde.id, period: '2026-05', amount: 10000.00, salesCount: 5, idempotencyKey: 'key-p5-past-sales', uploadedBy: adminUser.id } }); // Create APPROVED original settlement for 2026-05 // Payout = 10,000 * 2% + 500 bonus = 700.00 await tx.settlement.create({ data: { period: '2026-05', planId: testPlan.id, userId: colaboradorMde.id, salesAmount: 10000.00, goalAmount: 10000.00, achievementPercentage: 1.00, calculatedCommission: 200.00, calculatedBonus: 500.00, adjustmentAmount: 0.00, totalPayout: 700.00, status: 'APPROVED', approvedBy: adminUser.id, approvedAt: new Date() } }); // Now, simulate a cancellation/refund in the PMS: // Update the SalesResult for 2026-05 to be 5,000.00 instead of 10,000.00 await tx.salesResult.update({ where: { idempotencyKey: 'key-p5-past-sales' }, data: { amount: 5000.00 } }); }); // Calculate 2026-06 settlements again. // Current month (2026-06) sales = 9,000.00, Goal = 10,000.00. // Recalculating 2026-05: // - Live sales sum = 5,000.00 // - Achievement = 50% // - Corrected payout = 5,000 * 1% = 50.00 (No bonus since achievement < 1.0) // - Previously paid = 700.00 // - Delta = 50.00 - 700.00 = -650.00 // Current month proposed = 9,000 * 2% = 180.00 // Total payout = 180.00 + (-650.00) = -470.00 // Navigate to simulation page again await page.goto(`${BASE_URL}/sales/simulation`, { waitUntil: 'networkidle2' }); await setReactInput('#sim-period', '2026-06'); // Uncheck dry-run checkbox to write to DB const isChecked2 = await page.$eval('#sim-dry-run', el => el.checked); if (isChecked2) { await page.click('#sim-dry-run'); } await page.click('#btn-run-simulation'); await page.waitForSelector('#simulation-success-msg'); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '06_clawback_calculated.png') }); // Verify DB record const finalSettlement = await runAsAdmin(tx => tx.settlement.findFirst({ where: { userId: colaboradorMde.id, period: '2026-06', originalSettlementId: null } })); assert(finalSettlement !== null, "Main settlement record created for 2026-06"); assert(parseFloat(finalSettlement.adjustmentAmount) === -650.00, "Retroactive adjustment delta matches expected clawback (-650.00)"); assert(parseFloat(finalSettlement.totalPayout) === -470.00, "Total payout is correctly -470.00 (180 commission - 650 clawback)"); // Verify adjustment record in DB const adjSettlement = await runAsAdmin(tx => tx.settlement.findFirst({ where: { userId: colaboradorMde.id, period: '2026-06', originalSettlementId: { not: null } } })); assert(adjSettlement !== null, "Adjustment settlement record created in DB linked to original settlement ID"); assert(parseFloat(adjSettlement.adjustmentAmount) === -650.00, "Adjustment record amount is -650.00"); await cleanup(); console.log(`\n=== E2E TEST RESULTS: ${passed} PASSED, ${failed} FAILED ===`); if (failed > 0) { process.exit(1); } else { process.exit(0); } } async function cleanup() { console.log("\nCleaning up E2E test browser and server processes..."); try { if (browser) { await browser.close(); } } catch (e) {} try { await cleanupDb(); if (prisma) { await prisma.$disconnect(); } if (pool) { await pool.end(); } } catch (e) { console.error("Error during DB cleanup:", e); } if (nextProcess) { nextProcess.kill(); } } process.on('SIGINT', () => { cleanup(); process.exit(1); }); runTests().catch(async err => { console.error("Fatal E2E test runner error:", err); if (browser) { try { const pages = await browser.pages(); if (pages.length > 0) { await pages[0].screenshot({ path: path.join(SCREENSHOT_DIR, 'error_screenshot.png') }); console.log("Saved error_screenshot.png to", path.join(SCREENSHOT_DIR, 'error_screenshot.png')); } } catch (screenshotErr) { console.error("Failed to capture error screenshot:", screenshotErr); } } await cleanup(); process.exit(1); });