import { expect, test } from "@playwright/test"; import { Client } from "pg"; 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 Postgres, `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 client = new Client({ connectionString: process.env.DATABASE_URL }); await client.connect(); // Journey 7 creates a category/service with no deterministic id to // upsert against on a re-run — clean up by name instead. await client.query(`DELETE FROM "Service" WHERE name LIKE 'E2E Test Service%'`); await client.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 client.query(`DELETE FROM "User" WHERE email LIKE 'e2e-new-barber-%@e2e-test.local'`); await client.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"); await expect(page.getByRole("heading", { name: "Dashboard" })).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(); await expect(page.getByRole("link", { name: "Marcus Reed", exact: true })).toBeVisible(); await expect(page.getByRole("link", { name: "Leo Vance", exact: true })).toBeVisible(); // Filtering to one staff member narrows the day view to a single column. await page.getByRole("link", { name: "Leo Vance", exact: true }).click(); await expect(page).toHaveURL(/staffId=/); // The single remaining day-view column is headed by a

with the staff member's exact name // (the filter pill link has the same text, so scope to the paragraph specifically). await expect(page.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", exact: true }).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 Postgres instead of predicting it from wall-clock time. const client = new Client({ connectionString: process.env.DATABASE_URL }); await client.connect(); const { rows } = await client.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 client.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. const card = page.locator(".rounded-jumbo").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: "Dashboard" })).toHaveCount(0); }); test("12. the owner switches back to barber tools without losing the barber nav", async ({ page }) => { await signIn(page, SEEDED.owner.email, "password123"); await page.goto("/owner"); await page.getByRole("link", { name: "Barber Tools", exact: true }).click(); await expect(page).toHaveURL(/\/staff$/); await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible(); await expect(page.getByRole("link", { name: "Current", exact: true })).toBeVisible(); }); });