diff --git a/src/app/api/mental-health/assessments/__tests__/route.test.ts b/src/app/api/mental-health/assessments/__tests__/route.test.ts new file mode 100644 index 000000000..40bdc9bea --- /dev/null +++ b/src/app/api/mental-health/assessments/__tests__/route.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * v1.25.0 — `POST /api/mental-health/assessments` happy path. + * + * Regression guard for the dead-on-arrival bug: the handler used to pass the + * `safeJson` `{ data, error }` WRAPPER straight into `safeParse`, so the schema + * never matched and every POST returned 422. This pins that a valid body now + * (a) parses, (b) persists with a server-authoritative total / band / item-9 + * flag, (c) writes the derived `*_SCORE` Measurement, and (d) surfaces the + * locale-aware crisis set on a positive item 9. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + appSettings: { findUnique: vi.fn().mockResolvedValue(null) }, + mentalHealthAssessment: { create: vi.fn() }, + measurement: { create: vi.fn().mockResolvedValue({}) }, + }, +})); + +vi.mock("@/lib/modules/gate", async (importOriginal) => ({ + ...(await importOriginal()), + requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }), + resolveModuleMap: vi.fn().mockResolvedValue({}), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi + .fn() + .mockResolvedValue({ allowed: true, remaining: 29, resetAt: Date.now() }), + rateLimitHeaders: vi.fn().mockReturnValue({}), +})); + +vi.mock("@/lib/ai/coach/bytes-codec", () => ({ + encryptToBytes: vi.fn(() => Buffer.from("ciphertext")), +})); + +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("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { getSession } from "@/lib/auth/session"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { prisma } from "@/lib/db"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + locale: "en", + }, +}; + +const callPost = POST as unknown as (req: NextRequest) => Promise; + +function makeReq(body: unknown): NextRequest { + return new NextRequest( + new URL("http://localhost/api/mental-health/assessments"), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }, + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 29, + resetAt: Date.now(), + }); +}); + +describe("POST /api/mental-health/assessments", () => { + it("parses a valid PHQ-9 body and returns 201 with the server-computed total/band/flag", async () => { + // Item 9 = 2 → safety-flagged; total = 8*1 + 2 = 10 → moderate band. + vi.mocked(prisma.mentalHealthAssessment.create).mockResolvedValue({ + id: "mha_1", + instrument: "PHQ9", + locale: "en", + version: "standard", + totalScore: 10, + severityBand: "moderate", + item9Flagged: true, + crisisShownAt: new Date("2026-06-28T00:00:00.000Z"), + takenAt: new Date("2026-06-28T00:00:00.000Z"), + createdAt: new Date("2026-06-28T00:00:00.000Z"), + } as never); + + const res = await callPost( + makeReq({ + instrument: "PHQ9", + items: [1, 1, 1, 1, 1, 1, 1, 1, 2], + locale: "en", + }), + ); + + expect(res.status).toBe(201); + const body = (await res.json()) as { + data: { + assessment: { + totalScore: number; + severityBand: string; + item9Flagged: boolean; + }; + actionThreshold: number; + crisis: { emergencyNumber: string } | null; + }; + }; + expect(body.data.assessment.totalScore).toBe(10); + expect(body.data.assessment.severityBand).toBe("moderate"); + expect(body.data.assessment.item9Flagged).toBe(true); + expect(body.data.actionThreshold).toBe(10); + // Positive item 9 → crisis resources surfaced immediately. + expect(body.data.crisis).not.toBeNull(); + expect(body.data.crisis?.emergencyNumber).toBeTruthy(); + }); + + it("persists server-authoritative scoring (userId from session, total recomputed) + the derived measurement", async () => { + vi.mocked(prisma.mentalHealthAssessment.create).mockResolvedValue({ + id: "mha_2", + instrument: "GAD7", + locale: "en", + version: "standard", + totalScore: 6, + severityBand: "mild", + item9Flagged: false, + crisisShownAt: null, + takenAt: new Date("2026-06-28T00:00:00.000Z"), + createdAt: new Date("2026-06-28T00:00:00.000Z"), + } as never); + + // GAD-7, total 1+2+0+1+1+1+0 = 6 → mild, no safety item. + const res = await callPost( + makeReq({ + instrument: "GAD7", + items: [1, 2, 0, 1, 1, 1, 0], + }), + ); + expect(res.status).toBe(201); + + const createArg = vi.mocked(prisma.mentalHealthAssessment.create).mock + .calls[0][0] as { + data: { + userId: string; + instrument: string; + totalScore: number; + severityBand: string; + item9Flagged: boolean; + }; + }; + expect(createArg.data.userId).toBe("user-1"); + expect(createArg.data.instrument).toBe("GAD7"); + expect(createArg.data.totalScore).toBe(6); + expect(createArg.data.severityBand).toBe("mild"); + expect(createArg.data.item9Flagged).toBe(false); + + // Derived projection: one PHQ9_SCORE/GAD7_SCORE Measurement per assessment. + const measArg = vi.mocked(prisma.measurement.create).mock.calls[0][0] as { + data: { type: string; value: number; unit: string; externalId: string }; + }; + expect(measArg.data.type).toBe("GAD7_SCORE"); + expect(measArg.data.value).toBe(6); + expect(measArg.data.unit).toBe("score"); + expect(measArg.data.externalId).toBe("assessment:mha_2"); + }); + + it("rejects a wrong item count with 422 and never writes", async () => { + const res = await callPost( + makeReq({ instrument: "PHQ9", items: [1, 2, 3] }), // 3 ≠ 9 + ); + expect(res.status).toBe(422); + expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled(); + }); + + it("returns the module 403 when the mental-health module is off", async () => { + const { apiError } = await import("@/lib/api-response"); + vi.mocked(requireModuleEnabled).mockResolvedValue({ + enabled: false, + response: apiError("disabled", 403, { errorCode: "module.disabled" }), + }); + const res = await callPost( + makeReq({ instrument: "PHQ9", items: [0, 0, 0, 0, 0, 0, 0, 0, 0] }), + ); + expect(res.status).toBe(403); + expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/mental-health/__tests__/instruments.test.ts b/src/lib/mental-health/__tests__/instruments.test.ts new file mode 100644 index 000000000..a97e92853 --- /dev/null +++ b/src/lib/mental-health/__tests__/instruments.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; + +import { + INSTRUMENTS, + scoreTotal, + severityBand, + isSafetyFlagged, +} from "../instruments"; + +/** + * v1.25.0 — pin the highest-consequence numbers in the release: the PHQ-9 / + * GAD-7 totals, the validated severity-band edges (5 / 10 / 15 / 20 and + * 5 / 10 / 15), the ≥10 action threshold, and the item-9 ANY-non-zero safety + * flag. A future off-by-one on any band edge or the safety predicate must fail + * here. Ground truth: Kroenke 2001 (PHQ-9) / Spitzer 2006 (GAD-7), mirrored in + * `.planning/research/v125/clinical-instruments.md`. + */ +describe("mental-health instruments", () => { + describe("scoreTotal", () => { + it("sums the item answers", () => { + expect(scoreTotal([0, 1, 2, 3, 0, 1, 2, 3, 1])).toBe(13); + expect(scoreTotal([0, 0, 0, 0, 0, 0, 0])).toBe(0); + }); + + it("returns the max when every item is 3", () => { + expect(scoreTotal(Array(9).fill(3))).toBe(27); // PHQ-9 max + expect(scoreTotal(Array(7).fill(3))).toBe(21); // GAD-7 max + }); + }); + + describe("severityBand — PHQ-9 edges (0–4 / 5–9 / 10–14 / 15–19 / 20–27)", () => { + const cases: Array<[number, string]> = [ + [0, "minimal"], + [4, "minimal"], + [5, "mild"], + [9, "mild"], + [10, "moderate"], + [14, "moderate"], + [15, "modSevere"], + [19, "modSevere"], + [20, "severe"], + [27, "severe"], + ]; + it.each(cases)("PHQ9 total %i → %s", (total, band) => { + expect(severityBand("PHQ9", total)).toBe(band); + }); + }); + + describe("severityBand — GAD-7 edges (0–4 / 5–9 / 10–14 / 15–21)", () => { + const cases: Array<[number, string]> = [ + [0, "minimal"], + [4, "minimal"], + [5, "mild"], + [9, "mild"], + [10, "moderate"], + [14, "moderate"], + [15, "severe"], + [21, "severe"], + ]; + it.each(cases)("GAD7 total %i → %s", (total, band) => { + expect(severityBand("GAD7", total)).toBe(band); + }); + }); + + describe("action threshold is ≥10 for both instruments", () => { + it("PHQ-9", () => { + expect(INSTRUMENTS.PHQ9.actionThreshold).toBe(10); + }); + it("GAD-7", () => { + expect(INSTRUMENTS.GAD7.actionThreshold).toBe(10); + }); + }); + + describe("isSafetyFlagged — PHQ-9 item 9 (index 8), ANY non-zero", () => { + it("flags when item 9 = 1, 2, or 3 regardless of total", () => { + for (const answer of [1, 2, 3]) { + const items = [0, 0, 0, 0, 0, 0, 0, 0, answer]; + expect(isSafetyFlagged("PHQ9", items)).toBe(true); + } + }); + + it("does not flag when item 9 = 0 even with a high total", () => { + // Every other item maxed (24), item 9 = 0 → severe band, no flag. + const items = [3, 3, 3, 3, 3, 3, 3, 3, 0]; + expect(severityBand("PHQ9", scoreTotal(items))).toBe("severe"); + expect(isSafetyFlagged("PHQ9", items)).toBe(false); + }); + + it("never flags GAD-7 (no safety item)", () => { + expect(isSafetyFlagged("GAD7", [3, 3, 3, 3, 3, 3, 3])).toBe(false); + expect(INSTRUMENTS.GAD7.safetyItemIndex).toBeNull(); + }); + }); +});