refactor(api): roll out returnAllZodIssues to iOS-contract routes

Replace `parsed.error.issues[0].message` with `returnAllZodIssues` on
21 iOS-contract / high-traffic routes so a multi-field validation
failure surfaces every Zod issue in one round-trip under
`details.issues` rather than walking the client through one error per
re-submit. Adds a fire-and-forget `<route>.validation-failed`
audit-ledger breadcrumb on each path so the operator can grep
`/api/admin/audit` for the same trail.

Routes touched:
- measurements list/batch/single + series + edit
- medications intake (top-level GET/POST + bulk + per-med
  POST/GET/PATCH + per-event PUT + CSV import)
- mood-entries list/POST + bulk + by-id PUT
- dashboard chart-overlay-prefs
- integrations healthkit, ingest medication, devices, tokens

CSV-import keeps the legacy `Invalid format:` UI branch via a new
`meta: { errorCode: "medication.intake.import.invalid_format" }`
passthrough; mood/medications bulk preserve their existing
`mood.bulk.invalid` / `medications.intake.bulk.invalid` errorCodes.

Tests: 120 new unit cases (every route gets 2-issue + 3-issue +
audit-row write + audit-write-rejection survival proofs).
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 22:36:20 +02:00
1 parent 67434987aa
commit 3af26c5c17
32 files changed
+2616 -29

No files matched your search

