This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
app/apps/api/src/__tests__/search.test.ts
T
Scrubs McBarkley c826f65bd6 feat: quick-find search for clients and pets (GH #97, GRO-140)
Backend:
- GET /api/search?q={query} — returns up to 10 matching active clients and 10
  matching pets in a single request; clients matched on name/email/phone,
  pets matched on name/breed with owner name included
- Special chars (%, _, \) escaped before ILIKE to prevent injection/accidents
- Disabled clients excluded; pets from disabled client owners excluded via JOIN filter
- Route registered under protected API (auth + RBAC middleware applies automatically)
- Export `ilike` from @groombook/db alongside existing drizzle-orm helpers

Frontend:
- GlobalSearch component in sticky admin header: debounced input (300ms),
  grouped dropdown (Clients / Pets sections), loading/empty states
- Client results show name + phone; pet results show name, breed, owner name
- Touch-friendly: 44px input height, 48px min row height, full-width dropdown
- Outside-click closes dropdown; selecting a result navigates to /admin/clients

Tests (apps/api/src/__tests__/search.test.ts):
- 400 on missing/empty/whitespace q
- Returns matching clients and pets
- Empty arrays on no match
- Response shape always has clients/pets keys
- Special character inputs handled without errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:16:28 +00:00

173 lines
5.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { Hono } from "hono";
// ─── Mock data ────────────────────────────────────────────────────────────────
const ACTIVE_CLIENT = {
id: "client-1",
name: "Alice Johnson",
email: "alice@example.com",
phone: "555-1234",
};
const PET_ROW = {
id: "pet-1",
name: "Bella",
breed: "Golden Retriever",
clientId: "client-1",
ownerName: "Alice Johnson",
};
// ─── Mock DB ──────────────────────────────────────────────────────────────────
let clientResults: typeof ACTIVE_CLIENT[] = [];
let petResults: typeof PET_ROW[] = [];
vi.mock("@groombook/db", () => {
// Proxy objects for table/column references — values don't matter for tests
const tableProxy = (name: string) =>
new Proxy(
{ _name: name },
{ get: (t, p) => (p === "_name" ? name : { table: name, column: p }) }
);
const clients = tableProxy("clients");
const pets = tableProxy("pets");
function makeSelectChain(results: unknown[]): unknown {
const chain: Record<string, unknown> = {};
const terminal = () => Promise.resolve(results);
chain.from = () => chain;
chain.innerJoin = () => chain;
chain.where = () => chain;
chain.limit = terminal;
return chain;
}
return {
getDb: () => ({
select: (_fields?: unknown) => {
// Route which mock results to use based on a global flag set per test
return {
from: (table: { _name?: string }) => {
const results = table._name === "pets" ? petResults : clientResults;
const chain: Record<string, unknown> = {};
chain.where = () => chain;
chain.innerJoin = () => chain;
chain.limit = () => Promise.resolve(results);
return chain;
},
};
},
}),
clients,
pets,
and: (...args: unknown[]) => ({ and: args }),
or: (...args: unknown[]) => ({ or: args }),
eq: (a: unknown, b: unknown) => ({ eq: [a, b] }),
ilike: (col: unknown, pat: unknown) => ({ ilike: [col, pat] }),
};
});
// ─── App under test ───────────────────────────────────────────────────────────
async function makeApp() {
const { searchRouter } = await import("../routes/search.js");
const app = new Hono();
app.route("/search", searchRouter);
return app;
}
// ─── Tests ────────────────────────────────────────────────────────────────────
beforeEach(() => {
vi.resetModules();
clientResults = [];
petResults = [];
});
describe("GET /search", () => {
it("returns 400 when q is missing", async () => {
const app = await makeApp();
const res = await app.request("/search");
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toBeTruthy();
});
it("returns 400 when q is empty string", async () => {
const app = await makeApp();
const res = await app.request("/search?q=");
expect(res.status).toBe(400);
});
it("returns 400 when q is only whitespace", async () => {
const app = await makeApp();
const res = await app.request("/search?q= ");
expect(res.status).toBe(400);
});
it("returns matching clients and pets", async () => {
clientResults = [ACTIVE_CLIENT];
petResults = [PET_ROW];
const app = await makeApp();
const res = await app.request("/search?q=bell");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.clients).toEqual([ACTIVE_CLIENT]);
expect(body.pets).toEqual([PET_ROW]);
});
it("returns empty arrays when no matches", async () => {
clientResults = [];
petResults = [];
const app = await makeApp();
const res = await app.request("/search?q=xyzzy");
expect(res.status).toBe(200);
const body = await res.json();
expect(body.clients).toEqual([]);
expect(body.pets).toEqual([]);
});
it("returns shape with clients and pets keys", async () => {
const app = await makeApp();
const res = await app.request("/search?q=a");
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveProperty("clients");
expect(body).toHaveProperty("pets");
expect(Array.isArray(body.clients)).toBe(true);
expect(Array.isArray(body.pets)).toBe(true);
});
it("handles special characters in query without throwing", async () => {
clientResults = [];
petResults = [];
const app = await makeApp();
// These characters should be escaped, not cause errors
const res = await app.request("/search?q=foo%25bar_baz");
expect(res.status).toBe(200);
});
});
describe("escapeLike helper (via integration)", () => {
it("% in query does not break the request", async () => {
clientResults = [];
petResults = [];
const app = await makeApp();
const res = await app.request("/search?q=%25");
expect(res.status).toBe(200);
});
it("_ in query does not break the request", async () => {
clientResults = [];
petResults = [];
const app = await makeApp();
const res = await app.request("/search?q=_");
expect(res.status).toBe(200);
});
});