CI / ci (push) Canceled after 0s
The session built: Appointments page: a single list, newest first. Block Time: edit and delete. Customer CSV export: with barber notes excluded. Guest-link regression test: a test that an appointment id alone is rejected, plus fixes to the stale docs. It built the scheduler, job runner with live progress, backup and restore, and the health alerts. Its own report says a real delete-and-restore on throwaway Docker passed, but I haven’t repeated that. It also says real SMTP and large dumps were not exercised. The manager image needs rebuilding for the backups to work, because it now installs 7zip.
296 lines
15 KiB
TypeScript
296 lines
15 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import { IN_APP_NOTIFICATIONS_VISIBLE } from "../src/services/notifications/in-app";
|
|
import { SEEDED, completeBookingFlow, completeRescheduleFlow, findAppointmentCard, signIn, uniqueEmail } from "./helpers";
|
|
|
|
/**
|
|
* The 6 customer journeys required by the Phase 4 brief. Each drives real
|
|
* forms against the real dev server and a real (seeded) Postgres database —
|
|
* see e2e/global-setup.ts for how that database gets prepared, and
|
|
* prisma/seed.ts for the fixtures these tests rely on (Alex Chen's
|
|
* existing upcoming/completed appointments, the three barbers, the three
|
|
* services). Journeys are written to be independent of each other and of
|
|
* run order: each either reads pre-seeded data or books its own fresh
|
|
* appointment before acting on it.
|
|
*/
|
|
|
|
test.describe("customer booking journeys", () => {
|
|
test("1. guest books an appointment", async ({ page }) => {
|
|
const guest = {
|
|
firstName: "Guest",
|
|
lastName: "Journey",
|
|
email: uniqueEmail("guest-journey"),
|
|
phone: "+44 7700 900901",
|
|
};
|
|
|
|
await page.goto("/home");
|
|
await page.getByRole("link", { name: "Book an Appointment" }).click();
|
|
|
|
await completeBookingFlow(page, {
|
|
serviceName: SEEDED.services.haircut,
|
|
staffName: SEEDED.owner.name,
|
|
guest,
|
|
});
|
|
|
|
// 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();
|
|
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 }) => {
|
|
await signIn(page, SEEDED.registeredCustomer.email);
|
|
|
|
await page.goto("/book");
|
|
await completeBookingFlow(page, {
|
|
serviceName: SEEDED.services.skinfade,
|
|
staffName: SEEDED.barberSkinfadeBeard.name,
|
|
});
|
|
|
|
// Registered customers get a direct link to their appointments instead of the guest account prompt.
|
|
await expect(page.getByRole("link", { name: "View Appointment" })).toBeVisible();
|
|
|
|
await page.goto("/appointments");
|
|
await expect(findAppointmentCard(page, SEEDED.services.skinfade, SEEDED.barberSkinfadeBeard.name).first()).toBeVisible();
|
|
});
|
|
|
|
test("3. customer views all appointments in one list", async ({ page }) => {
|
|
await signIn(page, SEEDED.registeredCustomer.email);
|
|
await page.goto("/appointments");
|
|
|
|
// One list, no tabs.
|
|
await expect(page.getByRole("tab")).toHaveCount(0);
|
|
|
|
// Seeded: an upcoming CONFIRMED Haircut & Style with Marcus Reed — reschedulable and cancellable.
|
|
// The seed also has a completed Haircut & Style with Marcus Reed, so pick the card by status too.
|
|
const upcomingSeeded = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.owner.name).filter({ hasText: "Confirmed" });
|
|
await expect(upcomingSeeded).toBeVisible();
|
|
await expect(upcomingSeeded.getByText("Confirmed")).toBeVisible();
|
|
await expect(upcomingSeeded.getByRole("link", { name: "Reschedule" })).toBeVisible();
|
|
await expect(upcomingSeeded.getByRole("button", { name: "Cancel", exact: true })).toBeVisible();
|
|
await expect(upcomingSeeded.getByRole("button", { name: "Book Again" })).toHaveCount(0);
|
|
|
|
// Seeded: a COMPLETED Haircut & Style with Leo Vance — same list, Book Again instead.
|
|
const pastSeeded = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name).filter({ hasText: "Completed" });
|
|
await expect(pastSeeded).toBeVisible();
|
|
await expect(pastSeeded.getByText("Completed")).toBeVisible();
|
|
await expect(pastSeeded.getByRole("button", { name: "Book Again" })).toBeVisible();
|
|
await expect(pastSeeded.getByRole("link", { name: "Reschedule" })).toHaveCount(0);
|
|
|
|
// Newest first: the upcoming appointment is above the completed one.
|
|
const upcomingBox = await upcomingSeeded.boundingBox();
|
|
const pastBox = await pastSeeded.boundingBox();
|
|
expect(upcomingBox!.y).toBeLessThan(pastBox!.y);
|
|
});
|
|
|
|
test("4. customer reschedules an appointment", async ({ page }) => {
|
|
await signIn(page, SEEDED.registeredCustomer.email);
|
|
|
|
// Book a dedicated appointment for this test so it doesn't depend on the seeded one or on other tests.
|
|
await page.goto("/book");
|
|
await completeBookingFlow(page, { serviceName: SEEDED.services.beardTrim, staffName: SEEDED.owner.name });
|
|
|
|
await page.goto("/appointments");
|
|
const card = findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.owner.name);
|
|
await expect(card).toBeVisible();
|
|
const originalTime = (await card.innerText()).match(/\d{1,2}:\d{2}/)?.[0];
|
|
expect(originalTime).toBeTruthy();
|
|
|
|
await card.getByRole("link", { name: "Reschedule" }).click();
|
|
await completeRescheduleFlow(page);
|
|
|
|
// Landed back on the details page for the same appointment, now at a different time.
|
|
await expect(page.getByRole("heading", { name: "Appointment Details" })).toBeVisible();
|
|
await expect(page.getByText(SEEDED.services.beardTrim)).toBeVisible();
|
|
const newTime = (await page.locator("main").innerText()).match(/\d{1,2}:\d{2}/)?.[0];
|
|
expect(newTime).toBeTruthy();
|
|
expect(newTime).not.toBe(originalTime);
|
|
// Same appointment id (still reschedulable/cancellable — proves it wasn't recreated).
|
|
await expect(page.getByRole("link", { name: "Reschedule" })).toBeVisible();
|
|
});
|
|
|
|
test("5. customer cancels an appointment", async ({ page }) => {
|
|
await signIn(page, SEEDED.registeredCustomer.email);
|
|
|
|
await page.goto("/book");
|
|
await completeBookingFlow(page, { serviceName: SEEDED.services.beardTrim, staffName: SEEDED.barberSkinfadeBeard.name });
|
|
|
|
await page.goto("/appointments");
|
|
const upcomingCard = findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name).first();
|
|
await expect(upcomingCard).toBeVisible();
|
|
|
|
await upcomingCard.getByRole("button", { name: "Cancel", exact: true }).click();
|
|
// Scoped to the currently-open <dialog> — the list can have several
|
|
// cancellable cards, each with its own (closed) dialog in the DOM.
|
|
const dialog = page.locator("dialog[open]");
|
|
await expect(dialog.getByRole("heading", { name: "Cancel Appointment?" })).toBeVisible();
|
|
await dialog.getByPlaceholder("Reason (optional)").fill("E2E test cancellation");
|
|
await dialog.getByRole("button", { name: "Cancel it" }).click();
|
|
|
|
await expect(dialog.getByText("Your appointment has been cancelled.")).toBeVisible();
|
|
await dialog.getByRole("button", { name: "Done" }).click();
|
|
|
|
// Cancellation never deletes — the same card stays in the list, now with a Cancelled pill
|
|
// and Book Again instead of Reschedule/Cancel.
|
|
const cancelledCard = findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name).first();
|
|
await expect(cancelledCard).toBeVisible();
|
|
await expect(cancelledCard.getByText("Cancelled")).toBeVisible();
|
|
await expect(cancelledCard.getByRole("button", { name: "Book Again" })).toBeVisible();
|
|
await expect(cancelledCard.getByRole("link", { name: "Reschedule" })).toHaveCount(0);
|
|
});
|
|
|
|
test("6. customer books again from history", async ({ page }) => {
|
|
await signIn(page, SEEDED.registeredCustomer.email);
|
|
await page.goto("/appointments");
|
|
|
|
// Seeded COMPLETED appointment (Haircut & Style with Leo Vance).
|
|
const completedCard = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name).filter({ hasText: "Completed" });
|
|
await completedCard.getByRole("button", { name: "Book Again" }).click();
|
|
|
|
// Services (and the original barber, if still bookable) come preselected — the customer only re-confirms a fresh date/time.
|
|
await expect(page).toHaveURL(/\/book$/);
|
|
await expect(page.getByText("Booking again")).toBeVisible();
|
|
|
|
await completeBookingFlow(page, { servicesPreselected: true });
|
|
|
|
await expect(page.getByRole("link", { name: "View Appointment" })).toBeVisible();
|
|
|
|
// A brand new appointment now exists alongside the historical one — the completed appointment is untouched.
|
|
await page.goto("/appointments");
|
|
await expect(completedCard).toBeVisible();
|
|
});
|
|
|
|
test("7. booking confirmation creates an in-app notification the customer can read and mark read", async ({ page }) => {
|
|
test.skip(!IN_APP_NOTIFICATIONS_VISIBLE, "In-app notifications are hidden — see src/features/notifications/in-app.ts");
|
|
|
|
await signIn(page, SEEDED.registeredCustomer.email);
|
|
|
|
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);
|
|
|
|
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();
|
|
|
|
// The owner-configured terms line (see prisma/seed.ts) renders on the customer card even
|
|
// while a reward is available — previously this was silently replaced by synthesized
|
|
// "N more to your reward" text and never showed the real configured terms at all.
|
|
await expect(page.getByText("Valid for 12 months. One reward per visit.")).toBeVisible();
|
|
});
|
|
|
|
test("9. clicking Book on a Home service preselects it in the booking draft", async ({ page }) => {
|
|
await signIn(page, SEEDED.registeredCustomer.email);
|
|
await page.goto("/home");
|
|
|
|
// The description stays visible on the Services tab row — the Book button doesn't hide it.
|
|
const skinfadeRow = page
|
|
.locator("div.flex.items-center.justify-between.gap-4.py-4")
|
|
.filter({ hasText: SEEDED.services.skinfade });
|
|
await expect(skinfadeRow).toBeVisible();
|
|
|
|
await skinfadeRow.getByRole("button", { name: "Book" }).click();
|
|
|
|
// No existing draft — preselectServiceAction creates a fresh one with just this service, then redirects straight to /book.
|
|
await expect(page).toHaveURL(/\/book$/);
|
|
const skinfadeToggle = page.getByRole("button", { name: new RegExp(`^${SEEDED.services.skinfade}`) });
|
|
await expect(skinfadeToggle).toHaveAttribute("aria-pressed", "true");
|
|
await expect(page.getByText("1 selected")).toBeVisible();
|
|
|
|
// A second service clicked from Home is added to the existing draft, not a replacement of it.
|
|
await page.goto("/home");
|
|
const beardRow = page
|
|
.locator("div.flex.items-center.justify-between.gap-4.py-4")
|
|
.filter({ hasText: SEEDED.services.beardTrim });
|
|
await beardRow.getByRole("button", { name: "Book" }).click();
|
|
|
|
await expect(page).toHaveURL(/\/book$/);
|
|
await expect(skinfadeToggle).toHaveAttribute("aria-pressed", "true");
|
|
const beardToggle = page.getByRole("button", { name: new RegExp(`^${SEEDED.services.beardTrim}`) });
|
|
await expect(beardToggle).toHaveAttribute("aria-pressed", "true");
|
|
await expect(page.getByText("2 selected")).toBeVisible();
|
|
|
|
// Re-clicking Book for a service that's already selected doesn't add a duplicate.
|
|
await page.goto("/home");
|
|
await beardRow.getByRole("button", { name: "Book" }).click();
|
|
await expect(page).toHaveURL(/\/book$/);
|
|
await expect(page.getByText("2 selected")).toBeVisible();
|
|
|
|
// Deliberately doesn't complete the booking from here — this account's appointment history is
|
|
// asserted against by name+staff in several other journeys in this file, and creating one more
|
|
// here would make those lookups ambiguous. Journey 10 (a fresh guest) completes the flow end to end.
|
|
});
|
|
|
|
test("10. a guest can also preselect a service from Home before booking", async ({ page }) => {
|
|
await page.goto("/home");
|
|
// Cold Turbopack first-visit compile — see signIn()'s note in helpers.ts; a click landing before
|
|
// this settles is silently dropped, and this test (unlike the others) has no prior navigation to warm it.
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
const haircutRow = page
|
|
.locator("div.flex.items-center.justify-between.gap-4.py-4")
|
|
.filter({ hasText: SEEDED.services.haircut });
|
|
await haircutRow.getByRole("button", { name: "Book" }).click();
|
|
|
|
await expect(page).toHaveURL(/\/book$/);
|
|
await expect(page.getByRole("button", { name: new RegExp(`^${SEEDED.services.haircut}`) })).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
);
|
|
|
|
await completeBookingFlow(page, {
|
|
servicesPreselected: true,
|
|
guest: {
|
|
firstName: "Preselect",
|
|
lastName: "Guest",
|
|
email: uniqueEmail("preselect-guest"),
|
|
phone: "+44 7700 900902",
|
|
},
|
|
});
|
|
});
|
|
});
|