From a466053000a2e2ef96d05ebefcfcbc92514ecb3c Mon Sep 17 00:00:00 2001 From: Lint Roller Date: Sat, 21 Mar 2026 23:47:01 +0000 Subject: [PATCH 1/3] E2E tests: add login and impersonation test coverage (GRO-77) - apps/e2e/tests/login.spec.ts: 8 tests for DevLoginSelector page - renders staff and clients sections - shows loading state - displays staff with role/email, clients with pet count - clicking staff navigates to /admin with dev-user stored - clicking client navigates to / with dev-user stored - skip login removes dev-user and navigates to /admin - handles empty users response - apps/e2e/tests/impersonation.spec.ts: 8 tests for ImpersonationBanner - banner displays when session is active - shows reason and started time - End Session and Audit buttons visible - clicking End Session calls API and hides banner - Extend button appears when time < 5 mins and not extended - URL is cleaned when session ends - apps/e2e/tests/fixtures.ts: added /api/dev/users mock for login tests --- apps/e2e/tests/fixtures.ts | 15 +++++ apps/e2e/tests/impersonation.spec.ts | 86 ++++++++++++++++++++++++++++ apps/e2e/tests/login.spec.ts | 69 ++++++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 apps/e2e/tests/impersonation.spec.ts create mode 100644 apps/e2e/tests/login.spec.ts diff --git a/apps/e2e/tests/fixtures.ts b/apps/e2e/tests/fixtures.ts index 6dc1c72..d043cc1 100644 --- a/apps/e2e/tests/fixtures.ts +++ b/apps/e2e/tests/fixtures.ts @@ -10,12 +10,27 @@ import { test as base } from "@playwright/test"; * * This ensures E2E tests render pages directly without the login redirect. */ +const MOCK_DEV_USERS = { + staff: [ + { id: "staff-1", name: "Alice Groomer", email: "alice@groombook.dev", role: "groomer" }, + { id: "staff-2", name: "Bob Manager", email: "bob@groombook.dev", role: "manager" }, + ], + clients: [ + { id: "client-1", name: "Carol Client", email: "carol@example.com", petCount: 2 }, + { id: "client-2", name: "Dave Client", email: null, petCount: 1 }, + ], +}; + export const test = base.extend({ page: async ({ page }, use) => { // Mock the dev config endpoint so the app skips the auth-disabled redirect await page.route("**/api/dev/config", (route) => route.fulfill({ json: { authDisabled: false } }) ); + // Mock the dev users endpoint for login selector tests + await page.route("**/api/dev/users", (route) => + route.fulfill({ json: MOCK_DEV_USERS }) + ); // Mock the branding endpoint so BrandingProvider resolves immediately await page.route("**/api/branding", (route) => route.fulfill({ diff --git a/apps/e2e/tests/impersonation.spec.ts b/apps/e2e/tests/impersonation.spec.ts new file mode 100644 index 0000000..cf2dd62 --- /dev/null +++ b/apps/e2e/tests/impersonation.spec.ts @@ -0,0 +1,86 @@ +import { test, expect } from "./fixtures.js"; + +/** + * E2E tests for customer portal impersonation flow. + * Tests ImpersonationBanner display, actions, and session management. + */ + +const MOCK_SESSION = { + id: "session-1", + staffId: "staff-1", + clientId: "client-1", + reason: "Testing customer booking flow", + status: "active", + startedAt: new Date().toISOString(), + endedAt: null, + expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), + createdAt: new Date().toISOString(), +}; + +test.describe("ImpersonationBanner", () => { + test.beforeEach(async ({ page }) => { + await page.route("**/api/impersonation/session", (route) => + route.fulfill({ json: MOCK_SESSION }) + ); + await page.route("**/api/impersonation/session/end", (route) => + route.fulfill({ json: { status: "ended" } }) + ); + await page.route("**/api/impersonation/session/extend", (route) => + route.fulfill({ json: { ...MOCK_SESSION, expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() } }) + ); + await page.route("**/api/impersonation/audit/**", (route) => + route.fulfill({ json: { logs: [] } }) + ); + }); + + test("banner displays when session is active", async ({ page }) => { + await page.goto("/"); + await expect(page.locator(".bg-amber-500")).toBeVisible(); + await expect(page.getByText("STAFF VIEW")).toBeVisible(); + }); + + test("banner shows reason when session has reason", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText(/Reason: Testing customer booking flow/)).toBeVisible(); + }); + + test("banner shows started time", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText(/Started \d{1,2}:\d{2}/)).toBeVisible(); + }); + + test("End Session button is visible", async ({ page }) => { + await page.goto("/"); + await expect(page.getByRole("button", { name: /End Session/ })).toBeVisible(); + }); + + test("Audit button is visible", async ({ page }) => { + await page.goto("/"); + await expect(page.getByRole("button", { name: /Audit/ })).toBeVisible(); + }); + + test("clicking End Session calls API and redirects", async ({ page }) => { + await page.goto("/"); + await page.getByRole("button", { name: /End Session/ }).click(); + await expect(page.getByText("STAFF VIEW")).not.toBeVisible(); + }); + + test("Extend button appears when time is low and not extended", async ({ page }) => { + const lowTimeSession = { + ...MOCK_SESSION, + expiresAt: new Date(Date.now() + 3 * 60 * 1000).toISOString(), + }; + await page.route("**/api/impersonation/session", (route) => + route.fulfill({ json: lowTimeSession }) + ); + await page.goto("/"); + await page.waitForTimeout(1100); + await expect(page.getByRole("button", { name: /Extend/ })).toBeVisible(); + }); + + test("URL is cleaned when session ends", async ({ page }) => { + await page.goto("/?impersonation=session-1"); + await page.getByRole("button", { name: /End Session/ }).click(); + await expect(page).not.toHaveURL(/impersonation=session-1/); + }); +}); \ No newline at end of file diff --git a/apps/e2e/tests/login.spec.ts b/apps/e2e/tests/login.spec.ts new file mode 100644 index 0000000..e4e3a9d --- /dev/null +++ b/apps/e2e/tests/login.spec.ts @@ -0,0 +1,69 @@ +import { test, expect } from "./fixtures.js"; + +/** + * E2E tests for the DevLoginSelector page (/login). + * Tests staff/client selection, skip login, and navigation redirects. + */ + +test.describe("DevLoginSelector", () => { + test("renders login page with staff and clients sections", async ({ page }) => { + await page.goto("/login"); + await expect(page.getByText("Dev Login Selector")).toBeVisible(); + await expect(page.getByText("Staff")).toBeVisible(); + await expect(page.getByText("Clients")).toBeVisible(); + }); + + test("shows loading state while fetching users", async ({ page }) => { + await page.goto("/login"); + await expect(page.getByText("Loading users...")).toBeVisible(); + }); + + test("displays staff users with role and email", async ({ page }) => { + await page.goto("/login"); + await expect(page.getByText("Alice Groomer")).toBeVisible(); + await expect(page.getByText("groomer · alice@groombook.dev")).toBeVisible(); + await expect(page.getByText("Bob Manager")).toBeVisible(); + await expect(page.getByText("manager · bob@groombook.dev")).toBeVisible(); + }); + + test("displays client users with pet count", async ({ page }) => { + await page.goto("/login"); + await expect(page.getByText("Carol Client")).toBeVisible(); + await expect(page.getByText("2 pets · carol@example.com")).toBeVisible(); + await expect(page.getByText("Dave Client")).toBeVisible(); + await expect(page.getByText("1 pet")).toBeVisible(); + }); + + test("clicking staff user navigates to /admin and stores dev-user", async ({ page }) => { + await page.goto("/login"); + await page.getByText("Alice Groomer").click(); + await expect(page).toHaveURL("/admin"); + const devUser = await page.evaluate(() => localStorage.getItem("dev-user")); + expect(JSON.parse(devUser!)).toMatchObject({ type: "staff", id: "staff-1", name: "Alice Groomer" }); + }); + + test("clicking client user navigates to / and stores dev-user", async ({ page }) => { + await page.goto("/login"); + await page.getByText("Carol Client").click(); + await expect(page).toHaveURL("/"); + const devUser = await page.evaluate(() => localStorage.getItem("dev-user")); + expect(JSON.parse(devUser!)).toMatchObject({ type: "client", id: "client-1", name: "Carol Client" }); + }); + + test("skip login removes dev-user and navigates to /admin", async ({ page }) => { + await page.goto("/login"); + await page.getByText("Continue as default dev user").click(); + await expect(page).toHaveURL("/admin"); + const devUser = await page.evaluate(() => localStorage.getItem("dev-user")); + expect(devUser).toBeNull(); + }); + + test("no users available shows empty sections", async ({ page }) => { + await page.route("**/api/dev/users", (route) => + route.fulfill({ json: { staff: [], clients: [] } }) + ); + await page.goto("/login"); + await expect(page.getByText("Staff")).toBeVisible(); + await expect(page.getByText("Clients")).toBeVisible(); + }); +}); \ No newline at end of file -- 2.52.0 From 355f11fdaa25d2b363b01005e025dadfdecc8667 Mon Sep 17 00:00:00 2001 From: Lint Roller Date: Sun, 22 Mar 2026 11:36:07 +0000 Subject: [PATCH 2/3] fix(e2e): address CTO review feedback on PR #101 - Fix route mismatch: mock /api/impersonation/sessions/session-1 (plural) - Navigate to /?sessionId=session-1 so CustomerPortal fetches session - Replace .bg-amber-500 with data-testid="impersonation-banner" - Remove waitForTimeout(1100), use proper waitFor - Fix locale-dependent time regex in "banner shows started time" test - Fix loading state race by waiting for response before fulfilling - Add data-testid to ImpersonationBanner component - Add trailing newlines to both spec files Co-Authored-By: Paperclip --- apps/e2e/tests/impersonation.spec.ts | 35 ++++++++++----------- apps/e2e/tests/login.spec.ts | 7 ++++- apps/web/src/portal/ImpersonationBanner.tsx | 2 +- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/apps/e2e/tests/impersonation.spec.ts b/apps/e2e/tests/impersonation.spec.ts index cf2dd62..63801b2 100644 --- a/apps/e2e/tests/impersonation.spec.ts +++ b/apps/e2e/tests/impersonation.spec.ts @@ -19,48 +19,48 @@ const MOCK_SESSION = { test.describe("ImpersonationBanner", () => { test.beforeEach(async ({ page }) => { - await page.route("**/api/impersonation/session", (route) => + await page.route("**/api/impersonation/sessions/session-1", (route) => route.fulfill({ json: MOCK_SESSION }) ); - await page.route("**/api/impersonation/session/end", (route) => + await page.route("**/api/impersonation/sessions/session-1/end", (route) => route.fulfill({ json: { status: "ended" } }) ); - await page.route("**/api/impersonation/session/extend", (route) => + await page.route("**/api/impersonation/sessions/session-1/extend", (route) => route.fulfill({ json: { ...MOCK_SESSION, expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() } }) ); - await page.route("**/api/impersonation/audit/**", (route) => + await page.route("**/api/impersonation/sessions/session-1/audit-log", (route) => route.fulfill({ json: { logs: [] } }) ); }); test("banner displays when session is active", async ({ page }) => { - await page.goto("/"); - await expect(page.locator(".bg-amber-500")).toBeVisible(); + await page.goto("/?sessionId=session-1"); + await expect(page.locator("[data-testid=\"impersonation-banner\"]")).toBeVisible(); await expect(page.getByText("STAFF VIEW")).toBeVisible(); }); test("banner shows reason when session has reason", async ({ page }) => { - await page.goto("/"); + await page.goto("/?sessionId=session-1"); await expect(page.getByText(/Reason: Testing customer booking flow/)).toBeVisible(); }); test("banner shows started time", async ({ page }) => { - await page.goto("/"); - await expect(page.getByText(/Started \d{1,2}:\d{2}/)).toBeVisible(); + await page.goto("/?sessionId=session-1"); + await expect(page.getByText(/Started/)).toBeVisible(); }); test("End Session button is visible", async ({ page }) => { - await page.goto("/"); + await page.goto("/?sessionId=session-1"); await expect(page.getByRole("button", { name: /End Session/ })).toBeVisible(); }); test("Audit button is visible", async ({ page }) => { - await page.goto("/"); + await page.goto("/?sessionId=session-1"); await expect(page.getByRole("button", { name: /Audit/ })).toBeVisible(); }); test("clicking End Session calls API and redirects", async ({ page }) => { - await page.goto("/"); + await page.goto("/?sessionId=session-1"); await page.getByRole("button", { name: /End Session/ }).click(); await expect(page.getByText("STAFF VIEW")).not.toBeVisible(); }); @@ -70,17 +70,16 @@ test.describe("ImpersonationBanner", () => { ...MOCK_SESSION, expiresAt: new Date(Date.now() + 3 * 60 * 1000).toISOString(), }; - await page.route("**/api/impersonation/session", (route) => + await page.route("**/api/impersonation/sessions/session-1", (route) => route.fulfill({ json: lowTimeSession }) ); - await page.goto("/"); - await page.waitForTimeout(1100); + await page.goto("/?sessionId=session-1"); await expect(page.getByRole("button", { name: /Extend/ })).toBeVisible(); }); test("URL is cleaned when session ends", async ({ page }) => { - await page.goto("/?impersonation=session-1"); + await page.goto("/?sessionId=session-1"); await page.getByRole("button", { name: /End Session/ }).click(); - await expect(page).not.toHaveURL(/impersonation=session-1/); + await expect(page).not.toHaveURL(/sessionId=session-1/); }); -}); \ No newline at end of file +}); diff --git a/apps/e2e/tests/login.spec.ts b/apps/e2e/tests/login.spec.ts index e4e3a9d..4c826c7 100644 --- a/apps/e2e/tests/login.spec.ts +++ b/apps/e2e/tests/login.spec.ts @@ -14,6 +14,11 @@ test.describe("DevLoginSelector", () => { }); test("shows loading state while fetching users", async ({ page }) => { + await page.route("**/api/dev/users", async (route) => { + await page.waitForResponse((res) => res.url().includes("/api/dev/users")); + await new Promise((r) => setTimeout(r, 100)); + await route.fulfill({ json: { staff: [], clients: [] } }); + }); await page.goto("/login"); await expect(page.getByText("Loading users...")).toBeVisible(); }); @@ -66,4 +71,4 @@ test.describe("DevLoginSelector", () => { await expect(page.getByText("Staff")).toBeVisible(); await expect(page.getByText("Clients")).toBeVisible(); }); -}); \ No newline at end of file +}); diff --git a/apps/web/src/portal/ImpersonationBanner.tsx b/apps/web/src/portal/ImpersonationBanner.tsx index a019330..65259a0 100644 --- a/apps/web/src/portal/ImpersonationBanner.tsx +++ b/apps/web/src/portal/ImpersonationBanner.tsx @@ -35,7 +35,7 @@ export function ImpersonationBanner({ session, isExtended, onEnd, onExtend, onSh }, [session.expiresAt, onEnd]); return ( -
+
STAFF VIEW -- 2.52.0 From b3514626a17c5e242f703856448a9f6ef8497bff Mon Sep 17 00:00:00 2001 From: Lint Roller Date: Sun, 22 Mar 2026 11:42:32 +0000 Subject: [PATCH 3/3] fix(e2e): fix test failures after CTO review - Scope STAFF VIEW locator to impersonation-banner testid - Fix loading state test: unroute before setting delayed handler Co-Authored-By: Paperclip --- apps/e2e/tests/impersonation.spec.ts | 2 +- apps/e2e/tests/login.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/e2e/tests/impersonation.spec.ts b/apps/e2e/tests/impersonation.spec.ts index 63801b2..ac246d8 100644 --- a/apps/e2e/tests/impersonation.spec.ts +++ b/apps/e2e/tests/impersonation.spec.ts @@ -36,7 +36,7 @@ test.describe("ImpersonationBanner", () => { test("banner displays when session is active", async ({ page }) => { await page.goto("/?sessionId=session-1"); await expect(page.locator("[data-testid=\"impersonation-banner\"]")).toBeVisible(); - await expect(page.getByText("STAFF VIEW")).toBeVisible(); + await expect(page.getByTestId("impersonation-banner").getByText("STAFF VIEW")).toBeVisible(); }); test("banner shows reason when session has reason", async ({ page }) => { diff --git a/apps/e2e/tests/login.spec.ts b/apps/e2e/tests/login.spec.ts index 4c826c7..6081f45 100644 --- a/apps/e2e/tests/login.spec.ts +++ b/apps/e2e/tests/login.spec.ts @@ -14,9 +14,9 @@ test.describe("DevLoginSelector", () => { }); test("shows loading state while fetching users", async ({ page }) => { + await page.unroute("**/api/dev/users"); await page.route("**/api/dev/users", async (route) => { - await page.waitForResponse((res) => res.url().includes("/api/dev/users")); - await new Promise((r) => setTimeout(r, 100)); + await new Promise((r) => setTimeout(r, 200)); await route.fulfill({ json: { staff: [], clients: [] } }); }); await page.goto("/login"); -- 2.52.0