CI / ci (push) Canceled after 0s
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.
287 lines
14 KiB
TypeScript
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",
|
|
},
|
|
});
|
|
});
|
|
});
|