diff --git a/src/app/api/medications/[id]/phase-config/__tests__/route.test.ts b/src/app/api/medications/[id]/phase-config/__tests__/route.test.ts new file mode 100644 index 000000000..92010a61b --- /dev/null +++ b/src/app/api/medications/[id]/phase-config/__tests__/route.test.ts @@ -0,0 +1,96 @@ +/** + * v1.5.5 F-1 H-3 — phase-config PUT now returns the multi-issue 422 + * envelope rather than the flat `400 Invalid input`. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + reminderPhaseConfig: { upsert: vi.fn() }, + }, +})); + +vi.mock("@/lib/medications/route-guards", () => ({ + assertMedicationOwnership: vi.fn().mockResolvedValue(null), +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { PUT } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +const ROUTE_PARAMS = { params: Promise.resolve({ id: "med-1" }) }; + +function putReq(body: unknown): NextRequest { + return new NextRequest( + "http://localhost/api/medications/med-1/phase-config", + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(assertMedicationOwnership).mockResolvedValue(null); + vi.mocked(prisma.reminderPhaseConfig.upsert).mockResolvedValue({ + medicationId: "med-1", + } as never); +}); + +describe("PUT /api/medications/[id]/phase-config — F-1 H-3 multi-issue 422", () => { + it("returns the multi-issue envelope on a malformed body", async () => { + // Three issues: greenValue not int, greenMode not enum, redValue overflow. + const res = await PUT( + putReq({ + greenValue: "not-a-number", + greenMode: "BOGUS", + yellowValue: 30, + yellowMode: "MINUTES", + orangeValue: 0, + orangeMode: "MINUTES", + redValue: 10_000, + redMode: "MINUTES", + }), + ROUTE_PARAMS, + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + data: null; + error: string; + details: { + issues: Array<{ path: string; code: string; message: string }>; + }; + }; + expect(body.data).toBeNull(); + expect(body.error).toBe("Validation failed"); + expect(body.details.issues.length).toBeGreaterThanOrEqual(2); + for (const issue of body.details.issues) { + expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); + } + }); +}); diff --git a/src/app/api/medications/[id]/phase-config/route.ts b/src/app/api/medications/[id]/phase-config/route.ts index 65214be30..e64e87861 100644 --- a/src/app/api/medications/[id]/phase-config/route.ts +++ b/src/app/api/medications/[id]/phase-config/route.ts @@ -1,7 +1,11 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; import { phaseConfigSchema } from "@/lib/validations/phase-config"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { NextRequest } from "next/server"; @@ -59,7 +63,11 @@ export const PUT = apiHandler( if (jsonError) return jsonError; const parsed = phaseConfigSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid input", 400); + // v1.5.5 F-1 H-3 — every v1.5.5 route returns the multi-issue + // 422 envelope so iOS sees per-field feedback the same way the + // bulk-delete and intake routes already do. The old `400 Invalid + // input` flat response was the last hold-out in the surface. + return returnAllZodIssues(parsed.error, 422); } const config = await prisma.reminderPhaseConfig.upsert({