Files
NextChair/e2e/helpers.ts
T
Stefan Lazic ffee9d6754 Phase 5
Phase 5 is complete. All 53 tasks are done, verified on a fresh database in a single clean run: **121 unit tests**, **55 integration tests**, and **15 E2E journeys** (6 Phase 4 + 9 new Phase 5) all passing, plus a clean `tsc`/`eslint`/`build`.

Summary of this final stretch:

- **Fixed a real bug**: `StaffService`'s break/time-off update/delete functions threw uncaught instead of failing gracefully on a permission mismatch — fixed with proper `PERMISSION_DENIED` handling, covered by new integration tests.
- **Built the remaining Phase 5 UI**: Breaks editor, Time Off editor, Barber Profile, and Notification Preferences — all wired to real services, no placeholders.
- **Extended seed data**: more customers, loyalty accounts/transactions/rewards, and appointments spanning every status today (anchored to real time so the "Current Appointment" priority logic is reliably exercised).
- **Found and fixed a second real bug live in the browser**: the Break/Time-Off edit sheets showed stale default values instead of the item being edited (a classic `useState`-doesn't-reinitialize-on-prop-change bug) — fixed with a `key` prop to force remount.
- **Wrote the full Phase 5 test suite**: unit tests for `selectCurrentAppointment` and all `StaffService` validators, integration tests for `LoyaltyService` and `StaffService` (including the permission-fix regression tests), and 9 new Playwright E2E journeys covering the brief's exact required list.
- **Did the desktop-responsive pass**: a persistent sidebar nav replacing the bottom bar at `lg:`, a genuine calendar+"Right Now" side-by-side split, and 2-column layouts for the clients list and customer details — addressing the brief's explicit "more than simple centering" requirement.
- **Wrote [docs/phase-5-summary.md](docs/phase-5-summary.md)** documenting everything above in full.

Nothing is committed — the whole session (Phases 2–5) sits uncommitted on top of the initial template commit, same as you left it. Let me know if you'd like me to commit, or if you want to move on to Phase 6.
2026-08-02 23:44:18 +01:00

157 lines
7.4 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" },
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\/[^/]+$/);
}
/**
* 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\/[^/]+$/);
}