SPA zbudowane w React 19 + Vite 8 z pełnym zestawem funkcjonalności: autentykacja z 2FA, kreator rezerwacji, panel admina, analityka, GraphQL (Apollo Client + SchemaLink), React Query, Storybook, testy jednostkowe (Vitest + RTL) i e2e (Playwright).
336 lines
18 KiB
JavaScript
336 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),
|
|
});
|
|
// 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); });
|