fix(meds): phase-config returns the 422 multi-issue envelope

The phase-config PUT was the last v1.5.5 route still returning a
flat 400 on Zod failure. Returning the multi-issue envelope means
iOS gets per-field feedback the same way the bulk-delete and
intake routes do.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-29 00:53:23 +02:00
1 parent 56523bafb1
commit e6907d4ea9
2 files changed
+106 -2

No files matched your search

@@ -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"]);
}
});
});
@@ -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({