import { expect, test } from "@playwright/test"; import { createPool } from "mariadb"; import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config"; import { SEEDED, completeBookingFlow, signIn, uniqueEmail } from "./helpers"; /** * The 12 owner-administration journeys required by the Phase 6 brief. Same * principles as staff-journeys.spec.ts: real forms against the real dev * server and seeded MySQL (migrated from Postgres in Phase 7), `test.describe.serial` because several * journeys deliberately chain or share seeded fixtures (Marcus Reed is * both the seeded owner *and* a working barber — see prisma/seed.ts), and * every mutation a test makes to shared configuration (business details, * opening hours, booking rules, loyalty config) is restored at the end of * that same test so later journeys — and later re-runs — see the same * starting state. The one exception is journey 10 (redeem reward), which * consumes the seed's one-time AVAILABLE reward for Sam Osei; that's a * deliberate one-shot fixture, same category as staff-journeys.spec.ts's * own booking journeys. */ test.describe.serial("owner administration journeys", () => { test.afterAll(async () => { const pool = createPool(buildMysqlPoolConfig(process.env.DATABASE_URL)); // Journey 7 creates a category/service with no deterministic id to // upsert against on a re-run — clean up by name instead. await pool.query("DELETE FROM `Service` WHERE name LIKE 'E2E Test Service%'"); await pool.query("DELETE FROM `ServiceCategory` WHERE name LIKE 'E2E Test Category%'"); // Journey 5 adds a real staff member (User+StaffProfile+StaffInvitation) with no // deterministic id either — deleting the User cascades to the rest. await pool.query("DELETE FROM `User` WHERE email LIKE 'e2e-new-barber-%@e2e-test.local'"); await pool.end(); }); test("1. owner sees a real, data-backed dashboard with configuration warnings", async ({ page }) => { await signIn(page, SEEDED.owner.email, "password123"); await page.goto("/owner"); // The Manage hub's H1 (see (owner)/owner/page.tsx) — Dashboard content // is shown first/most prominently inside it rather than on its own // page, per the merged-nav brief's "Dashboard placement" requirement. await expect(page.getByRole("heading", { name: "Manage" })).toBeVisible(); // Seeded fixtures from prisma/seed.ts: Owen Clarke has no working hours, // and "Kids Haircut (Under 12)" has no eligible active staff member. await expect(page.getByText("Owen Clarke has no working hours set")).toBeVisible(); await expect(page.getByText(/no active staff member is eligible to perform it/)).toBeVisible(); await expect(page.getByText("Staff Today")).toBeVisible(); // Scoped exact match — "Marcus Reed" can otherwise also match a Recent Loyalty // Activity line naming him as the staff member who redeemed a reward. await expect(page.getByText("Marcus Reed", { exact: true })).toBeVisible(); await expect(page.getByText("Popular Services")).toBeVisible(); }); test("2. owner views the all-staff calendar, filters by staff, and switches to agenda", async ({ page }) => { await signIn(page, SEEDED.owner.email, "password123"); await page.goto("/owner/calendar"); await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible(); // The staff filter pills are avatar+name+hours cards now, so their // accessible name is "Marcus Reed 10:00 AM – 7:00 PM" etc — a // substring match (Playwright's default, no `exact`) still uniquely // identifies each pill's link. await expect(page.getByRole("link", { name: "Marcus Reed" })).toBeVisible(); await expect(page.getByRole("link", { name: "Leo Vance" })).toBeVisible(); // Filtering to one staff member narrows the day view to a single column. await page.getByRole("link", { name: "Leo Vance" }).click(); await expect(page).toHaveURL(/staffId=/); // The single remaining day-view column is headed by a

with the staff member's exact name // — the staff filter pill above it is *also* an avatar+name+hours card with its own

