mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
merge: QA fix mental-health into release/v1.25.0
This commit is contained in:
commit
8e4c107a18
19 files changed
+606
-104
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();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
@@ -26,6 +27,9 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
createAssessmentSchema,
|
||||
@@ -73,6 +77,11 @@ function shapeRow(row: {
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
// Opt-in module (default OFF): the screener history is unreachable until the
|
||||
// account turns the mental-health module on. Enforced server-side even for a
|
||||
// valid Bearer token (the nav entry / page redirect are UX-only).
|
||||
const gate = await requireModuleEnabled(user.id, "mentalHealth");
|
||||
if (!gate.enabled) return gate.response;
|
||||
const params = Object.fromEntries(request.nextUrl.searchParams);
|
||||
const parsed = listAssessmentsSchema.safeParse(params);
|
||||
if (!parsed.success) {
|
||||
@@ -112,9 +121,48 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
return apiSuccess({ assessments: rows.map(shapeRow) });
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// `withIdempotency` lets a double-tap / retry re-send the same
|
||||
// `Idempotency-Key` without minting a duplicate assessment row AND a duplicate
|
||||
// `*_SCORE` Measurement row that would skew the trend (the allergy / labs
|
||||
// create precedent).
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postAssessment));
|
||||
|
||||
async function postAssessment(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
const body = await safeJson(request);
|
||||
|
||||
// Opt-in module (default OFF) — same gate as GET.
|
||||
const gate = await requireModuleEnabled(user.id, "mentalHealth");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
// Write rate-limit: a screener is a deliberate, infrequent action, so the
|
||||
// bucket is tight — it only caps a double-tap / scripted loop minting
|
||||
// duplicate trend points (the recall window is 2 weeks; 30/hour is generous).
|
||||
const rl = await checkRateLimit(
|
||||
`mental-health-create:${user.id}`,
|
||||
30,
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const res = apiError(
|
||||
"Too many check-ins. Please wait before submitting another.",
|
||||
429,
|
||||
{ errorCode: "mentalHealth.rateLimited" },
|
||||
);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
res.headers.set(k, v);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// Bounded body read BEFORE validation (the item answers are small; cap so an
|
||||
// unbounded body can never reach `request.json()`). Destructure the
|
||||
// `{ data, error }` envelope — passing the wrapper to `safeParse` would never
|
||||
// match the schema, which is what left this route dead-on-arrival.
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 8 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = createAssessmentSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
@@ -210,4 +258,4 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
},
|
||||
201,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user