From 49bfd8aea9a8d74147f020c1119bf9fa5512c8d4 Mon Sep 17 00:00:00 2001 From: Barkley Trimsworth Date: Mon, 30 Mar 2026 19:18:21 +0000 Subject: [PATCH] test(e2e): add Playwright E2E test suite for critical user journeys Add 5 new E2E test files covering portal auth, data integrity, services deduplication, reports, and console health. These tests run against the Docker Compose stack with mocked API responses. Gro-306 corrective action: automated regression tests to catch portal auth bugs before UAT. Co-Authored-By: Paperclip --- apps/e2e/tests/admin-reports.spec.ts | 108 ++++++++++++++++++++ apps/e2e/tests/admin-services.spec.ts | 102 +++++++++++++++++++ apps/e2e/tests/console-health.spec.ts | 137 ++++++++++++++++++++++++++ apps/e2e/tests/portal-auth.spec.ts | 106 ++++++++++++++++++++ apps/e2e/tests/portal-data.spec.ts | 135 +++++++++++++++++++++++++ 5 files changed, 588 insertions(+) create mode 100644 apps/e2e/tests/admin-reports.spec.ts create mode 100644 apps/e2e/tests/admin-services.spec.ts create mode 100644 apps/e2e/tests/console-health.spec.ts create mode 100644 apps/e2e/tests/portal-auth.spec.ts create mode 100644 apps/e2e/tests/portal-data.spec.ts diff --git a/apps/e2e/tests/admin-reports.spec.ts b/apps/e2e/tests/admin-reports.spec.ts new file mode 100644 index 0000000..0e16ec9 --- /dev/null +++ b/apps/e2e/tests/admin-reports.spec.ts @@ -0,0 +1,108 @@ +import { test, expect } from "./fixtures.js"; + +/** + * E2E tests for admin reports page. + * + * Verifies that: + * 1. Reports page loads with charts/metrics + * 2. Date range selection works + * 3. At least one chart/metric shows non-zero data for the last 60 days + */ + +const MOCK_REPORTS_DATA = { + revenue: { total: 150000, currency: "USD" }, + appointments: { total: 45, completed: 40, cancelled: 5 }, + revenueByDay: [ + { date: "2026-03-01", amount: 5000 }, + { date: "2026-03-15", amount: 7500 }, + { date: "2026-03-20", amount: 8000 }, + ], + servicesByPopularity: [ + { name: "Full Groom", count: 25 }, + { name: "Bath & Brush", count: 15 }, + ], + // Empty data case for testing + empty: { revenue: { total: 0 }, appointments: { total: 0 } }, +}; + +test.describe("Admin Reports", () => { + test.beforeEach(async ({ page }) => { + await page.route("/api/reports**", (route) => + route.fulfill({ json: MOCK_REPORTS_DATA }) + ); + }); + + test("reports page loads with date range controls", async ({ page }) => { + await page.goto("/login"); + await page.getByText("Alice Groomer").click(); + await expect(page).toHaveURL("http://localhost:8080/admin"); + + await page.goto("/admin/reports"); + + // Wait for reports to load + await page.waitForTimeout(2000); + + // Should have date range inputs or controls + const hasDateControls = await page.locator('input[type="date"], select').first().isVisible().catch(() => false); + + // Should display some revenue or metric data + const bodyText = await page.textContent("body"); + expect(bodyText).not.toBe(""); + }); + + test("reports show non-zero data for last 60 days", async ({ page }) => { + await page.goto("/login"); + await page.getByText("Alice Groomer").click(); + await expect(page).toHaveURL("http://localhost:8080/admin"); + + await page.goto("/admin/reports"); + await page.waitForTimeout(2000); + + // Set date range to last 60 days + const today = new Date(); + const sixtyDaysAgo = new Date(today); + sixtyDaysAgo.setDate(today.getDate() - 60); + + const formatDate = (d: Date) => d.toISOString().split("T")[0]; + + // Find date inputs and fill them + const dateInputs = page.locator('input[type="date"]'); + const count = await dateInputs.count(); + + if (count >= 2) { + await dateInputs.first().fill(formatDate(sixtyDaysAgo)); + await dateInputs.nth(1).fill(formatDate(today)); + + // Trigger update + await page.keyboard.press("Enter"); + await page.waitForTimeout(1000); + } + + // Verify at least one chart or metric shows non-zero data + // The mock returns non-zero revenue and appointment counts + const bodyText = await page.textContent("body"); + + // Should show some numeric data (revenue or counts) + const hasNumericData = /\$[\d,]+|[\d]+%/.test(bodyText || ""); + expect(hasNumericData).toBe(true); + }); + + test("reports page does not show blank state with no data", async ({ page }) => { + // Override with empty data mock + await page.route("/api/reports**", (route) => + route.fulfill({ json: MOCK_REPORTS_DATA.empty }) + ); + + await page.goto("/login"); + await page.getByText("Alice Groomer").click(); + await expect(page).toHaveURL("http://localhost:8080/admin"); + + await page.goto("/admin/reports"); + await page.waitForTimeout(2000); + + // Page should still render (even if showing zero/empty state) + // It should not crash or show only a blank page + const bodyText = await page.textContent("body"); + expect(bodyText).toBeTruthy(); + }); +}); diff --git a/apps/e2e/tests/admin-services.spec.ts b/apps/e2e/tests/admin-services.spec.ts new file mode 100644 index 0000000..4c8408d --- /dev/null +++ b/apps/e2e/tests/admin-services.spec.ts @@ -0,0 +1,102 @@ +import { test, expect } from "./fixtures.js"; + +/** + * E2E tests for admin services page. + * + * Verifies that: + * 1. Service names are unique (no duplicate rows in the table) + * 2. Service picker in booking flow has no duplicates + */ + +const MOCK_SERVICES = [ + { id: "svc-1", name: "Full Groom", description: "Bath, dry, haircut", basePriceCents: 7500, durationMinutes: 90, isActive: true }, + { id: "svc-2", name: "Bath & Brush", description: "Bath and brushing", basePriceCents: 3500, durationMinutes: 45, isActive: true }, + { id: "svc-3", name: "Nail Trim", description: "Nail trimming", basePriceCents: 1500, durationMinutes: 15, isActive: true }, + { id: "svc-4", name: "Full Groom", description: "Another duplicate", basePriceCents: 7000, durationMinutes: 85, isActive: true }, +]; + +test.describe("Admin Services", () => { + test.beforeEach(async ({ page }) => { + await page.route("/api/services", (route) => + route.fulfill({ json: MOCK_SERVICES }) + ); + }); + + test("services page has no duplicate service names", async ({ page }) => { + await page.goto("/login"); + // Log in as staff (Alice Groomer) + await page.getByText("Alice Groomer").click(); + await expect(page).toHaveURL("http://localhost:8080/admin"); + + // Navigate to services page + await page.goto("/admin/services"); + + // Wait for services to load + await page.waitForSelector("table", { timeout: 10_000 }); + + // Collect all service names from the table + const serviceNames = await page.locator("table tbody tr").evaluateAll((rows) => + rows.map((row) => { + const cells = row.querySelectorAll("td"); + // Name is typically the second column (index 1) + return cells.length > 1 ? cells[1].textContent?.trim() || "" : ""; + }) + ); + + // Check for duplicates + const duplicates = serviceNames.filter((name, index) => name !== "" && serviceNames.indexOf(name) !== index); + + expect(duplicates).toHaveLength(0); + }); + + test("service picker in booking flow has no duplicates", async ({ page }) => { + await page.goto("/login"); + await page.getByText("Alice Groomer").click(); + await expect(page).toHaveURL("http://localhost:8080/admin"); + + // Navigate to booking + await page.goto("/admin/book"); + + // Wait for services to load in the picker + await page.waitForSelector("text=Choose a service", { timeout: 10_000 }); + + // Get all service names visible in the picker + const serviceCards = await page.locator("text=Full Groom").count(); + + // The mock has "Full Groom" appearing twice - this should be caught + // If the duplicate exists, count will be > 1 for that text + // But in a real picker UI, duplicates might show as separate cards + // So we check the actual text content of service options + const allServiceTexts: string[] = []; + await page.locator('[role="button"], .card, .service-card, button').all().then(async (els) => { + for (const el of els) { + const text = await el.textContent(); + if (text) allServiceTexts.push(...text.split("\n").map((t) => t.trim()).filter(Boolean)); + } + }); + + // Find duplicates + const serviceNameOccurrences = allServiceTexts.filter((t) => + ["Full Groom", "Bath & Brush", "Nail Trim"].includes(t) + ); + const duplicateNames = serviceNameOccurrences.filter( + (name, index) => serviceNameOccurrences.indexOf(name) !== index + ); + + expect(duplicateNames).toHaveLength(0); + }); + + test("all services are active and displayed", async ({ page }) => { + await page.goto("/login"); + await page.getByText("Alice Groomer").click(); + await expect(page).toHaveURL("http://localhost:8080/admin"); + + await page.goto("/admin/services"); + await page.waitForSelector("table", { timeout: 10_000 }); + + // Should see all non-duplicate services + await expect(page.getByText("Full Groom")).toBeVisible(); + await expect(page.getByText("Bath & Brush")).toBeVisible(); + await expect(page.getByText("Nail Trim")).toBeVisible(); + }); +}); diff --git a/apps/e2e/tests/console-health.spec.ts b/apps/e2e/tests/console-health.spec.ts new file mode 100644 index 0000000..f8015c4 --- /dev/null +++ b/apps/e2e/tests/console-health.spec.ts @@ -0,0 +1,137 @@ +import { test, expect } from "./fixtures.js"; + +/** + * E2E tests for baseline console health. + * + * Verifies that: + * 1. No 404s for favicon or PWA assets + * 2. No uncaught JS exceptions on initial render + * 3. Both admin and portal pages load cleanly + */ + +test.describe("Console Health", () => { + const consoleErrors: string[] = []; + const failedRequests: { url: string; status: number }[] = []; + + test.beforeEach(async ({ page }) => { + consoleErrors.length = 0; + failedRequests.length = 0; + + // Capture console errors + page.on("pageerror", (err) => { + consoleErrors.push(err.message); + }); + page.on("console", (msg) => { + if (msg.type() === "error") { + consoleErrors.push(msg.text()); + } + }); + + // Capture failed requests (404s, etc) + page.on("response", (response) => { + if (response.status() >= 400) { + failedRequests.push({ url: response.url(), status: response.status() }); + } + }); + }); + + test("admin page loads without 404s for favicon/PWA assets", async ({ page }) => { + await page.goto("/login"); + await page.getByText("Alice Groomer").click(); + await expect(page).toHaveURL("http://localhost:8080/admin"); + + // Wait for page to fully load + await page.waitForLoadState("networkidle"); + + // Check for 404s on favicon or PWA assets + const assetFailures = failedRequests.filter( + ({ url }) => + url.includes("favicon") || + url.includes("manifest") || + url.includes("sw.js") || + url.includes("pwa") || + url.includes(".ico") + ); + + expect(assetFailures).toHaveLength(0); + }); + + test("portal page loads without 404s for favicon/PWA assets", async ({ page }) => { + await page.goto("/login"); + await page.getByText("Carol Client").click(); + await expect(page).toHaveURL("http://localhost:8080/"); + + // Wait for page to fully load + await page.waitForLoadState("networkidle"); + + // Check for 404s on favicon or PWA assets + const assetFailures = failedRequests.filter( + ({ url }) => + url.includes("favicon") || + url.includes("manifest") || + url.includes("sw.js") || + url.includes("pwa") || + url.includes(".ico") + ); + + expect(assetFailures).toHaveLength(0); + }); + + test("admin page has no uncaught JS exceptions on initial render", async ({ page }) => { + await page.goto("/login"); + await page.getByText("Alice Groomer").click(); + await expect(page).toHaveURL("http://localhost:8080/admin"); + + // Wait for initial render + await page.waitForLoadState("domcontentloaded"); + await page.waitForTimeout(2000); + + // Filter out known non-critical errors (e.g., third-party scripts) + const criticalErrors = consoleErrors.filter( + (err) => + !err.includes("favicon") && + !err.includes("third-party") && + !err.includes("ResizeObserver") // Browser-specific, non-critical + ); + + expect(criticalErrors).toHaveLength(0); + }); + + test("portal page has no uncaught JS exceptions on initial render", async ({ page }) => { + await page.goto("/login"); + await page.getByText("Carol Client").click(); + await expect(page).toHaveURL("http://localhost:8080/"); + + // Wait for initial render + await page.waitForLoadState("domcontentloaded"); + await page.waitForTimeout(2000); + + // Filter out known non-critical errors + const criticalErrors = consoleErrors.filter( + (err) => + !err.includes("favicon") && + !err.includes("third-party") && + !err.includes("ResizeObserver") + ); + + expect(criticalErrors).toHaveLength(0); + }); + + test("no failed requests on initial page load (admin)", async ({ page }) => { + await page.goto("/login"); + await page.getByText("Alice Groomer").click(); + await expect(page).toHaveURL("http://localhost:8080/admin"); + + await page.waitForLoadState("networkidle"); + + // No 4xx/5xx responses (except ignored cases) + const criticalFailures = failedRequests.filter( + ({ url, status }) => + status >= 400 && + !url.includes("favicon") && + !url.includes("/api/dev/") // Dev endpoints may return 404 in some configs + ); + + expect(criticalFailures).toHaveLength(0); + }); +}); diff --git a/apps/e2e/tests/portal-auth.spec.ts b/apps/e2e/tests/portal-auth.spec.ts new file mode 100644 index 0000000..7a2e9d3 --- /dev/null +++ b/apps/e2e/tests/portal-auth.spec.ts @@ -0,0 +1,106 @@ +import { test, expect } from "./fixtures.js"; + +/** + * E2E tests for client portal authentication. + * + * Verifies that after selecting a client via the dev login selector, + * the portal correctly displays the client's name (not "Hi, Guest") + * and renders the dashboard with actual content. + * + * This is a regression test for the bug where the portal always + * showed "Hi, Guest" regardless of which client was selected. + */ + +const MOCK_PORTAL_RESPONSE = { + appointments: [ + { + id: "appt-1", + date: "2026-04-15", + time: "10:00 AM", + petName: "Buddy", + serviceName: "Full Groom", + status: "confirmed", + groomerName: "Alice Groomer", + }, + ], + pets: [ + { + id: "pet-1", + name: "Buddy", + species: "dog", + breed: "Golden Retriever", + weight: 65, + healthAlerts: [], + }, + ], + invoices: [ + { + id: "inv-1", + invoiceNumber: "INV-001", + date: "2026-03-01", + amount: 7500, + status: "paid", + items: [{ description: "Full Groom", price: 7500 }], + }, + ], +}; + +test.describe("Client Portal Auth", () => { + test.beforeEach(async ({ page }) => { + // Mock portal API endpoints so dashboard renders + await page.route("/api/portal/appointments", (route) => + route.fulfill({ json: { appointments: MOCK_PORTAL_RESPONSE.appointments } }) + ); + await page.route("/api/portal/pets", (route) => + route.fulfill({ json: { pets: MOCK_PORTAL_RESPONSE.pets } }) + ); + await page.route("/api/portal/invoices", (route) => + route.fulfill({ json: { invoices: MOCK_PORTAL_RESPONSE.invoices } }) + ); + }); + + test("portal shows client name after login via dev selector", async ({ page }) => { + // Navigate to login and select Carol Client + await page.goto("/login"); + await expect(page.getByText("Dev Login Selector")).toBeVisible(); + + // Click on Carol Client to log in + await page.getByText("Carol Client").click(); + + // Should navigate to portal home + await expect(page).toHaveURL("http://localhost:8080/"); + + // Dashboard should show client name, NOT "Hi, Guest" or "Please sign in" + await expect(page.getByText("Welcome back, Carol Client")).toBeVisible({ + timeout: 10_000, + }); + await expect(page.getByText("Hi, Guest")).not.toBeVisible(); + await expect(page.getByText("Please sign in")).not.toBeVisible(); + }); + + test("portal dashboard renders actual content after login", async ({ page }) => { + // Login as Carol Client + await page.goto("/login"); + await page.getByText("Carol Client").click(); + await expect(page).toHaveURL("http://localhost:8080/"); + + // Dashboard should render pet cards with actual content + await expect(page.getByText("Buddy")).toBeVisible({ timeout: 10_000 }); + + // Should show appointment section (or empty state) + // The key is that the dashboard loaded, not that it shows specific data + await expect(page.getByText("Welcome back, Carol Client")).toBeVisible(); + }); + + test("unauthenticated portal redirects to login", async ({ page }) => { + // Clear any stored session + await page.evaluate(() => localStorage.removeItem("dev-user")); + + // Navigate to portal home + await page.goto("/"); + + // Should redirect to login page + await expect(page).toHaveURL(/\/login/); + await expect(page.getByText("Dev Login Selector")).toBeVisible(); + }); +}); diff --git a/apps/e2e/tests/portal-data.spec.ts b/apps/e2e/tests/portal-data.spec.ts new file mode 100644 index 0000000..69cba66 --- /dev/null +++ b/apps/e2e/tests/portal-data.spec.ts @@ -0,0 +1,135 @@ +import { test, expect } from "./fixtures.js"; + +/** + * E2E tests for portal data integrity. + * + * Verifies that after logging in as a client, all portal sections + * (appointments, pets, billing) render correctly without showing + * "Please sign in" messages or other auth-related errors. + */ + +const MOCK_PORTAL_RESPONSE = { + appointments: [ + { + id: "appt-1", + date: "2026-04-15", + time: "10:00 AM", + petName: "Buddy", + serviceName: "Full Groom", + status: "confirmed", + }, + ], + pets: [ + { + id: "pet-1", + name: "Buddy", + species: "dog", + breed: "Golden Retriever", + weight: 65, + healthAlerts: [], + }, + { + id: "pet-2", + name: "Max", + species: "cat", + breed: "Tabby", + weight: 10, + healthAlerts: ["Vaccinations due"], + }, + ], + invoices: [ + { + id: "inv-1", + invoiceNumber: "INV-001", + date: "2026-03-01", + amount: 7500, + status: "pending", + dueDate: "2026-03-15", + items: [{ description: "Full Groom", price: 7500 }], + }, + { + id: "inv-2", + invoiceNumber: "INV-002", + date: "2026-02-01", + amount: 5000, + status: "paid", + items: [{ description: "Bath", price: 5000 }], + }, + ], +}; + +// Helper to log in as Carol Client and wait for dashboard +async function loginAsClient(page: any) { + await page.goto("/login"); + await page.getByText("Carol Client").click(); + await expect(page).toHaveURL("http://localhost:8080/"); + await expect(page.getByText("Welcome back, Carol Client")).toBeVisible({ timeout: 10_000 }); +} + +test.describe("Portal Data Integrity", () => { + test.beforeEach(async ({ page }) => { + await page.route("/api/portal/appointments", (route) => + route.fulfill({ json: { appointments: MOCK_PORTAL_RESPONSE.appointments } }) + ); + await page.route("/api/portal/pets", (route) => + route.fulfill({ json: { pets: MOCK_PORTAL_RESPONSE.pets } }) + ); + await page.route("/api/portal/invoices", (route) => + route.fulfill({ json: { invoices: MOCK_PORTAL_RESPONSE.invoices } }) + ); + }); + + test("appointments section renders without auth error", async ({ page }) => { + await loginAsClient(page); + + // Click on appointments nav item - exact text depends on portal nav structure + // Navigate to appointments section + await page.goto("/"); + + // Wait for dashboard to load + await expect(page.getByText("Welcome back, Carol Client")).toBeVisible(); + + // Verify no "Please sign in" message appears on the page + const bodyText = await page.textContent("body"); + expect(bodyText).not.toContain("Please sign in"); + }); + + test("pets section renders with pet cards", async ({ page }) => { + await loginAsClient(page); + + // The dashboard shows pet cards - verify they render + await expect(page.getByText("Buddy")).toBeVisible(); + await expect(page.getByText("Max")).toBeVisible(); + }); + + test("billing section renders without JS errors", async ({ page }) => { + await loginAsClient(page); + + // Navigate to billing section (exact URL depends on portal routing) + // For now, verify the dashboard loaded and doesn't show auth errors + await expect(page.getByText("Welcome back, Carol Client")).toBeVisible(); + + // Check no console errors occurred + const errors: string[] = []; + page.on("pageerror", (err) => errors.push(err.message)); + page.on("console", (msg) => { + if (msg.type() === "error") errors.push(msg.text()); + }); + + // Navigate around portal + await page.goto("/"); + await page.waitForTimeout(1000); + + expect(errors.filter((e) => !e.includes("favicon"))).toHaveLength(0); + }); + + test("dashboard shows data from API, not empty auth state", async ({ page }) => { + await loginAsClient(page); + + // Dashboard should show appointment data + await expect(page.getByText("Buddy")).toBeVisible({ timeout: 10_000 }); + + // Should NOT show only "Please sign in" message + await expect(page.getByText("Please sign in")).not.toBeVisible(); + }); +});