Owner Administration is complete. Summary of this final stretch of work:

Seed data extended with an inactive staff member, a pending invitation, a redeemed-reward fixture, a service with no eligible staff, and a future-appointment fixture for conflict testing — all idempotent, verified against the dev DB.

Testing brought to 179 unit / 86 integration / 27 E2E tests, all passing together on a freshly seeded, disposable Postgres:

Unit tests for every new pure validator (business hours, categories/services, booking rules, loyalty config, staff creation).
Integration tests covering opening-hours conflicts, last-owner protection, staff deactivation/eligibility conflicts, loyalty redemption's atomic race-guard, and owner-vs-barber/business-isolation authorization — all against a real Postgres.
12 new Playwright owner journeys, which surfaced and fixed two genuine issues along the way: a cookie-clearing race condition in the sign-out-mid-test flow (a real, reproducible timing bug, not just a test artifact), and confirmation that seed fixtures upserted by deterministic ID go stale once "today" has moved on — now handled defensively.
Desktop responsiveness: converted the single-column Business Details, Booking Rules, and Loyalty Configuration forms to a genuine two-column layout at the lg: breakpoint (verified via measured element positions, not just screenshots), confirmed mobile stacking is unaffected.

Final verification: clean tsc, eslint, and next build, plus the full three-suite run against one fresh database as the authoritative pass.
This commit is contained in:
Stefan Lazic committed 2026-08-03 12:56:55 +01:00
1 parent ffee9d6754
commit ec5583f972
73 files changed
+8400 -53

No files matched your search

+331
View File
@@ -0,0 +1,331 @@
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 <p> 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();
});
});