diff --git a/src/__tests__/portal.test.ts b/src/__tests__/portal.test.ts index 73f05ff..7fc32a3 100644 --- a/src/__tests__/portal.test.ts +++ b/src/__tests__/portal.test.ts @@ -4,6 +4,7 @@ import { Hono } from "hono"; const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001"; const APPOINTMENT_ID = "660e8400-e29b-41d4-a716-446655440002"; const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003"; +const PET_ID = "880e8400-e29b-41d4-a716-446655440004"; const futureDate = () => new Date(Date.now() + 30 * 60 * 1000); const pastDate = () => new Date(Date.now() - 5 * 60 * 1000); @@ -37,13 +38,38 @@ const APPOINTMENT = { cancelledAt: null, }; +const PET = { + id: PET_ID, + clientId: CLIENT_ID, + name: "Fido", + species: "dog", + breed: "Labrador", + weightKg: "30.00", + dateOfBirth: null, + healthAlerts: null, + groomingNotes: null, + cutStyle: null, + shampooPreference: null, + specialCareNotes: null, + coatType: null, + petSizeCategory: null, + customFields: {}, + photoKey: null, + photoUploadedAt: null, + image: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + let selectSessionRow: Record | null = null; let selectAppointmentRow: Record | null = null; +let selectPetRow: Record | null = null; let updatedValues: Record[] = []; function resetMock() { selectSessionRow = null; selectAppointmentRow = null; + selectPetRow = null; updatedValues = []; } @@ -62,6 +88,8 @@ vi.mock("@groombook/db", () => { return chain; } + let activeUpdateTable: string | null = null; + const impersonationSessions = new Proxy( { _name: "impersonationSessions" }, { get: (t, p) => (p === "_name" ? "impersonationSessions" : { table: "impersonationSessions", column: p }) } @@ -72,6 +100,16 @@ vi.mock("@groombook/db", () => { { get: (t, p) => (p === "_name" ? "appointments" : { table: "appointments", column: p }) } ); + const pets = new Proxy( + { _name: "pets" }, + { get: (t, p) => (p === "_name" ? "pets" : { table: "pets", column: p }) } + ); + + const impersonationAuditLogs = new Proxy( + { _name: "impersonationAuditLogs" }, + { get: (t, p) => (p === "_name" ? "impersonationAuditLogs" : { table: "impersonationAuditLogs", column: p }) } + ); + return { getDb: () => ({ select: () => ({ @@ -82,26 +120,44 @@ vi.mock("@groombook/db", () => { if (table._name === "appointments") { return makeChainable(selectAppointmentRow ? [selectAppointmentRow] : []); } + if (table._name === "pets") { + return makeChainable(selectPetRow ? [selectPetRow] : []); + } return makeChainable([]); }, }), - update: () => ({ - set: (vals: Record) => ({ - where: () => ({ - returning: () => { - if (selectAppointmentRow) { - const updated = { ...selectAppointmentRow, ...vals }; - updatedValues.push(vals); - return [updated]; - } - return []; - }, - }), + insert: () => ({ + values: () => ({ + returning: () => [{}], }), }), + update: (table: { _name: string }) => { + activeUpdateTable = table._name; + return { + set: (vals: Record) => ({ + where: () => ({ + returning: () => { + if (activeUpdateTable === "appointments" && selectAppointmentRow) { + const updated = { ...selectAppointmentRow, ...vals }; + updatedValues.push(vals); + return [updated]; + } + if (activeUpdateTable === "pets" && selectPetRow) { + const updated = { ...selectPetRow, ...vals }; + updatedValues.push(vals); + return [updated]; + } + return []; + }, + }), + }), + }; + }, }), impersonationSessions, appointments, + pets, + impersonationAuditLogs, eq: vi.fn(), and: vi.fn(), }; @@ -420,4 +476,81 @@ describe("POST /portal/appointments/:id/cancel", () => { ); expect(res.status).toBe(404); }); +}); + +// ─── PATCH /portal/pets/:id ─────────────────────────────────────────────────── + +function jsonPetPatch(path: string, body: unknown, headers?: Record) { + return app.request(path, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: JSON.stringify(body), + }); +} + +describe("PATCH /portal/pets/:id", () => { + it("updates a pet and returns the updated pet in portal shape", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = { ...PET, dateOfBirth: new Date("2020-01-15"), photoKey: "pets/test.jpg" }; + const res = await jsonPetPatch( + `/portal/pets/${PET_ID}`, + { name: "Fido Jr.", groomingNotes: "Needs extra brushing" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty("id"); + expect(body).toHaveProperty("name", "Fido Jr."); + expect(body).toHaveProperty("notes", "Needs extra brushing"); + expect(body).toHaveProperty("breed"); + expect(body).toHaveProperty("photoUrl"); + expect(body).not.toHaveProperty("clientId"); + expect(body).not.toHaveProperty("customFields"); + }); + + it("returns 401 without X-Impersonation-Session-Id header", async () => { + const res = await jsonPetPatch(`/portal/pets/${PET_ID}`, { name: "Test" }); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 401 with expired session", async () => { + selectSessionRow = EXPIRED_SESSION; + const res = await jsonPetPatch( + `/portal/pets/${PET_ID}`, + { name: "Test" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe("Unauthorized"); + }); + + it("returns 403 when pet belongs to a different client", async () => { + selectSessionRow = { ...ACTIVE_SESSION, clientId: "different-client-id" }; + selectPetRow = { ...PET }; + const res = await jsonPetPatch( + `/portal/pets/${PET_ID}`, + { name: "Hacked" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body.error).toBe("Forbidden"); + }); + + it("returns 404 when pet not found", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = null; + const res = await jsonPetPatch( + `/portal/pets/nonexistent-id`, + { name: "Ghost" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + expect(res.status).toBe(404); + }); }); \ No newline at end of file diff --git a/src/routes/portal.ts b/src/routes/portal.ts index a4c2b87..bbbc197 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -152,6 +152,67 @@ portalRouter.get("/pets", async (c) => { return c.json(clientPets.map(p => ({ id: p.id, name: p.name, breed: p.breed, weight: p.weightKg, birthDate: p.dateOfBirth, photoUrl: p.photoKey, notes: p.groomingNotes }))); }); +const portalUpdatePetSchema = z.object({ + name: z.string().min(1).max(200).optional(), + species: z.string().min(1).max(100).optional(), + breed: z.string().max(200).optional(), + weightKg: z.number().positive().optional(), + dateOfBirth: z.string().datetime().optional(), + healthAlerts: z.string().max(2000).optional(), + groomingNotes: z.string().max(2000).optional(), + cutStyle: z.string().max(500).optional(), + shampooPreference: z.string().max(500).optional(), + specialCareNotes: z.string().max(2000).optional(), + customFields: z.record(z.string(), z.string()).optional(), + petSizeCategory: z.enum(["small", "medium", "large", "extra_large"]).optional(), + coatType: z.enum(["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]).optional(), +}); + +portalRouter.patch( + "/pets/:id", + zValidator("json", portalUpdatePetSchema), + async (c) => { + const db = getDb(); + const petId = c.req.param("id"); + const clientId = c.get("portalClientId"); + const body = c.req.valid("json"); + + const [existing] = await db + .select() + .from(pets) + .where(eq(pets.id, petId)) + .limit(1); + + if (!existing) return c.json({ error: "Not found" }, 404); + if (existing.clientId !== clientId) return c.json({ error: "Forbidden" }, 403); + + const { weightKg, dateOfBirth, customFields, ...rest } = body; + const [updated] = await db + .update(pets) + .set({ + ...rest, + weightKg: weightKg?.toString(), + dateOfBirth: dateOfBirth ? new Date(dateOfBirth) : undefined, + ...(customFields !== undefined ? { customFields } : {}), + updatedAt: new Date(), + }) + .where(eq(pets.id, petId)) + .returning(); + + if (!updated) return c.json({ error: "Not found" }, 404); + + return c.json({ + id: updated.id, + name: updated.name, + breed: updated.breed, + weight: updated.weightKg, + birthDate: updated.dateOfBirth, + photoUrl: updated.photoKey, + notes: updated.groomingNotes, + }); + } +); + portalRouter.get("/invoices", async (c) => { const db = getDb(); const clientId = c.get("portalClientId");