Files
NextChair/e2e/customer-journeys.spec.ts
T
Stefan Lazic 94aa1b2f26
CI / ci (push) Canceled after 0s
OPT and Improved Installation Wizzard
1. Summary of changes
Password auth → email OTP, everywhere it existed for the tenant/business app: sign-in, customer self-registration (/register), staff-invitation acceptance, and the installer's administrator step. Flow is always: email → 6-digit code emailed → code entered → session created, via one shared service (otp.service.ts) reused by every entry point.
NextChair Manager's own auth was not touched — this only affects tenant-side (business admin/staff/customer) authentication, per the spec's explicit instruction.
Installer improvements: Step 2 (Administrator) now asks only Name + Email + optional Phone, verified inline via the same OTP widget (no redirect). Step 3 (Business Details) pre-fills Business Name from the Manager-provisioned BUSINESS_NAME, shows the booking URL (NEXT_PUBLIC_APP_URL) as a read-only info box instead of asking for it, and has a distinctly-labeled "Business Website (optional)" field with copy clarifying it's not the booking URL.
Installation cannot complete with an unverified admin email — enforced server-side (finalizeOtp runs inside the same DB transaction as user/business creation), not just trusted from client state. A mid-wizard refresh still can't create duplicate admins or partial records — that property was preserved by design, not incidentally.
2. Prisma/database changes
New EmailOtp model (email as PK — one row per address, so a new request naturally invalidates the previous code): codeHash, purpose, expiresAt, attempts, verifiedAt, consumedAt, timestamps. Only a bcrypt hash of the code is ever stored.
User.passwordHash — unchanged schema (already nullable), just annotated as deprecated. No destructive migration; historical rows are untouched.
Migration: prisma/migrations/20260905190022_email_otp_authentication — adds EmailOtp only. Generated and applied against throwaway containers only; the real next-chair-mysql production database was never touched (confirmed not even running on this host during this session).
3. New environment variables
None required for production. One new variable, E2E_TEST_MODE, exists purely for the Playwright suite (set only in playwright.config.ts's webServer.env) — it unlocks a /api/test/otp-code debug endpoint and bypasses OTP rate limiting so the same seeded users can sign in dozens of times across one test run. It must never be set on a real deployment; nothing in the app sets it anywhere else.

4. Email delivery configuration
Nothing new to configure. OTP emails reuse the existing EmailProvider abstraction and EMAIL_PROVIDER env var exactly as configured today — console in dev (prints to server logs), brevo in production via the same SMTP credentials already documented. The OTP email is a new template (otp-email.ts) sent directly (bypassing the async notification/cron pipeline, since a 10-minute-expiry code can't wait for a delivery retry loop).

5. Migration considerations for existing users/installations
Existing users lose password sign-in — the only way to authenticate now is email OTP. There is no in-app fallback; this is an intentional, sharp cutover. If existing customers/staff/admins need advance notice, that's a business/communications decision outside this change.
No account data is deleted or migrated — passwordHash rows are simply never read for auth anymore.
prisma/seed.ts / scripts/ensure-super-admin.ts still set a passwordHash for seeded dev users — harmless and vestigial now, not cleaned up (flagging it rather than touching seed data silently).
Installations already completed are unaffected; only new installer runs go through the OTP admin-verification step.
6. Files changed
New:

src/lib/auth/otp.service.ts — the core requestOtp/verifyOtp/finalizeOtp service
src/lib/auth/__tests__/otp.service.integration.test.ts
src/services/notifications/templates/otp-email.ts
src/features/auth/components/otp-code-step.tsx — shared code-entry UI
src/features/auth/actions/request-sign-in-otp.ts
src/app/api/test/otp-code/route.ts — e2e-only, gated on E2E_TEST_MODE
prisma/migrations/20260905190022_email_otp_authentication/
Modified — auth core: prisma/schema.prisma, src/auth.ts, src/features/auth/schemas.ts

Modified — sign-in/register/invitations: sign-in-form.tsx, register.ts, register-form.tsx, accept-invitation.ts, accept-invitation-form.tsx, staff-invitation.service.ts

Modified — installer: installer.service.ts, schema.ts, install.ts, install-wizard.tsx, install/page.tsx, installer.service.integration.test.ts

Modified — email / e2e infra: console-email-provider.ts, playwright.config.ts, e2e/helpers.ts, and the four e2e/*.spec.ts files (dropped the now-unused password argument from every signIn() call).

7. Verification performed
npx tsc --noEmit, npm run lint, npm test (276 unit) — all clean.
npm run test:integration (207 tests, throwaway DB) — includes 11 new OTP-service tests and a rewritten installer suite; a genuine race condition in requestOtp (concurrent requests for a brand-new email double-hitting the upsert's create branch) was caught and fixed here.
Full npm run test:e2e against a fresh throwaway database: 36/37 passed. The one failure (owner-journeys "opening hours conflict" test) is a pre-existing, auth-unrelated ordering flake — appointment-count ambiguity from earlier tests' bookings — not something this change touches or introduced.
Live manual verification in-browser: existing seeded customer sign-in via OTP; new customer registration via OTP (session created, landed on Home); installer wizard end-to-end through Step 3, confirming email-verification gating, Business Name pre-population from BUSINESS_NAME, and the read-only Booking URL / distinct Business Website fields.
All throwaway Docker containers/DBs used for this work have been removed; the real production database was never touched.
2026-09-05 21:03:36 +01:00

287 lines
14 KiB
TypeScript

import { expect, test } from "@playwright/test";
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 }) => {
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",
},
});
});
});