From 3af26c5c179d603ec4ba73f1911bdf914180c18d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 22:09:04 +0200 Subject: [PATCH] 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 `.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). --- .../__tests__/route.test.ts | 145 ++++++++++ .../dashboard/chart-overlay-prefs/route.ts | 29 +- src/app/api/devices/__tests__/route.test.ts | 75 ++++++ src/app/api/devices/route.ts | 29 +- .../ingest/medication/__tests__/route.test.ts | 147 ++++++++++ src/app/api/ingest/medication/route.ts | 24 +- .../healthkit/__tests__/route.test.ts | 83 ++++++ src/app/api/integrations/healthkit/route.ts | 28 +- .../measurements/[id]/__tests__/route.test.ts | 134 ++++++++++ src/app/api/measurements/[id]/route.ts | 22 +- .../__tests__/zod-multi-issue.test.ts | 253 ++++++++++++++++++ src/app/api/measurements/route.ts | 66 ++++- .../series/__tests__/route.test.ts | 65 ++++- src/app/api/measurements/series/route.ts | 26 +- .../intake/[eventId]/__tests__/route.test.ts | 141 ++++++++++ .../[id]/intake/[eventId]/route.ts | 31 ++- .../[id]/intake/__tests__/route.test.ts | 113 +++++++- .../intake/import/__tests__/route.test.ts | 151 +++++++++++ .../medications/[id]/intake/import/route.ts | 29 +- src/app/api/medications/[id]/intake/route.ts | 43 ++- .../intake/__tests__/route.test.ts | 90 +++++++ .../intake/bulk/__tests__/route.test.ts | 153 +++++++++++ src/app/api/medications/intake/bulk/route.ts | 24 +- src/app/api/medications/intake/route.ts | 42 ++- .../mood-entries/[id]/__tests__/route.test.ts | 134 ++++++++++ src/app/api/mood-entries/[id]/route.ts | 22 +- .../api/mood-entries/__tests__/route.test.ts | 176 ++++++++++++ .../mood-entries/bulk/__tests__/route.test.ts | 155 +++++++++++ src/app/api/mood-entries/bulk/route.ts | 24 +- src/app/api/mood-entries/route.ts | 42 ++- src/app/api/tokens/__tests__/route.test.ts | 120 +++++++++ src/app/api/tokens/route.ts | 29 +- 32 files changed, 2616 insertions(+), 29 deletions(-) create mode 100644 src/app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts create mode 100644 src/app/api/ingest/medication/__tests__/route.test.ts create mode 100644 src/app/api/measurements/[id]/__tests__/route.test.ts create mode 100644 src/app/api/measurements/__tests__/zod-multi-issue.test.ts create mode 100644 src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts create mode 100644 src/app/api/medications/[id]/intake/import/__tests__/route.test.ts create mode 100644 src/app/api/medications/intake/bulk/__tests__/route.test.ts create mode 100644 src/app/api/mood-entries/[id]/__tests__/route.test.ts create mode 100644 src/app/api/mood-entries/__tests__/route.test.ts create mode 100644 src/app/api/mood-entries/bulk/__tests__/route.test.ts create mode 100644 src/app/api/tokens/__tests__/route.test.ts diff --git a/src/app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts b/src/app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts new file mode 100644 index 000000000..cfc2b2d3e --- /dev/null +++ b/src/app/api/dashboard/chart-overlay-prefs/__tests__/route.test.ts @@ -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 }; + }; + 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); + }); +}); diff --git a/src/app/api/dashboard/chart-overlay-prefs/route.ts b/src/app/api/dashboard/chart-overlay-prefs/route.ts index c44015185..89c69a5ab 100644 --- a/src/app/api/dashboard/chart-overlay-prefs/route.ts +++ b/src/app/api/dashboard/chart-overlay-prefs/route.ts @@ -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 diff --git a/src/app/api/devices/__tests__/route.test.ts b/src/app/api/devices/__tests__/route.test.ts index f2f9a6201..ac5a464af 100644 --- a/src/app/api/devices/__tests__/route.test.ts +++ b/src/app/api/devices/__tests__/route.test.ts @@ -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 }; + }; + expect(body.details.issues.length).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts index 8fc42ed25..3311b5ba8 100644 --- a/src/app/api/devices/route.ts +++ b/src/app/api/devices/route.ts @@ -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 { diff --git a/src/app/api/ingest/medication/__tests__/route.test.ts b/src/app/api/ingest/medication/__tests__/route.test.ts new file mode 100644 index 000000000..cc2deb55d --- /dev/null +++ b/src/app/api/ingest/medication/__tests__/route.test.ts @@ -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 }; + }; + 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); + }); +}); diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index 3a8c6c130..0b62f771d 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -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; diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts index f31ecf4bb..faee976e1 100644 --- a/src/app/api/integrations/healthkit/__tests__/route.test.ts +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -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: (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; @@ -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 }; + }; + 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); + }); +}); diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts index df2b52edd..e9abb88d7 100644 --- a/src/app/api/integrations/healthkit/route.ts +++ b/src/app/api/integrations/healthkit/route.ts @@ -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({ diff --git a/src/app/api/measurements/[id]/__tests__/route.test.ts b/src/app/api/measurements/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..fca3c14a0 --- /dev/null +++ b/src/app/api/measurements/[id]/__tests__/route.test.ts @@ -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); + }); +}); diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index 947a49c8a..0b897d146 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -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; diff --git a/src/app/api/measurements/__tests__/zod-multi-issue.test.ts b/src/app/api/measurements/__tests__/zod-multi-issue.test.ts new file mode 100644 index 000000000..bba3ddc05 --- /dev/null +++ b/src/app/api/measurements/__tests__/zod-multi-issue.test.ts @@ -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 + * `.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: + (fn: (...args: Args) => Promise) => + (...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); + }); +}); diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index fd07e82a9..ec45296f8 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -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 } = diff --git a/src/app/api/measurements/series/__tests__/route.test.ts b/src/app/api/measurements/series/__tests__/route.test.ts index e93cbbb1d..f84dde299 100644 --- a/src/app/api/measurements/series/__tests__/route.test.ts +++ b/src/app/api/measurements/series/__tests__/route.test.ts @@ -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); + }); +}); diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index a91a6382f..86a5a7f25 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -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; diff --git a/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts new file mode 100644 index 000000000..d36c49bbd --- /dev/null +++ b/src/app/api/medications/[id]/intake/[eventId]/__tests__/route.test.ts @@ -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 }; + }; + 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); + }); +}); diff --git a/src/app/api/medications/[id]/intake/[eventId]/route.ts b/src/app/api/medications/[id]/intake/[eventId]/route.ts index c3271ca50..818d12602 100644 --- a/src/app/api/medications/[id]/intake/[eventId]/route.ts +++ b/src/app/api/medications/[id]/intake/[eventId]/route.ts @@ -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; diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index e5818c69d..2c034508a 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -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: + (fn: (...args: Args) => Promise) => + (...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 }; + }; + 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); + }); +}); diff --git a/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts new file mode 100644 index 000000000..671a11a8b --- /dev/null +++ b/src/app/api/medications/[id]/intake/import/__tests__/route.test.ts @@ -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 }; + 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); + }); +}); diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index b9e129730..ac66aa458 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -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; diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index fa4c1f925..b32611a74 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -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; diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index 9bf740a29..59d6b7b15 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -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 }; + }; + 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); + }); +}); diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts new file mode 100644 index 000000000..1ae2539ea --- /dev/null +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -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: + (fn: (...args: Args) => Promise) => + (...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 }; + 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); + }); +}); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 9eff15f1f..59488331a 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -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 { 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", }); } diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 957b499df..3acff8668 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -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; diff --git a/src/app/api/mood-entries/[id]/__tests__/route.test.ts b/src/app/api/mood-entries/[id]/__tests__/route.test.ts new file mode 100644 index 000000000..b42d92559 --- /dev/null +++ b/src/app/api/mood-entries/[id]/__tests__/route.test.ts @@ -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 }; + }; + 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); + }); +}); diff --git a/src/app/api/mood-entries/[id]/route.ts b/src/app/api/mood-entries/[id]/route.ts index c57faf321..7cec7f92e 100644 --- a/src/app/api/mood-entries/[id]/route.ts +++ b/src/app/api/mood-entries/[id]/route.ts @@ -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; diff --git a/src/app/api/mood-entries/__tests__/route.test.ts b/src/app/api/mood-entries/__tests__/route.test.ts new file mode 100644 index 000000000..34cf8ee17 --- /dev/null +++ b/src/app/api/mood-entries/__tests__/route.test.ts @@ -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: + (fn: (...args: Args) => Promise) => + (...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 }; + }; + 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); + }); +}); diff --git a/src/app/api/mood-entries/bulk/__tests__/route.test.ts b/src/app/api/mood-entries/bulk/__tests__/route.test.ts new file mode 100644 index 000000000..489e29c02 --- /dev/null +++ b/src/app/api/mood-entries/bulk/__tests__/route.test.ts @@ -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: + (fn: (...args: Args) => Promise) => + (...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 }; + 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); + }); +}); diff --git a/src/app/api/mood-entries/bulk/route.ts b/src/app/api/mood-entries/bulk/route.ts index c1090d550..ab445e213 100644 --- a/src/app/api/mood-entries/bulk/route.ts +++ b/src/app/api/mood-entries/bulk/route.ts @@ -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 { 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", }); } diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index 86c1a491d..3a3e1c5ca 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -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; diff --git a/src/app/api/tokens/__tests__/route.test.ts b/src/app/api/tokens/__tests__/route.test.ts new file mode 100644 index 000000000..950f8dee7 --- /dev/null +++ b/src/app/api/tokens/__tests__/route.test.ts @@ -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); + }); +}); diff --git a/src/app/api/tokens/route.ts b/src/app/api/tokens/route.ts index f75f4d795..f8766246b 100644 --- a/src/app/api/tokens/route.ts +++ b/src/app/api/tokens/route.ts @@ -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;