394 lines
14 KiB
JavaScript
394 lines
14 KiB
JavaScript
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: {
|
|
OR: [
|
|
{ idempotencyKey: { startsWith: 'test-key-p4' } },
|
|
{ idempotencyKey: { startsWith: 'key-' } }
|
|
]
|
|
}
|
|
});
|
|
// 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}...`);
|
|
const testEnv = { ...process.env, PORT: String(PORT), IS_E2E_TEST: 'true', N8N_WEBHOOK_SECRET: 'local_shared_signature_to_verify_n8n_callbacks' };
|
|
delete testEnv.N8N_WEBHOOK_URL; // Force direct database save mode for E2E tests
|
|
nextProcess = spawn('node', ['node_modules/next/dist/bin/next', 'start', '--port', String(PORT)], {
|
|
env: testEnv
|
|
});
|
|
|
|
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 });
|
|
await page.emulateMediaFeatures([{ name: 'prefers-color-scheme', value: 'dark' }]);
|
|
|
|
// --- 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('header'); // Wait for home page dashboard load
|
|
const hasCreatePlanBtn = await page.evaluate(() => !!document.getElementById('btn-create-plan'));
|
|
assert(!hasCreatePlanBtn, "Collaborator should not see the 'Nuevo Plan' button in the dashboard");
|
|
|
|
// 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.waitForSelector('#nav-import-sales');
|
|
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('tbody tr td:last-child')).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);
|
|
});
|