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

268 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { expect, test } from "@playwright/test";
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 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
* fight each other or depend on run order — the same principle
* customer-journeys.spec.ts documents. The exceptions are the two tests
* that intentionally chain (3 → 4, both driving the *same* fresh
* appointment through check-in/start/complete/stamp) and the time-off
* conflict test, which deliberately reuses the seed's untouched
* "Marcus + Priya, later today" CONFIRMED appointment as its conflict
* fixture.
*/
let chainedAppointmentUrl: string | null = null;
test.describe.serial("barber operations journeys", () => {
// Journeys 8 and 9 create a break/time-off with no deterministic id for
// the seed to upsert against, so a mid-test assertion failure (a real
// possibility while iterating on this suite) could leave them behind and
// break journey 1 on the *next* run — journey 1 asserts today's schedule
// is visible, which a leftover "whole day off" row would hide. This
// 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 pool = createPool(buildMysqlPoolConfig());
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 }) => {
await signIn(page, SEEDED.owner.email);
await page.goto("/staff");
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
// Seeded: Marcus has appointments in every status today. (.first() because on
// desktop the "Right Now" side panel repeats the in-progress customer's name.)
await expect(page.getByText("Alex Chen").first()).toBeVisible();
await expect(page.getByText("Sam Osei").first()).toBeVisible();
await expect(page.getByText("IN PROGRESS").first()).toBeVisible();
});
test("2. barber opens the current appointment", async ({ page }) => {
await signIn(page, SEEDED.owner.email);
await page.goto("/staff/current");
// Tier 1 of selectCurrentAppointment: the IN_PROGRESS row wins.
await expect(page.getByText("Sam Osei")).toBeVisible();
await expect(page.getByText("In Progress", { exact: true })).toBeVisible();
await expect(page.getByText(/stamps/)).toBeVisible();
});
test("3. barber books, checks in, and starts an appointment", async ({ page }) => {
await signIn(page, SEEDED.owner.email);
await page.goto("/staff/book?q=Jordan");
await page.getByRole("button", { name: /Jordan Blake/ }).click();
await completeStaffBookingFlow(page, {
serviceName: SEEDED.services.haircut,
staffName: SEEDED.owner.name,
});
chainedAppointmentUrl = page.url();
// A staff-created booking defaults to CONFIRMED (no PENDING approval step).
await expect(page.getByText("Confirmed", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Check In", exact: true }).click();
await expect(page.getByText("Arrived", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Start Appointment", exact: true }).click();
await expect(page.getByText("In Progress", { exact: true })).toBeVisible();
});
test("4. barber completes the appointment and adds a loyalty stamp", async ({ page }) => {
if (!chainedAppointmentUrl) throw new Error("journey 3 must run first to create the appointment");
await signIn(page, SEEDED.owner.email);
await page.goto(chainedAppointmentUrl);
await page.getByRole("button", { name: "Complete Appointment", exact: true }).click();
await page.getByRole("button", { name: "Complete", exact: true }).click();
await expect(page.getByText("This appointment is marked complete.")).toBeVisible();
// Jordan Blake has no eligibility restrictions in the seed, so completion auto-awards a stamp.
await expect(page.getByText("A loyalty stamp was added for this visit.")).toBeVisible();
await page.getByRole("button", { name: "Done", exact: true }).click();
await expect(page.getByText("Completed", { exact: true })).toBeVisible();
// The manual "Add Loyalty Stamp" action is independent of the auto-award above.
await page.getByRole("button", { name: "Add Loyalty Stamp", exact: true }).click();
await page.getByRole("button", { name: "Add Stamp", exact: true }).click();
await expect(page.getByText("The stamp has been recorded on this customer's loyalty account.")).toBeVisible();
await page.getByRole("button", { name: "Done", exact: true }).click();
// "Book Follow-Up" only appears once COMPLETED — hand off to journey 6.
await expect(page.getByRole("button", { name: "Book Follow-Up" })).toBeVisible();
});
test("5. barber finds a customer and adds a note", async ({ page }) => {
await signIn(page, SEEDED.owner.email);
await page.goto("/staff/clients");
await page.getByPlaceholder("Search by name, email, or phone").fill("Osei");
await page.keyboard.press("Enter");
await page.getByRole("link", { name: /Sam Osei/ }).click();
await expect(page.getByText("Sam Osei")).toBeVisible();
const noteText = `E2E note ${Date.now()}`;
await page.getByPlaceholder("Add a note about this customer…").fill(noteText);
await page.getByRole("button", { name: "Add Note", exact: true }).click();
await expect(page.getByText(noteText)).toBeVisible();
});
test("6. barber books a follow-up from a completed appointment", async ({ page }) => {
if (!chainedAppointmentUrl) throw new Error("journey 4 must run first to complete the appointment");
await signIn(page, SEEDED.owner.email);
await page.goto(chainedAppointmentUrl);
await page.getByRole("button", { name: "Book Follow-Up" }).click();
// Services (and barber, if still active/bookable) are preselected from the original visit,
// but the barber still confirms this step themselves before moving on.
await expect(page).toHaveURL(/\/staff\/book\/services/);
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page).toHaveURL(/\/staff\/book\/barber-time/);
await expect(page.locator("section", { hasText: "Date" }).locator("button[aria-pressed]").first()).toBeVisible();
await page.locator("section", { hasText: "Date" }).locator("button[aria-pressed]:not([disabled])").first().click();
await page.locator("section", { hasText: "Time" }).locator("button").first().click();
await page.getByRole("button", { name: "Continue", exact: true }).click();
await expect(page).toHaveURL(/\/staff\/book\/review/);
await page.getByRole("button", { name: "Confirm Booking" }).click();
await expect(page).toHaveURL(/\/staff\/appointments\/[^/]+$/);
await expect(page.getByText("Jordan Blake")).toBeVisible();
});
test("7. barber changes their working hours", async ({ page }) => {
await signIn(page, SEEDED.barberHaircutOnly.email);
await page.goto("/staff/settings/working-hours");
const sundaySwitch = page.getByRole("switch", { name: "Sunday working" });
const wasOff = (await sundaySwitch.getAttribute("data-state")) === "unchecked";
await sundaySwitch.click();
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByText("Saved.")).toBeVisible();
await page.reload();
const expectedState = wasOff ? "checked" : "unchecked";
await expect(page.getByRole("switch", { name: "Sunday working" })).toHaveAttribute(
"data-state",
expectedState,
);
// Restore the seeded default so this test is repeatable. This save can itself surface an
// unrelated conflict — the seed's own "genuinely future" fixture for Leo (see prisma/seed.ts,
// appt-leo-future) is dated relative to whenever the seed happened to run and can
// coincidentally land on a Sunday, which is off by default — so handle either outcome rather
// than assuming a clean save (same pattern as owner-journeys.spec.ts journey 4's restore step).
await page.getByRole("switch", { name: "Sunday working" }).click();
await page.getByRole("button", { name: "Save", exact: true }).click();
const saveAnywayButton = page.getByRole("button", { name: "Save Anyway", exact: true });
const savedStatus = page.getByText("Saved.");
await expect(saveAnywayButton.or(savedStatus)).toBeVisible();
if (await saveAnywayButton.isVisible()) {
await saveAnywayButton.click();
}
await expect(savedStatus).toBeVisible();
});
test("8. barber adds a recurring break", async ({ page }) => {
await signIn(page, SEEDED.barberSkinfadeBeard.email);
await page.goto("/staff/settings/breaks");
await page.getByRole("button", { name: "Add Break", exact: true }).click();
const sheet = page.getByRole("dialog");
await sheet.getByLabel("Label").fill("Afternoon tea");
await sheet.getByLabel("Start").fill("15:30");
await sheet.getByLabel("End").fill("15:45");
await sheet.getByRole("button", { name: "Tue", exact: true }).click();
await sheet.getByRole("button", { name: "Add Break", exact: true }).click();
await expect(page.getByText("Afternoon tea")).toBeVisible();
await expect(page.getByText(/3:30 PM–3:45 PM/)).toBeVisible();
// Clean up — this test has no unique/deterministic id to upsert against on a re-run,
// so leaving it behind would accumulate a duplicate break every time the suite runs.
await page.getByText("Afternoon tea").click();
await sheet.getByRole("button", { name: "Delete Break", exact: true }).click();
await expect(page.getByText("Afternoon tea")).toHaveCount(0);
});
test("9. barber adds time off and resolves a scheduling conflict", async ({ page }) => {
await signIn(page, SEEDED.owner.email);
await page.goto("/staff/settings/time-off");
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
await page.getByRole("button", { name: "Add Time Off", exact: true }).click();
const sheet = page.getByRole("dialog");
await sheet.getByLabel("Start date").fill(today);
await sheet.getByLabel("End date").fill(today);
await sheet.getByLabel("Reason (optional)").fill("E2E conflict test");
await sheet.getByRole("button", { name: "Add Time Off", exact: true }).click();
// Seeded: Marcus has a CONFIRMED appointment with Priya Kapoor later today — a real conflict.
await expect(sheet.getByText(/overlaps \d+ existing appointment/)).toBeVisible();
await expect(sheet.getByText("Priya Kapoor")).toBeVisible();
await sheet.getByRole("button", { name: "Save Anyway", exact: true }).click();
await expect(page.getByText("E2E conflict test")).toBeVisible();
// The conflicting appointment itself is untouched — never silently cancelled. (The
// calendar's own day view now shows "You're off this day" instead of the appointment
// list, since Marcus just marked the whole day as time off — a separate, deliberate
// UI choice, not a sign the appointment was cancelled.)
await page.goto("/staff/clients");
await page.getByPlaceholder("Search by name, email, or phone").fill("Priya");
await page.keyboard.press("Enter");
await page.getByRole("link", { name: /Priya Kapoor/ }).click();
await expect(page.getByRole("tab", { name: "Upcoming" })).toHaveAttribute("data-state", "active");
await expect(page.getByText(SEEDED.services.haircut).first()).toBeVisible();
await expect(page.getByText("Confirmed", { exact: true }).first()).toBeVisible();
// Clean up — this test has no unique/deterministic id to upsert against on a re-run,
// so leaving Marcus marked off today would break journey 1's assertions next time.
await page.goto("/staff/settings/time-off");
await page.getByText("E2E conflict test").click();
const editSheet = page.getByRole("dialog");
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 }) => {
// Deliberately books its own fresh appointment rather than relying on journey 3's — every
// other independent journey in this file follows that same "book a fresh appointment first"
// rule (see this file's header comment), and this one was the exception, which made it fail
// in confusing ways whenever it ran outside the full serial file (e.g. `-g` filtering past
// journey 3). See NotificationService/booking-notifications.ts, notifyBookingCreated, which
// queues STAFF_NEW_APPOINTMENT for the assigned barber on every booking.
await signIn(page, SEEDED.owner.email);
await page.goto("/staff/book?q=Jordan");
await page.getByRole("button", { name: /Jordan Blake/ }).click();
await completeStaffBookingFlow(page, {
serviceName: SEEDED.services.haircut,
staffName: SEEDED.owner.name,
});
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();
});
});