346 lines
18 KiB
JavaScript
346 lines
18 KiB
JavaScript
import { chromium } from 'playwright';
|
||
|
||
const BASE = 'http://localhost:5173';
|
||
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
// After a successful password submit, the app shows a 2FA screen with the code
|
||
// displayed in the UI. This helper waits for it, reads the code, and submits.
|
||
async function complete2FA(page) {
|
||
const codeEl = page.locator('[class*="code-number"]').first();
|
||
try {
|
||
await codeEl.waitFor({ state: 'visible', timeout: 4000 });
|
||
} catch {
|
||
return; // 2FA screen did not appear — user likely already navigated
|
||
}
|
||
const code = await codeEl.innerText().catch(() => '');
|
||
if (!code) return;
|
||
await page.locator('input#code').fill(code.trim());
|
||
await page.locator('button[type="submit"]').click();
|
||
await sleep(500);
|
||
}
|
||
|
||
// Reset seed reservations so the test is repeatable across runs.
|
||
async function resetDb() {
|
||
const api = 'http://localhost:3001';
|
||
const patch = (id, body) =>
|
||
fetch(`${api}/reservations/${id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
|
||
// Delete any reservations created by previous test runs (not part of seed r1–r56).
|
||
const seedIds = new Set(Array.from({ length: 56 }, (_, i) => `r${i + 1}`));
|
||
const all = await fetch(`${api}/reservations`).then((r) => r.json()).catch(() => []);
|
||
await Promise.all(
|
||
all
|
||
.filter((r) => !seedIds.has(r.id))
|
||
.map((r) => fetch(`${api}/reservations/${r.id}`, { method: 'DELETE' }))
|
||
);
|
||
|
||
// Ensure r1 and r2 are pending so admin-confirm test always finds a button.
|
||
await patch('r1', { status: 'pending' });
|
||
await patch('r2', { status: 'pending' });
|
||
}
|
||
|
||
async function run() {
|
||
await resetDb();
|
||
const browser = await chromium.launch({ headless: true });
|
||
const ctx = await browser.newContext();
|
||
const page = await ctx.newPage();
|
||
|
||
// Capture browser console errors
|
||
const pageErrors = [];
|
||
page.on('pageerror', (err) => pageErrors.push(err.message));
|
||
page.on('console', (msg) => {
|
||
if (msg.type() === 'error') pageErrors.push(msg.text());
|
||
});
|
||
|
||
const results = [];
|
||
const ok = (l, n='') => results.push(`PASS ${l}${n ? ' — '+n : ''}`);
|
||
const fail = (l, n='') => results.push(`FAIL ${l}${n ? ' — '+n : ''}`);
|
||
const warn = (l, n='') => results.push(`WARN ${l}${n ? ' — '+n : ''}`);
|
||
|
||
// ── 1. Login page loads ────────────────────────────────────────────────────
|
||
await page.goto(BASE + '/login');
|
||
await page.waitForLoadState('networkidle');
|
||
const hasEmailInput = await page.locator('input[type="email"]').isVisible().catch(() => false);
|
||
hasEmailInput ? ok('Login page loads (email field visible)') : fail('Login page missing email field');
|
||
|
||
// ── 2. Wrong credentials show error ───────────────────────────────────────
|
||
await page.fill('input[type="email"]', 'wrong@example.com');
|
||
await page.fill('input[type="password"]', 'wrongpass');
|
||
await page.click('button[type="submit"]');
|
||
await sleep(1000);
|
||
const errorVisible = await page.locator('text=/invalid|incorrect|wrong/i').isVisible().catch(() => false);
|
||
errorVisible ? ok('Wrong credentials → error message') : fail('Wrong credentials — no error shown');
|
||
|
||
// ── 3. Admin login ────────────────────────────────────────────────────────
|
||
await page.fill('input[type="email"]', 'admin@reservations.dev');
|
||
await page.fill('input[type="password"]', 'Admin1234!');
|
||
await page.click('button[type="submit"]');
|
||
await complete2FA(page);
|
||
await page.waitForURL('**/admin', { timeout: 5000 }).catch(() => {});
|
||
page.url().includes('/admin')
|
||
? ok('Admin login → /admin')
|
||
: fail('Admin redirect failed', page.url());
|
||
|
||
// ── 4. Admin table rows ───────────────────────────────────────────────────
|
||
await page.waitForSelector('table tbody tr', { timeout: 5000 }).catch(() => {});
|
||
const rowCount = await page.locator('tbody tr').count().catch(() => 0);
|
||
rowCount > 0 ? ok('Admin table', `${rowCount} rows`) : fail('Admin table empty');
|
||
|
||
// ── 5. Confirm button on pending row ─────────────────────────────────────
|
||
const confirmBtn = page.locator('[aria-label^="Confirm reservation"]').first();
|
||
const hasConfirm = await confirmBtn.isVisible().catch(() => false);
|
||
hasConfirm ? ok('Confirm button visible') : fail('Confirm button missing');
|
||
|
||
if (hasConfirm) {
|
||
await confirmBtn.click();
|
||
await sleep(1200);
|
||
const confirmedBadge = await page.locator('text=Confirmed').count();
|
||
confirmedBadge >= 1
|
||
? ok('Confirm click updates row to Confirmed')
|
||
: warn('Confirmed badge not found after click');
|
||
}
|
||
|
||
// ── 6. Delete modal ───────────────────────────────────────────────────────
|
||
// Wait for table to re-render after previous action
|
||
await page.waitForSelector('table tbody tr', { timeout: 4000 }).catch(() => {});
|
||
await sleep(500);
|
||
const deleteBtn = page.locator('[aria-label^="Delete reservation"]').first();
|
||
await deleteBtn.click().catch(() => {});
|
||
await sleep(600);
|
||
const modalVisible = await page.locator('[role="dialog"]').isVisible().catch(() => false);
|
||
modalVisible ? ok('Delete confirmation modal opens') : fail('Delete modal did not open');
|
||
// Dismiss via Cancel button
|
||
const cancelModalBtn = page.locator('[class*="modal__btn--cancel"]').first();
|
||
await cancelModalBtn.click().catch(() => {
|
||
page.keyboard.press('Escape').catch(() => {});
|
||
});
|
||
await sleep(300);
|
||
|
||
// ── 7. Calendar tab ───────────────────────────────────────────────────────
|
||
const calTab = page.locator('button', { hasText: /Calendar/i });
|
||
const hasCalTab = await calTab.isVisible().catch(() => false);
|
||
hasCalTab ? ok('Calendar tab button visible') : fail('Calendar tab missing');
|
||
|
||
if (hasCalTab) {
|
||
await calTab.click();
|
||
await sleep(600);
|
||
const monthTitle = await page.locator('text=/January|February|March|April|May|June|July|August|September|October|November|December/').first().innerText().catch(() => '');
|
||
monthTitle ? ok('Calendar view renders month', monthTitle) : fail('Calendar month title missing');
|
||
const monCell = await page.locator('text=Mon').first().isVisible().catch(() => false);
|
||
monCell ? ok('Calendar weekday headers render') : fail('Calendar weekday headers missing');
|
||
|
||
// Click a day cell that has bookings
|
||
const busyCell = page.locator('[class*="cell--busy"]').first();
|
||
const hasBusy = await busyCell.isVisible().catch(() => false);
|
||
if (hasBusy) {
|
||
await busyCell.click();
|
||
await sleep(400);
|
||
const panelVisible = await page.locator('[class*="cal__panel"]').isVisible().catch(() => false);
|
||
panelVisible ? ok('Clicking busy day opens side panel') : fail('Calendar side panel did not open');
|
||
} else {
|
||
warn('No busy day cells visible in current month view');
|
||
}
|
||
|
||
// Navigate to next month
|
||
await page.locator('[aria-label="Next month"]').click().catch(() => {});
|
||
await sleep(400);
|
||
const newMonth = await page.locator('text=/January|February|March|April|May|June|July|August|September|October|November|December/').first().innerText().catch(() => '');
|
||
newMonth !== monthTitle
|
||
? ok('Calendar next-month navigation', newMonth)
|
||
: fail('Calendar did not advance to next month');
|
||
}
|
||
|
||
// ── 8. Admin logout → client login ───────────────────────────────────────
|
||
await page.locator('button:has-text("Table")').click().catch(() => {});
|
||
await sleep(200);
|
||
await page.click('button:has-text("Sign out")').catch(() => {});
|
||
await page.waitForURL('**/login', { timeout: 3000 }).catch(() => {});
|
||
page.url().includes('/login') ? ok('Admin logout → /login') : warn('Logout redirect', page.url());
|
||
|
||
await page.fill('input[type="email"]', 'anna.kowalski@example.com');
|
||
await page.fill('input[type="password"]', 'Client1234!');
|
||
await page.click('button[type="submit"]');
|
||
await complete2FA(page);
|
||
await page.waitForURL('**/dashboard', { timeout: 5000 }).catch(() => {});
|
||
page.url().includes('/dashboard') ? ok('Client login → /dashboard') : fail('Dashboard redirect', page.url());
|
||
|
||
// ── 9. AvailabilitySearch ─────────────────────────────────────────────────
|
||
await page.waitForLoadState('networkidle');
|
||
await sleep(500);
|
||
const availTitle = await page.locator('text=Check Availability').isVisible().catch(() => false);
|
||
availTitle ? ok('AvailabilitySearch section renders') : fail('AvailabilitySearch missing');
|
||
|
||
// ── 10. URL sync ──────────────────────────────────────────────────────────
|
||
await page.locator('input[type="date"]').first().fill('2026-07-15');
|
||
await page.locator('select').first().selectOption({ index: 1 });
|
||
await sleep(1200);
|
||
const urlAfterFilter = page.url();
|
||
urlAfterFilter.includes('date=2026-07-15') ? ok('Date syncs to URL') : fail('Date param missing', urlAfterFilter);
|
||
urlAfterFilter.includes('service=') ? ok('Service syncs to URL') : fail('Service param missing', urlAfterFilter);
|
||
|
||
// ── 11. Time slot grid ────────────────────────────────────────────────────
|
||
const slotGrid = await page.locator('button:has-text("09:00")').isVisible().catch(() => false);
|
||
slotGrid ? ok('Time slot grid renders (09:00 visible)') : fail('Time slot grid missing');
|
||
|
||
// ── 12. Slot click pre-fills wizard ──────────────────────────────────────
|
||
const freeSlot = page.locator('[class*="slot--free"]').first();
|
||
const hasFreeSlot = await freeSlot.isVisible().catch(() => false);
|
||
if (hasFreeSlot) {
|
||
const slotText = await freeSlot.innerText().catch(() => '?');
|
||
await freeSlot.click();
|
||
await sleep(600);
|
||
const step3Active = await page.locator('text=/Special Requirements/i').isVisible().catch(() => false);
|
||
step3Active ? ok(`Slot click (${slotText}) jumps wizard to step 3`) : fail('Slot click did not advance wizard');
|
||
} else {
|
||
warn('No free slots for selected date/service');
|
||
}
|
||
|
||
// ── 13. BookingWizard step navigation ────────────────────────────────────
|
||
await page.goto(BASE + '/dashboard');
|
||
await page.waitForLoadState('networkidle');
|
||
await page.waitForSelector('[class*="wizard__step-title"]', { timeout: 5000 }).catch(() => {});
|
||
const wizardTitle = await page.locator('[class*="wizard__step-title"]').first().innerText().catch(() => '');
|
||
wizardTitle.includes('Select a Service')
|
||
? ok('Wizard renders at step 1 after reload')
|
||
: fail('Wizard step 1 title missing', `got: "${wizardTitle}"`);
|
||
|
||
// Pick a service card
|
||
const serviceCard = page.locator('[class*="service-card"]').first();
|
||
const hasCards = await serviceCard.isVisible().catch(() => false);
|
||
hasCards ? ok('Service cards render') : fail('Service cards missing');
|
||
if (hasCards) await serviceCard.click();
|
||
|
||
// Next → step 2
|
||
await page.locator('button:has-text("Next")').first().click().catch(() => {});
|
||
await sleep(400);
|
||
const step2Title = await page.locator('[class*="wizard__step-title"]').first().innerText().catch(() => '');
|
||
step2Title.includes('Date') ? ok('Wizard step 2 (Date & Time)') : fail('Step 2 not reached', step2Title);
|
||
|
||
// Fill date + time for a date with no conflicts
|
||
await page.locator('input[type="date"]').last().fill('2026-09-01');
|
||
await page.locator('input[type="time"]').last().fill('14:30');
|
||
await page.locator('button:has-text("Next")').first().click().catch(() => {});
|
||
await sleep(400);
|
||
|
||
const step3Title = await page.locator('[class*="wizard__step-title"]').first().innerText().catch(() => '');
|
||
step3Title.includes('Requirements') ? ok('Wizard step 3 (Requirements)') : fail('Step 3 not reached', step3Title);
|
||
|
||
await page.locator('button:has-text("Next")').first().click().catch(() => {});
|
||
await sleep(400);
|
||
|
||
const step4Title = await page.locator('[class*="wizard__step-title"]').first().innerText().catch(() => '');
|
||
step4Title.includes('Summary') ? ok('Wizard step 4 (Summary)') : fail('Step 4 not reached', step4Title);
|
||
|
||
// ── 14. Payment modal full flow ───────────────────────────────────────────
|
||
const payBtn = page.locator('button:has-text("Confirm & Pay")');
|
||
const hasPayBtn = await payBtn.isVisible().catch(() => false);
|
||
hasPayBtn ? ok('Confirm & Pay button in step 4') : fail('Confirm & Pay button missing');
|
||
|
||
if (hasPayBtn) {
|
||
await payBtn.click();
|
||
await sleep(500);
|
||
const payModalVisible = await page.locator('text=/Secure Payment/i').isVisible().catch(() => false);
|
||
payModalVisible ? ok('Payment modal opens') : fail('Payment modal did not open');
|
||
|
||
if (payModalVisible) {
|
||
const depositAmt = await page.locator('[class*="deposit-amt"]').innerText().catch(() => '');
|
||
depositAmt ? ok('Deposit amount displayed', depositAmt) : fail('Deposit amount missing');
|
||
|
||
// Fill card form using placeholder selectors
|
||
await page.locator('input[placeholder*="1234"]').fill('4111111111111111');
|
||
await page.locator('input[placeholder*="MM"]').fill('12/28');
|
||
await page.locator('input[type="password"]').fill('123');
|
||
await page.locator('input[placeholder*="John"]').fill('Jan Kowalski');
|
||
await sleep(300);
|
||
|
||
// Check no validation errors yet
|
||
const payDepositBtn = page.locator('[class*="pay__pay-btn"]');
|
||
await payDepositBtn.click().catch(() => {});
|
||
|
||
// Wait for PaymentModal to reach 'done' phase (~1.5s) then createReservation POST (~200ms)
|
||
// then setPaymentOpen(false) causing overlay to disappear
|
||
await page.locator('[class*="pay__overlay"]').waitFor({ state: 'hidden', timeout: 8000 }).catch(() => {});
|
||
await sleep(400);
|
||
|
||
const bookingConfirmed = await page.locator('text=/Booking Confirmed/i').isVisible().catch(() => false);
|
||
bookingConfirmed
|
||
? ok('Full booking flow — Booking Confirmed shown')
|
||
: fail('Booking Confirmed not shown after payment');
|
||
|
||
// Check receipt fields appear
|
||
const receiptVisible = await page.locator('[class*="wizard__receipt"]').isVisible().catch(() => false);
|
||
receiptVisible ? ok('Receipt block rendered') : warn('Receipt block not visible');
|
||
}
|
||
}
|
||
|
||
// ── 15. My Reservations sidebar ──────────────────────────────────────────
|
||
// Navigate fresh to see sidebar (not in done state)
|
||
await page.goto(BASE + '/dashboard');
|
||
await page.waitForLoadState('networkidle');
|
||
await sleep(600);
|
||
const myResVisible = await page.locator('text=/My Reservations/i').first().isVisible().catch(() => false);
|
||
myResVisible ? ok('My Reservations sidebar renders') : fail('My Reservations missing');
|
||
|
||
// Check it shows at least one reservation for this user (u2 has multiple)
|
||
const resItems = await page.locator('[class*="my-res__item"]').count().catch(() => 0);
|
||
resItems > 0 ? ok('My Reservations lists items', `${resItems} items`) : fail('No reservation items found');
|
||
|
||
// ── 16. Cancel from My Reservations ──────────────────────────────────────
|
||
const cancelResBtn = page.locator('[class*="my-res__cancel-btn"]').first();
|
||
const hasCancelRes = await cancelResBtn.isVisible().catch(() => false);
|
||
if (hasCancelRes) {
|
||
const countBefore = await page.locator('[class*="my-res__item"]').count();
|
||
await cancelResBtn.click();
|
||
await sleep(1000);
|
||
const cancelledBadge = await page.locator('text=Cancelled').count();
|
||
cancelledBadge >= 1 ? ok('Cancel from My Reservations updates status') : warn('Cancelled badge not found after cancel');
|
||
} else {
|
||
warn('No cancellable reservations visible in sidebar');
|
||
}
|
||
|
||
// ── 17. Dark mode toggle ─────────────────────────────────────────────────
|
||
const themeBtn = page.locator('[aria-label*="dark"],[aria-label*="light"],[aria-label*="theme"]').first();
|
||
const hasTheme = await themeBtn.isVisible().catch(() => false);
|
||
if (hasTheme) {
|
||
await themeBtn.click();
|
||
await sleep(300);
|
||
const theme = await page.evaluate(() => document.documentElement.getAttribute('data-theme'));
|
||
theme === 'dark' ? ok('Dark mode toggle works') : warn('data-theme after toggle', theme ?? 'null');
|
||
} else {
|
||
warn('Theme toggle not found');
|
||
}
|
||
|
||
// ── 18. Protected route (client blocked from /admin) ─────────────────────
|
||
await page.goto(BASE + '/admin');
|
||
await sleep(600);
|
||
page.url().includes('/dashboard')
|
||
? ok('Client blocked from /admin → redirected to /dashboard')
|
||
: fail('Client could access /admin', page.url());
|
||
|
||
// ── Errors ────────────────────────────────────────────────────────────────
|
||
if (pageErrors.length > 0) {
|
||
warn('Browser console errors during test', pageErrors.slice(0, 3).join(' | '));
|
||
}
|
||
|
||
await browser.close();
|
||
|
||
console.log('\n' + '═'.repeat(62));
|
||
console.log(' MANUAL TEST RESULTS — ' + new Date().toISOString().slice(0,19));
|
||
console.log('═'.repeat(62));
|
||
results.forEach((r) => console.log(' ' + r));
|
||
const passed = results.filter((r) => r.startsWith('PASS')).length;
|
||
const failed = results.filter((r) => r.startsWith('FAIL')).length;
|
||
const warned = results.filter((r) => r.startsWith('WARN')).length;
|
||
console.log('═'.repeat(62));
|
||
console.log(` ${passed} passed · ${warned} warnings · ${failed} failed`);
|
||
console.log('═'.repeat(62));
|
||
if (failed > 0) process.exit(1);
|
||
}
|
||
|
||
run().catch((err) => { console.error(err); process.exit(1); });
|