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.
This commit is contained in:
Stefan Lazic committed 2026-08-02 23:44:18 +01:00
1 parent 6ae145d187
commit ffee9d6754
81 files changed
+8024 -113

No files matched your search

+230
View File
@@ -0,0 +1,230 @@
import { expect, test } from "@playwright/test";
import { Client } from "pg";
import { SEEDED, completeStaffBookingFlow, signIn } from "./helpers";
/**
* The 9 barber-operations journeys required by the Phase 5 brief. Each
* drives real forms against the real dev server and the seeded Postgres
* database (see e2e/global-setup.ts / prisma/seed.ts). Journeys that only
* read data reuse the seed's fixed fixtures (today's appointments across
* every status, Sam Osei sitting at the loyalty threshold, Leo's time off);
* journeys that mutate state book a fresh appointment first so they don't
* fight each other or depend on run order — the same principle
* customer-journeys.spec.ts documents. The exceptions are the two tests
* that intentionally chain (3 → 4, both driving the *same* fresh
* appointment through check-in/start/complete/stamp) and the time-off
* conflict test, which deliberately reuses the seed's untouched
* "Marcus + Priya, later today" CONFIRMED appointment as its conflict
* fixture.
*/
let chainedAppointmentUrl: string | null = null;
test.describe.serial("barber operations journeys", () => {
// Journeys 8 and 9 create a break/time-off with no deterministic id for
// the seed to upsert against, so a mid-test assertion failure (a real
// possibility while iterating on this suite) could leave them behind and
// break journey 1 on the *next* run — journey 1 asserts today's schedule
// is visible, which a leftover "whole day off" row would hide. This
// safety net removes them directly regardless of how the tests above
// exited, on top of (not instead of) each test's own UI-driven cleanup.
test.afterAll(async () => {
const client = new Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
await client.query(`DELETE FROM "TimeOff" WHERE reason = 'E2E conflict test'`);
await client.query(`DELETE FROM "Break" WHERE label = 'Afternoon tea'`);
await client.end();
});
test("1. barber signs in and sees today's schedule", async ({ page }) => {
await signIn(page, SEEDED.owner.email, "password123");
await page.goto("/staff");
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
// Seeded: Marcus has appointments in every status today. (.first() because on
// desktop the "Right Now" side panel repeats the in-progress customer's name.)
await expect(page.getByText("Alex Chen").first()).toBeVisible();
await expect(page.getByText("Sam Osei").first()).toBeVisible();
await expect(page.getByText("IN PROGRESS").first()).toBeVisible();
});
test("2. barber opens the current appointment", async ({ page }) => {
await signIn(page, SEEDED.owner.email, "password123");
await page.goto("/staff/current");
// Tier 1 of selectCurrentAppointment: the IN_PROGRESS row wins.
await expect(page.getByText("Sam Osei")).toBeVisible();
await expect(page.getByText("In Progress", { exact: true })).toBeVisible();
await expect(page.getByText(/stamps/)).toBeVisible();
});
test("3. barber books, checks in, and starts an appointment", async ({ page }) => {
await signIn(page, SEEDED.owner.email, "password123");
await page.goto("/staff/book?q=Jordan");
await page.getByRole("button", { name: /Jordan Blake/ }).click();
await completeStaffBookingFlow(page, {
serviceName: SEEDED.services.haircut,
staffName: SEEDED.owner.name,
});
chainedAppointmentUrl = page.url();
// A staff-created booking defaults to CONFIRMED (no PENDING approval step).
await expect(page.getByText("Confirmed", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Check In", exact: true }).click();
await expect(page.getByText("Arrived", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Start Appointment", exact: true }).click();
await expect(page.getByText("In Progress", { exact: true })).toBeVisible();
});
test("4. barber completes the appointment and adds a loyalty stamp", async ({ page }) => {
if (!chainedAppointmentUrl) throw new Error("journey 3 must run first to create the appointment");
await signIn(page, SEEDED.owner.email, "password123");
await page.goto(chainedAppointmentUrl);
await page.getByRole("button", { name: "Complete Appointment", exact: true }).click();
await page.getByRole("button", { name: "Complete", exact: true }).click();
await expect(page.getByText("This appointment is marked complete.")).toBeVisible();
// Jordan Blake has no eligibility restrictions in the seed, so completion auto-awards a stamp.
await expect(page.getByText("A loyalty stamp was added for this visit.")).toBeVisible();
await page.getByRole("button", { name: "Done", exact: true }).click();
await expect(page.getByText("Completed", { exact: true })).toBeVisible();
// The manual "Add Loyalty Stamp" action is independent of the auto-award above.
await page.getByRole("button", { name: "Add Loyalty Stamp", exact: true }).click();
await page.getByRole("button", { name: "Add Stamp", exact: true }).click();
await expect(page.getByText("The stamp has been recorded on this customer's loyalty account.")).toBeVisible();
await page.getByRole("button", { name: "Done", exact: true }).click();
// "Book Follow-Up" only appears once COMPLETED — hand off to journey 6.
await expect(page.getByRole("button", { name: "Book Follow-Up" })).toBeVisible();
});
test("5. barber finds a customer and adds a note", async ({ page }) => {
await signIn(page, SEEDED.owner.email, "password123");
await page.goto("/staff/clients");
await page.getByPlaceholder("Search by name, email, or phone").fill("Osei");
await page.keyboard.press("Enter");
await page.getByRole("link", { name: /Sam Osei/ }).click();
await expect(page.getByText("Sam Osei")).toBeVisible();
const noteText = `E2E note ${Date.now()}`;
await page.getByPlaceholder("Add a note about this customer…").fill(noteText);
await page.getByRole("button", { name: "Add Note", exact: true }).click();
await expect(page.getByText(noteText)).toBeVisible();
});
test("6. barber books a follow-up from a completed appointment", async ({ page }) => {
if (!chainedAppointmentUrl) throw new Error("journey 4 must run first to complete the appointment");
await signIn(page, SEEDED.owner.email, "password123");
await page.goto(chainedAppointmentUrl);
await page.getByRole("button", { name: "Book Follow-Up" }).click();
// Services (and barber, if still active/bookable) are preselected from the original visit,
// but the barber still confirms this step themselves before moving on.
await expect(page).toHaveURL(/\/staff\/book\/services/);
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page).toHaveURL(/\/staff\/book\/barber-time/);
await expect(page.locator("section", { hasText: "Date" }).locator("button[aria-pressed]").first()).toBeVisible();
await page.locator("section", { hasText: "Date" }).locator("button[aria-pressed]:not([disabled])").first().click();
await page.locator("section", { hasText: "Time" }).locator("button").first().click();
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page).toHaveURL(/\/staff\/book\/review/);
await page.getByRole("button", { name: "Confirm Booking" }).click();
await expect(page).toHaveURL(/\/staff\/appointments\/[^/]+$/);
await expect(page.getByText("Jordan Blake")).toBeVisible();
});
test("7. barber changes their working hours", async ({ page }) => {
await signIn(page, SEEDED.barberHaircutOnly.email, "password123");
await page.goto("/staff/settings/working-hours");
const sundaySwitch = page.getByRole("switch", { name: "Sunday working" });
const wasOff = (await sundaySwitch.getAttribute("data-state")) === "unchecked";
await sundaySwitch.click();
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByText("Saved.")).toBeVisible();
await page.reload();
const expectedState = wasOff ? "checked" : "unchecked";
await expect(page.getByRole("switch", { name: "Sunday working" })).toHaveAttribute(
"data-state",
expectedState,
);
// Restore the seeded default so this test is repeatable.
await page.getByRole("switch", { name: "Sunday working" }).click();
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByText("Saved.")).toBeVisible();
});
test("8. barber adds a recurring break", async ({ page }) => {
await signIn(page, SEEDED.barberSkinfadeBeard.email, "password123");
await page.goto("/staff/settings/breaks");
await page.getByRole("button", { name: "Add Break", exact: true }).click();
const sheet = page.getByRole("dialog");
await sheet.getByLabel("Label").fill("Afternoon tea");
await sheet.getByLabel("Start").fill("15:30");
await sheet.getByLabel("End").fill("15:45");
await sheet.getByRole("button", { name: "Tue", exact: true }).click();
await sheet.getByRole("button", { name: "Add Break", exact: true }).click();
await expect(page.getByText("Afternoon tea")).toBeVisible();
await expect(page.getByText(/3:30 PM–3:45 PM/)).toBeVisible();
// Clean up — this test has no unique/deterministic id to upsert against on a re-run,
// so leaving it behind would accumulate a duplicate break every time the suite runs.
await page.getByText("Afternoon tea").click();
await sheet.getByRole("button", { name: "Delete Break", exact: true }).click();
await expect(page.getByText("Afternoon tea")).toHaveCount(0);
});
test("9. barber adds time off and resolves a scheduling conflict", async ({ page }) => {
await signIn(page, SEEDED.owner.email, "password123");
await page.goto("/staff/settings/time-off");
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
await page.getByRole("button", { name: "Add Time Off", exact: true }).click();
const sheet = page.getByRole("dialog");
await sheet.getByLabel("Start date").fill(today);
await sheet.getByLabel("End date").fill(today);
await sheet.getByLabel("Reason (optional)").fill("E2E conflict test");
await sheet.getByRole("button", { name: "Add Time Off", exact: true }).click();
// Seeded: Marcus has a CONFIRMED appointment with Priya Kapoor later today — a real conflict.
await expect(sheet.getByText(/overlaps \d+ existing appointment/)).toBeVisible();
await expect(sheet.getByText("Priya Kapoor")).toBeVisible();
await sheet.getByRole("button", { name: "Save Anyway", exact: true }).click();
await expect(page.getByText("E2E conflict test")).toBeVisible();
// The conflicting appointment itself is untouched — never silently cancelled. (The
// calendar's own day view now shows "You're off this day" instead of the appointment
// list, since Marcus just marked the whole day as time off — a separate, deliberate
// UI choice, not a sign the appointment was cancelled.)
await page.goto("/staff/clients");
await page.getByPlaceholder("Search by name, email, or phone").fill("Priya");
await page.keyboard.press("Enter");
await page.getByRole("link", { name: /Priya Kapoor/ }).click();
await expect(page.getByRole("tab", { name: "Upcoming" })).toHaveAttribute("data-state", "active");
await expect(page.getByText(SEEDED.services.haircut).first()).toBeVisible();
await expect(page.getByText("Confirmed", { exact: true }).first()).toBeVisible();
// Clean up — this test has no unique/deterministic id to upsert against on a re-run,
// so leaving Marcus marked off today would break journey 1's assertions next time.
await page.goto("/staff/settings/time-off");
await page.getByText("E2E conflict test").click();
const editSheet = page.getByRole("dialog");
await editSheet.getByRole("button", { name: "Remove Time Off", exact: true }).click();
await expect(page.getByText("E2E conflict test")).toHaveCount(0);
});
});