import { expect, test } from "@playwright/test"; import type { Page } from "@playwright/test"; import { SEEDED, signIn } from "./helpers"; /** * The 12 merged-navigation scenarios required by the Phase 11 brief * ("Merge Staff and Owner Navigation into One Role-Aware Menu"), run at * both mobile and desktop widths. See lib/navigation/nav-config.ts for the * single source of truth these assertions are checking against, and * (staff)/layout.tsx / (owner)/layout.tsx for where it's wired in. * * The bottom bar and desktop sidebar are both always present in the DOM * (see components/navigation/bottom-nav.tsx and staff-sidebar-nav.tsx) — * only one is visually shown per viewport width via CSS (`lg:hidden` / * `lg:flex`), so tests select by a class fragment unique to each rather * than by role/label alone, and independently verify the *other* one stays * hidden. * * The owner-role, mobile-viewport tests (3–10, 12) share one signed-in * page instead of each calling signIn() fresh — /sign-in rate-limits * repeated attempts for the same account (see * lib/security/rate-limit.ts's `signin:` bucket, 30/15min), and this file * plus owner-journeys.spec.ts plus staff-journeys.spec.ts all authenticate * as the same seeded owner throughout a single `npm run test:e2e` * process — the combined count was tipping over that limit before this * consolidation. Test 11 (desktop) and 13 (Super Administrator) still get * their own page, since they need a different viewport/account. */ const MOBILE = { width: 390, height: 844 }; const DESKTOP = { width: 1280, height: 800 }; function bottomBar(page: Page) { return page.locator('nav[aria-label="Primary"][class*="bottom-0"]'); } function sidebar(page: Page) { return page.locator('nav[aria-label="Primary"][class*="inset-y-0"]'); } test.describe.serial("merged role-aware navigation", () => { test("1. barber sees exactly the 4 merged bottom-nav items (mobile)", async ({ page }) => { await page.setViewportSize(MOBILE); await signIn(page, SEEDED.barberHaircutOnly.email); await page.goto("/staff"); const bar = bottomBar(page); await expect(bar).toBeVisible(); await expect(sidebar(page)).toBeHidden(); await expect(bar.getByRole("link")).toHaveText(["Calendar", "Now", "Clients", "Settings"]); }); test("2. barber does not see Manage and cannot reach owner routes directly", async ({ page }) => { await page.setViewportSize(MOBILE); await signIn(page, SEEDED.barberHaircutOnly.email); await page.goto("/staff"); await expect(bottomBar(page).getByRole("link", { name: "Manage" })).toHaveCount(0); // Route-level authorization must hold independent of what nav is shown // (see (owner)/layout.tsx's hasPermission(MANAGE_STAFF) gate) — a // barber hitting /owner directly is bounced to /staff, not merely kept // off a nav link. await page.goto("/owner"); await expect(page).toHaveURL(/\/staff$/); }); test.describe("owner, mobile — one shared session for tests 3–10, 12", () => { let page: Page; test.beforeAll(async ({ browser }) => { page = await browser.newPage(); await page.setViewportSize(MOBILE); await signIn(page, SEEDED.owner.email); }); test.afterAll(async () => { await page.close(); }); test("3. owner sees exactly the 5 merged bottom-nav items (mobile)", async () => { await page.goto("/owner"); const bar = bottomBar(page); await expect(bar).toBeVisible(); await expect(bar.getByRole("link")).toHaveText(["Calendar", "Now", "Clients", "Manage", "Settings"]); }); test("4. owner opens Now directly from the merged nav — no mode switch", async () => { await page.goto("/owner"); await bottomBar(page).getByRole("link", { name: "Now" }).click(); await expect(page).toHaveURL(/\/staff\/current$/); }); test("5. owner opens Manage and lands on the Dashboard overview", async () => { await page.goto("/staff"); await bottomBar(page).getByRole("link", { name: "Manage" }).click(); await expect(page).toHaveURL(/\/owner$/); await expect(page.getByRole("heading", { name: "Manage" })).toBeVisible(); await expect(page.getByText("Today's appointments")).toBeVisible(); }); test("6. owner reaches Team from Manage", async () => { await page.goto("/owner"); await page.getByRole("link", { name: "Team Management" }).click(); await expect(page).toHaveURL(/\/owner\/team$/); }); test("7. owner reaches Services from Manage", async () => { await page.goto("/owner"); await page.getByRole("link", { name: "Services", exact: true }).click(); await expect(page).toHaveURL(/\/owner\/services$/); }); test("8. owner reaches Business Details from Manage", async () => { await page.goto("/owner"); await page.getByRole("link", { name: "Business Details" }).click(); await expect(page).toHaveURL(/\/owner\/settings\/business$/); }); test("9. owner returns to Calendar from Manage without switching modes", async () => { await page.goto("/owner"); await bottomBar(page).getByRole("link", { name: "Calendar" }).click(); await expect(page).toHaveURL(/\/owner\/calendar$/); }); test("10. nested routes keep the correct primary tab active", async () => { await page.goto("/staff/current"); await expect(bottomBar(page).getByRole("link", { name: "Now" })).toHaveAttribute("aria-current", "page"); await page.goto("/owner/team"); await expect(bottomBar(page).getByRole("link", { name: "Manage" })).toHaveAttribute("aria-current", "page"); await page.goto("/owner/services"); await expect(bottomBar(page).getByRole("link", { name: "Manage" })).toHaveAttribute("aria-current", "page"); await page.goto("/owner/settings/loyalty"); await expect(bottomBar(page).getByRole("link", { name: "Manage" })).toHaveAttribute("aria-current", "page"); await page.goto("/staff/settings/calendar"); await expect(bottomBar(page).getByRole("link", { name: "Settings" })).toHaveAttribute("aria-current", "page"); }); test("12. no switch-mode controls remain anywhere in the app", async () => { for (const path of ["/staff/settings", "/owner", "/owner/settings"]) { await page.goto(path); await expect(page.getByText("Switch to Owner Admin")).toHaveCount(0); await expect(page.getByText("Switch to Barber Tools")).toHaveCount(0); } // /owner/settings is a compatibility redirect to /staff/settings (see // (owner)/owner/settings/page.tsx) — the old bookmark still works. await expect(page).toHaveURL(/\/staff\/settings$/); }); }); test("11. desktop sidebar uses the same merged structure as mobile", async ({ page }) => { await page.setViewportSize(DESKTOP); await signIn(page, SEEDED.owner.email); await page.goto("/owner"); const side = sidebar(page); await expect(side).toBeVisible(); await expect(bottomBar(page)).toBeHidden(); await expect(side.getByRole("link")).toHaveText(["Calendar", "Now", "Clients", "Manage", "Settings"]); await page.goto("/owner/team"); await expect(side.getByRole("link", { name: "Manage" })).toHaveAttribute("aria-current", "page"); }); test("13. a Super Administrator sees the business-wide merged nav with no personal schedule tab", async ({ page }) => { await page.setViewportSize(MOBILE); await signIn(page, SEEDED.superAdmin.email); await page.goto("/owner"); const bar = bottomBar(page); await expect(bar).toBeVisible(); await expect(bar.getByRole("link")).toHaveText(["Calendar", "Clients", "Manage", "Settings"]); await expect(bar.getByRole("link", { name: "Now" })).toHaveCount(0); await bar.getByRole("link", { name: "Settings" }).click(); await expect(page).toHaveURL(/\/staff\/settings$/); // Two "Notifications" links exist on this page — the fixed notification // bell (aria-label="Notifications") and the Settings hub's own // Notifications row — so scope to the settings list by href, not the bell. await expect(page.locator('a[href="/staff/settings/notifications"]')).toBeVisible(); await expect(page.getByRole("link", { name: "Working Hours" })).toHaveCount(0); await expect(page.getByRole("link", { name: "Google Calendar" })).toHaveCount(0); }); });