Seed data extended with a second barber (different hours/services), a beard-trim service + category, breaks, time off, a guest customer, and appointments in all three key states — verified against a live throwaway Postgres.
14 new integration tests (38 total with Phase 3's) covering guest/authenticated booking, any-barber assignment, slot-conflict handling, ownership enforcement, guest-data isolation, and book-again repricing.
Playwright E2E suite for the 6 required customer journeys, with its own global-setup.ts that migrates and seeds a database automatically.
Full verification pass, which caught and fixed three real bugs along the way (not just test-workarounds):
A date off-by-one bug in the calendar picker that made real weekdays show as closed for anyone in a UTC+ timezone (including the business's own).
A dialog that could vanish mid-confirmation on cancellation, before the customer could read the late-cancellation notice.
A silent test-cleanup leak in the Phase 3 fixtures that had been leaking stray businesses since Phase 3.
Plus a baseline desktop-responsive fix (centered max-w-2xl columns) across Home/Appointments/Settings/booking flow, since desktop had been effectively unstyled.
Final state: 90 unit tests, 38 integration tests, and all 6 E2E journeys passing together on a fresh database; clean tsc, eslint, and next build. Full writeup with routes, security model, guest-matching rules, known limitations, and what to check before Phase 5 is in docs/phase-4-summary.md.
This commit is contained in:
Stefan Lazic committed 2026-08-02 21:39:24 +01:00
1 parent 8b63cc66a9
commit 6ae145d187
66 files changed
+4599 -107

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
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<void> {
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<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. */
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<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\/[^/]+$/);
}