Files
NextChair/e2e/owner-journeys.spec.ts
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

371 lines
20 KiB
TypeScript
Raw Permalink 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, completeBookingFlow, signIn, uniqueEmail } from "./helpers";
/**
* The 12 owner-administration journeys required by the Phase 6 brief. Same
* principles as staff-journeys.spec.ts: real forms against the real dev
* server and seeded MySQL (migrated from Postgres in Phase 7), `test.describe.serial` because several
* journeys deliberately chain or share seeded fixtures (Marcus Reed is
* both the seeded owner *and* a working barber — see prisma/seed.ts), and
* every mutation a test makes to shared configuration (business details,
* opening hours, booking rules, loyalty config) is restored at the end of
* that same test so later journeys — and later re-runs — see the same
* starting state. The one exception is journey 10 (redeem reward), which
* consumes the seed's one-time AVAILABLE reward for Sam Osei; that's a
* deliberate one-shot fixture, same category as staff-journeys.spec.ts's
* own booking journeys.
*/
test.describe.serial("owner administration journeys", () => {
test.afterAll(async () => {
const pool = createPool(buildMysqlPoolConfig());
// Journey 7 creates a category/service with no deterministic id to
// upsert against on a re-run — clean up by name instead.
await pool.query("DELETE FROM `Service` WHERE name LIKE 'E2E Test Service%'");
await pool.query("DELETE FROM `ServiceCategory` WHERE name LIKE 'E2E Test Category%'");
// Journey 5 adds a real staff member (User+StaffProfile+StaffInvitation) with no
// deterministic id either — deleting the User cascades to the rest.
await pool.query("DELETE FROM `User` WHERE email LIKE 'e2e-new-barber-%@e2e-test.local'");
await pool.end();
});
test("1. owner sees a real, data-backed dashboard with configuration warnings", async ({ page }) => {
await signIn(page, SEEDED.owner.email);
await page.goto("/owner");
// The Manage hub's H1 (see (owner)/owner/page.tsx) — Dashboard content
// is shown first/most prominently inside it rather than on its own
// page, per the merged-nav brief's "Dashboard placement" requirement.
await expect(page.getByRole("heading", { name: "Manage" })).toBeVisible();
// Seeded fixtures from prisma/seed.ts: Owen Clarke has no working hours,
// and "Kids Haircut (Under 12)" has no eligible active staff member.
await expect(page.getByText("Owen Clarke has no working hours set")).toBeVisible();
await expect(page.getByText(/no active staff member is eligible to perform it/)).toBeVisible();
await expect(page.getByText("Staff Today")).toBeVisible();
// Scoped exact match — "Marcus Reed" can otherwise also match a Recent Loyalty
// Activity line naming him as the staff member who redeemed a reward.
await expect(page.getByText("Marcus Reed", { exact: true })).toBeVisible();
await expect(page.getByText("Popular Services")).toBeVisible();
});
test("2. owner views the all-staff calendar, filters by staff, and switches to agenda", async ({ page }) => {
await signIn(page, SEEDED.owner.email);
await page.goto("/owner/calendar");
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
// The staff filter pills are avatar+name+hours cards now, so their
// accessible name is "Marcus Reed 10:00 AM – 7:00 PM" etc — a
// substring match (Playwright's default, no `exact`) still uniquely
// identifies each pill's link.
await expect(page.getByRole("link", { name: "Marcus Reed" })).toBeVisible();
await expect(page.getByRole("link", { name: "Leo Vance" })).toBeVisible();
// Filtering to one staff member narrows the day view to a single column.
await page.getByRole("link", { name: "Leo Vance" }).click();
await expect(page).toHaveURL(/staffId=/);
// The single remaining day-view column is headed by a <p> with the staff member's exact name
// — the staff filter pill above it is *also* an avatar+name+hours card with its own <p> of the
// same text now, so scope to the grid itself (data-testid="calendar-grid") to disambiguate.
await expect(page.getByTestId("calendar-grid").locator("p").getByText("Leo Vance", { exact: true })).toBeVisible();
await page.getByRole("link", { name: "Agenda", exact: true }).click();
await expect(page).toHaveURL(/view=agenda/);
await expect(page.getByRole("link", { name: "Day", exact: true })).toBeVisible();
await page.getByRole("link", { name: "All Staff" }).click();
await expect(page).not.toHaveURL(/staffId=/);
});
test("3. owner edits business details and the change persists", async ({ page }) => {
await signIn(page, SEEDED.owner.email);
await page.goto("/owner/settings/business");
await expect(page.getByRole("heading", { name: "Business Details" })).toBeVisible();
const nameInput = page.getByLabel("Business Name");
await expect(nameInput).toHaveValue("Blake Heritage Barbershop");
await nameInput.fill("Blake Heritage Barbershop (E2E edited)");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByText("Saved — changes are live for customers immediately.")).toBeVisible();
await page.reload();
await expect(page.getByLabel("Business Name")).toHaveValue("Blake Heritage Barbershop (E2E edited)");
// Restore the seeded name so later runs (and other journeys) see the original.
await page.getByLabel("Business Name").fill("Blake Heritage Barbershop");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByText("Saved — changes are live for customers immediately.")).toBeVisible();
});
test("4. owner tightens opening hours, reviews the appointment conflict, and saves anyway without cancelling it", async ({ page }) => {
await signIn(page, SEEDED.owner.email);
// Marcus and Leo each have a seeded future CONFIRMED appointment (see
// prisma/seed.ts, appt-upcoming-confirmed / appt-leo-future). Those rows
// are upserted by a deterministic id, so on a database that already had
// them from an earlier seed run their date is whatever it was back
// then, not necessarily still "N days from today" — read the real date
// straight from the database instead of predicting it from wall-clock time.
const pool = createPool(buildMysqlPoolConfig());
const rows = await pool.query<{ id: string; start_at: Date; first_name: string; last_name: string }[]>(
"SELECT a.id, a.startAt as start_at, c.firstName as first_name, c.lastName as last_name" +
" FROM Appointment a JOIN CustomerProfile c ON c.id = a.customerId" +
" WHERE a.id LIKE '%-appt-upcoming-confirmed' OR a.id LIKE '%-appt-leo-future'" +
" ORDER BY a.startAt ASC",
);
await pool.end();
if (rows.length === 0) throw new Error("Seeded opening-hours conflict fixtures not found — check prisma/seed.ts");
// Prefer a fixture that doesn't fall on Sunday (already closed by default) so toggling it
// off is a genuine change; fall back to whichever fixture exists if only one does.
const candidate =
rows.find((r) => r.start_at.toLocaleDateString("en-GB", { weekday: "long" }) !== "Sunday") ?? rows[0]!;
const targetDay = candidate.start_at.toLocaleDateString("en-GB", { weekday: "long" });
const expectedCustomer = `${candidate.first_name} ${candidate.last_name}`;
await page.goto("/owner/settings/hours");
await expect(page.getByRole("heading", { name: "Opening Hours" })).toBeVisible();
await page.getByRole("switch", { name: `${targetDay} open` }).click();
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByText(/would leave \d+ upcoming appointment/)).toBeVisible();
await expect(page.getByText(expectedCustomer)).toBeVisible();
await page.getByRole("button", { name: "Save Anyway", exact: true }).click();
await expect(page.getByText("Saved. The booking engine reflects these hours immediately.")).toBeVisible();
// The conflicting appointment itself is untouched — never silently cancelled.
await page.reload();
await expect(page.getByRole("switch", { name: `${targetDay} open` })).toHaveAttribute("data-state", "unchecked");
// Restore the seeded default (open 10:00-19:00) so later runs see the same starting state.
// This save can itself surface an unrelated conflict — one of the seed's own "today"
// fixtures (see prisma/seed.ts) is timed relative to whenever the seed happened to run,
// and can coincidentally fall just before the business's 10:00 opening — so handle either
// outcome rather than assuming a clean save.
await page.getByRole("switch", { name: `${targetDay} open` }).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. The booking engine reflects these hours immediately.");
await expect(saveAnywayButton.or(savedStatus)).toBeVisible();
if (await saveAnywayButton.isVisible()) {
await saveAnywayButton.click();
}
await expect(savedStatus).toBeVisible();
});
test("5. owner adds a new barber and gets a dev invite link", async ({ page }) => {
await signIn(page, SEEDED.owner.email);
await page.goto("/owner/team/add");
await expect(page.getByRole("heading", { name: "Add Staff" })).toBeVisible();
const email = uniqueEmail("e2e-new-barber");
const name = `E2E New Barber ${Date.now()}`;
await page.getByLabel("Name").fill(name);
await page.getByLabel("Email").fill(email);
await page.getByRole("button", { name: "Add Staff Member", exact: true }).click();
await expect(page.getByText(`${name} has been added.`)).toBeVisible();
await expect(page.getByText(/\/invite\/accept\?token=/)).toBeVisible();
await page.getByRole("button", { name: "Done", exact: true }).click();
await expect(page).toHaveURL(/\/owner\/team$/);
await page.getByPlaceholder("Search by name or email").fill(name);
await page.keyboard.press("Enter");
await expect(page.getByRole("link", { name: new RegExp(name) })).toBeVisible();
});
test("6. owner changes a barber's role and service eligibility", async ({ page }) => {
await signIn(page, SEEDED.owner.email);
await page.goto("/owner/team?q=Jamie");
await page.getByRole("link", { name: /Jamie Wallace/ }).click();
await expect(page.getByRole("heading", { name: "Jamie Wallace", level: 2 })).toBeVisible();
// Round-trip the role: Barber -> Owner -> Barber, confirming the save
// path (and last-owner protection isn't a factor for a non-owner).
const roleSelect = page.getByLabel("Role");
await expect(roleSelect).toHaveValue(/.+/);
await roleSelect.selectOption({ label: "Owner" });
await page.getByRole("button", { name: "Save Role", exact: true }).click();
await expect(page.getByText("Saved.").first()).toBeVisible();
await page.reload();
await page.getByLabel("Role").selectOption({ label: "Barber" });
await page.getByRole("button", { name: "Save Role", exact: true }).click();
await expect(page.getByText("Saved.").first()).toBeVisible();
// Jamie isn't seeded eligible for Haircut & Style — add it, save, then remove it again.
const haircutCheckbox = page.getByRole("checkbox", { name: SEEDED.services.haircut });
await expect(haircutCheckbox).not.toBeChecked();
await haircutCheckbox.check();
await page.getByRole("button", { name: "Save Services", exact: true }).click();
await expect(page.getByText("Saved.").first()).toBeVisible();
await page.reload();
await page.getByRole("checkbox", { name: SEEDED.services.haircut }).uncheck();
await page.getByRole("button", { name: "Save Services", exact: true }).click();
await expect(page.getByText("Saved.").first()).toBeVisible();
});
test("7. owner creates a service category and service, visible immediately on the customer booking flow", async ({ page }) => {
await signIn(page, SEEDED.owner.email);
await page.goto("/owner/services");
await expect(page.getByRole("heading", { name: "Services", exact: true })).toBeVisible();
const categoryName = `E2E Test Category ${Date.now()}`;
await page.getByPlaceholder("New category name").fill(categoryName);
await page.getByRole("button", { name: "Add Category", exact: true }).click();
await expect(page.getByRole("heading", { name: categoryName, level: 3 })).toBeVisible();
const categorySection = page.locator("section").filter({ has: page.getByRole("heading", { name: categoryName, level: 3 }) });
await categorySection.getByRole("button", { name: "Add Service", exact: true }).click();
const serviceName = `E2E Test Service ${Date.now()}`;
const sheet = page.getByRole("dialog");
await sheet.getByLabel("Name").fill(serviceName);
await sheet.getByLabel("Duration (min)").fill("30");
await sheet.getByLabel("Price").fill("15.00");
await sheet.getByRole("button", { name: "Add Service", exact: true }).click();
await expect(page.getByText(serviceName)).toBeVisible();
// The new service is immediately bookable by customers.
await page.goto("/book");
await expect(page.getByRole("button", { name: new RegExp(`^${serviceName}`) })).toBeVisible();
});
test("8. owner requires booking approval, and a new customer booking reflects it immediately", async ({ page }) => {
await signIn(page, SEEDED.owner.email);
await page.goto("/owner/settings/booking-rules");
await expect(page.getByRole("heading", { name: "Booking Rules" })).toBeVisible();
const approvalSwitch = page.getByRole("switch", { name: "Require approval for new bookings" });
await expect(approvalSwitch).toHaveAttribute("data-state", "unchecked");
await approvalSwitch.click();
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByText(/Saved — the customer booking flow uses these rules immediately/)).toBeVisible();
// A fresh guest booking now lands as PENDING, not CONFIRMED. Sign out first —
// signed in as Marcus, /book/details books as him rather than showing guest fields.
// Wait for the save's background requests to fully settle before clearing cookies:
// otherwise a still-in-flight response can re-set the session cookie right after
// it's cleared (a real race, confirmed by reproducing it outside Playwright too).
await page.waitForLoadState("networkidle");
await page.context().clearCookies();
await page.goto("/book");
await completeBookingFlow(page, {
serviceName: SEEDED.services.beardTrim,
guest: {
firstName: "E2E",
lastName: "ApprovalTest",
email: uniqueEmail("e2e-approval"),
phone: "+44 7700 900111",
},
});
await expect(page.getByText("Pending Approval")).toBeVisible();
// Restore the seeded default so later journeys (and re-runs) see auto-confirmed bookings again.
await signIn(page, SEEDED.owner.email);
await page.goto("/owner/settings/booking-rules");
await page.getByRole("switch", { name: "Require approval for new bookings" }).click();
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByText(/Saved — the customer booking flow uses these rules immediately/)).toBeVisible();
});
test("9. owner configures the loyalty programme", async ({ page }) => {
await signIn(page, SEEDED.owner.email);
await page.goto("/owner/settings/loyalty");
await expect(page.getByRole("heading", { name: "Loyalty Programme" })).toBeVisible();
const stampsInput = page.getByLabel("Stamps required for reward");
await expect(stampsInput).toHaveValue("10");
await stampsInput.fill("8");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByText("Saved.").first()).toBeVisible();
await page.reload();
await expect(page.getByLabel("Stamps required for reward")).toHaveValue("8");
// Restore the seeded default.
await page.getByLabel("Stamps required for reward").fill("10");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByText("Saved.").first()).toBeVisible();
});
test("10. owner redeems a customer's available loyalty reward on an appointment", async ({ page }) => {
await signIn(page, SEEDED.owner.email);
await page.goto("/owner/calendar?view=agenda");
// Seeded: Sam Osei has a CHECKED_IN appointment with Leo today, and sits
// exactly at the loyalty reward threshold with one AVAILABLE reward.
// This page renders agenda cards via AppointmentCard -> Card (see
// components/ui/card.tsx), whose root class is `rounded-md` — not
// `rounded-jumbo`, which was never an actual class anywhere in this
// app (see findAppointmentCard in helpers.ts, which had the same bug).
const card = page.locator(".rounded-md").filter({ hasText: "Sam Osei" }).filter({ hasText: "Arrived" });
await card.getByRole("link", { name: "View", exact: true }).click();
await expect(page.getByText(/Subtotal/)).toBeVisible();
await page.getByRole("button", { name: "Redeem Reward", exact: true }).click();
const modal = page.getByRole("dialog");
await expect(modal.getByText(/This can't be undone/)).toBeVisible();
await modal.getByRole("button", { name: "Apply Reward", exact: true }).click();
await expect(modal.getByText(/has been applied to this appointment/)).toBeVisible();
await modal.getByRole("button", { name: "Done", exact: true }).click();
await expect(page.getByText("Loyalty reward")).toBeVisible();
});
test("11. a barber without MANAGE_STAFF is denied access to owner administration", async ({ page }) => {
await signIn(page, SEEDED.barberHaircutOnly.email);
await page.goto("/owner");
await expect(page).toHaveURL(/\/staff$/);
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Manage" })).toHaveCount(0);
});
test("12. the owner reaches barber tools straight from the merged nav — no mode switch", async ({ page }) => {
await signIn(page, SEEDED.owner.email);
await page.goto("/owner");
// The old "Switch to Owner Admin"/"Switch to Barber Tools" links are
// gone (see the merged-nav brief) — an owner reaches their own barber
// tools via the same "Now" nav item a barber uses, not a mode switch.
await expect(page.getByText("Barber Tools")).toHaveCount(0);
await expect(page.getByText("Switch to Barber Tools")).toHaveCount(0);
await page.getByRole("link", { name: "Now", exact: true }).first().click();
await expect(page).toHaveURL(/\/staff\/current$/);
await expect(page.getByRole("link", { name: "Settings", exact: true }).first()).toBeVisible();
});
test("13. owner reports show real, data-backed appointment/service/staff/customer/loyalty/notification figures", async ({ page }) => {
await signIn(page, SEEDED.owner.email);
await page.goto("/owner");
// "View Reports" was the old Dashboard footer's button label; Reports is
// now one of the Manage hub's grouped sections (see (owner)/owner/page.tsx).
await page.getByRole("link", { name: "Reports", exact: true }).click();
await expect(page).toHaveURL(/\/owner\/reports/);
await expect(page.getByRole("heading", { name: "Reports", exact: true })).toBeVisible();
// The section labels are <h2>s — role-scoped so the owner sidebar's
// own "Services" link (same text) doesn't collide (strict-mode violation).
for (const heading of ["Appointments", "Services", "Staff", "Customers", "Loyalty", "Notifications"]) {
await expect(page.getByRole("heading", { name: heading, exact: true, level: 2 })).toBeVisible();
}
// Seeded fixtures guarantee at least one completed Haircut & Style —
// real aggregated data, not an empty-state placeholder.
await expect(page.getByText("Haircut & Style")).toBeVisible();
// The 7/30/90-day presets are real links, not decoration.
await page.getByRole("link", { name: "Last 7 days" }).click();
await expect(page).toHaveURL(/from=.*to=/);
await expect(page.getByRole("heading", { name: "Reports" })).toBeVisible();
});
});