test: Phase 1 unit tests for API routes and web components #85
@@ -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<string, unknown>[] = [];
|
||||
let insertedValues: Record<string, unknown>[] = [];
|
||||
let updatedValues: Record<string, unknown>[] = [];
|
||||
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<string, unknown>) => {
|
||||
insertedValues.push(vals);
|
||||
return {
|
||||
returning: () => [{ ...ACTIVE_CLIENT, ...vals, id: "client-uuid-new" }],
|
||||
};
|
||||
},
|
||||
}),
|
||||
update: () => ({
|
||||
set: (vals: Record<string, unknown>) => ({
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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(
|
||||
<ImpersonationBanner
|
||||
session={makeSession()}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/STAFF VIEW/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onEnd when End Session is clicked", () => {
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={makeSession()}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /end session/i }));
|
||||
expect(onEnd).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onShowAudit when Audit is clicked", () => {
|
||||
render(
|
||||
<ImpersonationBanner
|
||||
session={makeSession()}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
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(
|
||||
<ImpersonationBanner
|
||||
session={session}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
|
||||
// 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(
|
||||
<ImpersonationBanner
|
||||
session={makeSession({ expiresAt })}
|
||||
isExtended={false}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
// 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(
|
||||
<ImpersonationBanner
|
||||
session={makeSession({ expiresAt })}
|
||||
isExtended={true}
|
||||
onEnd={onEnd}
|
||||
onExtend={onExtend}
|
||||
onShowAudit={onShowAudit}
|
||||
/>
|
||||
);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: /extend/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user