From 1f50fdff54e65edc61cb956fdded55f0399ad8e4 Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Sat, 21 Mar 2026 23:43:47 +0000 Subject: [PATCH 1/9] test(db): add unit tests for test factories (GitHub #94) Tests cover resetFactoryCounters(), counter determinism, override merging, and compile-time enforcement of required fields on buildAppointment. All 16 new tests pass (92 total). Co-Authored-By: Paperclip --- apps/api/package.json | 4 +- apps/api/src/__tests__/factories.test.ts | 216 +++++++++++++++++++++++ pnpm-lock.yaml | 7 +- 3 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/__tests__/factories.test.ts diff --git a/apps/api/package.json b/apps/api/package.json index b032eaf..d755d49 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -27,11 +27,11 @@ "@types/node": "^22.10.7", "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.17", - "@vitest/coverage-v8": "^3.0.4", + "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.18.0", "tsx": "^4.19.2", "typescript": "^5.7.3", "typescript-eslint": "^8.20.0", - "vitest": "^3.0.4" + "vitest": "^3.2.4" } } diff --git a/apps/api/src/__tests__/factories.test.ts b/apps/api/src/__tests__/factories.test.ts new file mode 100644 index 0000000..bdb7fad --- /dev/null +++ b/apps/api/src/__tests__/factories.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + resetFactoryCounters, + buildStaff, + buildClient, + buildPet, + buildService, + buildAppointment, +} from "@groombook/db/factories"; + +describe("resetFactoryCounters", () => { + it("resets all counters so IDs restart from 1", () => { + buildStaff(); + buildStaff(); + buildClient(); + resetFactoryCounters(); + + const staff = buildStaff(); + const client = buildClient(); + + expect(staff.id).toBe("staff-1"); + expect(client.id).toBe("client-1"); + }); + + it("resets counters for every entity type", () => { + const client = buildClient(); + const pet = buildPet({ clientId: client.id }); + const service = buildService(); + buildAppointment({ + clientId: client.id, + petId: pet.id, + serviceId: service.id, + staffId: "staff-1", + }); + + resetFactoryCounters(); + + expect(buildStaff().id).toBe("staff-1"); + expect(buildClient().id).toBe("client-1"); + expect(buildService().id).toBe("service-1"); + const c = buildClient(); + expect(buildPet({ clientId: c.id }).id).toBe("pet-1"); + const s = buildService(); + const p = buildPet({ clientId: c.id }); + expect( + buildAppointment({ clientId: c.id, petId: p.id, serviceId: s.id, staffId: "s-1" }).id + ).toBe("appointment-1"); + }); +}); + +describe("counter determinism", () => { + beforeEach(() => { + resetFactoryCounters(); + }); + + it("increments staff IDs sequentially", () => { + expect(buildStaff().id).toBe("staff-1"); + expect(buildStaff().id).toBe("staff-2"); + expect(buildStaff().id).toBe("staff-3"); + }); + + it("increments client IDs sequentially", () => { + expect(buildClient().id).toBe("client-1"); + expect(buildClient().id).toBe("client-2"); + }); + + it("increments pet IDs sequentially", () => { + const client = buildClient(); + expect(buildPet({ clientId: client.id }).id).toBe("pet-1"); + expect(buildPet({ clientId: client.id }).id).toBe("pet-2"); + }); + + it("increments service IDs sequentially", () => { + expect(buildService().id).toBe("service-1"); + expect(buildService().id).toBe("service-2"); + }); + + it("increments appointment IDs sequentially", () => { + const client = buildClient(); + const pet = buildPet({ clientId: client.id }); + const service = buildService(); + const required = { clientId: client.id, petId: pet.id, serviceId: service.id, staffId: "staff-1" }; + + expect(buildAppointment(required).id).toBe("appointment-1"); + expect(buildAppointment(required).id).toBe("appointment-2"); + }); + + it("each entity type maintains its own independent counter", () => { + buildStaff(); + buildStaff(); + buildClient(); + + // staff counter is at 2; client counter is at 1 + expect(buildStaff().id).toBe("staff-3"); + expect(buildClient().id).toBe("client-2"); + }); +}); + +describe("override merging", () => { + beforeEach(() => { + resetFactoryCounters(); + }); + + it("buildStaff applies overrides over defaults", () => { + const staff = buildStaff({ role: "manager", name: "Boss" }); + + expect(staff.role).toBe("manager"); + expect(staff.name).toBe("Boss"); + expect(staff.id).toBe("staff-1"); + expect(staff.active).toBe(true); // default preserved + }); + + it("buildStaff id override is respected without disrupting the counter", () => { + const staff = buildStaff({ id: "custom-id" }); + + expect(staff.id).toBe("custom-id"); + // counter still ticked — next call gets staff-2 + expect(buildStaff().id).toBe("staff-2"); + }); + + it("buildClient applies overrides over defaults", () => { + const client = buildClient({ name: "Alice Smith", emailOptOut: true }); + + expect(client.name).toBe("Alice Smith"); + expect(client.emailOptOut).toBe(true); + expect(client.status).toBe("active"); // default preserved + }); + + it("buildPet merges overrides and sets clientId from required arg", () => { + const pet = buildPet({ clientId: "client-99", name: "Fluffy", breed: "Poodle" }); + + expect(pet.clientId).toBe("client-99"); + expect(pet.name).toBe("Fluffy"); + expect(pet.breed).toBe("Poodle"); + expect(pet.species).toBe("Dog"); // default preserved + }); + + it("buildService applies overrides over defaults", () => { + const service = buildService({ basePriceCents: 9900, active: false }); + + expect(service.basePriceCents).toBe(9900); + expect(service.active).toBe(false); + expect(service.durationMinutes).toBe(60); // default preserved + }); + + it("buildAppointment applies overrides over defaults", () => { + const client = buildClient(); + const pet = buildPet({ clientId: client.id }); + const service = buildService(); + const appt = buildAppointment({ + clientId: client.id, + petId: pet.id, + serviceId: service.id, + staffId: "staff-1", + status: "confirmed", + notes: "allergic to lavender", + }); + + expect(appt.status).toBe("confirmed"); + expect(appt.notes).toBe("allergic to lavender"); + expect(appt.clientId).toBe(client.id); + expect(appt.petId).toBe(pet.id); + // defaults preserved + expect(appt.batherStaffId).toBeNull(); + expect(appt.priceCents).toBeNull(); + }); +}); + +describe("buildAppointment required fields", () => { + beforeEach(() => { + resetFactoryCounters(); + }); + + it("produces a fully-populated AppointmentRow", () => { + const client = buildClient(); + const pet = buildPet({ clientId: client.id }); + const service = buildService(); + const appt = buildAppointment({ + clientId: client.id, + petId: pet.id, + serviceId: service.id, + staffId: "staff-1", + }); + + expect(appt.id).toBeDefined(); + expect(appt.clientId).toBe(client.id); + expect(appt.petId).toBe(pet.id); + expect(appt.serviceId).toBe(service.id); + expect(appt.staffId).toBe("staff-1"); + expect(appt.startTime).toBeInstanceOf(Date); + expect(appt.endTime).toBeInstanceOf(Date); + expect(appt.status).toBe("scheduled"); + expect(appt.batherStaffId).toBeNull(); + expect(appt.seriesId).toBeNull(); + expect(appt.seriesIndex).toBeNull(); + expect(appt.groupId).toBeNull(); + expect(appt.notes).toBeNull(); + expect(appt.priceCents).toBeNull(); + expect(appt.createdAt).toBeInstanceOf(Date); + expect(appt.updatedAt).toBeInstanceOf(Date); + }); + + // TypeScript compile-time enforcement: omitting any required field produces a type error. + // The overrides parameter type is `Partial & { clientId: string; petId: string; serviceId: string; staffId: string }`. + // The test below verifies the type signature is correct by using @ts-expect-error. + it("type error when required fields are missing — compile-time enforcement", () => { + // @ts-expect-error clientId is required + buildAppointment({ petId: "p", serviceId: "s", staffId: "st" }); + // @ts-expect-error petId is required + buildAppointment({ clientId: "c", serviceId: "s", staffId: "st" }); + // @ts-expect-error serviceId is required + buildAppointment({ clientId: "c", petId: "p", staffId: "st" }); + // @ts-expect-error staffId is required + buildAppointment({ clientId: "c", petId: "p", serviceId: "s" }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ced239b..516de9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,7 +51,7 @@ importers: specifier: ^6.4.17 version: 6.4.23 '@vitest/coverage-v8': - specifier: ^3.0.4 + specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)) eslint: specifier: ^9.18.0 @@ -66,7 +66,7 @@ importers: specifier: ^8.20.0 version: 8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) vitest: - specifier: ^3.0.4 + specifier: ^3.2.4 version: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0) apps/e2e: @@ -166,6 +166,9 @@ importers: typescript: specifier: ^5.7.3 version: 5.9.3 + vitest: + specifier: ^3.0.4 + version: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0) packages/types: devDependencies: From a466053000a2e2ef96d05ebefcfcbc92514ecb3c Mon Sep 17 00:00:00 2001 From: Lint Roller Date: Sat, 21 Mar 2026 23:47:01 +0000 Subject: [PATCH 2/9] 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 From 891cc39ae141bdf3bfab23d64104fdf3183ca2bc Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Sat, 21 Mar 2026 23:47:15 +0000 Subject: [PATCH 3/9] fix: remove stale vitest entry from packages/db lockfile vitest was erroneously added to the packages/db importer in pnpm-lock.yaml during factory test setup, but packages/db/package.json does not declare vitest as a dependency. This caused CI to fail with ERR_PNPM_OUTDATED_LOCKFILE. Co-Authored-By: Paperclip --- pnpm-lock.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 516de9a..cb9f67b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,9 +166,6 @@ importers: typescript: specifier: ^5.7.3 version: 5.9.3 - vitest: - specifier: ^3.0.4 - version: 3.2.4(@types/node@22.19.15)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0) packages/types: devDependencies: From afde6b7857e04c1c9497f9db4cd881781444215e Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Sat, 21 Mar 2026 23:50:43 +0000 Subject: [PATCH 4/9] feat: unify site theming via CSS custom properties (GH #91) Replace all hardcoded brand color hex values with CSS custom properties so BrandingContext drives both the customer portal and staff site. - index.css: add derived accent/primary vars using color-mix() (--color-accent-hover, --color-accent-dark, --color-accent-light, --color-accent-lighter, --color-primary-dark); fix focus ring styles to use var(--color-primary) instead of hardcoded hex - BrandingContext.tsx: also update in sync with primaryColor so PWA theme-color tracks branding at runtime - portal/sections: replace bg-[#8b7355], text-[#6b5a42], bg-[#f0ebe4], bg-[#faf5ef], hover:bg-[#7a6549] etc. with Tailwind v4 CSS var utilities (bg-(--color-accent), text-(--color-accent-dark), etc.) - pages: replace inline style "#4f8a6f"/"#3d7a5f" with var(--color-primary) / var(--color-primary-dark) across Appointments, Book, Clients, GroupBooking, Invoices, Reports, Services, Staff, and DevSessionIndicator Closes #91 Co-Authored-By: Paperclip --- apps/web/src/BrandingContext.tsx | 8 +++++ .../src/components/DevSessionIndicator.tsx | 2 +- apps/web/src/index.css | 9 +++-- apps/web/src/pages/Appointments.tsx | 6 ++-- apps/web/src/pages/Book.tsx | 16 ++++----- apps/web/src/pages/Clients.tsx | 6 ++-- apps/web/src/pages/GroupBooking.tsx | 4 +-- apps/web/src/pages/Invoices.tsx | 4 +-- apps/web/src/pages/Reports.tsx | 2 +- apps/web/src/pages/Services.tsx | 4 +-- apps/web/src/pages/Staff.tsx | 4 +-- .../src/portal/sections/AccountSettings.tsx | 12 +++---- apps/web/src/portal/sections/Appointments.tsx | 34 +++++++++---------- .../src/portal/sections/BillingPayments.tsx | 22 ++++++------ .../web/src/portal/sections/Communication.tsx | 12 +++---- apps/web/src/portal/sections/Dashboard.tsx | 16 ++++----- apps/web/src/portal/sections/PetProfiles.tsx | 14 ++++---- apps/web/src/portal/sections/ReportCards.tsx | 16 ++++----- 18 files changed, 102 insertions(+), 89 deletions(-) diff --git a/apps/web/src/BrandingContext.tsx b/apps/web/src/BrandingContext.tsx index 00a761c..ec5fad3 100644 --- a/apps/web/src/BrandingContext.tsx +++ b/apps/web/src/BrandingContext.tsx @@ -45,6 +45,14 @@ export function BrandingProvider({ children }: { children: React.ReactNode }) { useEffect(() => { document.documentElement.style.setProperty("--color-primary", branding.primaryColor); document.documentElement.style.setProperty("--color-accent", branding.accentColor); + // Keep PWA theme-color meta tag in sync with primary color + let metaThemeColor = document.querySelector("meta[name='theme-color']"); + if (!metaThemeColor) { + metaThemeColor = document.createElement("meta"); + metaThemeColor.name = "theme-color"; + document.head.appendChild(metaThemeColor); + } + metaThemeColor.content = branding.primaryColor; }, [branding.primaryColor, branding.accentColor]); return ( diff --git a/apps/web/src/components/DevSessionIndicator.tsx b/apps/web/src/components/DevSessionIndicator.tsx index 993698d..ee66891 100644 --- a/apps/web/src/components/DevSessionIndicator.tsx +++ b/apps/web/src/components/DevSessionIndicator.tsx @@ -29,7 +29,7 @@ export function DevSessionIndicator() { @@ -374,7 +374,7 @@ export function AppointmentsPage() {
{saving ? "Saving…" diff --git a/apps/web/src/pages/Book.tsx b/apps/web/src/pages/Book.tsx index ecfd5bb..0e0710d 100644 --- a/apps/web/src/pages/Book.tsx +++ b/apps/web/src/pages/Book.tsx @@ -66,8 +66,8 @@ function StepIndicator({ step }: { step: number }) { padding: "0.5rem 0.25rem", fontSize: 12, fontWeight: active ? 700 : 400, - color: active ? "#4f8a6f" : done ? "#4f8a6f" : "#9ca3af", - borderBottom: `3px solid ${active ? "#4f8a6f" : done ? "#4f8a6f" : "#e5e7eb"}`, + color: active ? "var(--color-primary)" : done ? "var(--color-primary)" : "#9ca3af", + borderBottom: `3px solid ${active ? "var(--color-primary)" : done ? "var(--color-primary)" : "#e5e7eb"}`, }} >
-
+
{fmtPrice(svc.basePriceCents)}
{fmtDuration(svc.durationMinutes)}
@@ -349,8 +349,8 @@ export function BookPage() { style={{ padding: "0.4rem 0.85rem", borderRadius: 6, - border: `2px solid ${selectedSlot === slot ? "#4f8a6f" : "#d1d5db"}`, - background: selectedSlot === slot ? "#4f8a6f" : "#fff", + border: `2px solid ${selectedSlot === slot ? "var(--color-primary)" : "#d1d5db"}`, + background: selectedSlot === slot ? "var(--color-primary)" : "#fff", color: selectedSlot === slot ? "#fff" : "#374151", fontSize: 13, fontWeight: 500, diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index c5354d9..ba11584 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -367,7 +367,7 @@ export function ClientsPage() {

Clients

@@ -622,7 +622,7 @@ export function ClientsPage() { {clientFormError &&

{clientFormError}

}
- @@ -761,7 +761,7 @@ export function ClientsPage() { {logFormError &&

{logFormError}

}
- diff --git a/apps/web/src/pages/GroupBooking.tsx b/apps/web/src/pages/GroupBooking.tsx index 445530a..5cf8d39 100644 --- a/apps/web/src/pages/GroupBooking.tsx +++ b/apps/web/src/pages/GroupBooking.tsx @@ -287,7 +287,7 @@ function NewGroupBookingForm({ @@ -471,7 +471,7 @@ export function GroupBookingPage() { diff --git a/apps/web/src/pages/Invoices.tsx b/apps/web/src/pages/Invoices.tsx index d1c3457..5039dd3 100644 --- a/apps/web/src/pages/Invoices.tsx +++ b/apps/web/src/pages/Invoices.tsx @@ -129,7 +129,7 @@ function CreateFromAppointmentForm({ @@ -540,7 +540,7 @@ export function InvoicesPage() { diff --git a/apps/web/src/pages/Reports.tsx b/apps/web/src/pages/Reports.tsx index f7b3ceb..a3515ce 100644 --- a/apps/web/src/pages/Reports.tsx +++ b/apps/web/src/pages/Reports.tsx @@ -270,7 +270,7 @@ export function ReportsPage() { -
diff --git a/apps/web/src/pages/Services.tsx b/apps/web/src/pages/Services.tsx index eb952b1..65cf380 100644 --- a/apps/web/src/pages/Services.tsx +++ b/apps/web/src/pages/Services.tsx @@ -119,7 +119,7 @@ export function ServicesPage() {

Services

@@ -232,7 +232,7 @@ export function ServicesPage() { diff --git a/apps/web/src/pages/Staff.tsx b/apps/web/src/pages/Staff.tsx index 5e9b594..aac23a7 100644 --- a/apps/web/src/pages/Staff.tsx +++ b/apps/web/src/pages/Staff.tsx @@ -78,7 +78,7 @@ export function StaffPage() {

Staff

-
@@ -145,7 +145,7 @@ export function StaffPage() {
{formError &&

{formError}

}
- diff --git a/apps/web/src/portal/sections/AccountSettings.tsx b/apps/web/src/portal/sections/AccountSettings.tsx index 670e879..2377023 100644 --- a/apps/web/src/portal/sections/AccountSettings.tsx +++ b/apps/web/src/portal/sections/AccountSettings.tsx @@ -22,7 +22,7 @@ export function AccountSettings({ readOnly }: Props) { key={id} onClick={() => setTab(id)} className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${ - tab === id ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:bg-stone-50" + tab === id ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50" }`} > @@ -69,7 +69,7 @@ function PersonalInfo({ readOnly }: { readOnly: boolean }) {
))} {!readOnly && ( - )} @@ -103,7 +103,7 @@ function PasswordChange({ readOnly }: { readOnly: boolean }) {
-
@@ -116,7 +116,7 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
{PETS.map(pet => (
-
+
{pet.photo}
@@ -136,7 +136,7 @@ function ManagePets({ readOnly }: { readOnly: boolean }) {
))} {!readOnly && ( - @@ -165,7 +165,7 @@ function Agreements() { {new Date(agr.dateSigned).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} - + ))} diff --git a/apps/web/src/portal/sections/Appointments.tsx b/apps/web/src/portal/sections/Appointments.tsx index 7d1aa70..fdb9281 100644 --- a/apps/web/src/portal/sections/Appointments.tsx +++ b/apps/web/src/portal/sections/Appointments.tsx @@ -30,13 +30,13 @@ export function AppointmentsSection({ readOnly }: Props) {
@@ -44,7 +44,7 @@ export function AppointmentsSection({ readOnly }: Props) { {!readOnly && (
@@ -218,7 +218,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo key={pet.id} onClick={() => { setSelectedPet(pet); setStep(2); }} className={`w-full flex items-center gap-3 p-3 rounded-xl border text-left transition-colors ${ - selectedPet?.id === pet.id ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 hover:border-stone-300" + selectedPet?.id === pet.id ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 hover:border-stone-300" }`} > {pet.photo} @@ -246,7 +246,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo ); }} className={`w-full flex items-center justify-between p-3 rounded-xl border text-left ${ - selectedServices.find(s => s.id === svc.id) ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 hover:border-stone-300" + selectedServices.find(s => s.id === svc.id) ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 hover:border-stone-300" }`} >
@@ -273,7 +273,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo ); }} className={`w-full flex items-center justify-between p-2.5 rounded-lg border text-left text-sm ${ - selectedAddOns.find(s => s.id === svc.id) ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 hover:border-stone-300" + selectedAddOns.find(s => s.id === svc.id) ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 hover:border-stone-300" }`} >
@@ -291,7 +291,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo @@ -307,7 +307,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo @@ -426,7 +426,7 @@ function BookingFlow({ onClose, readOnly }: { onClose: () => void; readOnly: boo diff --git a/apps/web/src/portal/sections/BillingPayments.tsx b/apps/web/src/portal/sections/BillingPayments.tsx index be750f9..63d1240 100644 --- a/apps/web/src/portal/sections/BillingPayments.tsx +++ b/apps/web/src/portal/sections/BillingPayments.tsx @@ -38,7 +38,7 @@ export function BillingPayments({ readOnly }: Props) { > Add Tip -
@@ -57,7 +57,7 @@ export function BillingPayments({ readOnly }: Props) { key={id} onClick={() => setTab(id)} className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium ${ - tab === id ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:bg-stone-50" + tab === id ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:bg-stone-50" }`} > @@ -134,7 +134,7 @@ export function BillingPayments({ readOnly }: Props) {
))} {!readOnly && ( - @@ -145,8 +145,8 @@ export function BillingPayments({ readOnly }: Props) {
-
- +
+

Autopay

@@ -156,7 +156,7 @@ export function BillingPayments({ readOnly }: Props) { {!readOnly ? ( @@ -174,7 +174,7 @@ export function BillingPayments({ readOnly }: Props) { {PREPAID_PACKAGES.map(pkg => (
- +

{pkg.name}

@@ -184,7 +184,7 @@ export function BillingPayments({ readOnly }: Props) {
@@ -218,7 +218,7 @@ function TipModal({ onClose }: { onClose: () => void }) { key={pct} onClick={() => { setTipPercent(pct); setCustomTip(""); }} className={`flex-1 py-2 rounded-lg text-sm font-medium border ${ - tipPercent === pct ? "border-[#8b7355] bg-[#faf5ef] text-[#6b5a42]" : "border-stone-200 text-stone-600" + tipPercent === pct ? "border-(--color-accent) bg-(--color-accent-lighter) text-(--color-accent-dark)" : "border-stone-200 text-stone-600" }`} > {pct}% @@ -227,7 +227,7 @@ function TipModal({ onClose }: { onClose: () => void }) { - +
diff --git a/apps/web/src/portal/sections/Communication.tsx b/apps/web/src/portal/sections/Communication.tsx index d7fa4c9..a965054 100644 --- a/apps/web/src/portal/sections/Communication.tsx +++ b/apps/web/src/portal/sections/Communication.tsx @@ -16,7 +16,7 @@ export function Communication({ readOnly }: Props) { @@ -177,7 +177,7 @@ function NotificationPreferences({ readOnly }: { readOnly: boolean }) { onClick={() => toggle(cat.key, ch.key)} disabled={readOnly} className={`w-10 h-5 rounded-full transition-colors inline-block ${ - prefs[cat.key][ch.key] ? "bg-[#8b7355]" : "bg-stone-300" + prefs[cat.key][ch.key] ? "bg-(--color-accent)" : "bg-stone-300" } ${readOnly ? "cursor-not-allowed opacity-60" : ""}`} >
-
+
Next Appointment
@@ -71,7 +71,7 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
-
{daysUntil(nextAppt.date)}
+
{daysUntil(nextAppt.date)}
days away
@@ -103,7 +103,7 @@ export function Dashboard({ onNavigate, readOnly }: Props) { className="bg-white rounded-2xl border border-stone-200 p-4 shadow-sm text-left hover:border-stone-300 transition-colors" >
-
+
{pet.photo}
@@ -128,14 +128,14 @@ export function Dashboard({ onNavigate, readOnly }: Props) { {/* Loyalty Card */}
-
+
Loyalty Rewards

{LOYALTY.points} pts

@@ -161,7 +161,7 @@ export function Dashboard({ onNavigate, readOnly }: Props) { {!readOnly && ( @@ -176,7 +176,7 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
{recentEvents.map(evt => (
-
+
{evt.text} {formatDate(evt.date)}
@@ -184,7 +184,7 @@ export function Dashboard({ onNavigate, readOnly }: Props) {
diff --git a/apps/web/src/portal/sections/PetProfiles.tsx b/apps/web/src/portal/sections/PetProfiles.tsx index 5034be8..3f10d20 100644 --- a/apps/web/src/portal/sections/PetProfiles.tsx +++ b/apps/web/src/portal/sections/PetProfiles.tsx @@ -30,7 +30,7 @@ export function PetProfiles({ readOnly }: Props) { key={p.id} onClick={() => { setSelectedPetId(p.id); setActiveTab("info"); }} className={`flex items-center gap-3 px-4 py-3 rounded-xl border transition-colors ${ - p.id === selectedPetId ? "border-[#8b7355] bg-[#faf5ef]" : "border-stone-200 bg-white hover:border-stone-300" + p.id === selectedPetId ? "border-(--color-accent) bg-(--color-accent-lighter)" : "border-stone-200 bg-white hover:border-stone-300" }`} > {p.photo} @@ -45,7 +45,7 @@ export function PetProfiles({ readOnly }: Props) { {/* Profile Header */}
-
+
{pet.photo}
@@ -74,7 +74,7 @@ export function PetProfiles({ readOnly }: Props) { key={id} onClick={() => setActiveTab(id)} className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap ${ - activeTab === id ? "bg-[#f0ebe4] text-[#6b5a42]" : "text-stone-500 hover:text-stone-700" + activeTab === id ? "bg-(--color-accent-light) text-(--color-accent-dark)" : "text-stone-500 hover:text-stone-700" }`} > @@ -114,7 +114,7 @@ function BasicInfoTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) { {!readOnly && ( - )} @@ -148,7 +148,7 @@ function GroomingTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) { {!readOnly && ( - )} @@ -189,7 +189,7 @@ function VaccinationsTab({ pet, readOnly }: { pet: Pet; readOnly: boolean }) { {vax.documentUploaded ? ( Uploaded ) : !readOnly ? ( - @@ -226,7 +226,7 @@ function HistoryTab({ petHistory }: { petHistory: typeof PAST_APPOINTMENTS }) { {new Date(appt.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} {appt.reportCardId && ( - Report → + Report → )}
)) diff --git a/apps/web/src/portal/sections/ReportCards.tsx b/apps/web/src/portal/sections/ReportCards.tsx index dacfe60..88172ec 100644 --- a/apps/web/src/portal/sections/ReportCards.tsx +++ b/apps/web/src/portal/sections/ReportCards.tsx @@ -33,7 +33,7 @@ export function ReportCards() { className="w-full bg-white rounded-2xl border border-stone-200 p-5 shadow-sm text-left hover:border-stone-300 transition-colors" >
-
+
@@ -70,13 +70,13 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo return (
-
{/* Header */} -
+

{card.petName}'s Grooming Report

{card.beforeDescription}

-
-

After

-
+
+

After

+
Photo placeholder

{card.afterDescription}

@@ -148,7 +148,7 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo )} {/* Groomer's Note */} -
+

A Note from {card.groomerName}

"{card.groomerNote}"

@@ -161,7 +161,7 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo {new Date(card.nextRecommendedDate).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}

-
From a15585a8e6960cbed794bbffadb8e6d6dbdc1471 Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Sun, 22 Mar 2026 00:12:57 +0000 Subject: [PATCH 5/9] fix: address QA feedback on site theming PR (GH #91) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix gradient regression in ReportCards.tsx: use distinct color stops (--color-accent-lighter → --color-accent-light) to restore subtle gradient - Fix BrandingContext meta tag accumulation: cache ref with useRef instead of querying DOM on every render to prevent duplicate elements on remount - Add BrandingContext.test.tsx: verify CSS vars applied, theme-color meta created/updated, and no duplicate meta tags on rerender Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/BrandingContext.tsx | 17 +-- .../src/__tests__/BrandingContext.test.tsx | 106 ++++++++++++++++++ apps/web/src/portal/sections/ReportCards.tsx | 2 +- 3 files changed, 117 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/__tests__/BrandingContext.test.tsx diff --git a/apps/web/src/BrandingContext.tsx b/apps/web/src/BrandingContext.tsx index ec5fad3..1420e02 100644 --- a/apps/web/src/BrandingContext.tsx +++ b/apps/web/src/BrandingContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useEffect, useState, useCallback } from "react"; +import { createContext, useContext, useEffect, useRef, useState, useCallback } from "react"; export interface Branding { businessName: string; @@ -27,6 +27,7 @@ export function useBranding() { export function BrandingProvider({ children }: { children: React.ReactNode }) { const [branding, setBranding] = useState(DEFAULT_BRANDING); + const metaThemeColorRef = useRef(null); const fetchBranding = useCallback(() => { fetch("/api/branding") @@ -46,13 +47,15 @@ export function BrandingProvider({ children }: { children: React.ReactNode }) { document.documentElement.style.setProperty("--color-primary", branding.primaryColor); document.documentElement.style.setProperty("--color-accent", branding.accentColor); // Keep PWA theme-color meta tag in sync with primary color - let metaThemeColor = document.querySelector("meta[name='theme-color']"); - if (!metaThemeColor) { - metaThemeColor = document.createElement("meta"); - metaThemeColor.name = "theme-color"; - document.head.appendChild(metaThemeColor); + if (!metaThemeColorRef.current) { + metaThemeColorRef.current = document.querySelector("meta[name='theme-color']"); + if (!metaThemeColorRef.current) { + metaThemeColorRef.current = document.createElement("meta"); + metaThemeColorRef.current.name = "theme-color"; + document.head.appendChild(metaThemeColorRef.current); + } } - metaThemeColor.content = branding.primaryColor; + metaThemeColorRef.current.content = branding.primaryColor; }, [branding.primaryColor, branding.accentColor]); return ( diff --git a/apps/web/src/__tests__/BrandingContext.test.tsx b/apps/web/src/__tests__/BrandingContext.test.tsx new file mode 100644 index 0000000..2e7816c --- /dev/null +++ b/apps/web/src/__tests__/BrandingContext.test.tsx @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, waitFor } from "@testing-library/react"; +import { BrandingProvider, useBranding } from "../BrandingContext.js"; + +function BrandingConsumer() { + const { branding } = useBranding(); + return ( +
+ {branding.primaryColor} + {branding.accentColor} +
+ ); +} + +beforeEach(() => { + vi.restoreAllMocks(); + document.documentElement.style.removeProperty("--color-primary"); + document.documentElement.style.removeProperty("--color-accent"); + // Remove any theme-color meta tags + document.querySelectorAll("meta[name='theme-color']").forEach((el) => el.remove()); +}); + +describe("BrandingProvider", () => { + it("applies CSS vars to document root when branding loads", async () => { + const branding = { + businessName: "Test Salon", + primaryColor: "#123456", + accentColor: "#654321", + logoBase64: null, + logoMimeType: null, + }; + global.fetch = vi.fn(() => + Promise.resolve({ ok: true, json: async () => branding } as Response) + ) as unknown as typeof fetch; + + render( + + + + ); + + await waitFor(() => { + expect(document.documentElement.style.getPropertyValue("--color-primary")).toBe("#123456"); + expect(document.documentElement.style.getPropertyValue("--color-accent")).toBe("#654321"); + }); + }); + + it("creates and updates meta[name=theme-color]", async () => { + const branding = { + businessName: "Test Salon", + primaryColor: "#abcdef", + accentColor: "#fedcba", + logoBase64: null, + logoMimeType: null, + }; + global.fetch = vi.fn(() => + Promise.resolve({ ok: true, json: async () => branding } as Response) + ) as unknown as typeof fetch; + + render( + + + + ); + + await waitFor(() => { + const meta = document.querySelector("meta[name='theme-color']"); + expect(meta).not.toBeNull(); + expect(meta!.content).toBe("#abcdef"); + }); + }); + + it("does not create duplicate meta[name=theme-color] tags on rerender", async () => { + const branding = { + businessName: "Test Salon", + primaryColor: "#111111", + accentColor: "#222222", + logoBase64: null, + logoMimeType: null, + }; + global.fetch = vi.fn(() => + Promise.resolve({ ok: true, json: async () => branding } as Response) + ) as unknown as typeof fetch; + + const { rerender } = render( + + + + ); + + await waitFor(() => { + expect(document.querySelector("meta[name='theme-color']")).not.toBeNull(); + }); + + rerender( + + + + ); + + await waitFor(() => { + const metas = document.querySelectorAll("meta[name='theme-color']"); + expect(metas.length).toBe(1); + }); + }); +}); diff --git a/apps/web/src/portal/sections/ReportCards.tsx b/apps/web/src/portal/sections/ReportCards.tsx index 88172ec..8336285 100644 --- a/apps/web/src/portal/sections/ReportCards.tsx +++ b/apps/web/src/portal/sections/ReportCards.tsx @@ -76,7 +76,7 @@ function ReportCardDetail({ card, onBack }: { card: ReportCard; onBack: () => vo
{/* Header */} -
+

{card.petName}'s Grooming Report

+ (null); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const inputRef = useRef(null); + const dropdownRef = useRef(null); + const debounceRef = useRef | null>(null); + const navigate = useNavigate(); + + // Debounced search + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + + const trimmed = query.trim(); + if (trimmed.length === 0) { + setResults(null); + setOpen(false); + return; + } + + debounceRef.current = setTimeout(async () => { + setLoading(true); + try { + const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`); + if (res.ok) { + const data: SearchResults = await res.json(); + setResults(data); + setOpen(true); + } + } catch { + // ignore fetch errors + } finally { + setLoading(false); + } + }, 300); + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [query]); + + // Close dropdown on outside click + useEffect(() => { + function handleClick(e: MouseEvent) { + if ( + inputRef.current && + !inputRef.current.contains(e.target as Node) && + dropdownRef.current && + !dropdownRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, []); + + function handleClientClick(client: ClientResult) { + setOpen(false); + setQuery(""); + navigate("/admin/clients"); + } + + function handlePetClick(pet: PetResult) { + setOpen(false); + setQuery(""); + navigate("/admin/clients"); + } + + const hasResults = results && (results.clients.length > 0 || results.pets.length > 0); + + return ( +
+
+ + setQuery(e.target.value)} + onFocus={() => results && setOpen(true)} + style={{ + width: "100%", + boxSizing: "border-box", + height: 44, + paddingLeft: 32, + paddingRight: 12, + fontSize: 13, + border: "1px solid #e2e8f0", + borderRadius: 8, + outline: "none", + background: "#f8fafc", + color: "#1a202c", + }} + aria-label="Search clients and pets" + aria-expanded={open} + aria-haspopup="listbox" + role="combobox" + aria-autocomplete="list" + /> +
+ + {open && ( +
+ {loading && ( +
+ Searching… +
+ )} + + {!loading && !hasResults && ( +
+ No results found +
+ )} + + {!loading && results && results.clients.length > 0 && ( +
+
+ Clients +
+ {results.clients.map((client) => ( + + ))} +
+ )} + + {!loading && results && results.pets.length > 0 && ( +
+
+ Pets +
+ {results.pets.map((pet) => ( + + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 9ea804f..0ba0d5e 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -3,7 +3,7 @@ import postgres from "postgres"; import * as schema from "./schema.js"; export * from "./schema.js"; -export { and, asc, desc, eq, gte, gt, lt, lte, ne, or, sql } from "drizzle-orm"; +export { and, asc, desc, eq, gte, gt, ilike, lt, lte, ne, or, sql } from "drizzle-orm"; let _db: ReturnType | null = null; From 0c182da366c16fae669231868420f58cc86a9ea6 Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Sun, 22 Mar 2026 04:10:54 +0000 Subject: [PATCH 7/9] fix: address CTO review feedback on quick-find search (GH #97, GRO-134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused makeSelectChain function from search.test.ts (lint blocker) - Fix handleClientClick/handlePetClick to navigate to /admin/clients?highlight={id} so the target client is identified in the URL rather than silently ignored - Add console.warn for fetch errors in GlobalSearch instead of swallowing silently Auth middleware verified: searchRouter is registered on the api Hono instance which applies authMiddleware + resolveStaffMiddleware globally — no coverage gap. Co-Authored-By: Paperclip --- apps/api/src/__tests__/search.test.ts | 10 ---------- apps/web/src/components/GlobalSearch.tsx | 8 ++++---- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/apps/api/src/__tests__/search.test.ts b/apps/api/src/__tests__/search.test.ts index 979bc90..3c4ca9a 100644 --- a/apps/api/src/__tests__/search.test.ts +++ b/apps/api/src/__tests__/search.test.ts @@ -34,16 +34,6 @@ vi.mock("@groombook/db", () => { const clients = tableProxy("clients"); const pets = tableProxy("pets"); - function makeSelectChain(results: unknown[]): unknown { - const chain: Record = {}; - const terminal = () => Promise.resolve(results); - chain.from = () => chain; - chain.innerJoin = () => chain; - chain.where = () => chain; - chain.limit = terminal; - return chain; - } - return { getDb: () => ({ select: (_fields?: unknown) => { diff --git a/apps/web/src/components/GlobalSearch.tsx b/apps/web/src/components/GlobalSearch.tsx index 877c807..8971fde 100644 --- a/apps/web/src/components/GlobalSearch.tsx +++ b/apps/web/src/components/GlobalSearch.tsx @@ -52,8 +52,8 @@ export function GlobalSearch() { setResults(data); setOpen(true); } - } catch { - // ignore fetch errors + } catch (err) { + console.warn("GlobalSearch: fetch error", err); } finally { setLoading(false); } @@ -83,13 +83,13 @@ export function GlobalSearch() { function handleClientClick(client: ClientResult) { setOpen(false); setQuery(""); - navigate("/admin/clients"); + navigate(`/admin/clients?highlight=${client.id}`); } function handlePetClick(pet: PetResult) { setOpen(false); setQuery(""); - navigate("/admin/clients"); + navigate(`/admin/clients?highlight=${pet.clientId}`); } const hasResults = results && (results.clients.length > 0 || results.pets.length > 0); From 355f11fdaa25d2b363b01005e025dadfdecc8667 Mon Sep 17 00:00:00 2001 From: Lint Roller Date: Sun, 22 Mar 2026 11:36:07 +0000 Subject: [PATCH 8/9] 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 From b3514626a17c5e242f703856448a9f6ef8497bff Mon Sep 17 00:00:00 2001 From: Lint Roller Date: Sun, 22 Mar 2026 11:42:32 +0000 Subject: [PATCH 9/9] 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");