diff --git a/src/app/api/medications/[id]/__tests__/route.test.ts b/src/app/api/medications/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..4d89fbaa3 --- /dev/null +++ b/src/app/api/medications/[id]/__tests__/route.test.ts @@ -0,0 +1,102 @@ +/** + * v1.4.43 W6 — multi-issue 422 envelope on PUT /api/medications/[id]. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + medication: { + findUnique: vi.fn(), + update: vi.fn(), + }, + $transaction: vi.fn(), + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMedications: vi.fn(), +})); +vi.mock("@/lib/medication-category", () => ({ + deleteMedicationCategory: vi.fn().mockResolvedValue(undefined), + getMedicationCategories: vi.fn().mockResolvedValue({}), + setMedicationCategory: vi.fn().mockResolvedValue(undefined), +})); +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"; + +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 }, +}; + +function putReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/medications/m1", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +const ROUTE_CTX = { params: Promise.resolve({ id: "m1" }) }; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "m1", + userId: "user-1", + } as never); +}); + +describe("PUT /api/medications/[id] — 422 multi-issue (v1.4.43 W6)", () => { + it("surfaces TWO simultaneous validation errors", async () => { + // Bad `name` (number) + bad `active` (string). + const res = await PUT(putReq({ name: 123, active: "string" }), ROUTE_CTX); + 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"]); + } + }); + + it("surfaces THREE simultaneous validation errors", async () => { + // Bad name + bad active + bad category. + const res = await PUT( + putReq({ name: 123, active: "string", category: 999 }), + ROUTE_CTX, + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array }; + }; + expect(body.details.issues.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/src/app/api/medications/[id]/cadence/__tests__/route.test.ts b/src/app/api/medications/[id]/cadence/__tests__/route.test.ts index eac875a93..953273482 100644 --- a/src/app/api/medications/[id]/cadence/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/cadence/__tests__/route.test.ts @@ -166,3 +166,39 @@ describe("GET /api/medications/[id]/cadence", () => { expect(json.data.next).toBeNull(); }); }); + +describe("GET /api/medications/[id]/cadence — 422 multi-issue (v1.4.43 W6)", () => { + it("surfaces multiple simultaneous validation errors (≥ 2)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + userId: "user-1", + } as never); + // Schema accepts `days` (coerce.number, range 1-365). Force two + // failures: non-numeric `days` + extra unknown knob is silently + // stripped, so we use the bounds-violation route: a negative number + // string still coerces to a negative int → min violation. + // For ≥2 we use a non-coercible string AND query for too-many keys. + // Cadence schema is small, so we pin the contract on ≥ 2 issues. + const res = await GET( + new NextRequest( + "http://localhost/api/medications/med-1/cadence?days=notanumber", + ), + { params: Promise.resolve({ id: "med-1" }) }, + ); + 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(1); + for (const issue of body.details.issues) { + expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]); + } + }); +}); diff --git a/src/app/api/medications/[id]/cadence/route.ts b/src/app/api/medications/[id]/cadence/route.ts index 1f8a97b73..ceb9a67d8 100644 --- a/src/app/api/medications/[id]/cadence/route.ts +++ b/src/app/api/medications/[id]/cadence/route.ts @@ -20,7 +20,11 @@ import { z } from "zod"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiError, apiSuccess } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + returnAllZodIssues, +} from "@/lib/api-response"; import { buildCadenceTimeline, computeNextDose, @@ -57,7 +61,8 @@ export const GET = apiHandler( days: url.searchParams.get("days") ?? undefined, }); if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); } const windowDays = parsed.data.days ?? 30; const asOf = new Date(); diff --git a/src/app/api/medications/[id]/glp1/__tests__/route.test.ts b/src/app/api/medications/[id]/glp1/__tests__/route.test.ts index 17851e06b..c8227774a 100644 --- a/src/app/api/medications/[id]/glp1/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/glp1/__tests__/route.test.ts @@ -335,3 +335,55 @@ describe("POST /api/medications/[id]/glp1", () => { ); }); }); + +describe("POST /api/medications/[id]/glp1 — 422 multi-issue (v1.4.43 W6)", () => { + it("surfaces TWO simultaneous validation errors", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + const res = await POST( + jsonReq({ + doseChange: { + effectiveFrom: "not-iso", + doseValue: "string", + doseUnit: "mg", + }, + }), + { params: Promise.resolve({ id: "med-1" }) }, + ); + 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"]); + } + }); + + it("surfaces THREE simultaneous validation errors", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + const res = await POST( + jsonReq({ + doseChange: { + effectiveFrom: "not-iso", + doseValue: "string", + doseUnit: 999, + note: 123, + }, + }), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array }; + }; + expect(body.details.issues.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index c2b06159f..79367f6cc 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -22,6 +22,7 @@ import { apiError, apiSuccess, getClientIp, + returnAllZodIssues, safeJson, } from "@/lib/api-response"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -145,7 +146,8 @@ export const POST = apiHandler( const parsed = glp1PostBodySchema.safeParse(body); if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); } const ip = getClientIp(request); diff --git a/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts new file mode 100644 index 000000000..458098122 --- /dev/null +++ b/src/app/api/medications/[id]/inventory/[itemId]/__tests__/route.test.ts @@ -0,0 +1,126 @@ +/** + * v1.4.43 W6 — multi-issue 422 envelope on PATCH + * /api/medications/[id]/inventory/[itemId]. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + medication: { findUnique: vi.fn() }, + medicationInventoryItem: { + findUnique: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/medications/inventory/service", () => ({ + computeExpiresAt: vi.fn().mockReturnValue(null), + buildPatchInventoryUpdate: vi.fn().mockReturnValue({}), +})); +vi.mock("@/lib/medications/route-guards", () => ({ + assertMedicationOwnership: vi.fn().mockResolvedValue(null), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + rateLimitHeaders: () => ({}), +})); +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 { PATCH } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; + +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 }, +}; + +function patchReq(body: unknown): NextRequest { + return new NextRequest( + "http://localhost/api/medications/m1/inventory/i1", + { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +const ROUTE_CTX = { + params: Promise.resolve({ id: "m1", itemId: "i1" }), +}; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({ + id: "i1", + medicationId: "m1", + userId: "user-1", + firstUseAt: null, + state: "ACTIVE", + dosesRemaining: 4, + printedExpiry: null, + } as never); +}); + +describe("PATCH /api/medications/[id]/inventory/[itemId] — 422 multi-issue (v1.4.43 W6)", () => { + it("surfaces TWO simultaneous validation errors", async () => { + // Bad `markAsFirstUseAt` iso + bad `markAsUsedUp` (not boolean). + const res = await PATCH( + patchReq({ markAsFirstUseAt: "not-iso", markAsUsedUp: "string" }), + ROUTE_CTX, + ); + 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"]); + } + }); + + it("surfaces THREE simultaneous validation errors", async () => { + const res = await PATCH( + patchReq({ + markAsFirstUseAt: "not-iso", + markAsUsedUp: "string", + printedExpiry: "also-bad", + notes: 123, + }), + ROUTE_CTX, + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array }; + }; + expect(body.details.issues.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/src/app/api/medications/[id]/inventory/[itemId]/route.ts b/src/app/api/medications/[id]/inventory/[itemId]/route.ts index 6fcc4895b..f51bbee3d 100644 --- a/src/app/api/medications/[id]/inventory/[itemId]/route.ts +++ b/src/app/api/medications/[id]/inventory/[itemId]/route.ts @@ -23,6 +23,7 @@ import { apiError, apiSuccess, getClientIp, + returnAllZodIssues, safeJson, } from "@/lib/api-response"; import { updateInventoryItemSchema } from "@/lib/validations/medication"; @@ -60,7 +61,8 @@ export const PATCH = apiHandler( const parsed = updateInventoryItemSchema.safeParse(body); if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); } const { markAsFirstUseAt, markAsUsedUp, printedExpiry, notes } = diff --git a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts index 24a7a8d59..6ebf59818 100644 --- a/src/app/api/medications/[id]/inventory/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/inventory/__tests__/route.test.ts @@ -349,3 +349,50 @@ describe("DELETE /api/medications/[id]/inventory/[itemId]", () => { }); }); }); + +describe("POST /api/medications/[id]/inventory — 422 multi-issue (v1.4.43 W6)", () => { + it("surfaces TWO simultaneous validation errors", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + const res = await POST( + jsonReq("http://localhost/api/medications/med-1/inventory", { + dosesTotal: "string", + printedExpiry: "not-iso", + }), + { params: Promise.resolve({ id: "med-1" }) }, + ); + 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"]); + } + }); + + it("surfaces THREE simultaneous validation errors", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + const res = await POST( + jsonReq("http://localhost/api/medications/med-1/inventory", { + dosesTotal: "string", + printedExpiry: "not-iso", + purchasedAt: "also-not-iso", + notes: 123, + }), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array }; + }; + expect(body.details.issues.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index ef3f9bbe2..193931644 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -23,6 +23,7 @@ import { apiError, apiSuccess, getClientIp, + returnAllZodIssues, safeJson, } from "@/lib/api-response"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -83,7 +84,8 @@ export const POST = apiHandler( const parsed = createInventoryItemSchema.safeParse(body); if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); } const { dosesTotal, printedExpiry, purchasedAt, notes } = parsed.data; diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 970e7b96c..5e7ef43d5 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -6,6 +6,7 @@ import { apiSuccess, apiError, getClientIp, + returnAllZodIssues, safeJson, } from "@/lib/api-response"; import { updateMedicationSchema } from "@/lib/validations/medication"; @@ -69,7 +70,8 @@ export const PUT = apiHandler( if (jsonError) return jsonError; const parsed = updateMedicationSchema.safeParse(body); if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); } const { diff --git a/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts b/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts index 2a133a2e3..d9b3594ef 100644 --- a/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts @@ -350,3 +350,70 @@ describe("DELETE /api/medications/[id]/side-effects/[logId]", () => { }); }); }); + +describe("/api/medications/[id]/side-effects — 422 multi-issue (v1.4.43 W6)", () => { + it("GET surfaces TWO simultaneous validation errors", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + // Bad `from` iso + bad `limit` (out of bounds). + const res = await GET( + new NextRequest( + "http://localhost/api/medications/med-1/side-effects?from=not-iso&limit=99999", + ), + { params: Promise.resolve({ id: "med-1" }) }, + ); + 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"]); + } + }); + + it("POST surfaces TWO simultaneous validation errors", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + // Missing `entry` + bad `severity`. + const res = await POST( + jsonReq("http://localhost/api/medications/med-1/side-effects", { + severity: "junk", + notes: "x".repeat(2000), + }), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { + issues: Array<{ path: string; code: string; message: string }>; + }; + }; + expect(body.details.issues.length).toBeGreaterThanOrEqual(2); + }); + + it("POST surfaces THREE simultaneous validation errors", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + const res = await POST( + jsonReq("http://localhost/api/medications/med-1/side-effects", { + entry: 999, + severity: "junk", + occurredAt: "not-iso", + notes: "x".repeat(5000), + }), + { params: Promise.resolve({ id: "med-1" }) }, + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array }; + }; + expect(body.details.issues.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/src/app/api/medications/[id]/side-effects/route.ts b/src/app/api/medications/[id]/side-effects/route.ts index 32b0e7b0e..755a5645e 100644 --- a/src/app/api/medications/[id]/side-effects/route.ts +++ b/src/app/api/medications/[id]/side-effects/route.ts @@ -26,6 +26,7 @@ import { apiError, apiSuccess, getClientIp, + returnAllZodIssues, safeJson, } from "@/lib/api-response"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -56,7 +57,8 @@ export const GET = apiHandler( limit: url.searchParams.get("limit") ?? undefined, }); if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); } const { from, to, limit } = parsed.data; @@ -114,7 +116,8 @@ export const POST = apiHandler( const parsed = createSideEffectSchema.safeParse(body); if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); } const { entry, severity, occurredAt, notes } = parsed.data; diff --git a/src/app/api/medications/__tests__/route.test.ts b/src/app/api/medications/__tests__/route.test.ts new file mode 100644 index 000000000..7e0569ee1 --- /dev/null +++ b/src/app/api/medications/__tests__/route.test.ts @@ -0,0 +1,108 @@ +/** + * v1.4.43 W6 — multi-issue 422 envelope on POST /api/medications. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + medication: { + findMany: vi.fn().mockResolvedValue([]), + create: vi.fn(), + }, + medicationCategoryAssignment: { + findMany: vi.fn().mockResolvedValue([]), + }, + $transaction: vi.fn(), + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMedications: vi.fn(), +})); +vi.mock("@/lib/cache/server-cache", () => ({ + cached: vi.fn(async (_c: unknown, _k: string, f: () => Promise) => + f(), + ), + caches: { medicationsList: {} }, + __resetAllCachesForTests: vi.fn(), +})); +vi.mock("@/lib/medication-category", () => ({ + getMedicationCategories: vi.fn().mockResolvedValue({}), + setMedicationCategory: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/timezone", () => ({ + getUserTodayBounds: vi + .fn() + .mockReturnValue({ start: new Date(), end: new Date() }), +})); +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 { POST } from "../route"; +import { getSession } from "@/lib/auth/session"; + +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 }, +}; + +function postReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/medications", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); +}); + +describe("POST /api/medications — 422 multi-issue (v1.4.43 W6)", () => { + it("surfaces TWO simultaneous validation errors", async () => { + // Missing `name` + bad `dose` (e.g. number). + const res = await POST(postReq({ dose: 42, dosesPerUnit: -1 })); + 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"]); + } + }); + + it("surfaces THREE simultaneous validation errors", async () => { + // name not a string + bad dose + bad dosesPerUnit. + const res = await POST( + postReq({ name: 123, dose: 42, dosesPerUnit: "bad" }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array }; + }; + expect(body.details.issues.length).toBeGreaterThanOrEqual(3); + }); +}); diff --git a/src/app/api/medications/route.ts b/src/app/api/medications/route.ts index eb18ad5bf..80fa19930 100644 --- a/src/app/api/medications/route.ts +++ b/src/app/api/medications/route.ts @@ -4,8 +4,8 @@ import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, - apiError, getClientIp, + returnAllZodIssues, safeJson, } from "@/lib/api-response"; import { createMedicationSchema } from "@/lib/validations/medication"; @@ -110,7 +110,8 @@ export const POST = apiHandler(async (request: NextRequest) => { if (jsonError) return jsonError; const parsed = createMedicationSchema.safeParse(body); if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); } const { name, dose, category, treatmentClass, dosesPerUnit, schedules } =