import { expect, test } from "@playwright/test"; import { createPool } from "mariadb"; import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config"; 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 MySQL * database (migrated from Postgres in Phase 7 — 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 pool = createPool(buildMysqlPoolConfig()); await pool.query("DELETE FROM `TimeOff` WHERE reason = 'E2E conflict test'"); await pool.query("DELETE FROM `Break` WHERE label = 'Afternoon tea'"); await pool.end(); }); test("1. barber signs in and sees today's schedule", async ({ page }) => { await signIn(page, SEEDED.owner.email); 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); 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); 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); 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); 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); 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); 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. This save can itself surface an // unrelated conflict — the seed's own "genuinely future" fixture for Leo (see prisma/seed.ts, // appt-leo-future) is dated relative to whenever the seed happened to run and can // coincidentally land on a Sunday, which is off by default — so handle either outcome rather // than assuming a clean save (same pattern as owner-journeys.spec.ts journey 4's restore step). await page.getByRole("switch", { name: "Sunday working" }).click(); await page.getByRole("button", { name: "Save", exact: true }).click(); const saveAnywayButton = page.getByRole("button", { name: "Save Anyway", exact: true }); const savedStatus = page.getByText("Saved."); await expect(saveAnywayButton.or(savedStatus)).toBeVisible(); if (await saveAnywayButton.isVisible()) { await saveAnywayButton.click(); } await expect(savedStatus).toBeVisible(); }); test("8. barber adds a recurring break", async ({ page }) => { await signIn(page, SEEDED.barberSkinfadeBeard.email); 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); 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); }); test("10. barber receives a new-booking notification", async ({ page }) => { // Deliberately books its own fresh appointment rather than relying on journey 3's — every // other independent journey in this file follows that same "book a fresh appointment first" // rule (see this file's header comment), and this one was the exception, which made it fail // in confusing ways whenever it ran outside the full serial file (e.g. `-g` filtering past // journey 3). See NotificationService/booking-notifications.ts, notifyBookingCreated, which // queues STAFF_NEW_APPOINTMENT for the assigned barber on every booking. await signIn(page, SEEDED.owner.email); 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, }); await page.goto("/staff"); const bell = page.getByRole("link", { name: /Notifications, \d+ unread/ }); await expect(bell).toBeVisible(); await bell.click(); await expect(page).toHaveURL(/\/staff\/notifications/); await expect(page.getByText("New appointment").first()).toBeVisible(); await expect(page.getByText(/Jordan Blake/).first()).toBeVisible(); }); });