Staff / Owner
Merged staff and owner settings
This commit is contained in:
23 files changed
+693
-236
No files matched your search
+14
-1
@@ -15,7 +15,20 @@ export default function globalSetup() {
|
||||
);
|
||||
}
|
||||
|
||||
const env = { ...process.env };
|
||||
const env = {
|
||||
...process.env,
|
||||
// A Super Administrator is provisioned separately from prisma/seed.ts
|
||||
// (see scripts/ensure-super-admin.ts's own doc comment — it deliberately
|
||||
// stays out of the dev/demo seed) — the merged-nav E2E scenarios need
|
||||
// one to sign in as, so this fixes deterministic test-only credentials
|
||||
// when the caller hasn't already set their own (same fallback pattern
|
||||
// as AUTH_SECRET in playwright.config.ts).
|
||||
SUPER_ADMIN_EMAIL: process.env.SUPER_ADMIN_EMAIL || "superadmin@blakeheritage.co.uk",
|
||||
SUPER_ADMIN_PASSWORD: process.env.SUPER_ADMIN_PASSWORD || "password123",
|
||||
SUPER_ADMIN_FIRST_NAME: process.env.SUPER_ADMIN_FIRST_NAME || "Super",
|
||||
SUPER_ADMIN_LAST_NAME: process.env.SUPER_ADMIN_LAST_NAME || "Admin",
|
||||
};
|
||||
execFileSync("npx", ["prisma", "migrate", "deploy"], { stdio: "inherit", env });
|
||||
execFileSync("npx", ["tsx", "prisma/seed.ts"], { stdio: "inherit", env });
|
||||
execFileSync("npx", ["tsx", "scripts/ensure-super-admin.ts"], { stdio: "inherit", env });
|
||||
}
|
||||
+18
-2
@@ -11,6 +11,10 @@ export const SEEDED = {
|
||||
// first in the suite, so this is safe there).
|
||||
secondCustomer: { email: "sam.osei@example.com", password: "password123", name: "Sam Osei" },
|
||||
owner: { email: "marcus@blakeheritage.co.uk", name: "Marcus Reed" },
|
||||
// Provisioned by global-setup.ts via scripts/ensure-super-admin.ts, not
|
||||
// prisma/seed.ts — a Super Administrator deliberately has no StaffProfile
|
||||
// (see isSuperAdmin) and stays out of the dev/demo seed data on purpose.
|
||||
superAdmin: { email: "superadmin@blakeheritage.co.uk", password: "password123", name: "Super Admin" },
|
||||
barberHaircutOnly: { email: "leo@blakeheritage.co.uk", name: "Leo Vance" },
|
||||
barberSkinfadeBeard: { email: "jamie@blakeheritage.co.uk", name: "Jamie Wallace" },
|
||||
services: {
|
||||
@@ -98,9 +102,21 @@ export async function completeBookingFlow(
|
||||
await expect(page.getByRole("heading", { name: "You're all set!" })).toBeVisible();
|
||||
}
|
||||
|
||||
/** Locates a specific appointment card on /appointments by its rendered service + barber text, scoping later actions (Reschedule/Cancel/Book Again) to that one card so they can't accidentally hit a different appointment. */
|
||||
/**
|
||||
* Locates a specific appointment card on /appointments by its rendered
|
||||
* service + barber text, scoping later actions (Reschedule/Cancel/Book
|
||||
* Again) to that one card so they can't accidentally hit a different
|
||||
* appointment. Cards there render via AppointmentCard -> Card (see
|
||||
* components/ui/card.tsx), whose root class is `rounded-md` — not
|
||||
* `rounded-jumbo`, which was never an actual class in this app (confirmed:
|
||||
* zero matches anywhere in the rendered page). Every test using this
|
||||
* helper (2, 3, 4, 5, 6 in customer-journeys.spec.ts) was therefore always
|
||||
* timing out on a selector that could never match, not on any real
|
||||
* booking/query/rendering bug — same root cause already found and fixed
|
||||
* in owner-journeys.spec.ts's own loyalty-redemption test.
|
||||
*/
|
||||
export function findAppointmentCard(page: Page, serviceName: string, staffName: string) {
|
||||
return page.locator(".rounded-jumbo").filter({ hasText: serviceName }).filter({ hasText: `with ${staffName}` });
|
||||
return page.locator(".rounded-md").filter({ hasText: serviceName }).filter({ hasText: `with ${staffName}` });
|
||||
}
|
||||
|
||||
export function uniqueEmail(prefix: string): string {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -35,7 +35,10 @@ test.describe.serial("owner administration journeys", () => {
|
||||
await signIn(page, SEEDED.owner.email, "password123");
|
||||
await page.goto("/owner");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
||||
// 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();
|
||||
@@ -294,7 +297,11 @@ test.describe.serial("owner administration journeys", () => {
|
||||
|
||||
// Seeded: Sam Osei has a CHECKED_IN appointment with Leo today, and sits
|
||||
// exactly at the loyalty reward threshold with one AVAILABLE reward.
|
||||
const card = page.locator(".rounded-jumbo").filter({ hasText: "Sam Osei" }).filter({ hasText: "Arrived" });
|
||||
// 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();
|
||||
@@ -315,23 +322,29 @@ test.describe.serial("owner administration journeys", () => {
|
||||
await page.goto("/owner");
|
||||
await expect(page).toHaveURL(/\/staff$/);
|
||||
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Dashboard" })).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: "Manage" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("12. the owner switches back to barber tools without losing the barber nav", async ({ page }) => {
|
||||
test("12. the owner reaches barber tools straight from the merged nav — no mode switch", async ({ page }) => {
|
||||
await signIn(page, SEEDED.owner.email, "password123");
|
||||
await page.goto("/owner");
|
||||
|
||||
await page.getByRole("link", { name: "Barber Tools", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/staff$/);
|
||||
await expect(page.getByRole("heading", { name: "Calendar" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Current", exact: true })).toBeVisible();
|
||||
// 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, "password123");
|
||||
await page.goto("/owner");
|
||||
await page.getByRole("link", { name: "View Reports" }).click();
|
||||
// "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();
|
||||
|
||||
Reference in new issue
Block a user