From d85e09cb11e3e7b5abb501a31559717273ad85f7 Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Fri, 20 Mar 2026 22:30:00 +0000 Subject: [PATCH 1/3] test: add unit tests for email service, clients route, and ImpersonationBanner - Email service: 16 tests covering buildConfirmationEmail and buildReminderEmail (recipient, subject, body content, groomer presence/absence, reminder timing) - Clients route: 17 tests covering CRUD endpoints including validation, 404 handling, soft-disable (disabledAt), and confirm-required delete - ImpersonationBanner: 8 tests covering render, session expiry auto-end, Extend button visibility, and End/Audit button callbacks Part of GRO-76 (Phase 1 unit/integration tests). Co-Authored-By: Paperclip --- apps/api/src/__tests__/clients.test.ts | 279 ++++++++++++++++++ apps/api/src/__tests__/email.test.ts | 106 +++++++ .../__tests__/ImpersonationBanner.test.tsx | 154 ++++++++++ 3 files changed, 539 insertions(+) create mode 100644 apps/api/src/__tests__/clients.test.ts create mode 100644 apps/api/src/__tests__/email.test.ts create mode 100644 apps/web/src/__tests__/ImpersonationBanner.test.tsx diff --git a/apps/api/src/__tests__/clients.test.ts b/apps/api/src/__tests__/clients.test.ts new file mode 100644 index 0000000..f1b8e0e --- /dev/null +++ b/apps/api/src/__tests__/clients.test.ts @@ -0,0 +1,279 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// ─── Mock data ──────────────────────────────────────────────────────────────── + +const ACTIVE_CLIENT = { + id: "client-uuid-1", + name: "Alice", + email: "alice@example.com", + phone: "555-1234", + address: "1 Main St", + notes: null, + status: "active", + disabledAt: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const DISABLED_CLIENT = { + ...ACTIVE_CLIENT, + id: "client-uuid-2", + name: "Bob", + status: "disabled", + disabledAt: new Date(), +}; + +// ─── Queue-based mock DB ────────────────────────────────────────────────────── + +let selectRows: unknown[] = []; +let insertedValues: Record[] = []; +let updatedValues: Record[] = []; +let deletedId: string | null = null; + +function resetMock() { + selectRows = []; + insertedValues = []; + updatedValues = []; + deletedId = null; +} + +vi.mock("@groombook/db", () => { + function makeChainable(data: unknown[]): unknown { + const arr = [...data]; + const chain = new Proxy(arr, { + get(target, prop) { + if (prop === "where" || prop === "orderBy" || prop === "limit") { + return () => chain; + } + // @ts-expect-error proxy + return target[prop]; + }, + }); + return chain; + } + + const clients = new Proxy( + { _name: "clients" }, + { get: (t, p) => (p === "_name" ? "clients" : { table: "clients", column: p }) } + ); + + return { + getDb: () => ({ + select: () => ({ + from: () => makeChainable(selectRows), + }), + insert: () => ({ + values: (vals: Record) => { + insertedValues.push(vals); + return { + returning: () => [{ ...ACTIVE_CLIENT, ...vals, id: "client-uuid-new" }], + }; + }, + }), + update: () => ({ + set: (vals: Record) => ({ + where: () => { + updatedValues.push(vals); + return { + returning: () => + selectRows.length > 0 + ? [{ ...selectRows[0], ...vals }] + : [], + }; + }, + }), + }), + delete: () => ({ + where: () => { + deletedId = "client-uuid-1"; + return { + returning: () => + selectRows.length > 0 ? [selectRows[0]] : [], + }; + }, + }), + }), + clients, + eq: vi.fn(), + and: vi.fn(), + }; +}); + +// ─── App setup ──────────────────────────────────────────────────────────────── + +const { clientsRouter } = await import("../routes/clients.js"); + +const app = new Hono(); +app.route("/clients", clientsRouter); + +function jsonRequest(method: string, path: string, body?: unknown) { + return app.request(path, { + method, + headers: { "Content-Type": "application/json" }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); +} + +beforeEach(() => resetMock()); + +// ─── GET / ──────────────────────────────────────────────────────────────────── + +describe("GET /clients", () => { + it("returns active clients", async () => { + selectRows = [ACTIVE_CLIENT]; + const res = await app.request("/clients"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body).toHaveLength(1); + }); + + it("returns all clients when includeDisabled=true", async () => { + selectRows = [ACTIVE_CLIENT, DISABLED_CLIENT]; + const res = await app.request("/clients?includeDisabled=true"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveLength(2); + }); + + it("returns empty array when no clients exist", async () => { + selectRows = []; + const res = await app.request("/clients"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual([]); + }); +}); + +// ─── GET /:id ───────────────────────────────────────────────────────────────── + +describe("GET /clients/:id", () => { + it("returns a single client", async () => { + selectRows = [ACTIVE_CLIENT]; + const res = await app.request("/clients/client-uuid-1"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.id).toBe("client-uuid-1"); + expect(body.name).toBe("Alice"); + }); + + it("returns 404 for a nonexistent client", async () => { + selectRows = []; + const res = await app.request("/clients/nonexistent"); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error).toMatch(/not found/i); + }); +}); + +// ─── POST / ─────────────────────────────────────────────────────────────────── + +describe("POST /clients", () => { + it("creates a client with valid data", async () => { + const res = await jsonRequest("POST", "/clients", { + name: "Charlie", + email: "charlie@example.com", + }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.name).toBe("Charlie"); + expect(insertedValues).toHaveLength(1); + expect(insertedValues[0]!.name).toBe("Charlie"); + }); + + it("creates a client with only required name field", async () => { + const res = await jsonRequest("POST", "/clients", { name: "Dana" }); + expect(res.status).toBe(201); + expect(insertedValues[0]!.name).toBe("Dana"); + }); + + it("rejects empty name", async () => { + const res = await jsonRequest("POST", "/clients", { name: "" }); + expect(res.status).toBe(400); + }); + + it("rejects invalid email format", async () => { + const res = await jsonRequest("POST", "/clients", { + name: "Eve", + email: "not-an-email", + }); + expect(res.status).toBe(400); + }); + + it("rejects missing body", async () => { + const res = await app.request("/clients", { method: "POST" }); + expect(res.status).toBe(400); + }); +}); + +// ─── PATCH /:id ─────────────────────────────────────────────────────────────── + +describe("PATCH /clients/:id", () => { + it("updates client fields", async () => { + selectRows = [ACTIVE_CLIENT]; + const res = await jsonRequest("PATCH", "/clients/client-uuid-1", { + name: "Alice Updated", + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.name).toBe("Alice Updated"); + expect(updatedValues[0]!.name).toBe("Alice Updated"); + }); + + it("sets disabledAt when status is set to disabled", async () => { + selectRows = [ACTIVE_CLIENT]; + await jsonRequest("PATCH", "/clients/client-uuid-1", { + status: "disabled", + }); + expect(updatedValues[0]!.status).toBe("disabled"); + expect(updatedValues[0]!.disabledAt).toBeDefined(); + }); + + it("clears disabledAt when re-enabling", async () => { + selectRows = [DISABLED_CLIENT]; + await jsonRequest("PATCH", "/clients/client-uuid-2", { + status: "active", + }); + expect(updatedValues[0]!.disabledAt).toBeNull(); + }); + + it("returns 404 when client not found", async () => { + selectRows = []; + const res = await jsonRequest("PATCH", "/clients/nonexistent", { + name: "Ghost", + }); + expect(res.status).toBe(404); + }); +}); + +// ─── DELETE /:id ────────────────────────────────────────────────────────────── + +describe("DELETE /clients/:id", () => { + it("requires ?confirm=true", async () => { + const res = await app.request("/clients/client-uuid-1", { + method: "DELETE", + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/confirm/i); + }); + + it("deletes a client with ?confirm=true", async () => { + selectRows = [ACTIVE_CLIENT]; + const res = await app.request("/clients/client-uuid-1?confirm=true", { + method: "DELETE", + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + }); + + it("returns 404 when client not found", async () => { + selectRows = []; + const res = await app.request("/clients/nonexistent?confirm=true", { + method: "DELETE", + }); + expect(res.status).toBe(404); + }); +}); diff --git a/apps/api/src/__tests__/email.test.ts b/apps/api/src/__tests__/email.test.ts new file mode 100644 index 0000000..6ff56de --- /dev/null +++ b/apps/api/src/__tests__/email.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from "vitest"; +import { + buildConfirmationEmail, + buildReminderEmail, +} from "../services/email.js"; + +const START = new Date("2026-03-25T15:00:00Z"); + +const BASE = { + clientName: "Jane Doe", + petName: "Biscuit", + serviceName: "Full Groom", + groomerName: "Alex", + startTime: START, +}; + +describe("buildConfirmationEmail", () => { + it("addresses the correct recipient", () => { + const mail = buildConfirmationEmail("jane@example.com", BASE); + expect(mail.to).toBe("jane@example.com"); + }); + + it("includes the pet name in the subject", () => { + const mail = buildConfirmationEmail("jane@example.com", BASE); + expect(mail.subject).toContain("Biscuit"); + }); + + it("includes confirmation wording in subject", () => { + const mail = buildConfirmationEmail("jane@example.com", BASE); + expect(mail.subject).toMatch(/confirmed/i); + }); + + it("includes client name in the plain text body", () => { + const mail = buildConfirmationEmail("jane@example.com", BASE); + expect(mail.text).toContain("Jane Doe"); + }); + + it("includes service name in plain text body", () => { + const mail = buildConfirmationEmail("jane@example.com", BASE); + expect(mail.text).toContain("Full Groom"); + }); + + it("includes groomer name when provided", () => { + const mail = buildConfirmationEmail("jane@example.com", BASE); + expect(mail.text).toContain("Alex"); + }); + + it("omits groomer when groomerName is null", () => { + const mail = buildConfirmationEmail("jane@example.com", { + ...BASE, + groomerName: null, + }); + expect(mail.text).not.toContain("with "); + }); + + it("includes HTML body", () => { + const mail = buildConfirmationEmail("jane@example.com", BASE); + expect(mail.html).toBeTruthy(); + expect(mail.html).toContain("Biscuit"); + }); +}); + +describe("buildReminderEmail", () => { + it("addresses the correct recipient", () => { + const mail = buildReminderEmail("jane@example.com", BASE, 24); + expect(mail.to).toBe("jane@example.com"); + }); + + it("says 'tomorrow' for 24-hour reminder", () => { + const mail = buildReminderEmail("jane@example.com", BASE, 24); + expect(mail.subject).toContain("tomorrow"); + expect(mail.text).toContain("tomorrow"); + }); + + it("says 'in X hours' for sub-24-hour reminders", () => { + const mail = buildReminderEmail("jane@example.com", BASE, 2); + expect(mail.subject).toContain("in 2 hours"); + expect(mail.text).toContain("in 2 hours"); + }); + + it("includes pet name in subject", () => { + const mail = buildReminderEmail("jane@example.com", BASE, 24); + expect(mail.subject).toContain("Biscuit"); + }); + + it("includes service name in plain text body", () => { + const mail = buildReminderEmail("jane@example.com", BASE, 24); + expect(mail.text).toContain("Full Groom"); + }); + + it("includes groomer name when provided", () => { + const mail = buildReminderEmail("jane@example.com", BASE, 24); + expect(mail.text).toContain("Alex"); + }); + + it("omits groomer when groomerName is null", () => { + const mail = buildReminderEmail("jane@example.com", { ...BASE, groomerName: null }, 24); + expect(mail.text).not.toContain("with "); + }); + + it("includes HTML body", () => { + const mail = buildReminderEmail("jane@example.com", BASE, 24); + expect(mail.html).toBeTruthy(); + expect(mail.html).toContain("Biscuit"); + }); +}); diff --git a/apps/web/src/__tests__/ImpersonationBanner.test.tsx b/apps/web/src/__tests__/ImpersonationBanner.test.tsx new file mode 100644 index 0000000..988afbc --- /dev/null +++ b/apps/web/src/__tests__/ImpersonationBanner.test.tsx @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { ImpersonationBanner } from "../portal/ImpersonationBanner.js"; +import type { ImpersonationSession } from "../portal/mockData.js"; + +function makeSession(overrides: Partial = {}): ImpersonationSession { + const now = new Date(); + const expires = new Date(now.getTime() + 30 * 60 * 1000); // 30 min from now + return { + active: true, + staffName: "Jordan", + staffRole: "manager", + customerName: "Sarah Mitchell", + reason: "Customer requested help", + startedAt: now.toISOString(), + expiresAt: expires.toISOString(), + extended: false, + readOnly: true, + auditLog: [], + ...overrides, + }; +} + +describe("ImpersonationBanner", () => { + const onEnd = vi.fn(); + const onExtend = vi.fn(); + const onShowAudit = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders banner when session is active", () => { + render( + + ); + expect(screen.getByText(/STAFF VIEW/)).toBeInTheDocument(); + }); + + it("shows the customer name", () => { + render( + + ); + expect(screen.getByText("Sarah Mitchell")).toBeInTheDocument(); + }); + + it("returns null when session is not active", () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + it("calls onEnd when End Session is clicked", () => { + render( + + ); + fireEvent.click(screen.getByRole("button", { name: /end session/i })); + expect(onEnd).toHaveBeenCalledTimes(1); + }); + + it("calls onShowAudit when Audit is clicked", () => { + render( + + ); + fireEvent.click(screen.getByRole("button", { name: /audit/i })); + expect(onShowAudit).toHaveBeenCalledTimes(1); + }); + + it("calls onEnd automatically when session expires", async () => { + const expiredSoon = new Date(Date.now() + 500); + const session = makeSession({ expiresAt: expiredSoon.toISOString() }); + + render( + + ); + + // Advance past expiry + await act(async () => { + vi.advanceTimersByTime(2000); + }); + + expect(onEnd).toHaveBeenCalled(); + }); + + it("shows Extend button when warning is active and session not yet extended", () => { + // Set expiry to 3 min from now — within warning threshold (< 5 min) + const expiresAt = new Date(Date.now() + 3 * 60 * 1000).toISOString(); + render( + + ); + // Tick the timer once to trigger showWarning + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(screen.getByRole("button", { name: /extend/i })).toBeInTheDocument(); + }); + + it("does not show Extend button when already extended", () => { + const expiresAt = new Date(Date.now() + 3 * 60 * 1000).toISOString(); + render( + + ); + act(() => { + vi.advanceTimersByTime(1000); + }); + expect(screen.queryByRole("button", { name: /extend/i })).not.toBeInTheDocument(); + }); +}); From d4629baaea04e8550899dc17e3802e4d1e50cfb0 Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Sat, 21 Mar 2026 01:50:51 +0000 Subject: [PATCH 2/3] fix: update ImpersonationBanner tests to match current component API - Import ImpersonationSession from @groombook/types (component was updated in #78) - Remove stale tests: "shows customer name" and "returns null when inactive" (component no longer renders customer name or checks session.active) - Add isExtended prop to all render calls (component now takes isExtended as prop) - Fix "does not show Extend button when already extended" to pass isExtended={true} instead of session.extended (prop was extracted from session in #78) - Fix clients.test.ts: selectRows typed as Record[] to allow spread in returning() callbacks (resolves TS2698) Co-Authored-By: Paperclip --- apps/api/src/__tests__/clients.test.ts | 2 +- .../__tests__/ImpersonationBanner.test.tsx | 49 ++++++------------- 2 files changed, 16 insertions(+), 35 deletions(-) diff --git a/apps/api/src/__tests__/clients.test.ts b/apps/api/src/__tests__/clients.test.ts index f1b8e0e..90a4d69 100644 --- a/apps/api/src/__tests__/clients.test.ts +++ b/apps/api/src/__tests__/clients.test.ts @@ -26,7 +26,7 @@ const DISABLED_CLIENT = { // ─── Queue-based mock DB ────────────────────────────────────────────────────── -let selectRows: unknown[] = []; +let selectRows: Record[] = []; let insertedValues: Record[] = []; let updatedValues: Record[] = []; let deletedId: string | null = null; diff --git a/apps/web/src/__tests__/ImpersonationBanner.test.tsx b/apps/web/src/__tests__/ImpersonationBanner.test.tsx index 988afbc..4dca0d6 100644 --- a/apps/web/src/__tests__/ImpersonationBanner.test.tsx +++ b/apps/web/src/__tests__/ImpersonationBanner.test.tsx @@ -1,22 +1,21 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, act } from "@testing-library/react"; import { ImpersonationBanner } from "../portal/ImpersonationBanner.js"; -import type { ImpersonationSession } from "../portal/mockData.js"; +import type { ImpersonationSession } from "@groombook/types"; function makeSession(overrides: Partial = {}): ImpersonationSession { const now = new Date(); const expires = new Date(now.getTime() + 30 * 60 * 1000); // 30 min from now return { - active: true, - staffName: "Jordan", - staffRole: "manager", - customerName: "Sarah Mitchell", + id: "session-uuid-1", + staffId: "staff-uuid-1", + clientId: "client-uuid-1", reason: "Customer requested help", + status: "active", startedAt: now.toISOString(), + endedAt: null, expiresAt: expires.toISOString(), - extended: false, - readOnly: true, - auditLog: [], + createdAt: now.toISOString(), ...overrides, }; } @@ -39,6 +38,7 @@ describe("ImpersonationBanner", () => { render( { expect(screen.getByText(/STAFF VIEW/)).toBeInTheDocument(); }); - it("shows the customer name", () => { - render( - - ); - expect(screen.getByText("Sarah Mitchell")).toBeInTheDocument(); - }); - - it("returns null when session is not active", () => { - const { container } = render( - - ); - expect(container.firstChild).toBeNull(); - }); - it("calls onEnd when End Session is clicked", () => { render( { render( { render( { const expiresAt = new Date(Date.now() + 3 * 60 * 1000).toISOString(); render( { const expiresAt = new Date(Date.now() + 3 * 60 * 1000).toISOString(); render( Date: Sat, 21 Mar 2026 01:52:18 +0000 Subject: [PATCH 3/3] fix: assert on deletedId in DELETE test to resolve unused-vars lint error Co-Authored-By: Paperclip --- apps/api/src/__tests__/clients.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/src/__tests__/clients.test.ts b/apps/api/src/__tests__/clients.test.ts index 90a4d69..38a2886 100644 --- a/apps/api/src/__tests__/clients.test.ts +++ b/apps/api/src/__tests__/clients.test.ts @@ -267,6 +267,7 @@ describe("DELETE /clients/:id", () => { expect(res.status).toBe(200); const body = await res.json(); expect(body.ok).toBe(true); + expect(deletedId).toBe("client-uuid-1"); }); it("returns 404 when client not found", async () => {