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.
268 lines
14 KiB
TypeScript
268 lines
14 KiB
TypeScript
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();
|
||
});
|
||
});
|