of the // same text now, so scope to the grid itself (data-testid="calendar-grid") to disambiguate. await expect(page.getByTestId("calendar-grid").locator("p").getByText("Leo Vance", { exact: true })).toBeVisible(); await page.getByRole("link", { name: "Agenda", exact: true }).click(); await expect(page).toHaveURL(/view=agenda/); await expect(page.getByRole("link", { name: "Day", exact: true })).toBeVisible(); await page.getByRole("link", { name: "All Staff" }).click(); await expect(page).not.toHaveURL(/staffId=/); }); test("3. owner edits business details and the change persists", async ({ page }) => { await signIn(page, SEEDED.owner.email, "password123"); await page.goto("/owner/settings/business"); await expect(page.getByRole("heading", { name: "Business Details" })).toBeVisible(); const nameInput = page.getByLabel("Business Name"); await expect(nameInput).toHaveValue("Blake Heritage Barbershop"); await nameInput.fill("Blake Heritage Barbershop (E2E edited)"); await page.getByRole("button", { name: "Save", exact: true }).click(); await expect(page.getByText("Saved — changes are live for customers immediately.")).toBeVisible(); await page.reload(); await expect(page.getByLabel("Business Name")).toHaveValue("Blake Heritage Barbershop (E2E edited)"); // Restore the seeded name so later runs (and other journeys) see the original. await page.getByLabel("Business Name").fill("Blake Heritage Barbershop"); await page.getByRole("button", { name: "Save", exact: true }).click(); await expect(page.getByText("Saved — changes are live for customers immediately.")).toBeVisible(); }); test("4. owner tightens opening hours, reviews the appointment conflict, and saves anyway without cancelling it", async ({ page }) => { await signIn(page, SEEDED.owner.email, "password123"); // Marcus and Leo each have a seeded future CONFIRMED appointment (see // prisma/seed.ts, appt-upcoming-confirmed / appt-leo-future). Those rows // are upserted by a deterministic id, so on a database that already had // them from an earlier seed run their date is whatever it was back // then, not necessarily still "N days from today" — read the real date // straight from the database instead of predicting it from wall-clock time. const pool = createPool(buildMysqlPoolConfig(process.env.DATABASE_URL)); const rows = await pool.query<{ id: string; start_at: Date; first_name: string; last_name: string }[]>( "SELECT a.id, a.startAt as start_at, c.firstName as first_name, c.lastName as last_name" + " FROM Appointment a JOIN CustomerProfile c ON c.id = a.customerId" + " WHERE a.id LIKE '%-appt-upcoming-confirmed' OR a.id LIKE '%-appt-leo-future'" + " ORDER BY a.startAt ASC", ); await pool.end(); if (rows.length === 0) throw new Error("Seeded opening-hours conflict fixtures not found — check prisma/seed.ts"); // Prefer a fixture that doesn't fall on Sunday (already closed by default) so toggling it // off is a genuine change; fall back to whichever fixture exists if only one does. const candidate = rows.find((r) => r.start_at.toLocaleDateString("en-GB", { weekday: "long" }) !== "Sunday") ?? rows[0]!; const targetDay = candidate.start_at.toLocaleDateString("en-GB", { weekday: "long" }); const expectedCustomer = `${candidate.first_name} ${candidate.last_name}`; await page.goto("/owner/settings/hours"); await expect(page.getByRole("heading", { name: "Opening Hours" })).toBeVisible(); await page.getByRole("switch", { name: `${targetDay} open` }).click(); await page.getByRole("button", { name: "Save", exact: true }).click(); await expect(page.getByText(/would leave \d+ upcoming appointment/)).toBeVisible(); await expect(page.getByText(expectedCustomer)).toBeVisible(); await page.getByRole("button", { name: "Save Anyway", exact: true }).click(); await expect(page.getByText("Saved. The booking engine reflects these hours immediately.")).toBeVisible(); // The conflicting appointment itself is untouched — never silently cancelled. await page.reload(); await expect(page.getByRole("switch", { name: `${targetDay} open` })).toHaveAttribute("data-state", "unchecked"); // Restore the seeded default (open 10:00-19:00) so later runs see the same starting state. // This save can itself surface an unrelated conflict — one of the seed's own "today" // fixtures (see prisma/seed.ts) is timed relative to whenever the seed happened to run, // and can coincidentally fall just before the business's 10:00 opening — so handle either // outcome rather than assuming a clean save. await page.getByRole("switch", { name: `${targetDay} open` }).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. The booking engine reflects these hours immediately."); await expect(saveAnywayButton.or(savedStatus)).toBeVisible(); if (await saveAnywayButton.isVisible()) { await saveAnywayButton.click(); } await expect(savedStatus).toBeVisible(); }); test("5. owner adds a new barber and gets a dev invite link", async ({ page }) => { await signIn(page, SEEDED.owner.email, "password123"); await page.goto("/owner/team/add"); await expect(page.getByRole("heading", { name: "Add Staff" })).toBeVisible(); const email = uniqueEmail("e2e-new-barber"); const name = `E2E New Barber ${Date.now()}`; await page.getByLabel("Name").fill(name); await page.getByLabel("Email").fill(email); await page.getByRole("button", { name: "Add Staff Member", exact: true }).click(); await expect(page.getByText(`${name} has been added.`)).toBeVisible(); await expect(page.getByText(/\/invite\/accept\?token=/)).toBeVisible(); await page.getByRole("button", { name: "Done", exact: true }).click(); await expect(page).toHaveURL(/\/owner\/team$/); await page.getByPlaceholder("Search by name or email").fill(name); await page.keyboard.press("Enter"); await expect(page.getByRole("link", { name: new RegExp(name) })).toBeVisible(); }); test("6. owner changes a barber's role and service eligibility", async ({ page }) => { await signIn(page, SEEDED.owner.email, "password123"); await page.goto("/owner/team?q=Jamie"); await page.getByRole("link", { name: /Jamie Wallace/ }).click(); await expect(page.getByRole("heading", { name: "Jamie Wallace", level: 2 })).toBeVisible(); // Round-trip the role: Barber -> Owner -> Barber, confirming the save // path (and last-owner protection isn't a factor for a non-owner). const roleSelect = page.getByLabel("Role"); await expect(roleSelect).toHaveValue(/.+/); await roleSelect.selectOption({ label: "Owner" }); await page.getByRole("button", { name: "Save Role", exact: true }).click(); await expect(page.getByText("Saved.").first()).toBeVisible(); await page.reload(); await page.getByLabel("Role").selectOption({ label: "Barber" }); await page.getByRole("button", { name: "Save Role", exact: true }).click(); await expect(page.getByText("Saved.").first()).toBeVisible(); // Jamie isn't seeded eligible for Haircut & Style — add it, save, then remove it again. const haircutCheckbox = page.getByRole("checkbox", { name: SEEDED.services.haircut }); await expect(haircutCheckbox).not.toBeChecked(); await haircutCheckbox.check(); await page.getByRole("button", { name: "Save Services", exact: true }).click(); await expect(page.getByText("Saved.").first()).toBeVisible(); await page.reload(); await page.getByRole("checkbox", { name: SEEDED.services.haircut }).uncheck(); await page.getByRole("button", { name: "Save Services", exact: true }).click(); await expect(page.getByText("Saved.").first()).toBeVisible(); }); test("7. owner creates a service category and service, visible immediately on the customer booking flow", async ({ page }) => { await signIn(page, SEEDED.owner.email, "password123"); await page.goto("/owner/services"); await expect(page.getByRole("heading", { name: "Services", exact: true })).toBeVisible(); const categoryName = `E2E Test Category ${Date.now()}`; await page.getByPlaceholder("New category name").fill(categoryName); await page.getByRole("button", { name: "Add Category", exact: true }).click(); await expect(page.getByRole("heading", { name: categoryName, level: 3 })).toBeVisible(); const categorySection = page.locator("section").filter({ has: page.getByRole("heading", { name: categoryName, level: 3 }) }); await categorySection.getByRole("button", { name: "Add Service", exact: true }).click(); const serviceName = `E2E Test Service ${Date.now()}`; const sheet = page.getByRole("dialog"); await sheet.getByLabel("Name").fill(serviceName); await sheet.getByLabel("Duration (min)").fill("30"); await sheet.getByLabel("Price").fill("15.00"); await sheet.getByRole("button", { name: "Add Service", exact: true }).click(); await expect(page.getByText(serviceName)).toBeVisible(); // The new service is immediately bookable by customers. await page.goto("/book"); await expect(page.getByRole("button", { name: new RegExp(`^${serviceName}`) })).toBeVisible(); }); test("8. owner requires booking approval, and a new customer booking reflects it immediately", async ({ page }) => { await signIn(page, SEEDED.owner.email, "password123"); await page.goto("/owner/settings/booking-rules"); await expect(page.getByRole("heading", { name: "Booking Rules" })).toBeVisible(); const approvalSwitch = page.getByRole("switch", { name: "Require approval for new bookings" }); await expect(approvalSwitch).toHaveAttribute("data-state", "unchecked"); await approvalSwitch.click(); await page.getByRole("button", { name: "Save", exact: true }).click(); await expect(page.getByText(/Saved — the customer booking flow uses these rules immediately/)).toBeVisible(); // A fresh guest booking now lands as PENDING, not CONFIRMED. Sign out first — // signed in as Marcus, /book/details books as him rather than showing guest fields. // Wait for the save's background requests to fully settle before clearing cookies: // otherwise a still-in-flight response can re-set the session cookie right after // it's cleared (a real race, confirmed by reproducing it outside Playwright too). await page.waitForLoadState("networkidle"); await page.context().clearCookies(); await page.goto("/book"); await completeBookingFlow(page, { serviceName: SEEDED.services.beardTrim, guest: { firstName: "E2E", lastName: "ApprovalTest", email: uniqueEmail("e2e-approval"), phone: "+44 7700 900111", }, }); await expect(page.getByText("Pending Approval")).toBeVisible(); // Restore the seeded default so later journeys (and re-runs) see auto-confirmed bookings again. await signIn(page, SEEDED.owner.email, "password123"); await page.goto("/owner/settings/booking-rules"); await page.getByRole("switch", { name: "Require approval for new bookings" }).click(); await page.getByRole("button", { name: "Save", exact: true }).click(); await expect(page.getByText(/Saved — the customer booking flow uses these rules immediately/)).toBeVisible(); }); test("9. owner configures the loyalty programme", async ({ page }) => { await signIn(page, SEEDED.owner.email, "password123"); await page.goto("/owner/settings/loyalty"); await expect(page.getByRole("heading", { name: "Loyalty Programme" })).toBeVisible(); const stampsInput = page.getByLabel("Stamps required for reward"); await expect(stampsInput).toHaveValue("10"); await stampsInput.fill("8"); await page.getByRole("button", { name: "Save", exact: true }).click(); await expect(page.getByText("Saved.").first()).toBeVisible(); await page.reload(); await expect(page.getByLabel("Stamps required for reward")).toHaveValue("8"); // Restore the seeded default. await page.getByLabel("Stamps required for reward").fill("10"); await page.getByRole("button", { name: "Save", exact: true }).click(); await expect(page.getByText("Saved.").first()).toBeVisible(); }); test("10. owner redeems a customer's available loyalty reward on an appointment", async ({ page }) => { await signIn(page, SEEDED.owner.email, "password123"); await page.goto("/owner/calendar?view=agenda"); // Seeded: Sam Osei has a CHECKED_IN appointment with Leo today, and sits // exactly at the loyalty reward threshold with one AVAILABLE reward. // This page renders agenda cards via AppointmentCard -> Card (see // components/ui/card.tsx), whose root class is `rounded-md` — not // `rounded-jumbo`, which was never an actual class anywhere in this // app (see findAppointmentCard in helpers.ts, which had the same bug). const card = page.locator(".rounded-md").filter({ hasText: "Sam Osei" }).filter({ hasText: "Arrived" }); await card.getByRole("link", { name: "View", exact: true }).click(); await expect(page.getByText(/Subtotal/)).toBeVisible(); await page.getByRole("button", { name: "Redeem Reward", exact: true }).click(); const modal = page.getByRole("dialog"); await expect(modal.getByText(/This can't be undone/)).toBeVisible(); await modal.getByRole("button", { name: "Apply Reward", exact: true }).click(); await expect(modal.getByText(/has been applied to this appointment/)).toBeVisible(); await modal.getByRole("button", { name: "Done", exact: true }).click(); await expect(page.getByText("Loyalty reward")).toBeVisible(); }); test("11. a barber without MANAGE_STAFF is denied access to owner administration", async ({ page }) => { await signIn(page, SEEDED.barberHaircutOnly.email, "password123"); await page.goto("/owner"); await expect(page).toHaveURL(/\/staff$/); await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Manage" })).toHaveCount(0); }); test("12. the owner reaches barber tools straight from the merged nav — no mode switch", async ({ page }) => { await signIn(page, SEEDED.owner.email, "password123"); await page.goto("/owner"); // The old "Switch to Owner Admin"/"Switch to Barber Tools" links are // gone (see the merged-nav brief) — an owner reaches their own barber // tools via the same "Now" nav item a barber uses, not a mode switch. await expect(page.getByText("Barber Tools")).toHaveCount(0); await expect(page.getByText("Switch to Barber Tools")).toHaveCount(0); await page.getByRole("link", { name: "Now", exact: true }).first().click(); await expect(page).toHaveURL(/\/staff\/current$/); await expect(page.getByRole("link", { name: "Settings", exact: true }).first()).toBeVisible(); }); test("13. owner reports show real, data-backed appointment/service/staff/customer/loyalty/notification figures", async ({ page }) => { await signIn(page, SEEDED.owner.email, "password123"); await page.goto("/owner"); // "View Reports" was the old Dashboard footer's button label; Reports is // now one of the Manage hub's grouped sections (see (owner)/owner/page.tsx). await page.getByRole("link", { name: "Reports", exact: true }).click(); await expect(page).toHaveURL(/\/owner\/reports/); await expect(page.getByRole("heading", { name: "Reports", exact: true })).toBeVisible(); // The section labels are

s — role-scoped so the owner sidebar's // own "Services" link (same text) doesn't collide (strict-mode violation). for (const heading of ["Appointments", "Services", "Staff", "Customers", "Loyalty", "Notifications"]) { await expect(page.getByRole("heading", { name: heading, exact: true, level: 2 })).toBeVisible(); } // Seeded fixtures guarantee at least one completed Haircut & Style — // real aggregated data, not an empty-state placeholder. await expect(page.getByText("Haircut & Style")).toBeVisible(); // The 7/30/90-day presets are real links, not decoration. await page.getByRole("link", { name: "Last 7 days" }).click(); await expect(page).toHaveURL(/from=.*to=/); await expect(page.getByRole("heading", { name: "Reports" })).toBeVisible(); }); });