559 lines
18 KiB
JavaScript
559 lines
18 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 = 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);
|
|
});
|