465 lines
17 KiB
JavaScript
465 lines
17 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 = 3010;
|
|
const BASE_URL = `http://localhost:${PORT}`;
|
|
const SCREENSHOT_DIR = path.join(__dirname, 'screenshots');
|
|
|
|
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 runTests() {
|
|
console.log("=== STARTING PHASE 3 E2E PUPPETEER TEST SUITE ===");
|
|
|
|
// 1. Clean database records first
|
|
console.log("Resetting test environment data...");
|
|
await runAsAdmin(async (tx) => {
|
|
// Delete calculation rules associated with E2E test plans
|
|
await tx.calculationRule.deleteMany({
|
|
where: { plan: { code: 'PLAN-E2E-PUPP' } }
|
|
});
|
|
// Delete E2E test goals
|
|
await tx.goal.deleteMany({
|
|
where: { period: '2026-06', amount: 75000.00 }
|
|
});
|
|
// Delete E2E test plans
|
|
await tx.compensationPlan.deleteMany({
|
|
where: { code: 'PLAN-E2E-PUPP' }
|
|
});
|
|
});
|
|
|
|
// 2. Start Next.js production server on port 3010
|
|
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 dev server failed to start within timeout.");
|
|
await cleanup();
|
|
process.exit(1);
|
|
}
|
|
console.log("Next.js dev 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' }]);
|
|
|
|
// 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);
|
|
};
|
|
|
|
// --- STEP 1: LOGIN FLOW ---
|
|
console.log("\n[Step 1] Navigating to login page...");
|
|
await page.goto(`${BASE_URL}/login`, { waitUntil: 'networkidle2' });
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '01_login_page.png') });
|
|
|
|
console.log("Entering admin credentials...");
|
|
await page.type('#username', 'admin');
|
|
await page.type('#password', 'password123');
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '02_login_typed.png') });
|
|
|
|
console.log("Clicking submit...");
|
|
await page.click('button[type="submit"]');
|
|
await page.waitForSelector('#btn-create-plan');
|
|
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '03_dashboard_loaded.png') });
|
|
assert(page.url().endsWith('/plans'), "Redirected to /plans after successful login");
|
|
|
|
// --- STEP 2: CREATE PLAN FLOW ---
|
|
console.log("\n[Step 2] Creating a new plan...");
|
|
await page.click('#btn-create-plan');
|
|
await page.waitForSelector('div[role="dialog"]');
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '04_create_modal_open.png') });
|
|
|
|
// Trigger save without filling fields to test HTML5 validations
|
|
console.log("Testing empty form submission block...");
|
|
await page.click('#btn-save-plan');
|
|
await sleep(1000);
|
|
const isModalOpen = await page.evaluate(() => {
|
|
return document.querySelector('div[role="dialog"]') !== null;
|
|
});
|
|
assert(isModalOpen, "HTML5 constraint validation: Modal remains open when attempting to submit empty fields");
|
|
|
|
// Fill in form details
|
|
await page.type('#plan-name', 'Plan Ventas E2E Puppeteer');
|
|
await page.type('#plan-code', 'PLAN-E2E-PUPP');
|
|
await setReactInput('#plan-validity-start', '2026-06-01');
|
|
await page.select('#plan-type', 'SCALE');
|
|
await page.type('#plan-meta-amount', '100000');
|
|
await page.type('#plan-max-cap', '20000');
|
|
await page.select('#plan-status', 'DRAFT');
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '05_create_form_filled.png') });
|
|
|
|
// Save the plan
|
|
await page.click('#btn-save-plan');
|
|
await page.waitForSelector('div[role="dialog"]', { hidden: true });
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '06_plan_created_grid.png') });
|
|
|
|
// Verify plan is in the database
|
|
const createdPlan = await runAsAdmin(tx => tx.compensationPlan.findFirst({
|
|
where: { code: 'PLAN-E2E-PUPP' }
|
|
}));
|
|
assert(createdPlan !== null, "Plan 'PLAN-E2E-PUPP' successfully created in database");
|
|
assert(createdPlan.status === 'DRAFT', "Plan initially set to status 'DRAFT'");
|
|
|
|
// --- STEP 3: RULES CONFIG FLOW ---
|
|
// Helper to clear input and type new text
|
|
const clearAndType = async (selector, text) => {
|
|
await page.click(selector, { clickCount: 3 });
|
|
await page.keyboard.press('Backspace');
|
|
await page.type(selector, text);
|
|
};
|
|
|
|
console.log("\n[Step 3] Configuring rules for the new plan...");
|
|
const rulesBtnSelector = `#btn-rules-${createdPlan.id}`;
|
|
await page.click(rulesBtnSelector);
|
|
await page.waitForSelector('#rule-min-0');
|
|
await sleep(1500); // Allow React reconciliation to settle
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '07_rules_config_page.png') });
|
|
assert(page.url().includes(`/plans/${createdPlan.id}/rules`), "Successfully navigated to rules page");
|
|
|
|
// Test invalid rule boundary constraint (min >= max)
|
|
console.log("Testing rule validation constraints (min >= max)...");
|
|
await setReactInput('#rule-min-0', '0.95');
|
|
await setReactInput('#rule-max-0', '0.90');
|
|
await page.click('#btn-save-rules');
|
|
await page.waitForSelector('#rules-error-msg');
|
|
const rulesError = await page.$eval('#rules-error-msg', el => el.textContent);
|
|
assert(rulesError.includes("El logro mínimo") && rulesError.includes("debe ser menor"), "Validation error shows when rule minimum exceeds maximum");
|
|
|
|
// Modify first row back to valid values
|
|
await setReactInput('#rule-min-0', '0.0');
|
|
await setReactInput('#rule-max-0', '0.9');
|
|
await setReactInput('#rule-rate-0', '0.0');
|
|
await setReactInput('#rule-payout-0', '0.0');
|
|
|
|
// Add a second row
|
|
await page.click('#btn-add-rule');
|
|
await page.waitForSelector('#rule-min-1');
|
|
await sleep(500); // Allow React state to settle
|
|
|
|
// Fill second row
|
|
await setReactInput('#rule-min-1', '0.9');
|
|
await setReactInput('#rule-max-1', '1.0');
|
|
await setReactInput('#rule-rate-1', '0.025');
|
|
await setReactInput('#rule-payout-1', '150.0');
|
|
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '08_rules_populated.png') });
|
|
|
|
// Save rules
|
|
await page.click('#btn-save-rules');
|
|
await page.waitForSelector('#rules-success-msg');
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '09_rules_saved_successfully.png') });
|
|
|
|
// Verify rules exist in database
|
|
const rulesInDb = await runAsAdmin(tx => tx.calculationRule.findMany({
|
|
where: { planId: createdPlan.id }
|
|
}));
|
|
assert(rulesInDb.length === 2, "2 calculation rules successfully inserted into the database");
|
|
|
|
// --- STEP 4: VERSIONING ACTIVATION FLOW ---
|
|
console.log("\n[Step 4] Activating and versioning check...");
|
|
// Go back to plans
|
|
await page.goto(`${BASE_URL}/plans`, { waitUntil: 'networkidle2' });
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '10_back_to_plans.png') });
|
|
|
|
// Toggle status to ACTIVE
|
|
const activateSelector = `#btn-toggle-status-${createdPlan.id}`;
|
|
await page.click(activateSelector);
|
|
await sleep(1000); // Wait for toggle update refresh
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '11_plan_activated.png') });
|
|
|
|
// Verify status is ACTIVE in db
|
|
let activePlan = await runAsAdmin(tx => tx.compensationPlan.findUnique({
|
|
where: { id: createdPlan.id }
|
|
}));
|
|
assert(activePlan.status === 'ACTIVE', "Plan status in database updated to 'ACTIVE'");
|
|
|
|
// Toggle status AGAIN while active to trigger versioning duplicate logic
|
|
await page.click(activateSelector);
|
|
await sleep(1500); // Wait for clone and page reload
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '12_plan_versioned.png') });
|
|
|
|
// Verify database contains cloned version
|
|
const allVersions = await runAsAdmin(tx => tx.compensationPlan.findMany({
|
|
where: { code: 'PLAN-E2E-PUPP' },
|
|
orderBy: { version: 'asc' }
|
|
}));
|
|
|
|
assert(allVersions.length === 2, "Cloned version successfully created (2 versions exist)");
|
|
assert(allVersions[0].status === 'INACTIVE' && allVersions[0].validityEnd !== null, "Version 1 is now INACTIVE with validity_end set");
|
|
assert(allVersions[1].status === 'ACTIVE' && allVersions[1].version === 2, "Version 2 is now ACTIVE with version = 2");
|
|
|
|
// Verify rules cloned for version 2
|
|
const clonedRules = await runAsAdmin(tx => tx.calculationRule.findMany({
|
|
where: { planId: allVersions[1].id }
|
|
}));
|
|
assert(clonedRules.length === 2, "Calculation rules cloned to Version 2 successfully");
|
|
|
|
// --- STEP 5: GOAL ASSIGNMENT FLOW ---
|
|
console.log("\n[Step 5] Assigning commercial goal...");
|
|
await page.goto(`${BASE_URL}/goals`, { waitUntil: 'networkidle2' });
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '13_goals_page.png') });
|
|
|
|
// Select colaborador_mde
|
|
const colaboradorMde = await runAsAdmin(tx => tx.user.findUnique({
|
|
where: { username: 'colaborador_mde' }
|
|
}));
|
|
|
|
await page.select('#goal-target-type', 'INDIVIDUAL');
|
|
await page.select('#goal-target-id', colaboradorMde.id.toString());
|
|
await setReactInput('#goal-period', '2026-06');
|
|
|
|
// Test invalid goal amount validation
|
|
console.log("Testing invalid goal amount validation...");
|
|
await setReactInput('#goal-amount', '-100');
|
|
await page.click('#btn-save-goal');
|
|
await page.waitForSelector('#goal-error-msg');
|
|
const goalErrorMsg = await page.$eval('#goal-error-msg', el => el.textContent);
|
|
assert(goalErrorMsg.includes("El monto debe ser un número positivo"), "Validation error shows when entering negative goal amount");
|
|
|
|
// Enter valid amount
|
|
await setReactInput('#goal-amount', '75000');
|
|
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '14_goal_form_filled.png') });
|
|
await page.click('#btn-save-goal');
|
|
|
|
// Wait for success alert
|
|
await page.waitForSelector('#goal-success-msg');
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '15_goal_saved.png') });
|
|
|
|
// Check db for goal
|
|
const savedGoal = await runAsAdmin(tx => tx.goal.findFirst({
|
|
where: {
|
|
targetType: 'INDIVIDUAL',
|
|
targetId: colaboradorMde.id,
|
|
period: '2026-06'
|
|
}
|
|
}));
|
|
assert(savedGoal !== null, "Goal record successfully created in the database");
|
|
assert(parseFloat(savedGoal.amount) === 75000.00, "Goal amount is correctly set to 75,000.00");
|
|
|
|
console.log("\n[Step 6] Verifying RLS boundaries on Goals page...");
|
|
// Log out 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 sleep(1500);
|
|
|
|
// Log in as Colaborador
|
|
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');
|
|
const hasCreatePlanBtn = await page.evaluate(() => !!document.getElementById('btn-create-plan'));
|
|
assert(!hasCreatePlanBtn, "Collaborator should not see the 'Nuevo Plan' button in the dashboard");
|
|
|
|
// Navigate directly to /goals and verify list shows only their goal
|
|
await page.goto(`${BASE_URL}/goals`, { waitUntil: 'networkidle2' });
|
|
await page.screenshot({ path: path.join(SCREENSHOT_DIR, '16_goals_colaborador_rls.png') });
|
|
|
|
// Evaluate list rows shown to the collaborator
|
|
const rowCount = await page.evaluate(() => {
|
|
// Count all table rows in tbody
|
|
return document.querySelectorAll('tbody tr').length;
|
|
});
|
|
|
|
assert(rowCount === 1, "RLS restriction verified: Colaborador only sees 1 goal (their own) in the goals dashboard");
|
|
|
|
// Verify API security boundary: Colaborador cannot create plans or assign goals
|
|
console.log("Verifying collaborator is blocked from executing ADMIN/DIRECTOR APIs...");
|
|
const planBlockRes = await page.evaluate(async () => {
|
|
const res = await fetch('/api/plans', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name: 'Plan Hack',
|
|
code: 'PLAN-HACK',
|
|
validityStart: '2026-06-01',
|
|
type: 'PERCENTAGE'
|
|
})
|
|
});
|
|
return { status: res.status };
|
|
});
|
|
assert(planBlockRes.status === 403, "API security check: Collaborator blocked from creating a plan (POST /api/plans returns 403)");
|
|
|
|
const goalBlockRes = await page.evaluate(async () => {
|
|
const res = await fetch('/api/goals', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
targetType: 'INDIVIDUAL',
|
|
targetId: 7,
|
|
period: '2026-06',
|
|
amount: 85000.00
|
|
})
|
|
});
|
|
return { status: res.status };
|
|
});
|
|
assert(goalBlockRes.status === 403, "API security check: Collaborator blocked from assigning goals (POST /api/goals returns 403)");
|
|
|
|
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 {
|
|
if (prisma) {
|
|
await runAsAdmin(async (tx) => {
|
|
// Delete calculation rules associated with E2E test plans
|
|
await tx.calculationRule.deleteMany({
|
|
where: { plan: { code: 'PLAN-E2E-PUPP' } }
|
|
});
|
|
// Delete E2E test goals
|
|
await tx.goal.deleteMany({
|
|
where: { period: '2026-06', amount: 75000.00 }
|
|
});
|
|
// Delete E2E test plans
|
|
await tx.compensationPlan.deleteMany({
|
|
where: { code: 'PLAN-E2E-PUPP' }
|
|
});
|
|
});
|
|
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);
|
|
});
|