import { expect } from "@playwright/test"; import type { Page } from "@playwright/test"; /** Seeded in prisma/seed.ts — see that file for the full fixture set this suite relies on. */ export const SEEDED = { registeredCustomer: { email: "alex.chen@example.com", password: "password123", name: "Alex Chen" }, owner: { email: "marcus@blakeheritage.co.uk", name: "Marcus Reed" }, barberHaircutOnly: { email: "leo@blakeheritage.co.uk", name: "Leo Vance" }, barberSkinfadeBeard: { email: "jamie@blakeheritage.co.uk", name: "Jamie Wallace" }, services: { haircut: "Haircut & Style", skinfade: "Skinfade / Taper & Style", beardTrim: "Beard Trim & Shape", }, }; export async function signIn(page: Page, email: string, password: string): Promise { await page.goto("/sign-in"); await page.getByLabel("Email Address").fill(email); await page.getByLabel("Password", { exact: true }).fill(password); await page.getByRole("button", { name: "Sign In", exact: true }).click(); await expect(page).toHaveURL(/\/home/); } function section(page: Page, headingName: string) { return page.locator("section").filter({ has: page.getByRole("heading", { name: headingName, exact: true }) }); } /** * Drives the real /book flow end to end — every step is a genuine form * submission through the actual Server Actions, matching the Phase 4 * brief's requirement that these journeys not rely on mocks or snapshots. * * Assumes the page is already on /book (or /book/barber-time if * `servicesPreselected` — e.g. arriving via "Book Again", which * preselects services and redirects straight past the services step). */ export async function completeBookingFlow( page: Page, options: { serviceName?: string; servicesPreselected?: boolean; staffName?: string; // omit for "Anyone available" guest?: { firstName: string; lastName: string; email: string; phone: string }; }, ): Promise { const { serviceName, servicesPreselected = false, staffName, guest } = options; await expect(page).toHaveURL(/\/book$/); if (!servicesPreselected) { if (!serviceName) throw new Error("serviceName is required unless servicesPreselected is true"); await page.getByRole("button", { name: new RegExp(`^${serviceName}`) }).click(); } // Even with services preselected (e.g. "Book Again"), the customer still confirms this step themselves. await page.getByRole("button", { name: "Continue", exact: true }).click(); await expect(page).toHaveURL(/\/book\/barber-time/); if (staffName) { await section(page, "Barber").locator("select").selectOption({ label: staffName }); // Choosing a barber refetches that barber's specific availability — // wait for it to settle so the date grid below reflects the right person. await page.waitForLoadState("networkidle"); } // Wait for the date grid to (re)render for the chosen barber before picking a day. await expect(section(page, "Date").locator("button[aria-pressed]").first()).toBeVisible(); await section(page, "Date").locator("button[aria-pressed]:not([disabled])").first().click(); const timeSection = section(page, "Time"); await expect(timeSection.locator("button").first()).toBeVisible(); await timeSection.locator("button").first().click(); await page.getByRole("button", { name: "Continue", exact: true }).click(); await expect(page).toHaveURL(/\/book\/details/); if (guest) { await page.getByLabel("First Name").fill(guest.firstName); await page.getByLabel("Last Name").fill(guest.lastName); await page.getByLabel("Email Address").fill(guest.email); await page.getByLabel("Phone Number").fill(guest.phone); } await page.getByLabel("I acknowledge the cancellation policy").check(); await page.getByRole("button", { name: "Continue to Review" }).click(); await expect(page).toHaveURL(/\/book\/review/); await page.getByRole("button", { name: "Confirm Booking" }).click(); await expect(page).toHaveURL(/\/book\/confirmation/); await expect(page.getByRole("heading", { name: "You're all set!" })).toBeVisible(); } /** Locates a specific appointment card on /appointments by its rendered service + barber text, scoping later actions (Reschedule/Cancel/Book Again) to that one card so they can't accidentally hit a different appointment. */ export function findAppointmentCard(page: Page, serviceName: string, staffName: string) { return page.locator(".rounded-jumbo").filter({ hasText: serviceName }).filter({ hasText: `with ${staffName}` }); } export function uniqueEmail(prefix: string): string { return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 100000)}@e2e-test.local`; } /** * Drives /appointments/[id]/reschedule — same shared BarberDateTimePicker * as booking, but a single step that redirects straight to the * appointment's details page. Deliberately picks the *last* available * date/slot rather than the first: the original appointment's own slot * becomes available again once it's excluded from the conflict check * (see `excludeAppointmentId` in rescheduleBooking), so "first available" * could otherwise reselect the exact same time and make the reschedule a * no-op from the test's point of view. */ export async function completeRescheduleFlow(page: Page): Promise { await expect(page).toHaveURL(/\/reschedule/); await expect(section(page, "Date").locator("button[aria-pressed]").first()).toBeVisible(); await section(page, "Date").locator("button[aria-pressed]:not([disabled])").last().click(); const timeSection = section(page, "Time"); await expect(timeSection.locator("button").first()).toBeVisible(); await timeSection.locator("button").last().click(); await page.getByRole("button", { name: "Confirm Reschedule", exact: true }).click(); await expect(page).toHaveURL(/\/appointments\/[^/]+$/); } /** * Drives the staff "book for customer" flow — same shared ServiceSelector * and BarberDateTimePicker as the customer /book flow, mounted at the * /staff/book/* routes instead (see src/app/staff/book). Assumes the page * is already on /staff/book/services, i.e. a customer has just been * selected (search result click or NewGuestCustomerForm submit both land * there via a redirect). */ export async function completeStaffBookingFlow( page: Page, options: { serviceName: string; staffName?: string; notes?: string }, ): Promise { const { serviceName, staffName, notes } = options; await expect(page).toHaveURL(/\/staff\/book\/services/); await page.getByRole("button", { name: new RegExp(`^${serviceName}`) }).click(); await page.getByRole("button", { name: "Continue", exact: true }).click(); await expect(page).toHaveURL(/\/staff\/book\/barber-time/); if (staffName) { await section(page, "Barber").locator("select").selectOption({ label: staffName }); await page.waitForLoadState("networkidle"); } await expect(section(page, "Date").locator("button[aria-pressed]").first()).toBeVisible(); await section(page, "Date").locator("button[aria-pressed]:not([disabled])").first().click(); const timeSection = section(page, "Time"); await expect(timeSection.locator("button").first()).toBeVisible(); await timeSection.locator("button").first().click(); await page.getByRole("button", { name: "Continue", exact: true }).click(); await expect(page).toHaveURL(/\/staff\/book\/review/); if (notes) { await page.getByLabel("Booking Notes (optional)").fill(notes); } await page.getByRole("button", { name: "Confirm Booking" }).click(); await expect(page).toHaveURL(/\/staff\/appointments\/[^/]+$/); }