test(e2e): add login and impersonation test coverage (GRO-77) #101

Merged
lint-roller-qa[bot] merged 4 commits from feat/e2e-login-impersonation-gro-77 into main 2026-03-22 15:43:01 +00:00
4 changed files with 175 additions and 1 deletions
+15
View File
@@ -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({
+85
View File
@@ -0,0 +1,85 @@
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/sessions/session-1", (route) =>
route.fulfill({ json: MOCK_SESSION })
);
await page.route("**/api/impersonation/sessions/session-1/end", (route) =>
route.fulfill({ json: { status: "ended" } })
);
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/sessions/session-1/audit-log", (route) =>
route.fulfill({ json: { logs: [] } })
);
});
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.getByTestId("impersonation-banner").getByText("STAFF VIEW")).toBeVisible();
});
test("banner shows reason when session has reason", async ({ page }) => {
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("/?sessionId=session-1");
await expect(page.getByText(/Started/)).toBeVisible();
});
test("End Session button is visible", async ({ page }) => {
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("/?sessionId=session-1");
await expect(page.getByRole("button", { name: /Audit/ })).toBeVisible();
});
test("clicking End Session calls API and redirects", async ({ page }) => {
await page.goto("/?sessionId=session-1");
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/sessions/session-1", (route) =>
route.fulfill({ json: lowTimeSession })
);
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("/?sessionId=session-1");
await page.getByRole("button", { name: /End Session/ }).click();
await expect(page).not.toHaveURL(/sessionId=session-1/);
});
});
+74
View File
@@ -0,0 +1,74 @@
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.unroute("**/api/dev/users");
await page.route("**/api/dev/users", async (route) => {
await new Promise((r) => setTimeout(r, 200));
await route.fulfill({ json: { staff: [], clients: [] } });
});
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();
});
});
+1 -1
View File
@@ -35,7 +35,7 @@ export function ImpersonationBanner({ session, isExtended, onEnd, onExtend, onSh
}, [session.expiresAt, onEnd]);
return (
<div className="sticky top-0 z-40 bg-amber-500 text-amber-950 px-4 py-2.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm font-medium shadow-md">
<div data-testid="impersonation-banner" className="sticky top-0 z-40 bg-amber-500 text-amber-950 px-4 py-2.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm font-medium shadow-md">
<span className="flex items-center gap-1.5">
<Eye size={16} />
STAFF VIEW