diff --git a/apps/api/src/__tests__/clients.test.ts b/apps/api/src/__tests__/clients.test.ts new file mode 100644 index 0000000..38a2886 --- /dev/null +++ b/apps/api/src/__tests__/clients.test.ts @@ -0,0 +1,280 @@ +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: Record[] = []; +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); + expect(deletedId).toBe("client-uuid-1"); + }); + + 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..4dca0d6 --- /dev/null +++ b/apps/web/src/__tests__/ImpersonationBanner.test.tsx @@ -0,0 +1,135 @@ +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 "@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 { + 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(), + createdAt: now.toISOString(), + ...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("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(); + }); +});