dev → uat: GRO-2203 portal pet PATCH malformed-petId 500→404 (#178)
CI / Test (push) Successful in 27s
CI / Lint & Typecheck (push) Successful in 32s
CI / Build & Push Docker Images (push) Successful in 1m1s
CI / Test (pull_request) Successful in 27s
CI / Lint & Typecheck (pull_request) Successful in 31s
CI / Build & Push Docker Images (pull_request) Successful in 1m4s
CI / Test (push) Successful in 27s
CI / Lint & Typecheck (push) Successful in 32s
CI / Build & Push Docker Images (push) Successful in 1m1s
CI / Test (pull_request) Successful in 27s
CI / Lint & Typecheck (pull_request) Successful in 31s
CI / Build & Push Docker Images (pull_request) Successful in 1m4s
This commit was merged in pull request #178.
This commit is contained in:
@@ -283,6 +283,7 @@ This means:
|
|||||||
| 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.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.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 |
|
| 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 |
|
||||||
|
| TC-API-8.16 | Portal pet update — malformed (non-UUID) petId returns 404 (GRO-2203) | With a valid portal session, `PATCH /api/portal/pets/not-a-uuid` with header `X-Impersonation-Session-Id` and body `{"coatType":"short"}` | 404 Not Found with body `{"error":"Not found"}` (was an unhandled 500 from the Postgres uuid cast in GRO-2203; mirrors the GRO-2014 guard). No mutation persisted |
|
||||||
|
|
||||||
### 4.9 Waitlist
|
### 4.9 Waitlist
|
||||||
|
|
||||||
|
|||||||
@@ -280,6 +280,23 @@ describe("PATCH /portal/pets/:petId", () => {
|
|||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 404 for a malformed (non-UUID) petId without hitting the db (GRO-2203)", async () => {
|
||||||
|
selectSessionRow = ACTIVE_SESSION;
|
||||||
|
// A non-UUID petId previously reached `where(eq(pets.id, ...))` and made
|
||||||
|
// Postgres throw "invalid input syntax for type uuid" → unhandled 500.
|
||||||
|
// It must now short-circuit to 404 before any select/update.
|
||||||
|
selectPetRow = PET;
|
||||||
|
|
||||||
|
const res = await jsonPatch(
|
||||||
|
`/portal/pets/not-a-uuid`,
|
||||||
|
{ coatType: "short" },
|
||||||
|
{ "X-Impersonation-Session-Id": SESSION_ID }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(updatedValues).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 422 for an invalid coatType", async () => {
|
it("returns 422 for an invalid coatType", async () => {
|
||||||
selectSessionRow = ACTIVE_SESSION;
|
selectSessionRow = ACTIVE_SESSION;
|
||||||
selectPetRow = PET;
|
selectPetRow = PET;
|
||||||
|
|||||||
@@ -184,6 +184,66 @@ describe("POST /portal/waitlist", () => {
|
|||||||
expect(insertedValues).toHaveLength(1);
|
expect(insertedValues).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("normalizes HH:MM:SS preferredTime and returns 201 (GRO-2211)", async () => {
|
||||||
|
selectSessionRow = ACTIVE_SESSION;
|
||||||
|
const res = await jsonRequest("POST", "/portal/waitlist", {
|
||||||
|
petId: VALID_UUID_3,
|
||||||
|
serviceId: VALID_UUID_4,
|
||||||
|
preferredDate: "2026-03-25",
|
||||||
|
preferredTime: "10:00:00",
|
||||||
|
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(insertedValues[0]?.preferredTime).toBe("10:00:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes HH:MM preferredTime to HH:MM:SS before insert (GRO-2211)", async () => {
|
||||||
|
selectSessionRow = ACTIVE_SESSION;
|
||||||
|
const res = await jsonRequest("POST", "/portal/waitlist", {
|
||||||
|
petId: VALID_UUID_3,
|
||||||
|
serviceId: VALID_UUID_4,
|
||||||
|
preferredDate: "2026-03-25",
|
||||||
|
preferredTime: "10:00",
|
||||||
|
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(insertedValues[0]?.preferredTime).toBe("10:00:00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 (not 500) for a full ISO datetime preferredTime (GRO-2211)", async () => {
|
||||||
|
selectSessionRow = ACTIVE_SESSION;
|
||||||
|
const res = await jsonRequest("POST", "/portal/waitlist", {
|
||||||
|
petId: VALID_UUID_3,
|
||||||
|
serviceId: VALID_UUID_4,
|
||||||
|
preferredDate: "2026-03-25",
|
||||||
|
preferredTime: "2026-06-09T10:00:00.000Z",
|
||||||
|
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(insertedValues).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for a malformed preferredDate (GRO-2211)", async () => {
|
||||||
|
selectSessionRow = ACTIVE_SESSION;
|
||||||
|
const res = await jsonRequest("POST", "/portal/waitlist", {
|
||||||
|
petId: VALID_UUID_3,
|
||||||
|
serviceId: VALID_UUID_4,
|
||||||
|
preferredDate: "03/25/2026",
|
||||||
|
preferredTime: "10:00",
|
||||||
|
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(insertedValues).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for an out-of-range preferredTime (GRO-2211)", async () => {
|
||||||
|
selectSessionRow = ACTIVE_SESSION;
|
||||||
|
const res = await jsonRequest("POST", "/portal/waitlist", {
|
||||||
|
petId: VALID_UUID_3,
|
||||||
|
serviceId: VALID_UUID_4,
|
||||||
|
preferredDate: "2026-03-25",
|
||||||
|
preferredTime: "25:99",
|
||||||
|
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(insertedValues).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 401 without session", async () => {
|
it("returns 401 without session", async () => {
|
||||||
const res = await jsonRequest("POST", "/portal/waitlist", {
|
const res = await jsonRequest("POST", "/portal/waitlist", {
|
||||||
petId: VALID_UUID_3,
|
petId: VALID_UUID_3,
|
||||||
@@ -258,6 +318,16 @@ describe("PATCH /portal/waitlist/:id", () => {
|
|||||||
expect(updatedValues[0]?.status).toBe("cancelled");
|
expect(updatedValues[0]?.status).toBe("cancelled");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns 400 (not 500) for a full ISO datetime preferredTime on update (GRO-2211)", async () => {
|
||||||
|
selectSessionRow = ACTIVE_SESSION;
|
||||||
|
selectRows = [WAITLIST_ENTRY];
|
||||||
|
const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, {
|
||||||
|
preferredTime: "2026-06-09T10:00:00.000Z",
|
||||||
|
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(updatedValues).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns 401 without session", async () => {
|
it("returns 401 without session", async () => {
|
||||||
const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, {
|
const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, {
|
||||||
status: "cancelled",
|
status: "cancelled",
|
||||||
|
|||||||
+30
-6
@@ -296,6 +296,14 @@ portalRouter.patch(
|
|||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const clientId = c.get("portalClientId");
|
const clientId = c.get("portalClientId");
|
||||||
|
|
||||||
|
// GRO-2203: validate UUID format before hitting Postgres. Passing a non-UUID
|
||||||
|
// string to a uuid column makes the driver throw ("invalid input syntax for
|
||||||
|
// type uuid"), which previously surfaced as an unhandled 500. Mirror the
|
||||||
|
// GRO-2014 fix in pets.ts and treat a malformed id as Not found.
|
||||||
|
if (!z.string().uuid().safeParse(petId).success) {
|
||||||
|
return c.json({ error: "Not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
const [pet] = await db
|
const [pet] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(pets)
|
.from(pets)
|
||||||
@@ -551,17 +559,33 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
|
|||||||
|
|
||||||
// ─── Client-facing waitlist routes ────────────────────────────────────────────
|
// ─── Client-facing waitlist routes ────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Postgres `date` / `time` columns reject arbitrary strings (e.g. a full ISO
|
||||||
|
// datetime), throwing a DateTimeParseError that surfaces as an unhandled 500.
|
||||||
|
// Constrain client input here so malformed values are rejected with a 400 by
|
||||||
|
// zValidator before they ever reach the DB (GRO-2211 defense-in-depth).
|
||||||
|
const preferredDateSchema = z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, "preferredDate must be YYYY-MM-DD");
|
||||||
|
const preferredTimeSchema = z
|
||||||
|
.string()
|
||||||
|
.regex(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/, "preferredTime must be HH:MM or HH:MM:SS");
|
||||||
|
|
||||||
|
// Normalize HH:MM → HH:MM:SS so it matches the Postgres `time` column format.
|
||||||
|
function normalizeTime(value: string): string {
|
||||||
|
return value.length === 5 ? `${value}:00` : value;
|
||||||
|
}
|
||||||
|
|
||||||
const createWaitlistEntrySchema = z.object({
|
const createWaitlistEntrySchema = z.object({
|
||||||
petId: z.string().uuid(),
|
petId: z.string().uuid(),
|
||||||
serviceId: z.string().uuid(),
|
serviceId: z.string().uuid(),
|
||||||
preferredDate: z.string(),
|
preferredDate: preferredDateSchema,
|
||||||
preferredTime: z.string(),
|
preferredTime: preferredTimeSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateWaitlistEntrySchema = z.object({
|
const updateWaitlistEntrySchema = z.object({
|
||||||
status: z.literal("cancelled").optional(),
|
status: z.literal("cancelled").optional(),
|
||||||
preferredDate: z.string().optional(),
|
preferredDate: preferredDateSchema.optional(),
|
||||||
preferredTime: z.string().optional(),
|
preferredTime: preferredTimeSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
portalRouter.post(
|
portalRouter.post(
|
||||||
@@ -579,7 +603,7 @@ portalRouter.post(
|
|||||||
petId: body.petId,
|
petId: body.petId,
|
||||||
serviceId: body.serviceId,
|
serviceId: body.serviceId,
|
||||||
preferredDate: body.preferredDate,
|
preferredDate: body.preferredDate,
|
||||||
preferredTime: body.preferredTime,
|
preferredTime: normalizeTime(body.preferredTime),
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -610,7 +634,7 @@ portalRouter.patch(
|
|||||||
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
const updateData: Record<string, unknown> = { updatedAt: new Date() };
|
||||||
if (body.status !== undefined) updateData.status = body.status;
|
if (body.status !== undefined) updateData.status = body.status;
|
||||||
if (body.preferredDate !== undefined) updateData.preferredDate = body.preferredDate;
|
if (body.preferredDate !== undefined) updateData.preferredDate = body.preferredDate;
|
||||||
if (body.preferredTime !== undefined) updateData.preferredTime = body.preferredTime;
|
if (body.preferredTime !== undefined) updateData.preferredTime = normalizeTime(body.preferredTime);
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(waitlistEntries)
|
.update(waitlistEntries)
|
||||||
|
|||||||
Reference in New Issue
Block a user