refactor(api/medications): roll out returnAllZodIssues to CRUD routes

Replace `parsed.error.issues[0].message` with `returnAllZodIssues` on
8 medication-CRUD sites (top-level POST, by-id PUT, glp1, inventory +
per-item PATCH, cadence GET, side-effects GET + POST). No audit-ledger
breadcrumb on this batch — these are low-traffic admin / detail-page
flows where the 422 response itself is the diagnostic signal.

Tests: 17 new unit cases (2-issue + 3-issue per site; cadence pinned
on ≥ 1 because its schema only has a single `days` knob).
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 22:36:27 +02:00
1 parent 3af26c5c17
commit 1592fcc52b
14 files changed
+565 -10

No files matched your search

@@ -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<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
@@ -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"]);
}
});
});
@@ -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();
@@ -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<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
+3 -1
View File
@@ -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);
@@ -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<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
@@ -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 } =
@@ -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<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
@@ -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;
+3 -1
View File
@@ -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 {
@@ -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<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
@@ -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;
@@ -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<unknown>) =>
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<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
+3 -2
View File
@@ -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 } =