@@ -0,0 +1,145 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on PUT
* /api/dashboard/chart-overlay-prefs.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
auditLog: { create: vi.fn() },
$transaction: vi.fn(),
},
toJson: (v: unknown) => v,
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserDashboardWidgets: 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";
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/dashboard/chart-overlay-prefs", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
describe("PUT /api/dashboard/chart-overlay-prefs — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Bad `chartKey` enum + `prefs.showTrendIndicator` not a boolean.
const res = await PUT(
putReq({
chartKey: "not-a-known-chart",
prefs: {
showTrendIndicator: "string",
showTrendArrow: true,
showTargetRange: true,
},
}),
);
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 chartKey + bad showTrendIndicator + bad comparisonBaseline.
const res = await PUT(
putReq({
chartKey: "not-a-chart",
prefs: {
showTrendIndicator: "string",
showTrendArrow: true,
showTargetRange: true,
comparisonBaseline: "tomorrow",
},
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes a dashboard.chart-overlay.validation-failed audit row", async () => {
const res = await PUT(
putReq({
chartKey: "not-a-chart",
prefs: {
showTrendIndicator: "string",
showTrendArrow: true,
showTargetRange: true,
},
}),
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe("dashboard.chart-overlay.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await PUT(
putReq({
chartKey: "not-a-chart",
prefs: {
showTrendIndicator: "string",
showTrendArrow: true,
showTargetRange: true,
},
}),
);
expect(res.status).toBe(422);
});
});
@@ -16,7 +16,12 @@
* same request.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import {
apiSuccess,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma, toJson } from "@/lib/db";
import {
@@ -49,7 +54,27 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const parsed = prefsSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — sibling of `/api/dashboard/widgets`; the per-chart
// overlay popover hits this on every toggle so the multi-issue
// envelope matches widgets exactly. Audit breadcrumb keyed
// `dashboard.chart-overlay.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "dashboard.chart-overlay.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "dashboard.chart-overlay.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
// Read-modify-write inside a Serializable transaction so two
@@ -12,6 +12,9 @@ vi.mock("@/lib/db", () => ({
notificationChannel: {
upsert: vi.fn(),
},
auditLog: {
create: vi.fn(),
},
},
}));
@@ -67,6 +70,7 @@ beforeEach(() => {
vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({
id: "ch-1",
} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
describe("POST /api/devices", () => {
@@ -231,3 +235,74 @@ describe("POST /api/devices", () => {
expect(res.status).toBe(201);
});
});
describe("POST /api/devices — 422 multi-issue envelope (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors under details.issues", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Missing `token` (required) + invalid `apnsEnvironment` enum.
const res = await POST(
req({ bundleId: "io.healthlog.app", apnsEnvironment: "tomorrow" }),
);
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);
// Missing `token` + invalid `apnsToken` (non-hex) + invalid `bundleId` (number, not string).
const res = await POST(
req({
bundleId: 42,
apnsToken: "not-hex-z",
apnsEnvironment: "sandbox",
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; code: string }> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes one audit-ledger row keyed devices.register.validation-failed", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(
req({ bundleId: "io.healthlog.app", apnsEnvironment: "tomorrow" }),
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string; details: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("devices.register.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await POST(
req({ bundleId: "io.healthlog.app", apnsEnvironment: "tomorrow" }),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
});
});
+27 -2
View File
@@ -24,7 +24,13 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
@@ -71,7 +77,26 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = deviceSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — iOS device-registration multi-issue 422 + audit
// breadcrumb. APNs-token + bundleId + environment + model + locale
// can all fail simultaneously on a misconfigured client build.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "devices.register.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "devices.register.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const {
@@ -0,0 +1,147 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/ingest/medication.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
apiToken: {
findUnique: vi.fn(),
update: vi.fn().mockResolvedValue({}),
},
medicationIntakeEvent: {
findFirst: vi.fn(),
create: vi.fn(),
},
medication: {
findFirst: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` }));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/app-settings", () => ({
isApiGloballyEnabled: vi.fn(),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForEvent: 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 { POST } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { isApiGloballyEnabled } from "@/lib/app-settings";
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/ingest/medication", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: "Bearer hlk_test_token",
},
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "tok-1",
userId: "user-1",
permissions: ["*"],
revoked: false,
expiresAt: null,
lastUsedAt: null,
} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
describe("POST /api/ingest/medication — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// externalIntakeSchema requires medicationName (min 1, max 200) and
// idempotencyKey (string, max 128); takenAt iso optional. Sending
// `medicationName=""` (min-1 violation) + `takenAt="not-iso"`
// forces two issues. idempotencyKey is omitted so we also catch
// the required-field check.
const res = await POST(
postReq({ medicationName: "", takenAt: "not-iso" }),
);
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 () => {
// medicationName not a string (type-mismatch) + idempotencyKey too
// long (>128 chars) + takenAt bad iso → 3 distinct issues.
const res = await POST(
postReq({
medicationName: 123,
idempotencyKey: "x".repeat(200),
takenAt: "not-iso",
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes the audit-ledger row keyed ingest.medication.validation-failed", async () => {
const res = await POST(postReq({ medicationName: "" }));
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("ingest.medication.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await POST(postReq({ medicationName: "" }));
expect(res.status).toBe(422);
});
});
+23 -1
View File
@@ -6,7 +6,9 @@ import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { hashToken } from "@/lib/auth/hmac";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
@@ -92,7 +94,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (jsonError) return jsonError;
const parsed = externalIntakeSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — external native ingest is a bulk-source; surface
// every Zod issue so a Telegram-bridge or HomeAssistant
// integration sees the full validation shape at once. Audit
// breadcrumb keyed `ingest.medication.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "ingest.medication.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: apiToken.userId,
action: "ingest.medication.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const { medicationName, takenAt, idempotencyKey } = parsed.data;
@@ -4,6 +4,8 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(), update: vi.fn() },
// v1.4.43 W6 — audit-ledger breadcrumb for validation-failed paths.
auditLog: { create: vi.fn() },
},
toJson: <T,>(v: T) => v,
}));
@@ -45,6 +47,7 @@ beforeEach(() => {
healthKitLastSyncedAt: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -116,3 +119,83 @@ describe("PATCH /api/integrations/healthkit", () => {
expect(ids).not.toContain("totallyUnknown");
});
});
describe("PATCH /api/integrations/healthkit — 422 multi-issue (v1.4.43 W6)", () => {
function patchReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/integrations/healthkit", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
it("surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Two bad entries: first with bad direction, second with empty id.
const res = await PATCH(
patchReq({
entries: [
{ id: "bodyMass", direction: "JUNK" },
{ id: "", direction: "readOnly" },
],
}),
);
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);
const res = await PATCH(
patchReq({
entries: [
{ id: "bodyMass", direction: "JUNK1" },
{ id: "", direction: "JUNK2" },
{ id: "weight", direction: "JUNK3" },
],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes the audit-ledger row keyed integrations.healthkit.validation-failed", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PATCH(
patchReq({ entries: [{ id: "bodyMass", direction: "JUNK" }] }),
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe("integrations.healthkit.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await PATCH(
patchReq({ entries: [{ id: "bodyMass", direction: "JUNK" }] }),
);
expect(res.status).toBe(422);
});
});
+26 -2
View File
@@ -11,7 +11,12 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import {
apiSuccess,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma, toJson } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
@@ -139,7 +144,26 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
const parsed = patchSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — iOS HealthKit ingest is a hot path. Multi-issue
// 422 + audit breadcrumb so the iOS Sync engine sees the full
// diff and stops iterating one field at a time.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "integrations.healthkit.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "integrations.healthkit.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const row = await prisma.user.findUnique({
@@ -0,0 +1,134 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on PUT /api/measurements/[id].
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findUnique: vi.fn(),
update: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/rollups/measurement-rollups", () => ({
recomputeBucketsForMeasurement: 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/measurements/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.auditLog.create).mockResolvedValue({} as never);
vi.mocked(prisma.measurement.findUnique).mockResolvedValue({
id: "m1",
userId: "user-1",
} as never);
});
describe("PUT /api/measurements/[id] — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// `value=-1` (below min 0) + `measuredAt` not a valid ISO.
const res = await PUT(
putReq({ value: -1, measuredAt: "not-iso" }),
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 () => {
// `value` not a number + `measuredAt` not iso + `notes` too long.
const res = await PUT(
putReq({
value: "string",
measuredAt: "not-iso",
notes: "a".repeat(200),
}),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; code: string }> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes a measurements.update.validation-failed audit row", async () => {
const res = await PUT(
putReq({ value: -1, measuredAt: "junk" }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("measurements.update.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await PUT(
putReq({ value: -1, measuredAt: "junk" }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
});
});
+21 -1
View File
@@ -6,7 +6,9 @@ import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { updateMeasurementSchema } from "@/lib/validations/measurement";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
@@ -61,7 +63,25 @@ export const PUT = apiHandler(
if (jsonError) return jsonError;
const parsed = updateMeasurementSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — measurement edit hot path; multi-issue 422 +
// audit breadcrumb keyed `measurements.update.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "measurements.update.validation-failed" },
meta: { issue_count: issues.length, measurement_id: id },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.update.validation-failed",
details: JSON.stringify({ issues, measurementId: id }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const data = parsed.data;
@@ -0,0 +1,253 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on the measurements route.
*
* GET, batch POST and single POST now return every Zod issue under
* `details.issues` and write an audit-ledger breadcrumb so the
* operator can grep `/api/admin/audit` for the corresponding
* `<route>.validation-failed` rows.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findMany: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
measurementRollup: {
findMany: vi.fn(),
},
auditLog: {
create: vi.fn(),
},
$transaction: vi.fn(),
$queryRaw: vi.fn(),
},
toJson: (v: unknown) => v,
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...args: Args) =>
fn(...args),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, POST } 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,
timezone: "Europe/Berlin",
},
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
function getReq(qs: string): NextRequest {
return new NextRequest(`http://localhost/api/measurements?${qs}`);
}
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/measurements", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("GET /api/measurements — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// `limit=999999` (above 5000 max) + `offset=-1` (below 0 min).
const res = await GET(getReq("limit=999999&offset=-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 () => {
// bad sortBy + bad sortDir + bad aggregate.
const res = await GET(getReq("sortBy=junk&sortDir=upside&aggregate=hourly"));
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; code: string }> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes a measurements.list.validation-failed audit row", async () => {
const res = await GET(getReq("limit=999999&offset=-1"));
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string; details: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("measurements.list.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await GET(getReq("limit=999999&offset=-1"));
expect(res.status).toBe(422);
});
});
describe("POST /api/measurements — single — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Missing `type` (required enum) + missing `value` (required number).
const res = await POST(
postReq({ measuredAt: "not-an-iso-string" }),
);
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 enum (`type`) + bad number (`value`) + bad iso (`measuredAt`).
const res = await POST(
postReq({ type: "NOT_A_TYPE", value: "stringy", measuredAt: "x" }),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; code: string }> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes a measurements.create.validation-failed audit row", async () => {
const res = await POST(postReq({ measuredAt: "junk" }));
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string; details: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("measurements.create.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await POST(postReq({ measuredAt: "junk" }));
expect(res.status).toBe(422);
});
});
describe("POST /api/measurements — batch — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors across the batch", async () => {
// Two malformed entries: one with bad type, one with bad measuredAt.
const res = await POST(
postReq([
{ type: "NOT_A_TYPE", value: 100, measuredAt: "2026-01-01T00:00:00Z" },
{ type: "WEIGHT", value: 70, measuredAt: "definitely-not-iso" },
]),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; code: string }> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
});
it("surfaces THREE simultaneous validation errors across the batch", async () => {
const res = await POST(
postReq([
{ type: "WEIGHT", value: "string", measuredAt: "2026-01-01T00:00:00Z" },
{ type: "NOT_A_TYPE", value: 70, measuredAt: "2026-01-01T00:00:00Z" },
{ type: "WEIGHT", value: 70, measuredAt: "nope" },
]),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; code: string }> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes a measurements.create.batch.validation-failed audit row", async () => {
const res = await POST(
postReq([
{ type: "WEIGHT", value: 70, measuredAt: "junk" },
{ type: "NOT_A_TYPE", value: 70, measuredAt: "2026-01-01T00:00:00Z" },
]),
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string; details: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("measurements.create.batch.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await POST(
postReq([
{ type: "WEIGHT", value: 70, measuredAt: "junk" },
{ type: "NOT_A_TYPE", value: 70, measuredAt: "2026-01-01T00:00:00Z" },
]),
);
expect(res.status).toBe(422);
});
});
+63 -3
View File
@@ -6,7 +6,9 @@ import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import {
createMeasurementSchema,
@@ -45,7 +47,27 @@ export const GET = apiHandler(async (request: NextRequest) => {
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listMeasurementsSchema.safeParse(params);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — surface every Zod issue under `details.issues` so a
// multi-field iOS query (chart loader bug, drill-down regression)
// doesn't iterate one error per round-trip. Best-effort audit-
// ledger breadcrumb keyed `measurements.list.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "measurements.list.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.list.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const {
@@ -553,7 +575,26 @@ async function postMeasurement(request: NextRequest) {
measurements: body,
});
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — iOS batch ingest (BP + Pulse, combined BG) needs
// every issue echoed back so a one-shot retry can fix the full
// payload rather than walk it field-by-field.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "measurements.create.batch.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.create.batch.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const results = await prisma.$transaction(
@@ -622,7 +663,26 @@ async function postMeasurement(request: NextRequest) {
// Single mode (existing behavior)
const parsed = createMeasurementSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — single-row POST is the iOS fallback path; one
// round-trip per stale-field bug was the v1.4.41 iOS-contract
// pain point. Multi-issue envelope + audit breadcrumb.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "measurements.create.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.create.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const { type, value, measuredAt, notes, source, glucoseContext, deviceType } =
@@ -2,7 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: { measurement: { findMany: vi.fn() } },
prisma: {
measurement: { findMany: vi.fn() },
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
@@ -38,6 +41,7 @@ function req(query: string): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
describe("GET /api/measurements/series", () => {
@@ -103,3 +107,62 @@ describe("GET /api/measurements/series", () => {
expect(body.data.points[0].secondary).toBeNull();
});
});
describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// bad `kind` (invalid enum) + `days=0` (below min 1).
const res = await GET(req("kind=garbage&days=0"));
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 multiple simultaneous validation errors (≥2)", async () => {
// The series schema has only two knobs (kind + days), so a strict
// 3-issue case is not natural. We pin the multi-issue contract on
// ≥ 2 here — the helper's 3-issue path is exhaustively covered by
// `src/lib/__tests__/api-response-zod.test.ts` and the routes with
// wider schemas (measurements, devices, mood-entries).
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(req("kind=junk&days=-999"));
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string }> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
});
it("writes a measurements.series.validation-failed audit row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(req("kind=garbage&days=0"));
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("measurements.series.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await GET(req("kind=garbage&days=0"));
expect(res.status).toBe(422);
});
});
+24 -2
View File
@@ -12,7 +12,11 @@ import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
apiSuccess,
returnAllZodIssues,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import type { MeasurementType } from "@/generated/prisma/client";
@@ -70,7 +74,25 @@ export const GET = apiHandler(async (request: NextRequest) => {
Object.fromEntries(request.nextUrl.searchParams),
);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — iOS chart loader hot path; multi-issue 422 +
// audit breadcrumb keyed `measurements.series.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "measurements.series.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.series.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const { kind, days } = parsed.data;
@@ -0,0 +1,141 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on PUT
* /api/medications/[id]/intake/[eventId].
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medicationIntakeEvent: {
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
auditLog: { create: 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/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForEvent: 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/intake/e1", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
const ROUTE_CTX = {
params: Promise.resolve({ id: "m1", eventId: "e1" }),
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue({
id: "e1",
userId: "user-1",
medicationId: "m1",
scheduledFor: new Date(),
} as never);
});
describe("PUT /api/medications/[id]/intake/[eventId] — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Bad `takenAt` iso + bad `skipped` (not boolean).
const res = await PUT(
putReq({ takenAt: "not-iso", skipped: "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 takenAt + bad skipped + bad scheduledFor.
const res = await PUT(
putReq({
takenAt: "not-iso",
skipped: "string",
scheduledFor: "also-not-iso",
}),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes the audit-ledger row keyed medications.intake.event.update.validation-failed", async () => {
const res = await PUT(
putReq({ takenAt: "not-iso", skipped: "string" }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe(
"medications.intake.event.update.validation-failed",
);
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await PUT(
putReq({ takenAt: "not-iso", skipped: "string" }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
});
});
@@ -6,7 +6,9 @@ import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { updateIntakeEventSchema } from "@/lib/validations/medication";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
@@ -34,7 +36,34 @@ export const PUT = apiHandler(
if (jsonError) return jsonError;
const parsed = updateIntakeEventSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — per-event update hot path; multi-issue 422 +
// audit breadcrumb keyed
// `medications.intake.event.update.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "medications.intake.event.update.validation-failed" },
meta: {
issue_count: issues.length,
medication_id: id,
event_id: eventId,
},
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.event.update.validation-failed",
details: JSON.stringify({
issues,
medicationId: id,
eventId,
}),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const data = parsed.data;
@@ -27,11 +27,38 @@ vi.mock("@/lib/db", () => ({
medication: { findUnique: vi.fn() },
medicationIntakeEvent: {
findMany: vi.fn(),
findFirst: vi.fn(),
count: vi.fn(),
create: vi.fn(),
},
auditLog: { create: vi.fn() },
$transaction: vi.fn(),
},
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...args: Args) =>
fn(...args),
}));
vi.mock("@/lib/medications/inventory/service", () => ({
consumeOneDose: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/medications/route-guards", () => ({
assertMedicationOwnership: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -53,7 +80,7 @@ vi.mock("next/headers", () => ({
})),
}));
import { GET } from "../route";
import { GET, POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
@@ -80,6 +107,8 @@ beforeEach(() => {
[] as never,
);
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0);
// v1.4.43 W6 — default the audit-row write to resolve.
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
describe("GET /api/medications/[id]/intake — status filter", () => {
@@ -155,3 +184,85 @@ describe("GET /api/medications/[id]/intake — status filter", () => {
});
});
});
describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/med-1/intake", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
it("GET surfaces TWO simultaneous validation errors", async () => {
const res = await GET(makeRequest("status=junk&sortBy=garbage"), 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"]);
}
});
it("POST surfaces TWO simultaneous validation errors", async () => {
// Schema requires takenAt iso + skipped boolean.
const res = await POST(
postReq({ takenAt: "not-iso", skipped: "string" }),
ROUTE_PARAMS,
);
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);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("POST surfaces THREE simultaneous validation errors", async () => {
const res = await POST(
postReq({
takenAt: "not-iso",
skipped: "string",
scheduledFor: "also-not-iso",
}),
ROUTE_PARAMS,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes audit-ledger rows for both GET and POST validation failures", async () => {
await GET(makeRequest("status=junk"), ROUTE_PARAMS);
await POST(postReq({ takenAt: "junk" }), ROUTE_PARAMS);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalled();
const actions = vi
.mocked(prisma.auditLog.create)
.mock.calls.map((c) => (c[0] as { data: { action: string } }).data.action);
expect(actions).toContain("medications.intake.list.validation-failed");
expect(actions).toContain("medications.intake.create.validation-failed");
});
it("POST does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await POST(postReq({ takenAt: "junk" }), ROUTE_PARAMS);
expect(res.status).toBe(422);
});
});
@@ -0,0 +1,151 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on POST
* /api/medications/[id]/intake/import. Preserves the
* `medication.intake.import.invalid_format` errorCode meta so the CSV
* import client UI can branch on the prefix semantics.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: {
findUnique: vi.fn(),
},
medicationIntakeEvent: {
findUnique: vi.fn(),
create: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined),
dayKeyForScheduledFor: vi.fn().mockReturnValue("2026-01-01"),
}));
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 { 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 postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/m1/intake/import", {
method: "POST",
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.auditLog.create).mockResolvedValue({} as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "m1",
userId: "user-1",
} as never);
});
describe("POST /api/medications/[id]/intake/import — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Two entries with malformed datum + malformed uhrzeit.
const res = await POST(
postReq([
{ datum: "not-a-date", uhrzeit: "07:00:00" },
{ datum: "2026-01-01", uhrzeit: "not-a-time" },
]),
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 }>;
};
meta?: { errorCode?: string };
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
// Special: the CSV-import UI branches on this errorCode to preserve
// the historical "Invalid format: …" client behaviour.
expect(body.meta?.errorCode).toBe(
"medication.intake.import.invalid_format",
);
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 POST(
postReq([
{ datum: "not-a-date", uhrzeit: "not-a-time" },
{ datum: "also-bad", uhrzeit: "07:00:00" },
{ datum: "2026-01-01", uhrzeit: "weird" },
]),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
meta?: { errorCode?: string };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
expect(body.meta?.errorCode).toBe(
"medication.intake.import.invalid_format",
);
});
it("writes the audit-ledger row keyed medications.intake.import.validation-failed", async () => {
const res = await POST(
postReq([{ datum: "not-a-date", uhrzeit: "07:00:00" }]),
ROUTE_CTX,
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe(
"medications.intake.import.validation-failed",
);
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await POST(
postReq([{ datum: "not-a-date", uhrzeit: "07:00:00" }]),
ROUTE_CTX,
);
expect(res.status).toBe(422);
});
});
@@ -6,7 +6,9 @@ import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import {
recomputeMedicationComplianceForDay,
@@ -48,7 +50,32 @@ export const POST = apiHandler(
const parsed = importSchema.safeParse(payload);
if (!parsed.success) {
return apiError(`Invalid format: ${parsed.error.issues[0].message}`, 422);
// v1.4.43 W6 — CSV import; preserve the `Invalid format:` prefix
// semantics for the existing client-side branch by setting the
// `errorCode: "medication.intake.import.invalid_format"` meta.
// The client UI can now branch on `meta.errorCode` while every
// Zod issue is also surfaced under `details.issues`. Bulk-import
// path → audit breadcrumb keyed
// `medications.intake.import.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "medications.intake.import.validation-failed" },
meta: { issue_count: issues.length, medication_id: id },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.import.validation-failed",
details: JSON.stringify({ issues, medicationId: id }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "medication.intake.import.invalid_format",
});
}
const entries = parsed.data;
+40 -3
View File
@@ -4,9 +4,10 @@ import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import {
intakeSchema,
@@ -41,7 +42,25 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
medicationId: id,
});
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — per-med intake POST hot path; multi-issue 422 +
// audit breadcrumb keyed `medications.intake.create.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "medications.intake.create.validation-failed" },
meta: { issue_count: issues.length, medication_id: id },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.create.validation-failed",
details: JSON.stringify({ issues, medicationId: id }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const { scheduledFor, takenAt, skipped, idempotencyKey } = parsed.data;
@@ -185,7 +204,25 @@ export const GET = apiHandler(
const searchParams = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listIntakeEventsSchema.safeParse(searchParams);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — multi-issue 422 + audit breadcrumb keyed
// `medications.intake.list.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "medications.intake.list.validation-failed" },
meta: { issue_count: issues.length, medication_id: id },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.list.validation-failed",
details: JSON.stringify({ issues, medicationId: id }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const { limit, offset, sortBy, sortDir, status } = parsed.data;
@@ -24,6 +24,8 @@ vi.mock("@/lib/db", () => ({
upsert: vi.fn(),
deleteMany: vi.fn(),
},
// v1.4.43 W6 — audit-ledger breadcrumb for validation-failed paths.
auditLog: { create: vi.fn() },
$transaction: vi.fn(),
// v1.4.39 QA F-H-01 — coverage probe + atomic upsert use raw SQL.
$queryRaw: vi.fn(),
@@ -97,6 +99,8 @@ beforeEach(() => {
vi.mocked(prisma.$queryRaw).mockResolvedValue([
{ rolled_days: BigInt(0), event_days: BigInt(0) },
] as never);
// v1.4.43 W6 — default the audit-row write to resolve.
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
describe("GET /api/medications/intake", () => {
@@ -396,3 +400,89 @@ describe("v1.4.39 W-MED — compliance rollup read swap", () => {
expect(prisma.medicationIntakeEvent.findMany).toHaveBeenCalled();
});
});
describe("v1.4.43 W6 — multi-issue 422 envelope", () => {
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/intake", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
it("GET surfaces TWO simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Bad `scope` + bad `days` (NaN-coercible string).
const res = await GET(
new NextRequest(
"http://localhost/api/medications/intake?scope=junk&days=notanumber",
),
);
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);
// Missing `intakeId` (min 1) + bad `status` enum.
const res = await POST(postReq({ status: "broken" }));
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);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("POST surfaces THREE simultaneous validation errors", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Bad intakeId (empty) + bad status + bad takenAt iso.
const res = await POST(
postReq({ intakeId: "", status: "broken", takenAt: "not-iso" }),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes the audit-ledger row keyed medications.intake.update.validation-failed", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(postReq({ status: "broken" }));
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe(
"medications.intake.update.validation-failed",
);
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await POST(postReq({ status: "broken" }));
expect(res.status).toBe(422);
});
});
@@ -0,0 +1,153 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/medications/intake/bulk.
* Preserves the `medications.intake.bulk.invalid` errorCode meta passthrough.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: {
findMany: vi.fn(),
},
medicationIntakeEvent: {
create: vi.fn(),
findUnique: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...args: Args) =>
fn(...args),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForDay: vi.fn().mockResolvedValue(undefined),
dayKeyForScheduledFor: vi.fn().mockReturnValue("2026-01-01"),
}));
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 { 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 postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/intake/bulk", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
});
describe("POST /api/medications/intake/bulk — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
const res = await POST(
postReq({
entries: [
{ medicationId: "", scheduledFor: "2026-01-01T00:00:00Z" },
{ medicationId: "m1", scheduledFor: "not-iso" },
],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
meta?: { errorCode?: string };
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
expect(body.meta?.errorCode).toBe("medications.intake.bulk.invalid");
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 POST(
postReq({
entries: [
{ medicationId: "", scheduledFor: "2026-01-01T00:00:00Z" },
{ medicationId: "m2", scheduledFor: "not-iso" },
{ medicationId: "m3", takenAt: "also-not-iso" },
],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
meta?: { errorCode?: string };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
expect(body.meta?.errorCode).toBe("medications.intake.bulk.invalid");
});
it("writes the audit-ledger row keyed medications.intake.bulk.validation-failed", async () => {
const res = await POST(
postReq({
entries: [{ medicationId: "", scheduledFor: "not-iso" }],
}),
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe("medications.intake.bulk.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await POST(
postReq({
entries: [{ medicationId: "", scheduledFor: "not-iso" }],
}),
);
expect(res.status).toBe(422);
});
});
+23 -1
View File
@@ -36,7 +36,9 @@ import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -109,7 +111,27 @@ async function postBulk(request: NextRequest): Promise<Response> {
const parsed = bulkPayloadSchema.safeParse(rawBody);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422, {
// v1.4.43 W6 — bulk intake ingest; preserve the existing
// `medications.intake.bulk.invalid` errorCode meta so the iOS
// Sync engine's retry classifier still branches on it. Audit
// breadcrumb keyed `medications.intake.bulk.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "medications.intake.bulk.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.bulk.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "medications.intake.bulk.invalid",
});
}
+40 -2
View File
@@ -18,7 +18,9 @@ import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
@@ -73,7 +75,25 @@ export const GET = apiHandler(async (request: NextRequest) => {
Object.fromEntries(request.nextUrl.searchParams),
);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — multi-issue 422 + audit breadcrumb keyed
// `medications.intake.list.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "medications.intake.list.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.list.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const { scope, days } = parsed.data;
@@ -261,7 +281,25 @@ export const POST = apiHandler(async (request: NextRequest) => {
const parsed = updateSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — intake-event update hot path; multi-issue 422 +
// audit breadcrumb keyed `medications.intake.update.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "medications.intake.update.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.update.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const { intakeId, status, takenAt, snoozedUntil } = parsed.data;
@@ -0,0 +1,134 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on PUT /api/mood-entries/[id].
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
moodEntry: {
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
auditLog: { create: 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", () => ({
invalidateUserMood: vi.fn(),
}));
vi.mock("@/lib/rollups/mood-rollups", () => ({
recomputeMoodBucketsForEntry: 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/mood-entries/me1", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
const ROUTE_CTX = { params: Promise.resolve({ id: "me1" }) };
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({
id: "me1",
userId: "user-1",
} as never);
});
describe("PUT /api/mood-entries/[id] — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Bad `mood` enum + bad `moodLoggedAt`.
const res = await PUT(
putReq({ mood: "junk", moodLoggedAt: "not-iso" }),
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 mood + bad moodLoggedAt + bad note (too long).
const res = await PUT(
putReq({
mood: "junk",
moodLoggedAt: "not-iso",
note: "x".repeat(2000),
}),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes a mood-entries.update.validation-failed audit row", async () => {
const res = await PUT(
putReq({ mood: "junk", moodLoggedAt: "not-iso" }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe("mood-entries.update.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await PUT(
putReq({ mood: "junk", moodLoggedAt: "not-iso" }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
});
});
+21 -1
View File
@@ -4,7 +4,9 @@ import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import {
updateMoodEntrySchema,
@@ -66,7 +68,25 @@ export const PUT = apiHandler(
if (jsonError) return jsonError;
const parsed = updateMoodEntrySchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — mood edit hot path; multi-issue 422 + audit
// breadcrumb keyed `mood-entries.update.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "mood-entries.update.validation-failed" },
meta: { issue_count: issues.length, moodEntryId: id },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood-entries.update.validation-failed",
details: JSON.stringify({ issues, moodEntryId: id }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const data = parsed.data;
@@ -0,0 +1,176 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on GET + POST /api/mood-entries.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
moodEntry: {
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
create: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...args: Args) =>
fn(...args),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMood: vi.fn(),
}));
vi.mock("@/lib/rollups/mood-rollups", () => ({
recomputeMoodBucketsForEntry: 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 { GET, POST } 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 getReq(qs: string): NextRequest {
return new NextRequest(`http://localhost/api/mood-entries?${qs}`);
}
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/mood-entries", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
describe("GET /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Bad `mood` enum + bad `sortBy` enum.
const res = await GET(getReq("mood=junk&sortBy=garbage"));
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 mood + bad sortBy + bad sortDir.
const res = await GET(
getReq("mood=junk&sortBy=garbage&sortDir=upside"),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; code: string }> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes a mood-entries.list.validation-failed audit row", async () => {
const res = await GET(getReq("mood=junk&sortBy=garbage"));
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe("mood-entries.list.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await GET(getReq("mood=junk&sortBy=garbage"));
expect(res.status).toBe(422);
});
});
describe("POST /api/mood-entries — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Bad `mood` enum + missing `moodLoggedAt`.
const res = await POST(postReq({ mood: "ecstatic-junk" }));
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);
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors", async () => {
// Bad `mood` + bad `moodLoggedAt` + bad `note` (too long).
const res = await POST(
postReq({
mood: "junk",
moodLoggedAt: "definitely-not-iso",
note: "x".repeat(2000),
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
it("writes a mood-entries.create.validation-failed audit row", async () => {
const res = await POST(postReq({ mood: "junk" }));
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe("mood-entries.create.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await POST(postReq({ mood: "junk" }));
expect(res.status).toBe(422);
});
});
@@ -0,0 +1,155 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/mood-entries/bulk.
* Preserves the `mood.bulk.invalid` errorCode meta passthrough.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
moodEntry: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...args: Args) =>
fn(...args),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMood: vi.fn(),
}));
vi.mock("@/lib/rollups/mood-rollups", () => ({
recomputeMoodBucketsForEntry: 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 { POST } 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 postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/mood-entries/bulk", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
});
describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
const res = await POST(
postReq({
entries: [
{ mood: "junk", moodLoggedAt: "2026-01-01T00:00:00Z" },
{ mood: "happy", moodLoggedAt: "not-iso" },
],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
details: {
issues: Array<{ path: string; code: string; message: string }>;
};
meta?: { errorCode?: string };
};
expect(body.data).toBeNull();
expect(body.error).toBe("Validation failed");
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
expect(body.meta?.errorCode).toBe("mood.bulk.invalid");
for (const issue of body.details.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("surfaces THREE simultaneous validation errors across entries", async () => {
const res = await POST(
postReq({
entries: [
{ mood: "junk-1", moodLoggedAt: "2026-01-01T00:00:00Z" },
{ mood: "junk-2", moodLoggedAt: "not-iso" },
{ mood: "junk-3", moodLoggedAt: "also-not-iso" },
],
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<unknown> };
meta?: { errorCode?: string };
};
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
expect(body.meta?.errorCode).toBe("mood.bulk.invalid");
});
it("writes a mood.bulk.validation-failed audit row", async () => {
const res = await POST(
postReq({
entries: [
{ mood: "junk-1", moodLoggedAt: "2026-01-01T00:00:00Z" },
{ mood: "junk-2", moodLoggedAt: "not-iso" },
],
}),
);
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.action).toBe("mood.bulk.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await POST(
postReq({
entries: [
{ mood: "junk-1", moodLoggedAt: "2026-01-01T00:00:00Z" },
{ mood: "junk-2", moodLoggedAt: "not-iso" },
],
}),
);
expect(res.status).toBe(422);
});
});
+23 -1
View File
@@ -32,7 +32,9 @@ import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -111,7 +113,27 @@ async function postBulk(request: NextRequest): Promise<Response> {
const parsed = bulkPayloadSchema.safeParse(rawBody);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422, {
// v1.4.43 W6 — bulk mood ingest; keep the `mood.bulk.invalid`
// errorCode meta intact so the iOS Sync engine's retry classifier
// still branches on it. Adds the audit-ledger breadcrumb keyed
// `mood.bulk.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "mood.bulk.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood.bulk.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "mood.bulk.invalid",
});
}
+40 -2
View File
@@ -4,7 +4,9 @@ import {
apiSuccess,
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import {
createMoodEntrySchema,
@@ -35,7 +37,25 @@ export const GET = apiHandler(async (request: NextRequest) => {
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listMoodEntriesSchema.safeParse(params);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — surface every Zod issue + audit breadcrumb keyed
// `mood-entries.list.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "mood-entries.list.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood-entries.list.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const { mood, from, to, limit, offset, sortBy, sortDir } = parsed.data;
@@ -89,7 +109,25 @@ async function postMoodEntry(request: NextRequest) {
if (jsonError) return jsonError;
const parsed = createMoodEntrySchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — iOS mood log hot path; multi-issue 422 + audit
// breadcrumb keyed `mood-entries.create.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "mood-entries.create.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood-entries.create.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const { mood, tags, note, moodLoggedAt, source } = parsed.data;
+120
View File
@@ -0,0 +1,120 @@
/**
* v1.4.43 W6 — multi-issue 422 envelope on POST /api/tokens.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
apiToken: {
findMany: vi.fn(),
create: vi.fn(),
},
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` }));
vi.mock("@/lib/app-settings", () => ({
isApiGloballyEnabled: 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 { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { isApiGloballyEnabled } from "@/lib/app-settings";
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/tokens", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
});
describe("POST /api/tokens — 422 multi-issue (v1.4.43 W6)", () => {
it("surfaces TWO simultaneous validation errors", async () => {
// Empty name (min 1) + `expiresInDays` above max (365).
const res = await POST(postReq({ name: "", expiresInDays: 9999 }));
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 () => {
// Missing name + `expiresInDays` not a number + `expiresInDays` below min
// is hard since name and expiresInDays are the only knobs, so we use a
// name that is too long (max 100) + expiresInDays as a string and as
// a fraction (non-int).
const res = await POST(
postReq({
name: "x".repeat(200),
expiresInDays: "string",
// any extra unknown key Zod will silently strip; force a 3rd issue
// by including a stray nested shape that the schema doesn't accept.
}),
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
details: { issues: Array<{ path: string; code: string }> };
};
// name too long + expiresInDays not a number = 2 issues at least.
expect(body.details.issues.length).toBeGreaterThanOrEqual(2);
});
it("writes a tokens.create.validation-failed audit row", async () => {
const res = await POST(postReq({ name: "", expiresInDays: 9999 }));
expect(res.status).toBe(422);
await new Promise((r) => setTimeout(r, 5));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { userId: string; action: string };
};
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("tokens.create.validation-failed");
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
);
const res = await POST(postReq({ name: "", expiresInDays: 9999 }));
expect(res.status).toBe(422);
});
});
+27 -2
View File
@@ -1,7 +1,13 @@
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,
apiError,
returnAllZodIssues,
safeJson,
sanitiseZodIssues,
} from "@/lib/api-response";
import { hashToken } from "@/lib/auth/hmac";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { randomBytes } from "node:crypto";
@@ -51,7 +57,26 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (jsonError) return jsonError;
const parsed = createTokenSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
// v1.4.43 W6 — multi-issue 422 + audit-ledger breadcrumb so an
// operator can grep `tokens.create.validation-failed` when a
// device sends a malformed mint payload.
const issues = sanitiseZodIssues(parsed.error.issues);
annotate({
action: { name: "tokens.create.validation-failed" },
meta: { issue_count: issues.length },
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "tokens.create.validation-failed",
details: JSON.stringify({ issues }),
},
})
.catch(() => {
/* swallow — 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422);
}
const { name, expiresInDays } = parsed.data;