Files
HealthLog/tests/integration/life-circumstances.test.ts
T

672 lines
22 KiB
TypeScript

/**
* v1.43 (#614) — the life circumstances through the real routes against real
* Postgres: every new fact kind written, corrected and removed and read back
* through `GET /api/anamnesis/life-circumstances`; the closed legacy lists
* (`GET /api/anamnesis/facts`, `GET /api/mental-health/assessments`) never
* showing a new kind or AUDIT-C; the AUDIT-C cut-off from the entry's
* language and the profile's sex; the AI switches leaving the about-me wire
* at its eight values; and the sealed values never sitting in the clear.
*/
import { Buffer } from "node:buffer";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
type Handler = (request: Request, ctx?: unknown) => Promise<Response>;
let counter = 0;
async function makeUser(overrides: Record<string, unknown> = {}) {
const suffix = `${counter++}`;
return getPrismaClient().user.create({
data: {
username: `life-${suffix}`,
email: `life-${suffix}@example.test`,
role: "USER",
timezone: "UTC",
locale: "en",
onboardingCompletedAt: new Date(),
modulePreferencesJson: { mentalHealth: true },
...overrides,
},
});
}
async function signIn(userId: string) {
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
}
function request(method: string, path: string, body?: unknown): Request {
return new Request(`http://localhost${path}`, {
method,
headers: { "content-type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
}
async function data<T>(response: Response, status = 200): Promise<T> {
const text = await response.text();
expect(response.status, text).toBe(status);
return (JSON.parse(text) as { data: T }).data;
}
async function postFact(body: unknown): Promise<Response> {
const { POST } = await import("@/app/api/anamnesis/facts/route");
return (POST as unknown as Handler)(
request("POST", "/api/anamnesis/facts", body),
);
}
async function patchFact(id: string, body: unknown): Promise<Response> {
const { PATCH } = await import("@/app/api/anamnesis/facts/[id]/route");
return (PATCH as unknown as Handler)(
request("PATCH", `/api/anamnesis/facts/${id}`, body),
{ params: Promise.resolve({ id }) },
);
}
async function deleteFact(id: string): Promise<Response> {
const { DELETE } = await import("@/app/api/anamnesis/facts/[id]/route");
return (DELETE as unknown as Handler)(
request("DELETE", `/api/anamnesis/facts/${id}`),
{ params: Promise.resolve({ id }) },
);
}
interface Life {
sections: Array<{
key: string;
aiIncluded: boolean;
lastConfirmedAt: string | null;
facts: Array<{ id: string; kind: string; value: unknown }>;
}>;
derived: {
packYears: { value: number | null; status: string };
activityMinutesPerWeek: {
value: number | null;
meetsWho150: boolean | null;
};
freeWeekdays: { days: number[]; source: string };
};
auditC: {
latest: {
id: string;
totalScore: number;
cutoff: { value: number; source: string } | null;
aboveCutoff: boolean | null;
} | null;
history: Array<{ id: string }>;
offerDue: boolean;
instrument: { locale: string; validatedLocale: boolean };
};
}
async function readLife(): Promise<Life> {
const { GET } = await import("@/app/api/anamnesis/life-circumstances/route");
return data<Life>(await (GET as unknown as () => Promise<Response>)());
}
function factOf(life: Life, kind: string) {
return life.sections.flatMap((s) => s.facts).find((f) => f.kind === kind);
}
const VALUES = {
TOBACCO_DETAIL: {
schema: 1,
products: ["cigarettes"],
cigarettesPerDay: 20,
startYear: 1990,
quitMonth: "2020-06",
secondhandSmoke: false,
},
ACTIVITY_HABIT: {
schema: 1,
moderateDaysPerWeek: 3,
moderateMinutesPerDay: 40,
sittingHoursWorkday: 7.5,
},
WORK_CONTEXT: {
schema: 1,
situation: "outdoor",
physicalNature: "heavy_physical",
nightWork: null,
exposures: ["heat", "dust"],
},
FREE_WEEKDAYS: { schema: 1, days: [2, 3] },
HABITS: {
schema: 1,
napRegularly: true,
snoringReported: null,
dietPattern: "vegetarian",
dietSpecifics: ["low_carb"],
lateMealsPerWeek: 2,
fruitVegPortionsPerDay: 4,
caffeineDrinksPerDay: 3,
latestCaffeine: "15_18",
},
HEALTH_GOALS: { schema: 1, goals: ["sleep", "fitness"] },
} as const;
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
counter = 0;
});
describe("life-circumstance facts", () => {
it("writes, corrects and removes every new kind, and the closed list never shows one", async () => {
const user = await makeUser();
await signIn(user.id);
await data(
await postFact({ kind: "SMOKING_STATUS", value: "FORMER" }),
201,
);
for (const [kind, value] of Object.entries(VALUES)) {
await data(await postFact({ kind, value }), 201);
}
let life = await readLife();
for (const [kind, value] of Object.entries(VALUES)) {
expect(factOf(life, kind)?.value, kind).toEqual(value);
}
expect(life.sections.map((s) => s.key)).toEqual([
"tobacco",
"alcohol",
"activity",
"work",
"habits",
"goals",
]);
expect(life.derived.packYears).toEqual({ value: 30, status: "computed" });
expect(life.derived.activityMinutesPerWeek).toMatchObject({
value: 120,
meetsWho150: false,
});
expect(life.derived.freeWeekdays).toEqual({ days: [2, 3], source: "fact" });
// A second current answer of the same kind is the existing 409.
expect(
(await postFact({ kind: "HABITS", value: VALUES.HABITS })).status,
).toBe(409);
// Correct: a dated successor with the new value.
const goals = factOf(life, "HEALTH_GOALS")!;
const corrected = await data<{ id: string; value: unknown }>(
await patchFact(goals.id, {
value: { schema: 1, goals: ["quit_smoking"] },
validFrom: "2026-01-01",
}),
);
expect(corrected.value).toEqual({ schema: 1, goals: ["quit_smoking"] });
// A string for an object kind is the existing 422.
expect((await patchFact(corrected.id, { value: "SLEEP" })).status).toBe(
422,
);
// Remove: the interval closes; the kind reads as not recorded.
const habits = factOf(life, "HABITS")!;
await data(await deleteFact(habits.id));
life = await readLife();
expect(factOf(life, "HABITS")).toBeUndefined();
expect(factOf(life, "HEALTH_GOALS")?.value).toEqual({
schema: 1,
goals: ["quit_smoking"],
});
// The closed list: three kinds, nothing else, in `current` and `history`.
const { GET } = await import("@/app/api/anamnesis/facts/route");
const legacy = await data<{
current: Record<string, unknown>;
history: Array<{ kind: string }>;
}>(await (GET as unknown as () => Promise<Response>)());
expect(Object.keys(legacy.current).sort()).toEqual([
"ALCOHOL_PATTERN",
"SHIFT_SCHEDULE",
"SMOKING_STATUS",
]);
expect(
legacy.history.every((row) =>
["SMOKING_STATUS", "ALCOHOL_PATTERN", "SHIFT_SCHEDULE"].includes(
row.kind,
),
),
).toBe(true);
expect(legacy.history).toHaveLength(1);
});
it("seals the values: no answer sits in the clear", async () => {
const user = await makeUser();
await signIn(user.id);
await data(
await postFact({ kind: "WORK_CONTEXT", value: VALUES.WORK_CONTEXT }),
201,
);
const row =
await getPrismaClient().healthProfileFactRevision.findFirstOrThrow({
where: { userId: user.id, kind: "WORK_CONTEXT" },
});
const raw = Buffer.from(row.valueEncrypted).toString("latin1");
expect(raw).not.toContain("outdoor");
expect(raw).not.toContain("heavy_physical");
});
it("dates an answer back to the month given", async () => {
const user = await makeUser();
await signIn(user.id);
const created = await data<{ validFrom: string }>(
await postFact({
kind: "FREE_WEEKDAYS",
value: VALUES.FREE_WEEKDAYS,
validFrom: "2025-04-01",
}),
201,
);
expect(created.validFrom).toBe("2025-04-01T12:00:00.000Z");
});
});
describe("free weekdays in a prompt", () => {
it("are read for a prompt only while the work section is released", async () => {
const user = await makeUser();
await signIn(user.id);
await data(
await postFact({ kind: "FREE_WEEKDAYS", value: VALUES.FREE_WEEKDAYS }),
201,
);
const { loadFreeWeekdaySpans, loadFreeWeekdaySpansForPrompt } =
await import("@/lib/profile/life-circumstances");
await getPrismaClient().userHealthProfile.upsert({
where: { userId: user.id },
create: { userId: user.id, aiIncludedSections: ["WORK"] },
update: { aiIncludedSections: ["WORK"] },
});
expect(await loadFreeWeekdaySpansForPrompt(user.id)).toHaveLength(1);
await getPrismaClient().userHealthProfile.update({
where: { userId: user.id },
data: { aiIncludedSections: ["ABOUT_ME"] },
});
expect(await loadFreeWeekdaySpansForPrompt(user.id)).toEqual([]);
// The data surfaces still show the person their own answer.
expect(await loadFreeWeekdaySpans(user.id)).toHaveLength(1);
});
});
describe("AUDIT-C", () => {
async function postAuditC(body: unknown): Promise<Response> {
const { POST } = await import("@/app/api/anamnesis/audit-c/route");
return (POST as unknown as Handler)(
request("POST", "/api/anamnesis/audit-c", body),
);
}
it("scores a German entry of a woman with sum 4 at the S3 cut-off, and none without a sex", async () => {
const woman = await makeUser({ locale: "de", gender: "FEMALE" });
await signIn(woman.id);
const { result } = await data<{
result: {
totalScore: number;
aboveCutoff: boolean | null;
cutoff: { value: number; source: string; sourceLabel: string } | null;
validatedLocale: boolean;
};
}>(
await postAuditC({ items: [2, 1, 1], locale: "de", source: "WEB" }),
201,
);
expect(result.totalScore).toBe(4);
expect(result.aboveCutoff).toBe(true);
expect(result.cutoff).toMatchObject({ value: 4, source: "S3_ALCOHOL_DE" });
expect(result.validatedLocale).toBe(true);
const nobody = await makeUser({ locale: "de", gender: null });
await signIn(nobody.id);
const unsexed = await data<{
result: { cutoff: unknown; aboveCutoff: unknown };
}>(
await postAuditC({ items: [2, 1, 1], locale: "de", source: "WEB" }),
201,
);
expect(unsexed.result.cutoff).toBeNull();
expect(unsexed.result.aboveCutoff).toBeNull();
});
it("replays one externalId to the same entry and never lists it among the screeners", async () => {
const user = await makeUser({ locale: "fr", gender: "MALE" });
await signIn(user.id);
const body = {
items: [1, 0, 0],
source: "IOS",
externalId: "outbox-audit-1",
};
const first = await data<{ result: { id: string } }>(
await postAuditC(body),
201,
);
const again = await data<{ result: { id: string } }>(
await postAuditC(body),
200,
);
expect(again.result.id).toBe(first.result.id);
// A screener with the same client id is its own record, not this one.
const { POST: postScreener, GET: listScreeners } =
await import("@/app/api/mental-health/assessments/route");
const screener = await (postScreener as unknown as Handler)(
request("POST", "/api/mental-health/assessments", {
instrument: "WHO5",
items: [3, 3, 3, 3, 3],
source: "IOS",
externalId: "outbox-audit-1",
}),
);
expect(screener.status).toBe(201);
const { NextRequest } = await import("next/server");
const list = await data<{ assessments: Array<{ instrument: string }> }>(
await (listScreeners as unknown as (r: Request) => Promise<Response>)(
new NextRequest("http://localhost/api/mental-health/assessments"),
),
);
expect(list.assessments.map((a) => a.instrument)).toEqual(["WHO5"]);
// The detail route answers 404 for an AUDIT-C id.
const { GET: detail } =
await import("@/app/api/mental-health/assessments/[id]/route");
const byId = await (detail as unknown as Handler)(
new NextRequest(
`http://localhost/api/mental-health/assessments/${first.result.id}`,
),
{ params: Promise.resolve({ id: first.result.id }) },
);
expect(byId.status).toBe(404);
// The read route carries it, with the English items for French.
const life = await readLife();
expect(life.auditC.latest?.id).toBe(first.result.id);
expect(life.auditC.offerDue).toBe(false);
expect(life.auditC.instrument).toMatchObject({
locale: "en",
validatedLocale: false,
});
});
it("scores against the wording offered and refuses another one or a future date", async () => {
// An English account is shown the English wording; a German cut-off for
// it would be the stricter S3 table applied to questions never asked.
const man = await makeUser({ locale: "en", gender: "MALE" });
await signIn(man.id);
const wrong = await postAuditC({
items: [2, 1, 1],
locale: "de",
source: "IOS",
});
expect(wrong.status).toBe(422);
const { result } = await data<{
result: { locale: string; cutoff: { source: string } | null };
}>(await postAuditC({ items: [2, 1, 1], source: "IOS" }), 201);
expect(result.locale).toBe("en");
expect(result.cutoff?.source).toBe("BRADLEY_2007");
// French has no validated wording, so the English one is offered and
// stored, whatever the account language says.
const french = await makeUser({ locale: "fr", gender: "MALE" });
await signIn(french.id);
const fr = await data<{ result: { locale: string } }>(
await postAuditC({ items: [0, 0, 1], locale: "fr", source: "WEB" }),
201,
);
expect(fr.result.locale).toBe("en");
const future = await postAuditC({
items: [0, 0, 0],
source: "WEB",
takenAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
});
expect(future.status).toBe(422);
const skew = await postAuditC({
items: [0, 0, 0],
source: "WEB",
takenAt: new Date(Date.now() + 60 * 1000).toISOString(),
});
expect(skew.status).toBe(201);
});
it("reads latest and the offer from the newest entry whose answers open", async () => {
const user = await makeUser();
await signIn(user.id);
const older = await data<{ result: { id: string } }>(
await postAuditC({
items: [1, 0, 0],
source: "WEB",
takenAt: "2025-01-10T09:00:00.000Z",
}),
201,
);
// A newer entry this host cannot open (its key is gone).
const sealed = await getPrismaClient().mentalHealthAssessment.create({
data: {
userId: user.id,
instrument: "AUDIT_C",
locale: "en",
responsesEncrypted: new Uint8Array([9, 9, 9, 9]),
totalScore: 11,
severityBand: "unbanded",
takenAt: new Date(),
source: "WEB",
},
});
const life = await readLife();
expect(life.auditC.latest?.id).toBe(older.result.id);
// Timed from the result shown, not from the entry nobody can read.
expect(life.auditC.offerDue).toBe(true);
// Still listed, so it can be removed.
expect(life.auditC.history.map((h) => h.id)).toContain(sealed.id);
});
it("soft-deletes an entry once, and only an AUDIT-C entry of the record", async () => {
const user = await makeUser();
await signIn(user.id);
const { result } = await data<{ result: { id: string } }>(
await postAuditC({ items: [0, 0, 0], source: "WEB" }),
201,
);
const { DELETE } = await import("@/app/api/anamnesis/audit-c/[id]/route");
const remove = (id: string) =>
(DELETE as unknown as Handler)(
request("DELETE", `/api/anamnesis/audit-c/${id}`),
{ params: Promise.resolve({ id }) },
);
const removed = await data<{ id: string; removedAt: string }>(
await remove(result.id),
);
expect(removed.id).toBe(result.id);
const again = await remove(result.id);
expect(again.status).toBe(404);
expect(
((await again.json()) as { error: { code?: string }; meta?: unknown })
.error,
).toBeTruthy();
const row =
await getPrismaClient().mentalHealthAssessment.findUniqueOrThrow({
where: { id: result.id },
});
expect(row.deletedAt).not.toBeNull();
expect((await readLife()).auditC.latest).toBeNull();
});
});
describe("AI switches and confirmations", () => {
it("switches a life section without showing it on the about-me wire, and a PUT there keeps it", async () => {
const user = await makeUser({
modulePreferencesJson: { mentalHealth: true, coach: true },
});
await signIn(user.id);
const { PATCH } =
await import("@/app/api/anamnesis/life-circumstances/ai-sections/route");
const switched = await data<{ aiSections: Record<string, boolean> }>(
await (PATCH as unknown as Handler)(
request("PATCH", "/api/anamnesis/life-circumstances/ai-sections", {
sections: { HABITS: true, SMOKING_STATUS: false },
}),
),
);
expect(switched.aiSections).toMatchObject({
HABITS: true,
SMOKING_STATUS: false,
ACTIVITY: false,
});
const life = await readLife();
expect(life.sections.find((s) => s.key === "habits")?.aiIncluded).toBe(
true,
);
expect(life.sections.find((s) => s.key === "tobacco")?.aiIncluded).toBe(
false,
);
const aboutMe = await import("@/app/api/coach/about-me/route");
const read = await data<{ aiIncludedSections: string[] }>(
await (aboutMe.GET as unknown as () => Promise<Response>)(),
);
expect(read.aiIncludedSections).not.toContain("HABITS");
await data(
await (aboutMe.PUT as unknown as Handler)(
request("PUT", "/api/coach/about-me", {
aiIncludedSections: ["ABOUT_ME"],
}),
),
);
const stored = await getPrismaClient().userHealthProfile.findUniqueOrThrow({
where: { userId: user.id },
});
expect([...stored.aiIncludedSections].sort()).toEqual([
"ABOUT_ME",
"HABITS",
]);
});
it("never turns a section on again that a concurrent switch turned off", async () => {
const user = await makeUser({
modulePreferencesJson: { mentalHealth: true, coach: true },
});
await signIn(user.id);
const prisma = getPrismaClient();
await prisma.userHealthProfile.create({
data: { userId: user.id, aiIncludedSections: ["ABOUT_ME", "HABITS"] },
});
const { lockAiSections } = await import("@/lib/profile/life-circumstances");
const aboutMe = await import("@/app/api/coach/about-me/route");
// The switch-off holds its transaction open while the PUT starts.
let release!: () => void;
const held = new Promise<void>((resolve) => (release = resolve));
let locked!: () => void;
const isLocked = new Promise<void>((resolve) => (locked = resolve));
const switchOff = prisma.$transaction(async (tx) => {
await lockAiSections(tx, user.id);
await tx.userHealthProfile.update({
where: { userId: user.id },
data: { aiIncludedSections: ["ABOUT_ME"] },
});
locked();
await held;
});
await isLocked;
const put = (aboutMe.PUT as unknown as Handler)(
request("PUT", "/api/coach/about-me", {
aiIncludedSections: ["ABOUT_ME", "CONDITIONS"],
}),
);
// Long enough for an unlocked PUT to have read the old array.
await new Promise((resolve) => setTimeout(resolve, 300));
release();
await switchOff;
await data(await put);
const stored = await prisma.userHealthProfile.findUniqueOrThrow({
where: { userId: user.id },
});
expect([...stored.aiIncludedSections].sort()).toEqual([
"ABOUT_ME",
"CONDITIONS",
]);
});
it("answers a stale token with about_me_conflict", async () => {
const user = await makeUser();
await signIn(user.id);
await getPrismaClient().userHealthProfile.create({
data: { userId: user.id },
});
const { PATCH } =
await import("@/app/api/anamnesis/life-circumstances/ai-sections/route");
const response = await (PATCH as unknown as Handler)(
request("PATCH", "/api/anamnesis/life-circumstances/ai-sections", {
sections: { WORK: true },
baseUpdatedAt: "2020-01-01T00:00:00.000Z",
}),
);
expect(response.status).toBe(409);
expect(
((await response.json()) as { error: { errorCode?: string } }).error,
).toBeTruthy();
});
it("stores a confirmation as a stamp, never as a revision", async () => {
const user = await makeUser();
await signIn(user.id);
const { POST } =
await import("@/app/api/anamnesis/life-circumstances/confirm/route");
const confirmed = await data<{ section: string; lastConfirmedAt: string }>(
await (POST as unknown as Handler)(
request("POST", "/api/anamnesis/life-circumstances/confirm", {
section: "work",
}),
),
);
expect(confirmed.section).toBe("work");
const life = await readLife();
expect(life.sections.find((s) => s.key === "work")?.lastConfirmedAt).toBe(
confirmed.lastConfirmedAt,
);
expect(
await getPrismaClient().healthProfileFactRevision.count({
where: { userId: user.id },
}),
).toBe(0);
});
});