192 lines
9.6 KiB
TypeScript
192 lines
9.6 KiB
TypeScript
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" },
|
|
// Seeded right at their loyalty reward threshold (10/10 stamps, one
|
|
// AVAILABLE reward) — see prisma/seed.ts. owner-journeys.spec.ts's
|
|
// journey 10 redeems this exact reward, so any test relying on it still
|
|
// being AVAILABLE must run before that (customer-journeys.spec.ts runs
|
|
// first in the suite, so this is safe there).
|
|
secondCustomer: { email: "sam.osei@example.com", password: "password123", name: "Sam Osei" },
|
|
owner: { email: "marcus@blakeheritage.co.uk", name: "Marcus Reed" },
|
|
// Provisioned by global-setup.ts via scripts/ensure-super-admin.ts, not
|
|
// prisma/seed.ts — a Super Administrator deliberately has no StaffProfile
|
|
// (see isSuperAdmin) and stays out of the dev/demo seed data on purpose.
|
|
superAdmin: { email: "superadmin@blakeheritage.co.uk", password: "password123", name: "Super Admin" },
|
|
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<void> {
|
|
await page.goto("/sign-in");
|
|
// Turbopack dev-mode compiles each route group on first visit — on a
|
|
// cold server, a click that lands before that finishes (and before
|
|
// React attaches the form's submit handler) is silently dropped. Wait
|
|
// for the page to go idle once before interacting, so this doesn't
|
|
// race the very first visit to an as-yet-uncompiled route.
|
|
await page.waitForLoadState("networkidle");
|
|
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();
|
|
// Not asserting a specific destination here — a bare sign-in (no
|
|
// callbackUrl, which this helper never sets) now lands on a role-aware
|
|
// page (customer -> /home, barber -> /staff, owner/super admin ->
|
|
// /owner or /owner/calendar; see src/app/page.tsx), not uniformly
|
|
// /home. Every caller already `page.goto()`s to the specific route it
|
|
// needs right after this — this just confirms sign-in actually
|
|
// succeeded and navigated away from the form.
|
|
await expect(page).not.toHaveURL(/\/sign-in/);
|
|
}
|
|
|
|
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<void> {
|
|
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. Cards there render via AppointmentCard -> Card (see
|
|
* components/ui/card.tsx), whose root class is `rounded-md` — not
|
|
* `rounded-jumbo`, which was never an actual class in this app (confirmed:
|
|
* zero matches anywhere in the rendered page). Every test using this
|
|
* helper (2, 3, 4, 5, 6 in customer-journeys.spec.ts) was therefore always
|
|
* timing out on a selector that could never match, not on any real
|
|
* booking/query/rendering bug — same root cause already found and fixed
|
|
* in owner-journeys.spec.ts's own loyalty-redemption test.
|
|
*/
|
|
export function findAppointmentCard(page: Page, serviceName: string, staffName: string) {
|
|
return page.locator(".rounded-md").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<void> {
|
|
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<void> {
|
|
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\/[^/]+$/);
|
|
}
|