test(mental-health): cover PHQ-9/GAD-7 scoring and the assessment POST

Pin the highest-consequence numbers in the release: every PHQ-9/GAD-7 severity
band edge, the >=10 action threshold, and the item-9 ANY-non-zero safety flag
(true on item9 1/2/3, false on 0 or for GAD-7). Add a POST happy-path test
that would have caught the dead-on-arrival bug — a valid body now parses,
persists server-authoritative scoring, writes the derived score measurement,
and surfaces the crisis set on a positive item 9.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 18:25:43 +02:00
1 parent 12579466c0
commit d9c419aab2
2 files changed
+305

No files matched your search

@@ -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<typeof import("@/lib/modules/gate")>()),
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<Response>;
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();
});
});
@@ -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();
});
});
});