Files
NextChair/e2e/merged-nav.spec.ts
T
Stefan Lazic 3300045d9e Staff / Owner
Merged staff and owner settings
2026-08-05 16:35:52 +01:00

196 lines
8.3 KiB
TypeScript
Raw 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 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, "password123");
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, "password123");
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, "password123");
});
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, "password123");
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, SEEDED.superAdmin.password);
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);
});
});