This commit is contained in:
Stefan Lazic committed 2026-08-03 21:15:24 +01:00
1 parent ec5583f972
commit 546613e677
189 files changed
+9873 -655

No files matched your search

+59 -2
View File
@@ -30,10 +30,23 @@ test.describe("customer booking journeys", () => {
guest,
});
// Guest confirmation offers an account-creation prompt but doesn't force it.
// Guest confirmation offers an account-creation prompt but doesn't force it —
// and also a real, secured "View Appointment" link (Phase 7: a guest's only
// credential is a single-purpose access token in the URL, not the bare
// appointment id — see src/services/scheduling/guest-access.service.ts).
await expect(page.getByText("Want to manage this booking later?")).toBeVisible();
await expect(page.getByRole("link", { name: "Create an Account" })).toBeVisible();
await expect(page.getByRole("link", { name: "View Appointment" })).toHaveCount(0);
const viewLink = page.getByRole("link", { name: "View Appointment" });
await expect(viewLink).toBeVisible();
const href = await viewLink.getAttribute("href");
expect(href).toMatch(/^\/appointments\/[^/]+\?token=.+/);
// The token in the link actually works, and a guessed/blank token doesn't.
await page.goto(href!);
await expect(page.getByRole("heading", { name: "Appointment Details" })).toBeVisible();
const appointmentId = href!.split("?")[0]!.split("/").pop();
await page.goto(`/appointments/${appointmentId}`);
await expect(page.getByRole("heading", { name: "This page could not be found." })).toBeVisible();
});
test("2. registered customer books an appointment", async ({ page }) => {
@@ -151,4 +164,48 @@ test.describe("customer booking journeys", () => {
await page.getByRole("tab", { name: "Past" }).click();
await expect(completedCard).toBeVisible();
});
test("7. booking confirmation creates an in-app notification the customer can read and mark read", async ({ page }) => {
await signIn(page, SEEDED.registeredCustomer.email, SEEDED.registeredCustomer.password);
await page.goto("/book");
await completeBookingFlow(page, {
serviceName: SEEDED.services.beardTrim,
staffName: SEEDED.barberSkinfadeBeard.name,
});
// The bell in the persistent customer layout shows an unread badge — see (customer)/layout.tsx.
await page.goto("/home");
const bell = page.getByRole("link", { name: /Notifications, \d+ unread/ });
await expect(bell).toBeVisible();
await bell.click();
await expect(page).toHaveURL(/\/notifications/);
await expect(page.getByText("Booking confirmed").first()).toBeVisible();
await expect(page.getByRole("status", { name: "Unread" }).first()).toBeVisible();
// "Mark All Read" clears every unread notification — the nav badge disappears entirely
// afterward, regardless of how many notifications this account has accumulated.
await page.getByRole("button", { name: "Mark All Read" }).click();
await page.waitForLoadState("networkidle");
await expect(page.getByRole("status", { name: "Unread" })).toHaveCount(0);
await page.goto("/home");
await expect(page.getByRole("link", { name: "Notifications" })).toBeVisible();
await expect(page.getByRole("link", { name: /Notifications, \d+ unread/ })).toHaveCount(0);
});
test("8. customer sees their available loyalty reward on Home and the Loyalty page", async ({ page }) => {
// Seeded at exactly 10/10 stamps with one AVAILABLE reward (see prisma/seed.ts) —
// must run before owner-journeys.spec.ts's journey 10, which redeems it.
await signIn(page, SEEDED.secondCustomer.email, SEEDED.secondCustomer.password);
await page.goto("/home");
await expect(page.getByText(/Reward available/)).toBeVisible();
await page.getByText(/Reward available/).click();
await expect(page).toHaveURL(/\/loyalty/);
await expect(page.getByRole("heading", { name: "Loyalty", exact: true })).toBeVisible();
await expect(page.getByText("Reward available:")).toBeVisible();
await expect(page.getByText("stamp added").first()).toBeVisible();
});
});
+12
View File
@@ -4,6 +4,12 @@ import type { Page } from "@playwright/test";
/** Seeded in prisma/seed.ts — see that file for the full fixture set this suite relies on. */
export const SEEDED = {
registeredCustomer: { email: "alex.chen@example.com", password: "password123", name: "Alex Chen" },
// Seeded right at their loyalty reward threshold (10/10 stamps, one
// AVAILABLE reward) — see prisma/seed.ts. owner-journeys.spec.ts's
// journey 10 redeems this exact reward, so any test relying on it still
// being AVAILABLE must run before that (customer-journeys.spec.ts runs
// first in the suite, so this is safe there).
secondCustomer: { email: "sam.osei@example.com", password: "password123", name: "Sam Osei" },
owner: { email: "marcus@blakeheritage.co.uk", name: "Marcus Reed" },
barberHaircutOnly: { email: "leo@blakeheritage.co.uk", name: "Leo Vance" },
barberSkinfadeBeard: { email: "jamie@blakeheritage.co.uk", name: "Jamie Wallace" },
@@ -16,6 +22,12 @@ export const SEEDED = {
export async function signIn(page: Page, email: string, password: string): Promise<void> {
await page.goto("/sign-in");
// Turbopack dev-mode compiles each route group on first visit — on a
// cold server, a click that lands before that finishes (and before
// React attaches the form's submit handler) is silently dropped. Wait
// for the page to go idle once before interacting, so this doesn't
// race the very first visit to an as-yet-uncompiled route.
await page.waitForLoadState("networkidle");
await page.getByLabel("Email Address").fill(email);
await page.getByLabel("Password", { exact: true }).fill(password);
await page.getByRole("button", { name: "Sign In", exact: true }).click();
+38 -17
View File
@@ -1,11 +1,12 @@
import { expect, test } from "@playwright/test";
import { Client } from "pg";
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 Postgres, `test.describe.serial` because several
* 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,
@@ -19,16 +20,15 @@ import { SEEDED, completeBookingFlow, signIn, uniqueEmail } from "./helpers";
test.describe.serial("owner administration journeys", () => {
test.afterAll(async () => {
const client = new Client({ connectionString: process.env.DATABASE_URL });
await client.connect();
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 client.query(`DELETE FROM "Service" WHERE name LIKE 'E2E Test Service%'`);
await client.query(`DELETE FROM "ServiceCategory" WHERE name LIKE 'E2E Test Category%'`);
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 client.query(`DELETE FROM "User" WHERE email LIKE 'e2e-new-barber-%@e2e-test.local'`);
await client.end();
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 }) => {
@@ -99,16 +99,15 @@ test.describe.serial("owner administration journeys", () => {
// 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`,
// 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 client.end();
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
@@ -328,4 +327,26 @@ test.describe.serial("owner administration journeys", () => {
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
await expect(page.getByRole("link", { name: "Current", exact: true })).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");
await page.getByRole("link", { name: "View Reports" }).click();
await expect(page).toHaveURL(/\/owner\/reports/);
await expect(page.getByRole("heading", { name: "Reports", exact: true })).toBeVisible();
// The section labels are <h2>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();
});
});
+25 -8
View File
@@ -1,11 +1,13 @@
import { expect, test } from "@playwright/test";
import { Client } from "pg";
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 Postgres
* database (see e2e/global-setup.ts / prisma/seed.ts). Journeys that only
* 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
@@ -29,11 +31,10 @@ test.describe.serial("barber operations journeys", () => {
// 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();
const pool = createPool(buildMysqlPoolConfig(process.env.DATABASE_URL));
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 }) => {
@@ -227,4 +228,20 @@ test.describe.serial("barber operations journeys", () => {
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 }) => {
// Journey 3 (this file runs serially) already booked Marcus a fresh appointment for Jordan
// Blake — see NotificationService/booking-notifications.ts, notifyBookingCreated, which
// queues STAFF_NEW_APPOINTMENT for the assigned barber on every booking.
await signIn(page, SEEDED.owner.email, "password123");
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();
});
});