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