CI / ci (push) Canceled after 0s
Policies section (business-details-form.tsx) Late Arrival Policy, Booking Terms and Privacy Policy URL are gone. The section now has Cancellation Policy plus the Terms & Privacy name and email dropdowns. Their database columns stay, so nothing is lost. Saving no longer touches them. Email and push only One switch, IN_APP_NOTIFICATIONS_VISIBLE in in-app.ts, is set to false and controls everything below. Bell icon: gone for customers, staff and owners, including the unread badge. Notification pages: /notifications, /staff/notifications and /owner/notifications now return 404. Settings: Settings → Notifications shows only Email and Push toggles. Testing: I checked all of this in a local test app as the owner.
290 lines
14 KiB
TypeScript
290 lines
14 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 upcoming appointments", async ({ page }) => {
|
|
await signIn(page, SEEDED.registeredCustomer.email);
|
|
await page.goto("/appointments");
|
|
|
|
// Seeded: an upcoming CONFIRMED Haircut & Style with Marcus Reed.
|
|
await expect(page.getByRole("tab", { name: "Upcoming" })).toHaveAttribute("data-state", "active");
|
|
const upcomingSeeded = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.owner.name);
|
|
await expect(upcomingSeeded).toBeVisible();
|
|
await expect(upcomingSeeded.getByText("Confirmed")).toBeVisible();
|
|
|
|
// Seeded: a COMPLETED Haircut & Style with Leo Vance.
|
|
await page.getByRole("tab", { name: "Past" }).click();
|
|
const pastSeeded = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name);
|
|
await expect(pastSeeded).toBeVisible();
|
|
await expect(pastSeeded.getByText("Completed")).toBeVisible();
|
|
|
|
// Alex Chen has no cancelled appointments in the seed data.
|
|
await page.getByRole("tab", { name: "Cancelled" }).click();
|
|
await expect(page.getByText("No cancelled appointments")).toBeVisible();
|
|
});
|
|
|
|
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);
|
|
await expect(upcomingCard).toBeVisible();
|
|
|
|
await upcomingCard.getByRole("button", { name: "Cancel", exact: true }).click();
|
|
// Scoped to the currently-open <dialog> — the Upcoming tab 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 — it moves from Upcoming to the Cancelled tab.
|
|
await expect(findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name)).toHaveCount(0);
|
|
await page.getByRole("tab", { name: "Cancelled" }).click();
|
|
const cancelledCard = findAppointmentCard(page, SEEDED.services.beardTrim, SEEDED.barberSkinfadeBeard.name);
|
|
await expect(cancelledCard).toBeVisible();
|
|
await expect(cancelledCard.getByText("Cancelled")).toBeVisible();
|
|
});
|
|
|
|
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).
|
|
await page.getByRole("tab", { name: "Past" }).click();
|
|
const completedCard = findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name);
|
|
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(findAppointmentCard(page, SEEDED.services.haircut, SEEDED.barberHaircutOnly.name)).toBeVisible();
|
|
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 }) => {
|
|
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",
|
|
},
|
|
});
|
|
});
|
|
});
|