Merge branch 'main' into feat/pet-photo-upload-gh93

This commit is contained in:
groombook-ceo[bot]
2026-03-22 19:45:35 +00:00
committed by GitHub
32 changed files with 1121 additions and 96 deletions
+216
View File
@@ -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<AppointmentRow> & { 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" });
});
});
+162
View File
@@ -0,0 +1,162 @@
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");
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);
});
});
+2
View File
@@ -14,6 +14,7 @@ import { appointmentGroupsRouter } from "./routes/appointmentGroups.js";
import { groomingLogsRouter } from "./routes/groomingLogs.js";
import { impersonationRouter } from "./routes/impersonation.js";
import { settingsRouter } from "./routes/settings.js";
import { searchRouter } from "./routes/search.js";
import { getDb, businessSettings } from "@groombook/db";
import { authMiddleware } from "./middleware/auth.js";
import { resolveStaffMiddleware, requireRole } from "./middleware/rbac.js";
@@ -113,6 +114,7 @@ api.route("/appointment-groups", appointmentGroupsRouter);
api.route("/grooming-logs", groomingLogsRouter);
api.route("/impersonation", impersonationRouter);
api.route("/admin/settings", settingsRouter);
api.route("/search", searchRouter);
const port = Number(process.env.PORT ?? 3000);
console.log(`API server listening on port ${port}`);
+70
View File
@@ -0,0 +1,70 @@
import { Hono } from "hono";
import { and, eq, getDb, clients, ilike, or, pets } from "@groombook/db";
export const searchRouter = new Hono();
const LIMIT = 10;
/** Escape %, _, and \ in user input before wrapping with ILIKE wildcards. */
function escapeLike(s: string): string {
return `%${s.replace(/[%_\\]/g, "\\$&")}%`;
}
/**
* GET /api/search?q={query}
*
* Returns up to 10 matching active clients and up to 10 matching pets.
* Clients are matched on name, email, or phone.
* Pets are matched on name or breed; includes owner name.
*/
searchRouter.get("/", async (c) => {
const q = c.req.query("q");
if (!q || q.trim().length === 0) {
return c.json({ error: "Query parameter q is required" }, 400);
}
const pattern = escapeLike(q.trim());
const db = getDb();
const [matchingClients, matchingPets] = await Promise.all([
db
.select({
id: clients.id,
name: clients.name,
email: clients.email,
phone: clients.phone,
})
.from(clients)
.where(
and(
eq(clients.status, "active"),
or(
ilike(clients.name, pattern),
ilike(clients.email, pattern),
ilike(clients.phone, pattern)
)
)
)
.limit(LIMIT),
db
.select({
id: pets.id,
name: pets.name,
breed: pets.breed,
clientId: pets.clientId,
ownerName: clients.name,
})
.from(pets)
.innerJoin(clients, and(eq(pets.clientId, clients.id), eq(clients.status, "active")))
.where(
or(
ilike(pets.name, pattern),
ilike(pets.breed, pattern)
)
)
.limit(LIMIT),
]);
return c.json({ clients: matchingClients, pets: matchingPets });
});