05cb91a13e
1. Fix vi.mock factory: importOriginal -> db.and/eq/exists/or stubs (removes ReferenceError from undeclared imports in test) 2. Remove MedicalAlert.id — not in schema/migration/DB, only in types 3. Replace z.string().max(100) coatType with z.enum for CoatType union 4. Fix test expecting coatType "smooth" (invalid) -> "double" (valid) 5. Add TC-API-3.8 through TC-API-3.15 to UAT_PLAYBOOK.md §4.3 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
415 lines
13 KiB
TypeScript
415 lines
13 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||
import { Hono } from "hono";
|
||
import type { AppEnv, StaffRow } from "../middleware/rbac.js";
|
||
import { petsRouter } from "../routes/pets.js";
|
||
|
||
// ─── Mock staff fixtures ──────────────────────────────────────────────────────
|
||
|
||
const MANAGER: StaffRow = {
|
||
id: "staff-manager-id",
|
||
oidcSub: "oidc-manager-sub",
|
||
userId: null,
|
||
role: "manager",
|
||
isSuperUser: true,
|
||
name: "Manager McManager",
|
||
email: "manager@example.com",
|
||
active: true,
|
||
icalToken: null,
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
};
|
||
|
||
// ─── Mutable mock state ───────────────────────────────────────────────────────
|
||
|
||
const CLIENT_ID = "a0000000-0000-4000-8000-000000000001";
|
||
const PET_ID = "b0000000-0000-4000-8000-000000000002";
|
||
|
||
let petRows: Record<string, unknown>[] = [];
|
||
let appointmentRows: Record<string, unknown>[] = [];
|
||
let insertedValues: Record<string, unknown>[] = [];
|
||
let updatedValues: Record<string, unknown>[] = [];
|
||
let deletedId: string | null = null;
|
||
|
||
function resetMock() {
|
||
petRows = [{
|
||
id: PET_ID,
|
||
clientId: CLIENT_ID,
|
||
name: "Biscuit",
|
||
species: "dog",
|
||
breed: "Golden Retriever",
|
||
weightKg: "30.00",
|
||
dateOfBirth: null,
|
||
healthAlerts: null,
|
||
groomingNotes: null,
|
||
cutStyle: null,
|
||
shampooPreference: null,
|
||
specialCareNotes: null,
|
||
customFields: {},
|
||
photoKey: null,
|
||
photoUploadedAt: null,
|
||
image: null,
|
||
coatType: null,
|
||
temperamentScore: null,
|
||
temperamentFlags: [],
|
||
medicalAlerts: [],
|
||
preferredCuts: [],
|
||
createdAt: new Date(),
|
||
updatedAt: new Date(),
|
||
}];
|
||
appointmentRows = [];
|
||
insertedValues = [];
|
||
updatedValues = [];
|
||
deletedId = null;
|
||
}
|
||
|
||
function makeSelectChainable(rows: unknown[]): unknown {
|
||
const chain = new Proxy([...rows], {
|
||
get(target, prop) {
|
||
if (prop === "where" || prop === "orderBy" || prop === "limit") {
|
||
return () => chain;
|
||
}
|
||
// @ts-expect-error proxy
|
||
return target[prop];
|
||
},
|
||
});
|
||
return chain;
|
||
}
|
||
|
||
function makeInsertChainable(): unknown {
|
||
let vals: Record<string, unknown> = {};
|
||
const chain = new Proxy({}, {
|
||
get(target, prop) {
|
||
if (prop === "values") {
|
||
return (v: Record<string, unknown>) => { vals = v; return chain; };
|
||
}
|
||
if (prop === "returning") {
|
||
return () => {
|
||
insertedValues.push(vals);
|
||
return [vals.id ? { ...vals, id: vals.id ?? PET_ID } : { ...vals, id: PET_ID }];
|
||
};
|
||
}
|
||
return chain;
|
||
},
|
||
});
|
||
return chain;
|
||
}
|
||
|
||
function makeUpdateChainable(): unknown {
|
||
let vals: Record<string, unknown> = {};
|
||
let whereId: string | null = null;
|
||
const chain = new Proxy({}, {
|
||
get(target, prop) {
|
||
if (prop === "set") {
|
||
return (v: Record<string, unknown>) => { vals = v; return chain; };
|
||
}
|
||
if (prop === "where") {
|
||
return (cond: unknown) => {
|
||
// Extract id from condition if it's an eq call
|
||
if (whereId) vals = { ...vals };
|
||
return chain;
|
||
};
|
||
}
|
||
if (prop === "returning") {
|
||
return () => {
|
||
const merged = { ...petRows[0], ...vals };
|
||
updatedValues.push(vals);
|
||
return [merged];
|
||
};
|
||
}
|
||
return chain;
|
||
},
|
||
});
|
||
return chain;
|
||
}
|
||
|
||
function makeDeleteChainable(): unknown {
|
||
let whereId: string | null = null;
|
||
const chain = new Proxy({}, {
|
||
get(target, prop) {
|
||
if (prop === "where") {
|
||
return (cond: unknown) => {
|
||
whereId = PET_ID;
|
||
return chain;
|
||
};
|
||
}
|
||
if (prop === "returning") {
|
||
return () => {
|
||
const row = petRows[0];
|
||
deletedId = row.id as string;
|
||
return [row];
|
||
};
|
||
}
|
||
return chain;
|
||
},
|
||
});
|
||
return chain;
|
||
}
|
||
|
||
vi.mock("../db", async (importOriginal) => {
|
||
const db = await importOriginal<typeof import("../db/index.js")>();
|
||
const pets = new Proxy({ _name: "pets" }, { get: (t, p) => p === "_name" ? "pets" : {} });
|
||
const appointments = new Proxy({ _name: "appointments" }, { get: (t, p) => p === "_name" ? "appointments" : {} });
|
||
return {
|
||
getDb: () => ({
|
||
select: () => ({
|
||
from: (table: unknown) => {
|
||
const name = (table as { _name?: string })._name;
|
||
if (name === "appointments") return makeSelectChainable(appointmentRows);
|
||
return makeSelectChainable(petRows);
|
||
},
|
||
}),
|
||
insert: () => makeInsertChainable(),
|
||
update: () => makeUpdateChainable(),
|
||
delete: () => makeDeleteChainable(),
|
||
}),
|
||
pets,
|
||
appointments,
|
||
and: (...conds: unknown[]) => conds,
|
||
eq: (col: unknown, val: unknown) => ({ col, val }),
|
||
exists: (q: unknown) => q,
|
||
or: (...conds: unknown[]) => conds,
|
||
};
|
||
});
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function makeApp(staff: StaffRow = MANAGER) {
|
||
const app = new Hono<AppEnv>();
|
||
app.use("*", async (c, next) => {
|
||
c.set("staff", staff);
|
||
await next();
|
||
});
|
||
return app.route("/pets", petsRouter);
|
||
}
|
||
|
||
function createApp() {
|
||
const app = makeApp(MANAGER);
|
||
return app;
|
||
}
|
||
|
||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||
|
||
describe("Extended pet profile fields — validation", () => {
|
||
beforeEach(resetMock);
|
||
|
||
it("rejects temperamentScore of 0 (below min)", async () => {
|
||
const app = createApp();
|
||
const res = await app.request("/pets", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: 0 }),
|
||
});
|
||
expect(res.status).toBe(400);
|
||
const body = await res.json();
|
||
expect(body.success).toBe(false);
|
||
});
|
||
|
||
it("rejects temperamentScore of 6 (above max)", async () => {
|
||
const app = createApp();
|
||
const res = await app.request("/pets", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: 6 }),
|
||
});
|
||
expect(res.status).toBe(400);
|
||
const body = await res.json();
|
||
expect(body.success).toBe(false);
|
||
});
|
||
|
||
it("rejects non-integer temperamentScore", async () => {
|
||
const app = createApp();
|
||
const res = await app.request("/pets", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: 3.5 }),
|
||
});
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it("rejects invalid medicalAlert severity", async () => {
|
||
const app = createApp();
|
||
const res = await app.request("/pets", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
clientId: CLIENT_ID,
|
||
name: "Test",
|
||
species: "dog",
|
||
medicalAlerts: [{ type: "seizure", description: "xyz", severity: "critical" }],
|
||
}),
|
||
});
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it("accepts valid temperamentScore 1–5", async () => {
|
||
const app = createApp();
|
||
for (const score of [1, 2, 3, 4, 5]) {
|
||
resetMock();
|
||
const res = await app.request("/pets", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentScore: score }),
|
||
});
|
||
expect(res.status).toBe(201);
|
||
}
|
||
});
|
||
|
||
it("accepts all valid medicalAlert severity values", async () => {
|
||
const app = createApp();
|
||
for (const severity of ["low", "medium", "high"] as const) {
|
||
resetMock();
|
||
const res = await app.request("/pets", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
clientId: CLIENT_ID,
|
||
name: "Test",
|
||
species: "dog",
|
||
medicalAlerts: [{ type: "allergy", description: "Sensitive to chicken", severity }],
|
||
}),
|
||
});
|
||
expect(res.status).toBe(201);
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("Extended pet profile fields — create", () => {
|
||
beforeEach(resetMock);
|
||
|
||
it("accepts all extended fields on create", async () => {
|
||
const app = createApp();
|
||
const res = await app.request("/pets", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
clientId: CLIENT_ID,
|
||
name: "Biscuit",
|
||
species: "dog",
|
||
breed: "Golden Retriever",
|
||
coatType: "double",
|
||
temperamentScore: 4,
|
||
temperamentFlags: ["anxious_with_dryers", "gentle"],
|
||
medicalAlerts: [
|
||
{ type: "seizure", description: "Occasional episodes", severity: "medium" },
|
||
],
|
||
preferredCuts: ["puppy cut", "teddy bear"],
|
||
}),
|
||
});
|
||
expect(res.status).toBe(201);
|
||
const body = await res.json();
|
||
expect(body.coatType).toBe("double");
|
||
expect(body.temperamentScore).toBe(4);
|
||
expect(body.temperamentFlags).toEqual(["anxious_with_dryers", "gentle"]);
|
||
expect(body.medicalAlerts).toEqual([{ type: "seizure", description: "Occasional episodes", severity: "medium" }]);
|
||
expect(body.preferredCuts).toEqual(["puppy cut", "teddy bear"]);
|
||
});
|
||
|
||
it("create without extended fields works (all optional)", async () => {
|
||
const app = createApp();
|
||
const res = await app.request("/pets", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Basil", species: "cat" }),
|
||
});
|
||
expect(res.status).toBe(201);
|
||
});
|
||
});
|
||
|
||
describe("Extended pet profile fields — update", () => {
|
||
beforeEach(resetMock);
|
||
|
||
it("updates coatType", async () => {
|
||
const app = createApp();
|
||
const res = await app.request(`/pets/${PET_ID}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ coatType: "double" }),
|
||
});
|
||
expect(res.status).toBe(200);
|
||
const body = await res.json();
|
||
expect(body.coatType).toBe("double");
|
||
});
|
||
|
||
it("updates temperamentScore", async () => {
|
||
const app = createApp();
|
||
const res = await app.request(`/pets/${PET_ID}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ temperamentScore: 2 }),
|
||
});
|
||
expect(res.status).toBe(200);
|
||
const body = await res.json();
|
||
expect(body.temperamentScore).toBe(2);
|
||
});
|
||
|
||
it("rejects temperamentScore 0 on update", async () => {
|
||
const app = createApp();
|
||
const res = await app.request(`/pets/${PET_ID}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ temperamentScore: 0 }),
|
||
});
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it("rejects invalid severity on update", async () => {
|
||
const app = createApp();
|
||
const res = await app.request(`/pets/${PET_ID}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
medicalAlerts: [{ type: "x", description: "y", severity: "urgent" }],
|
||
}),
|
||
});
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it("rejects too many temperamentFlags (>20)", async () => {
|
||
const app = createApp();
|
||
const flags = Array.from({ length: 21 }, (_, i) => `flag_${i}`);
|
||
const res = await app.request("/pets", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", temperamentFlags: flags }),
|
||
});
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it("rejects too many preferredCuts (>20)", async () => {
|
||
const app = createApp();
|
||
const cuts = Array.from({ length: 21 }, (_, i) => `cut_${i}`);
|
||
const res = await app.request("/pets", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", preferredCuts: cuts }),
|
||
});
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it("rejects too many medicalAlerts (>50)", async () => {
|
||
const app = createApp();
|
||
const alerts = Array.from({ length: 51 }, (_, i) => ({
|
||
type: `type_${i}`,
|
||
description: `desc_${i}`,
|
||
severity: "low" as const,
|
||
}));
|
||
const res = await app.request("/pets", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ clientId: CLIENT_ID, name: "Test", species: "dog", medicalAlerts: alerts }),
|
||
});
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
it("returns extended fields in GET response", async () => {
|
||
petRows = [{ ...petRows[0], coatType: "wire", temperamentScore: 3, temperamentFlags: ["gentle"], medicalAlerts: [], preferredCuts: ["scissor cut"] }];
|
||
const app = createApp();
|
||
const res = await app.request(`/pets/${PET_ID}`);
|
||
expect(res.status).toBe(200);
|
||
const body = await res.json();
|
||
expect(body.coatType).toBe("wire");
|
||
expect(body.temperamentScore).toBe(3);
|
||
expect(body.temperamentFlags).toEqual(["gentle"]);
|
||
expect(body.preferredCuts).toEqual(["scissor cut"]);
|
||
});
|
||
}); |