Files
NextChair/e2e/helpers.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

227 lines
11 KiB
TypeScript

import { expect } from "@playwright/test";
import type { Page } from "@playwright/test";
/**
* Seeded in prisma/seed.ts — see that file for the full fixture set this
* suite relies on. No `password` fields here anymore: sign-in is OTP-only
* now (see signIn() below), so a seeded user's passwordHash (still written
* by prisma/seed.ts / ensure-super-admin.ts) is vestigial and unused by
* any auth path.
*/
export const SEEDED = {
registeredCustomer: { email: "alex.chen@example.com", 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", name: "Sam Osei" },
owner: { email: "marcus@blakeheritage.co.uk", name: "Marcus Reed" },
// Provisioned by global-setup.ts via scripts/ensure-super-admin.ts, not
// prisma/seed.ts — a Super Administrator deliberately has no StaffProfile
// (see isSuperAdmin) and stays out of the dev/demo seed data on purpose.
superAdmin: { email: "superadmin@blakeheritage.co.uk", name: "Super Admin" },
barberHaircutOnly: { email: "leo@blakeheritage.co.uk", name: "Leo Vance" },
barberSkinfadeBeard: { email: "jamie@blakeheritage.co.uk", name: "Jamie Wallace" },
services: {
haircut: "Haircut & Style",
skinfade: "Skinfade / Taper & Style",
beardTrim: "Beard Trim & Shape",
},
};
/**
* Reads a just-sent OTP code back out of the dev server's ConsoleEmailProvider
* capture buffer via /api/test/otp-code (see that route's own header — it
* 404s unless E2E_TEST_MODE is set, which playwright.config.ts's webServer
* does for exactly this). Polls briefly since the email is sent by a
* Server Action that may not have resolved the instant the client-side
* step transition happens.
*
* `sentAfter` (epoch ms) must be captured right before whatever action
* triggers the send — the same seeded users sign in repeatedly across this
* suite, so without it this could hand back a *previous*, already-consumed
* code still sitting in the buffer instead of the one just requested (see
* that route's own `since` param doc for the full reasoning).
*/
async function fetchOtpCode(page: Page, email: string, sentAfter: number): Promise<string> {
let code: string | null = null;
await expect(async () => {
const res = await page.request.get(`/api/test/otp-code?email=${encodeURIComponent(email)}&since=${sentAfter}`);
expect(res.ok()).toBe(true);
code = ((await res.json()) as { code: string }).code;
}).toPass({ timeout: 10_000 });
if (!code) throw new Error(`Test setup failed: no OTP code captured for ${email}.`);
return code;
}
export async function signIn(page: Page, email: 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);
const requestedAt = Date.now();
await page.getByRole("button", { name: "Continue", exact: true }).click();
const code = await fetchOtpCode(page, email, requestedAt);
await page.getByLabel("Verification Code").fill(code);
await page.getByRole("button", { name: "Sign In", exact: true }).click();
// Not asserting a specific destination here — a bare sign-in (no
// callbackUrl, which this helper never sets) now lands on a role-aware
// page (customer -> /home, barber -> /staff, owner/super admin ->
// /owner or /owner/calendar; see src/app/page.tsx), not uniformly
// /home. Every caller already `page.goto()`s to the specific route it
// needs right after this — this just confirms sign-in actually
// succeeded and navigated away from the form.
await expect(page).not.toHaveURL(/\/sign-in/);
}
function section(page: Page, headingName: string) {
return page.locator("section").filter({ has: page.getByRole("heading", { name: headingName, exact: true }) });
}
/**
* Drives the real /book flow end to end — every step is a genuine form
* submission through the actual Server Actions, matching the Phase 4
* brief's requirement that these journeys not rely on mocks or snapshots.
*
* Assumes the page is already on /book (or /book/barber-time if
* `servicesPreselected` — e.g. arriving via "Book Again", which
* preselects services and redirects straight past the services step).
*/
export async function completeBookingFlow(
page: Page,
options: {
serviceName?: string;
servicesPreselected?: boolean;
staffName?: string; // omit for "Anyone available"
guest?: { firstName: string; lastName: string; email: string; phone: string };
},
): Promise<void> {
const { serviceName, servicesPreselected = false, staffName, guest } = options;
await expect(page).toHaveURL(/\/book$/);
if (!servicesPreselected) {
if (!serviceName) throw new Error("serviceName is required unless servicesPreselected is true");
await page.getByRole("button", { name: new RegExp(`^${serviceName}`) }).click();
}
// Even with services preselected (e.g. "Book Again"), the customer still confirms this step themselves.
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page).toHaveURL(/\/book\/barber-time/);
if (staffName) {
await section(page, "Barber").locator("select").selectOption({ label: staffName });
// Choosing a barber refetches that barber's specific availability —
// wait for it to settle so the date grid below reflects the right person.
await page.waitForLoadState("networkidle");
}
// Wait for the date grid to (re)render for the chosen barber before picking a day.
await expect(section(page, "Date").locator("button[aria-pressed]").first()).toBeVisible();
await section(page, "Date").locator("button[aria-pressed]:not([disabled])").first().click();
const timeSection = section(page, "Time");
await expect(timeSection.locator("button").first()).toBeVisible();
await timeSection.locator("button").first().click();
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page).toHaveURL(/\/book\/details/);
if (guest) {
await page.getByLabel("First Name").fill(guest.firstName);
await page.getByLabel("Last Name").fill(guest.lastName);
await page.getByLabel("Email Address").fill(guest.email);
await page.getByLabel("Phone Number").fill(guest.phone);
}
await page.getByLabel("I acknowledge the cancellation policy").check();
await page.getByRole("button", { name: "Continue to Review" }).click();
await expect(page).toHaveURL(/\/book\/review/);
await page.getByRole("button", { name: "Confirm Booking" }).click();
await expect(page).toHaveURL(/\/book\/confirmation/);
await expect(page.getByRole("heading", { name: "You're all set!" })).toBeVisible();
}
/**
* Locates a specific appointment card on /appointments by its rendered
* service + barber text, scoping later actions (Reschedule/Cancel/Book
* Again) to that one card so they can't accidentally hit a different
* appointment. Cards there render via AppointmentCard -> Card (see
* components/ui/card.tsx), whose root class is `rounded-md` — not
* `rounded-jumbo`, which was never an actual class in this app (confirmed:
* zero matches anywhere in the rendered page). Every test using this
* helper (2, 3, 4, 5, 6 in customer-journeys.spec.ts) was therefore always
* timing out on a selector that could never match, not on any real
* booking/query/rendering bug — same root cause already found and fixed
* in owner-journeys.spec.ts's own loyalty-redemption test.
*/
export function findAppointmentCard(page: Page, serviceName: string, staffName: string) {
return page.locator(".rounded-md").filter({ hasText: serviceName }).filter({ hasText: `with ${staffName}` });
}
export function uniqueEmail(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 100000)}@e2e-test.local`;
}
/**
* Drives /appointments/[id]/reschedule — same shared BarberDateTimePicker
* as booking, but a single step that redirects straight to the
* appointment's details page. Deliberately picks the *last* available
* date/slot rather than the first: the original appointment's own slot
* becomes available again once it's excluded from the conflict check
* (see `excludeAppointmentId` in rescheduleBooking), so "first available"
* could otherwise reselect the exact same time and make the reschedule a
* no-op from the test's point of view.
*/
export async function completeRescheduleFlow(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/reschedule/);
await expect(section(page, "Date").locator("button[aria-pressed]").first()).toBeVisible();
await section(page, "Date").locator("button[aria-pressed]:not([disabled])").last().click();
const timeSection = section(page, "Time");
await expect(timeSection.locator("button").first()).toBeVisible();
await timeSection.locator("button").last().click();
await page.getByRole("button", { name: "Confirm Reschedule", exact: true }).click();
await expect(page).toHaveURL(/\/appointments\/[^/]+$/);
}
/**
* Drives the staff "book for customer" flow — same shared ServiceSelector
* and BarberDateTimePicker as the customer /book flow, mounted at the
* /staff/book/* routes instead (see src/app/staff/book). Assumes the page
* is already on /staff/book/services, i.e. a customer has just been
* selected (search result click or NewGuestCustomerForm submit both land
* there via a redirect).
*/
export async function completeStaffBookingFlow(
page: Page,
options: { serviceName: string; staffName?: string; notes?: string },
): Promise<void> {
const { serviceName, staffName, notes } = options;
await expect(page).toHaveURL(/\/staff\/book\/services/);
await page.getByRole("button", { name: new RegExp(`^${serviceName}`) }).click();
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page).toHaveURL(/\/staff\/book\/barber-time/);
if (staffName) {
await section(page, "Barber").locator("select").selectOption({ label: staffName });
await page.waitForLoadState("networkidle");
}
await expect(section(page, "Date").locator("button[aria-pressed]").first()).toBeVisible();
await section(page, "Date").locator("button[aria-pressed]:not([disabled])").first().click();
const timeSection = section(page, "Time");
await expect(timeSection.locator("button").first()).toBeVisible();
await timeSection.locator("button").first().click();
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page).toHaveURL(/\/staff\/book\/review/);
if (notes) {
await page.getByLabel("Booking Notes (optional)").fill(notes);
}
await page.getByRole("button", { name: "Confirm Booking" }).click();
await expect(page).toHaveURL(/\/staff\/appointments\/[^/]+$/);
}