Phase 7
This commit is contained in:
189 files changed
+9873
-655
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user