import { expect, test } from "@playwright/test"; import { SEEDED, completeBookingFlow, completeRescheduleFlow, findAppointmentCard, signIn, uniqueEmail } from "./helpers"; /** * The 6 customer journeys required by the Phase 4 brief. Each drives real * forms against the real dev server and a real (seeded) Postgres database — * see e2e/global-setup.ts for how that database gets prepared, and * prisma/seed.ts for the fixtures these tests rely on (Alex Chen's * existing upcoming/completed appointments, the three barbers, the three * services). Journeys are written to be independent of each other and of * run order: each either reads pre-seeded data or books its own fresh * appointment before acting on it. */ test.describe("customer booking journeys", () => { test("1. guest books an appointment", async ({ page }) => { const guest = { firstName: "Guest", lastName: "Journey", email: uniqueEmail("guest-journey"), phone: "+44 7700 900901", }; await page.goto("/home"); await page.getByRole("link", { name: "Book an Appointment" }).click(); await completeBookingFlow(page, { serviceName: SEEDED.services.haircut, staffName: SEEDED.owner.name, guest, }); // Guest confirmation offers an account-creation prompt but doesn't force it — // and also a real, secured "View Appointment" link (Phase 7: a guest's only // credential is a single-purpose access token in the URL, not the bare // appointment id — see src/services/scheduling/guest-access.service.ts). await expect(page.getByText("Want to manage this booking later?")).toBeVisible(); await expect(page.getByRole("link", { name: "Create an Account" })).toBeVisible(); const viewLink = page.getByRole("link", { name: "View Appointment" }); await expect(viewLink).toBeVisible(); const href = await viewLink.getAttribute("href"); expect(href).toMatch(/^\/appointments\/[^/]+\?token=.+/); // The token in the link actually works, and a guessed/blank token doesn't. await page.goto(href!); await expect(page.getByRole("heading", { name: "Appointment Details" })).toBeVisible(); const appointmentId = href!.split("?")[0]!.split("/").pop(); await page.goto(`/appointments/${appointmentId}`); await expect(page.getByRole("heading", { name: "This page could not be found." })).toBeVisible(); }); test("2. registered customer books an appointment", async ({ page }) => { await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); await page.goto("/book"); await completeBookingFlow(page, { serviceName: SEEDED.services.skinfade, staffName: SEEDED.barberSkinfadeBeard.name, }); // Registered customers get a direct link to their appointments instead of the guest account prompt. await expect(page.getByRole("link", { name: "View Appointment" })).toBeVisible(); await page.goto("/appointments"); await expect(findAppointmentCard(page, SEEDED.services.skinfade, SEEDED.barberSkinfadeBeard.name).first()).toBeVisible(); }); test("3. customer views upcoming appointments", async ({ page }) => { await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); await page.goto("/appointments"); // Seeded: an upcoming CONFIRMED Haircut & Style with Marcus Reed. await expect(page.getByRole("tab", { name: "Upcoming" })).toHaveAttribute("data-state", "active"); const upcomingSeeded = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.owner.name); await expect(upcomingSeeded).toBeVisible(); await expect(upcomingSeeded.getByText("Confirmed")).toBeVisible(); // Seeded: a COMPLETED Haircut & Style with Leo Vance. await page.getByRole("tab", { name: "Past" }).click(); const pastSeeded = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name); await expect(pastSeeded).toBeVisible(); await expect(pastSeeded.getByText("Completed")).toBeVisible(); // Alex Chen has no cancelled appointments in the seed data. await page.getByRole("tab", { name: "Cancelled" }).click(); await expect(page.getByText("No cancelled appointments")).toBeVisible(); }); test("4. customer reschedules an appointment", async ({ page }) => { await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); // Book a dedicated appointment for this test so it doesn't depend on the seeded one or on other tests. await page.goto("/book"); await completeBookingFlow(page, { serviceName: SEEDED.services.beardTrim, staffName: SEEDED.owner.name }); await page.goto("/appointments"); const card = findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.owner.name); await expect(card).toBeVisible(); const originalTime = (await card.innerText()).match(/\d{1,2}:\d{2}/)?.[0]; expect(originalTime).toBeTruthy(); await card.getByRole("link", { name: "Reschedule" }).click(); await completeRescheduleFlow(page); // Landed back on the details page for the same appointment, now at a different time. await expect(page.getByRole("heading", { name: "Appointment Details" })).toBeVisible(); await expect(page.getByText(SEEDED.services.beardTrim)).toBeVisible(); const newTime = (await page.locator("main").innerText()).match(/\d{1,2}:\d{2}/)?.[0]; expect(newTime).toBeTruthy(); expect(newTime).not.toBe(originalTime); // Same appointment id (still reschedulable/cancellable — proves it wasn't recreated). await expect(page.getByRole("link", { name: "Reschedule" })).toBeVisible(); }); test("5. customer cancels an appointment", async ({ page }) => { await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); await page.goto("/book"); await completeBookingFlow(page, { serviceName: SEEDED.services.beardTrim, staffName: SEEDED.barberSkinfadeBeard.name }); await page.goto("/appointments"); const upcomingCard = findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name); await expect(upcomingCard).toBeVisible(); await upcomingCard.getByRole("button", { name: "Cancel", exact: true }).click(); // Scoped to the currently-open — the Upcoming tab can have several // cancellable cards, each with its own (closed) dialog in the DOM. const dialog = page.locator("dialog[open]"); await expect(dialog.getByRole("heading", { name: "Cancel Appointment?" })).toBeVisible(); await dialog.getByPlaceholder("Reason (optional)").fill("E2E test cancellation"); await dialog.getByRole("button", { name: "Cancel it" }).click(); await expect(dialog.getByText("Your appointment has been cancelled.")).toBeVisible(); await dialog.getByRole("button", { name: "Done" }).click(); // Cancellation never deletes — it moves from Upcoming to the Cancelled tab. await expect(findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name)).toHaveCount(0); await page.getByRole("tab", { name: "Cancelled" }).click(); const cancelledCard = findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name); await expect(cancelledCard).toBeVisible(); await expect(cancelledCard.getByText("Cancelled")).toBeVisible(); }); test("6. customer books again from history", async ({ page }) => { await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); await page.goto("/appointments"); // Seeded COMPLETED appointment (Haircut & Style with Leo Vance). await page.getByRole("tab", { name: "Past" }).click(); const completedCard = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name); await completedCard.getByRole("button", { name: "Book Again" }).click(); // Services (and the original barber, if still bookable) come preselected — the customer only re-confirms a fresh date/time. await expect(page).toHaveURL(/\/book$/); await expect(page.getByText("Booking again")).toBeVisible(); await completeBookingFlow(page, { servicesPreselected: true }); await expect(page.getByRole("link", { name: "View Appointment" })).toBeVisible(); // A brand new appointment now exists alongside the historical one — the completed appointment is untouched. await page.goto("/appointments"); await expect(findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name)).toBeVisible(); await page.getByRole("tab", { name: "Past" }).click(); await expect(completedCard).toBeVisible(); }); test("7. booking confirmation creates an in-app notification the customer can read and mark read", async ({ page }) => { await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); await page.goto("/book"); await completeBookingFlow(page, { serviceName: SEEDED.services.beardTrim, staffName: SEEDED.barberSkinfadeBeard.name, }); // The bell in the persistent customer layout shows an unread badge — see (customer)/layout.tsx. await page.goto("/home"); const bell = page.getByRole("link", { name: /Notifications, \d+ unread/ }); await expect(bell).toBeVisible(); await bell.click(); await expect(page).toHaveURL(/\/notifications/); await expect(page.getByText("Booking confirmed").first()).toBeVisible(); await expect(page.getByRole("status", { name: "Unread" }).first()).toBeVisible(); // "Mark All Read" clears every unread notification — the nav badge disappears entirely // afterward, regardless of how many notifications this account has accumulated. await page.getByRole("button", { name: "Mark All Read" }).click(); await page.waitForLoadState("networkidle"); await expect(page.getByRole("status", { name: "Unread" })).toHaveCount(0); await page.goto("/home"); await expect(page.getByRole("link", { name: "Notifications" })).toBeVisible(); await expect(page.getByRole("link", { name: /Notifications, \d+ unread/ })).toHaveCount(0); }); test("8. customer sees their available loyalty reward on Home and the Loyalty page", async ({ page }) => { // Seeded at exactly 10/10 stamps with one AVAILABLE reward (see prisma/seed.ts) — // must run before owner-journeys.spec.ts's journey 10, which redeems it. await signIn(page, SEEDED.secondCustomer.email, SEEDED.secondCustomer.password); await page.goto("/home"); await expect(page.getByText(/Reward available/)).toBeVisible(); await page.getByText(/Reward available/).click(); await expect(page).toHaveURL(/\/loyalty/); await expect(page.getByRole("heading", { name: "Loyalty", exact: true })).toBeVisible(); await expect(page.getByText("Reward available:")).toBeVisible(); await expect(page.getByText("stamp added").first()).toBeVisible(); // The owner-configured terms line (see prisma/seed.ts) renders on the customer card even // while a reward is available — previously this was silently replaced by synthesized // "N more to your reward" text and never showed the real configured terms at all. await expect(page.getByText("Valid for 12 months. One reward per visit.")).toBeVisible(); }); test("9. clicking Book on a Home service preselects it in the booking draft", async ({ page }) => { await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password); await page.goto("/home"); // The description stays visible on the Services tab row — the Book button doesn't hide it. const skinfadeRow = page .locator("div.flex.items-center.justify-between.gap-4.py-4") .filter({ hasText: SEEDED.services.skinfade }); await expect(skinfadeRow).toBeVisible(); await skinfadeRow.getByRole("button", { name: "Book" }).click(); // No existing draft — preselectServiceAction creates a fresh one with just this service, then redirects straight to /book. await expect(page).toHaveURL(/\/book$/); const skinfadeToggle = page.getByRole("button", { name: new RegExp(`^${SEEDED.services.skinfade}`) }); await expect(skinfadeToggle).toHaveAttribute("aria-pressed", "true"); await expect(page.getByText("1 selected")).toBeVisible(); // A second service clicked from Home is added to the existing draft, not a replacement of it. await page.goto("/home"); const beardRow = page .locator("div.flex.items-center.justify-between.gap-4.py-4") .filter({ hasText: SEEDED.services.beardTrim }); await beardRow.getByRole("button", { name: "Book" }).click(); await expect(page).toHaveURL(/\/book$/); await expect(skinfadeToggle).toHaveAttribute("aria-pressed", "true"); const beardToggle = page.getByRole("button", { name: new RegExp(`^${SEEDED.services.beardTrim}`) }); await expect(beardToggle).toHaveAttribute("aria-pressed", "true"); await expect(page.getByText("2 selected")).toBeVisible(); // Re-clicking Book for a service that's already selected doesn't add a duplicate. await page.goto("/home"); await beardRow.getByRole("button", { name: "Book" }).click(); await expect(page).toHaveURL(/\/book$/); await expect(page.getByText("2 selected")).toBeVisible(); // Deliberately doesn't complete the booking from here — this account's appointment history is // asserted against by name+staff in several other journeys in this file, and creating one more // here would make those lookups ambiguous. Journey 10 (a fresh guest) completes the flow end to end. }); test("10. a guest can also preselect a service from Home before booking", async ({ page }) => { await page.goto("/home"); // Cold Turbopack first-visit compile — see signIn()'s note in helpers.ts; a click landing before // this settles is silently dropped, and this test (unlike the others) has no prior navigation to warm it. await page.waitForLoadState("networkidle"); const haircutRow = page .locator("div.flex.items-center.justify-between.gap-4.py-4") .filter({ hasText: SEEDED.services.haircut }); await haircutRow.getByRole("button", { name: "Book" }).click(); await expect(page).toHaveURL(/\/book$/); await expect(page.getByRole("button", { name: new RegExp(`^${SEEDED.services.haircut}`) })).toHaveAttribute( "aria-pressed", "true", ); await completeBookingFlow(page, { servicesPreselected: true, guest: { firstName: "Preselect", lastName: "Guest", email: uniqueEmail("preselect-guest"), phone: "+44 7700 900902", }, }); }); });