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 = 3012; const BASE_URL = `http://localhost:${PORT}`; const SCREENSHOT_DIR = path.join(__dirname, 'screenshots-phase4'); 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 E2E test sales import records in database..."); await runAsAdmin(async (tx) => { // Clean sales results created by test scripts await tx.salesResult.deleteMany({ where: { idempotencyKey: { startsWith: 'test-key-p4' } } }); // Clean audit logs associated await tx.auditLog.deleteMany({ where: { action: 'CREATE', targetTable: 'sales_results' } }); }); } async function runTests() { console.log("=== STARTING PHASE 4 DATA IMPORT & INTEGRATIONS E2E PUPPETEER TEST SUITE ==="); // 1. Clean database records first await cleanupDb(); // 2. Start Next.js production server on port 3012 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) } }); 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!"); // 3. 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 }); // --- TEST 1: ROLE SEGREGATION (COLLABORATOR BLOCKED) --- console.log("\n[Test 1] Logging in as collaborator and verifying block on /sales/import..."); await page.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle2' }); await page.type('#username', 'colaborador_mde'); await page.type('#password', 'password123'); await page.click('button[type="submit"]'); await page.waitForSelector('#btn-create-plan'); // Wait for home page dashboard load // Try navigating to import page await page.goto(`${BASE_URL}/sales/import`, { waitUntil: 'networkidle2' }); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '01_collaborator_unauthorized.png') }); // Check if header Cargar Ventas is not visible, and redirect or error occurs const hasImportLink = await page.evaluate(() => { return document.querySelector('#nav-import-sales') !== null; }); assert(!hasImportLink, "Collaborator is not shown the 'Cargar Ventas' link in the header"); // Verify direct API POST block (returns 403) const apiBlockRes = await page.evaluate(async () => { const res = await fetch('/api/sales/import', { method: 'POST', headers: { 'idempotency-key': 'test-key-p4-fake' } }); return { status: res.status }; }); assert(apiBlockRes.status === 403, "API POST /api/sales/import returns 403 Forbidden for collaborator"); // Logout collaborator 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 sleep(1500); // --- TEST 2: ADMIN ACCESS & UI ELEMENTS --- console.log("\n[Test 2] Logging in as admin and verifying import page..."); 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('#btn-create-plan'); // Navigate to import page await page.click('#nav-import-sales'); await page.waitForSelector('#btn-download-template'); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '02_admin_import_view.png') }); assert(page.url().endsWith('/sales/import'), "Redirected to /sales/import for authorized Admin role"); const downloadLinkExists = await page.evaluate(() => { const el = document.querySelector('#btn-download-template'); return el && el.getAttribute('href') === '/templates/import_sales_template.xlsx'; }); assert(downloadLinkExists, "Template download button points to the correct static template URL"); // --- TEST 3: ATOMIC EXCEL FILE VALIDATION --- console.log("\n[Test 3] Uploading invalid test Excel file and verifying inconsistency panel..."); const invalidFilePath = path.join(__dirname, '..', 'public', 'templates', 'import_sales_test.xlsx'); // Select file in input const fileInput = await page.$('input[type="file"]'); await fileInput.uploadFile(invalidFilePath); await sleep(1000); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '03_invalid_file_selected.png') }); // Click submit await page.click('#btn-submit-upload'); await page.waitForSelector('#inconsistency-panel'); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '04_validation_errors_rendered.png') }); // Validate error rendering contents const validationErrorCount = await page.evaluate(() => { return document.querySelectorAll('tbody tr').length; }); assert(validationErrorCount === 4, "Atomic validation catches all 4 invalid rows in the sheet"); const errorTexts = await page.evaluate(() => { return Array.from(document.querySelectorAll('.cellMsg')).map(el => el.textContent); }); assert(errorTexts.some(t => t.includes("colaborador_inexistente")), "Rendered 'colaborador_inexistente' username validation error"); assert(errorTexts.some(t => t.includes("2026/06")), "Rendered invalid Period format validation error"); assert(errorTexts.some(t => t.includes("EST-FAKE")), "Rendered non-existent Hotel code validation error"); assert(errorTexts.some(t => t.includes("monto")), "Rendered negative sales amount validation error"); // Check that no rows were inserted in DB const dbCount = await runAsAdmin(tx => tx.salesResult.count()); assert(dbCount === 0, "No records written to the database (atomic roll-back verified)"); // --- TEST 4: SUCCESSFUL DIRECT IMPORT & IDEMPOTENCY --- console.log("\n[Test 4] Uploading valid template Excel file and checking direct database save..."); const validFilePath = path.join(__dirname, '..', 'public', 'templates', 'import_sales_template.xlsx'); // Refresh page to reset state and key await page.reload({ waitUntil: 'networkidle2' }); // Attach valid file const fileInputValid = await page.$('input[type="file"]'); await fileInputValid.uploadFile(validFilePath); await sleep(1000); // Submit await page.click('#btn-submit-upload'); await page.waitForSelector('#upload-success-msg'); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '05_valid_upload_success.png') }); // Check db records const dbSales = await runAsAdmin(tx => tx.salesResult.findMany({ where: { idempotencyKey: { startsWith: 'key-' } } })); assert(dbSales.length === 1, "1 sales result successfully imported into the database"); assert(parseFloat(dbSales[0].amount) === 15000.00, "Monto of imported sales result matches template value (15,000.00)"); assert(dbSales[0].salesCount === 5, "Cantidad of imported sales result matches template value (5)"); // Attempt upload again with the SAME key (verify idempotency check) console.log("Uploading the same file again (verifying idempotency)..."); // Trigger upload button again without refreshing (maintains current session idempotencyKey state) await page.click('#btn-submit-upload'); await sleep(1500); await page.screenshot({ path: path.join(SCREENSHOT_DIR, '06_idempotency_duplicate.png') }); const finalDbCount = await runAsAdmin(tx => tx.salesResult.count({ where: { idempotencyKey: { startsWith: 'key-' } } })); assert(finalDbCount === 1, "Idempotency verified: duplicate upload request was blocked from inserting duplicate rows"); // --- TEST 5: N8N CALLBACK AUTHENTICATION AND BATCH-SAVE --- console.log("\n[Test 5] Simulating n8n callback `/api/sales/batch-save` signature checking..."); // Trigger callback with invalid signature const callbackUnauthorized = await page.evaluate(async () => { const res = await fetch('/api/sales/batch-save', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-n8n-signature': 'invalid-secret' }, body: JSON.stringify({ idempotencyKey: 'test-key-p4-n8n', uploaderId: 1, sales: [] }) }); return { status: res.status }; }); assert(callbackUnauthorized.status === 401, "Webhook callback returns 401 Unauthorized for invalid signature"); // Trigger callback with valid signature const callbackSuccess = await page.evaluate(async () => { const res = await fetch('/api/sales/batch-save', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-n8n-signature': 'local_shared_signature_to_verify_n8n_callbacks' }, body: JSON.stringify({ idempotencyKey: 'test-key-p4-n8n', uploaderId: 1, sales: [ { username: 'colaborador_mde', hotelCode: 'EST-MDE', period: '2026-06', amount: 35000.00, salesCount: 8, transactionId: 'TX-N8N-001' } ] }) }); return { status: res.status }; }); assert(callbackSuccess.status === 201, "Webhook callback returns 201 Created for valid signature and payload"); // Verify n8n-inserted record in database const n8nSales = await runAsAdmin(tx => tx.salesResult.findMany({ where: { idempotencyKey: { startsWith: 'test-key-p4-n8n' } } })); assert(n8nSales.length === 1, "n8n callback successfully inserted sales result row in database"); assert(parseFloat(n8nSales[0].amount) === 35000.00, "n8n sales result row amount matches callback payload"); 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); });