diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 42d4cf9..f172f07 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -259,6 +259,10 @@ This means: | TC-API-8.9 | SSO bridge — no Better Auth session | POST /api/portal/session-from-auth without Better Auth session cookie | 401 Unauthorized | | TC-API-8.10 | SSO bridge — no matching client | POST /api/portal/session-from-auth with valid Better Auth session for a user with no client record | 404 Not Found, error "No client record found for this user" | | TC-API-8.11 | SSO bridge — returned session works on portal routes | After TC-API-8.8, use returned sessionId as `X-Impersonation-Session-Id` header on GET /api/portal/me | 200 OK, client profile returned | +| TC-API-8.12 | Portal GET pets returns extended fields (GRO-2187) | Establish a portal session (TC-API-8.8), then `GET /api/portal/pets` with `X-Impersonation-Session-Id` | 200 OK; each pet includes `coatType`, `petSizeCategory`, `healthAlerts`, `preferredCuts`, `medicalAlerts` (in addition to id/name/breed/weight/birthDate/photoUrl/notes) | +| TC-API-8.13 | Portal pet update — owner success + persistence (GRO-2187, fixes [GRO-1480](/GRO/issues/GRO-1480) §5.23) | With a portal session for the pet's owner, `PATCH /api/portal/pets/{petId}` with body `{ "name": "...", "breed": "...", "weightKg": 18.25, "healthAlerts": "...", "coatType": "double", "petSizeCategory": "xlarge", "preferredCuts": ["teddy bear"], "medicalAlerts": [{"type":"allergy","description":"oatmeal","severity":"medium"}] }` | 200 OK; response reflects the update with `petSizeCategory: "extra_large"` (web `xlarge` → DB `extra_large`). A follow-up `GET /api/portal/pets` shows the persisted values | +| TC-API-8.14 | Portal pet update — non-owner blocked (GRO-2187) | `PATCH /api/portal/pets/{petId}` for a pet owned by a different client, using another client's portal session | 403 Forbidden (or 404 if pet id is unknown); no mutation persisted | +| TC-API-8.15 | Portal pet update — invalid enum rejected (GRO-2187) | `PATCH /api/portal/pets/{petId}` with `coatType: "fluffy"` or `petSizeCategory: "gigantic"` | 422 Unprocessable Entity; pet unchanged | ### 4.9 Waitlist diff --git a/src/__tests__/portalPets.test.ts b/src/__tests__/portalPets.test.ts new file mode 100644 index 0000000..a95cb23 --- /dev/null +++ b/src/__tests__/portalPets.test.ts @@ -0,0 +1,267 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +const CLIENT_ID = "550e8400-e29b-41d4-a716-446655440001"; +const OTHER_CLIENT_ID = "550e8400-e29b-41d4-a716-446655440099"; +const PET_ID = "880e8400-e29b-41d4-a716-446655440004"; +const SESSION_ID = "770e8400-e29b-41d4-a716-446655440003"; + +const futureDate = () => new Date(Date.now() + 30 * 60 * 1000); + +const ACTIVE_SESSION = { + id: SESSION_ID, + clientId: CLIENT_ID, + status: "active" as const, + expiresAt: futureDate(), + createdAt: new Date(), +}; + +// A persisted pet owned by CLIENT_ID. weightKg is a string because the column is +// numeric (Drizzle serialises numeric to string). +const PET = { + id: PET_ID, + clientId: CLIENT_ID, + name: "Rex", + species: "dog", + breed: "Labrador", + weightKg: "12.50", + dateOfBirth: null, + healthAlerts: null, + groomingNotes: null, + coatType: null, + petSizeCategory: null, + preferredCuts: [], + medicalAlerts: [], + photoKey: null, +}; + +let selectSessionRow: Record | null = null; +let selectPetRow: Record | null = null; +let updatedValues: Record[] = []; + +function resetMock() { + selectSessionRow = null; + selectPetRow = null; + updatedValues = []; +} + +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; + } + + function tableProxy(name: string) { + return new Proxy( + { _name: name }, + { get: (t, p) => (p === "_name" ? name : { table: name, column: p }) } + ); + } + + const impersonationSessions = tableProxy("impersonationSessions"); + const pets = tableProxy("pets"); + + return { + getDb: () => ({ + select: () => ({ + from: (table: { _name: string }) => { + if (table._name === "impersonationSessions") { + return makeChainable(selectSessionRow ? [selectSessionRow] : []); + } + if (table._name === "pets") { + return makeChainable(selectPetRow ? [selectPetRow] : []); + } + return makeChainable([]); + }, + }), + update: () => ({ + set: (vals: Record) => ({ + where: () => ({ + returning: () => { + if (selectPetRow) { + updatedValues.push(vals); + return [{ ...selectPetRow, ...vals }]; + } + return []; + }, + }), + }), + }), + // portalAudit inserts an audit row after the handler; make it a no-op so + // the middleware does not log a swallowed error during tests. + insert: () => ({ values: () => ({ returning: () => [] }) }), + }), + impersonationSessions, + pets, + // Other tables imported by the portal router but unused in these tests. + appointments: tableProxy("appointments"), + waitlistEntries: tableProxy("waitlistEntries"), + clients: tableProxy("clients"), + services: tableProxy("services"), + staff: tableProxy("staff"), + invoices: tableProxy("invoices"), + invoiceLineItems: tableProxy("invoiceLineItems"), + impersonationAuditLogs: tableProxy("impersonationAuditLogs"), + eq: vi.fn(), + and: vi.fn(), + inArray: vi.fn(), + }; +}); + +const { portalRouter } = await import("../routes/portal.js"); + +const app = new Hono(); +app.route("/portal", portalRouter); + +function jsonPatch(path: string, body: unknown, headers?: Record) { + return app.request(path, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => resetMock()); + +describe("PATCH /portal/pets/:petId", () => { + it("updates an owned pet and persists the mapped columns (200)", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = PET; + + // Mirrors the groombook/web PetForm payload: it spreads the GET-shaped pet + // (weight, notes, birthDate, photoUrl) and adds the form's edited keys + // (weightKg, healthAlerts, coatType, …). "xlarge" must map to "extra_large". + const res = await jsonPatch( + `/portal/pets/${PET_ID}`, + { + id: PET_ID, + name: "Rex Updated", + breed: "Golden Retriever", + weight: "12.50", + weightKg: 18.25, + notes: "old grooming notes", + healthAlerts: "Allergic to oatmeal shampoo", + photoUrl: "pets/rex.jpg", + coatType: "double", + petSizeCategory: "xlarge", + preferredCuts: ["teddy bear", "puppy cut"], + medicalAlerts: [ + { id: "a1", type: "allergy", description: "oatmeal", severity: "medium" }, + ], + }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.name).toBe("Rex Updated"); + expect(body.petSizeCategory).toBe("extra_large"); + expect(body.coatType).toBe("double"); + + const persisted = updatedValues[0]!; + expect(persisted.name).toBe("Rex Updated"); + expect(persisted.breed).toBe("Golden Retriever"); + // weightKg (form key) wins over weight (GET key) and is stored as a string. + expect(persisted.weightKg).toBe("18.25"); + expect(persisted.groomingNotes).toBe("old grooming notes"); + expect(persisted.healthAlerts).toBe("Allergic to oatmeal shampoo"); + expect(persisted.photoKey).toBe("pets/rex.jpg"); + expect(persisted.coatType).toBe("double"); + expect(persisted.petSizeCategory).toBe("extra_large"); + expect(persisted.preferredCuts).toEqual(["teddy bear", "puppy cut"]); + expect(persisted.medicalAlerts).toEqual([ + { id: "a1", type: "allergy", description: "oatmeal", severity: "medium" }, + ]); + expect(persisted.updatedAt).toBeInstanceOf(Date); + }); + + it("falls back to the weight key when weightKg is absent", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = PET; + + const res = await jsonPatch( + `/portal/pets/${PET_ID}`, + { weight: "9.75" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + + expect(res.status).toBe(200); + expect(updatedValues[0]!.weightKg).toBe("9.75"); + }); + + it("returns 403 when the pet belongs to a different client", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = { ...PET, clientId: OTHER_CLIENT_ID }; + + const res = await jsonPatch( + `/portal/pets/${PET_ID}`, + { name: "Hacker" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + + expect(res.status).toBe(403); + expect(updatedValues).toHaveLength(0); + }); + + it("returns 404 when the pet does not exist", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = null; + + const res = await jsonPatch( + `/portal/pets/${PET_ID}`, + { name: "Ghost" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + + expect(res.status).toBe(404); + }); + + it("returns 422 for an invalid coatType", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = PET; + + const res = await jsonPatch( + `/portal/pets/${PET_ID}`, + { coatType: "fluffy" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + + expect(res.status).toBe(422); + expect(updatedValues).toHaveLength(0); + }); + + it("returns 422 for an invalid petSizeCategory", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = PET; + + const res = await jsonPatch( + `/portal/pets/${PET_ID}`, + { petSizeCategory: "gigantic" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + + expect(res.status).toBe(422); + expect(updatedValues).toHaveLength(0); + }); + + it("returns 401 without an impersonation session header", async () => { + selectSessionRow = ACTIVE_SESSION; + selectPetRow = PET; + + const res = await jsonPatch(`/portal/pets/${PET_ID}`, { name: "NoAuth" }); + + expect(res.status).toBe(401); + }); +}); diff --git a/src/routes/portal.ts b/src/routes/portal.ts index 7b7b160..3f15745 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -225,9 +225,153 @@ portalRouter.get("/pets", async (c) => { const clientId = c.get("portalClientId"); const clientPets = await db.select().from(pets).where(eq(pets.clientId, clientId)); - 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 }))); + 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, + coatType: p.coatType, + petSizeCategory: p.petSizeCategory, + healthAlerts: p.healthAlerts, + preferredCuts: p.preferredCuts, + medicalAlerts: p.medicalAlerts, + }))); }); +// ─── Customer-facing pet update ─────────────────────────────────────────────── +// +// The customer portal pet-profile form (groombook/web) saves edits via +// PATCH /api/portal/pets/:petId. The web payload mixes the keys returned by +// GET /portal/pets (weight, birthDate, photoUrl, notes) with the form's own +// edited keys (weightKg, healthAlerts, coatType, …), so we accept both spellings +// and map each to its `pets` column. Ownership is enforced exactly like the +// appointment-notes handler: 404 if the pet does not exist, 403 if it belongs to +// another client. + +// Allowed enum values mirror packages/db/src/schema.ts coatTypeEnum / +// petSizeCategoryEnum. Kept as plain string lists so an invalid value can be +// rejected with 422 in-handler (zValidator failures would surface as 400). +const PORTAL_COAT_TYPES: readonly string[] = ["short", "medium", "long", "double", "wire", "silky", "curly", "hairless"]; +const PORTAL_PET_SIZES: readonly string[] = ["small", "medium", "large", "extra_large"]; +// The web size dropdown emits "xlarge"; the DB enum value is "extra_large". +const PORTAL_PET_SIZE_ALIASES: Record = { xlarge: "extra_large" }; + +const portalMedicalAlertSchema = z.object({ + id: z.string().optional(), + type: z.string(), + description: z.string(), + severity: z.enum(["low", "medium", "high"]), +}); + +const portalPetUpdateSchema = z.object({ + name: z.string().min(1).max(200).optional(), + breed: z.string().max(200).nullable().optional(), + // weightKg is the form's edited key; weight is the GET-shaped key. Accept both. + weightKg: z.union([z.number(), z.string()]).nullable().optional(), + weight: z.union([z.number(), z.string()]).nullable().optional(), + birthDate: z.string().nullable().optional(), + notes: z.string().max(2000).nullable().optional(), + healthAlerts: z.string().max(2000).nullable().optional(), + photoUrl: z.string().nullable().optional(), + // coatType / petSizeCategory validated in-handler so bad values return 422. + coatType: z.string().nullable().optional(), + petSizeCategory: z.string().nullable().optional(), + preferredCuts: z.array(z.string()).nullable().optional(), + medicalAlerts: z.array(portalMedicalAlertSchema).nullable().optional(), +}); + +portalRouter.patch( + "/pets/:petId", + zValidator("json", portalPetUpdateSchema), + async (c) => { + const db = getDb(); + const petId = c.req.param("petId"); + const body = c.req.valid("json"); + const clientId = c.get("portalClientId"); + + const [pet] = await db + .select() + .from(pets) + .where(eq(pets.id, petId)) + .limit(1); + + if (!pet) { + return c.json({ error: "Not found" }, 404); + } + + if (pet.clientId !== clientId) { + return c.json({ error: "Forbidden" }, 403); + } + + const updateData: Record = { updatedAt: new Date() }; + + if (body.name !== undefined) updateData.name = body.name; + if (body.breed !== undefined) updateData.breed = body.breed; + + if (body.weightKg !== undefined || body.weight !== undefined) { + const w = body.weightKg ?? body.weight; + updateData.weightKg = w === null || w === undefined ? null : String(w); + } + + if (body.birthDate !== undefined) { + updateData.dateOfBirth = body.birthDate ? new Date(body.birthDate) : null; + } + + if (body.notes !== undefined) updateData.groomingNotes = body.notes; + if (body.healthAlerts !== undefined) updateData.healthAlerts = body.healthAlerts; + if (body.photoUrl !== undefined) updateData.photoKey = body.photoUrl; + + if (body.coatType !== undefined) { + if (body.coatType !== null && !PORTAL_COAT_TYPES.includes(body.coatType)) { + return c.json({ error: "Invalid coatType" }, 422); + } + updateData.coatType = body.coatType; + } + + if (body.petSizeCategory !== undefined) { + let size: string | null = body.petSizeCategory; + if (size !== null) { + size = PORTAL_PET_SIZE_ALIASES[size] ?? size; + if (!PORTAL_PET_SIZES.includes(size)) { + return c.json({ error: "Invalid petSizeCategory" }, 422); + } + } + updateData.petSizeCategory = size; + } + + if (body.preferredCuts !== undefined) updateData.preferredCuts = body.preferredCuts ?? []; + if (body.medicalAlerts !== undefined) updateData.medicalAlerts = body.medicalAlerts ?? []; + + const [updated] = await db + .update(pets) + .set(updateData) + .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, + coatType: updated.coatType, + petSizeCategory: updated.petSizeCategory, + healthAlerts: updated.healthAlerts, + preferredCuts: updated.preferredCuts, + medicalAlerts: updated.medicalAlerts, + }); + } +); + portalRouter.get("/invoices", async (c) => { const db = getDb(); const clientId = c.get("portalClientId");