mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
test(anamnesis): drive the life circumstances, AUDIT-C and its backup through real Postgres and the manage matrix
This commit is contained in:
1 parent
88aa5ccacd
commit
4d2c8c2bbb
8 files changed
+718
-16
No files matched your search
@@ -16,7 +16,7 @@ import {
|
||||
correctLifeFact,
|
||||
removeHealthProfileFact,
|
||||
} from "@/lib/profile/health-facts";
|
||||
import { dayStartUtc } from "@/lib/profile/life-derived";
|
||||
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
|
||||
import {
|
||||
validateCorrectedFactValue,
|
||||
type HealthProfileFactKind,
|
||||
@@ -63,7 +63,7 @@ export const PATCH = apiHandler(
|
||||
// circumstances (v1.43, #614).
|
||||
const now = new Date();
|
||||
const validFrom = parsed.data.validFrom
|
||||
? dayStartUtc(parsed.data.validFrom)
|
||||
? dateOnlyAtNoonUtc(parsed.data.validFrom)
|
||||
: undefined;
|
||||
let corrected: { id: string; kind: string } | null;
|
||||
if (isLifeFactKind(current.kind)) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
createLifeFact,
|
||||
readHealthProfileFacts,
|
||||
} from "@/lib/profile/health-facts";
|
||||
import { dayStartUtc } from "@/lib/profile/life-derived";
|
||||
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
|
||||
import { factWriteSchema } from "@/lib/validations/life-circumstances";
|
||||
|
||||
/**
|
||||
@@ -51,7 +51,7 @@ async function postFact(request: NextRequest): Promise<Response> {
|
||||
|
||||
const now = new Date();
|
||||
const validFrom = parsed.data.validFrom
|
||||
? dayStartUtc(parsed.data.validFrom)
|
||||
? dateOnlyAtNoonUtc(parsed.data.validFrom)
|
||||
: undefined;
|
||||
try {
|
||||
const input = parsed.data;
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
* Server-only — reads `@/lib/db`.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
@@ -508,6 +509,8 @@ export interface LifeFactForPrompt {
|
||||
export interface AuditCForPrompt {
|
||||
totalScore: number;
|
||||
takenAt: Date;
|
||||
/** The zone the entry was taken in, for its calendar day. */
|
||||
tz: string;
|
||||
cutoff: number | null;
|
||||
aboveCutoff: boolean | null;
|
||||
}
|
||||
@@ -624,7 +627,7 @@ export function composeLifeCircumstancesText(
|
||||
lines.push(
|
||||
`${L.auditC}: ${fill(L.auditCValue, {
|
||||
score: auditC.totalScore,
|
||||
date: auditC.takenAt.toISOString().slice(0, 10),
|
||||
date: userDayKey(auditC.takenAt, auditC.tz),
|
||||
})}${verdict}`,
|
||||
);
|
||||
}
|
||||
@@ -645,12 +648,12 @@ async function loadAuditCForPrompt(
|
||||
const audit = await prisma.mentalHealthAssessment.findFirst({
|
||||
where: { userId, instrument: "AUDIT_C", deletedAt: null },
|
||||
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
|
||||
select: { totalScore: true, takenAt: true, locale: true },
|
||||
select: { totalScore: true, takenAt: true, locale: true, tz: true },
|
||||
});
|
||||
if (!audit) return null;
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { gender: true, locale: true },
|
||||
select: { gender: true, locale: true, timezone: true },
|
||||
});
|
||||
const cutoff = auditCCutoff(
|
||||
audit.locale,
|
||||
@@ -660,6 +663,7 @@ async function loadAuditCForPrompt(
|
||||
return {
|
||||
totalScore: audit.totalScore,
|
||||
takenAt: audit.takenAt,
|
||||
tz: audit.tz ?? user?.timezone ?? "UTC",
|
||||
cutoff: cutoff?.value ?? null,
|
||||
aboveCutoff: cutoff ? audit.totalScore >= cutoff.value : null,
|
||||
};
|
||||
|
||||
@@ -62,11 +62,6 @@ export const LIFE_KIND_AI_SECTION: Readonly<
|
||||
export const REVIEW_AFTER_DAYS = 365;
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
/** UTC midnight of a `YYYY-MM-DD` calendar day. */
|
||||
export function dayStartUtc(day: string): Date {
|
||||
return new Date(`${day}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
/** Whether an instant is at least `REVIEW_AFTER_DAYS` before `now`. */
|
||||
export function isReviewDue(at: Date | null, now: Date): boolean {
|
||||
return (
|
||||
|
||||
@@ -249,7 +249,7 @@ const validFromDay = z.iso
|
||||
.date()
|
||||
.optional()
|
||||
.describe(
|
||||
"The calendar day the answer holds from (UTC midnight). Omitted, it holds from now. Never later than today; an earlier day than the end of the previous answer of the same kind starts at that end instead.",
|
||||
"The calendar day the answer holds from, stored at noon UTC like every date-only value. Omitted, it holds from now. Never later than today; an earlier day than the end of the previous answer of the same kind starts at that end instead.",
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@ process.env.ENCRYPTION_KEY =
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { restoreProfileData } from "@/lib/export/profile-backup";
|
||||
import { restoreSensitiveData } from "@/lib/export/sensitive-backup";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
@@ -172,4 +173,130 @@ describe("health profile disaster-recovery round trip", () => {
|
||||
await prisma.userHealthProfile.count({ where: { userId: user.id } }),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("carries the life circumstances both ways: new kinds, new AI sections, AUDIT-C (v1.43)", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "life-roundtrip",
|
||||
email: "life-roundtrip@example.test",
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
await prisma.userHealthProfile.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
aiIncludedSections: ["ABOUT_ME", "HABITS", "GOALS"],
|
||||
lifeConfirmedJson: { work: "2026-09-01T10:00:00.000Z" },
|
||||
},
|
||||
});
|
||||
const goals = { schema: 1, goals: ["sleep", "fitness"] };
|
||||
const tobacco = {
|
||||
schema: 1,
|
||||
products: ["cigarettes"],
|
||||
cigarettesPerDay: 10,
|
||||
startYear: 2001,
|
||||
quitMonth: null,
|
||||
secondhandSmoke: null,
|
||||
};
|
||||
await prisma.healthProfileFactRevision.createMany({
|
||||
data: [
|
||||
{
|
||||
id: "goals-current",
|
||||
userId: user.id,
|
||||
kind: "HEALTH_GOALS",
|
||||
valueEncrypted: encryptToBytes(JSON.stringify(goals)),
|
||||
validFrom: new Date("2026-08-01T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "tobacco-current",
|
||||
userId: user.id,
|
||||
kind: "TOBACCO_DETAIL",
|
||||
valueEncrypted: encryptToBytes(JSON.stringify(tobacco)),
|
||||
validFrom: new Date("2026-08-01T00:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
await prisma.mentalHealthAssessment.create({
|
||||
data: {
|
||||
id: "audit-c-entry",
|
||||
userId: user.id,
|
||||
instrument: "AUDIT_C",
|
||||
locale: "de",
|
||||
responsesEncrypted: encryptToBytes(
|
||||
JSON.stringify({ schema: 1, items: [2, 1, 1] }),
|
||||
),
|
||||
totalScore: 4,
|
||||
severityBand: "unbanded",
|
||||
takenAt: new Date("2026-09-15T08:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
|
||||
// Disaster recovery: everything, the AUDIT-C entry included.
|
||||
const dr = parseBackupPayload(
|
||||
JSON.parse(
|
||||
JSON.stringify(
|
||||
(
|
||||
await buildFullBackupPayload(prisma, user.id, {
|
||||
purpose: "disaster-recovery",
|
||||
})
|
||||
).payload,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(dr.healthProfile?.aiIncludedSections).toEqual([
|
||||
"ABOUT_ME",
|
||||
"HABITS",
|
||||
"GOALS",
|
||||
]);
|
||||
expect(dr.healthProfileFacts.map((f) => f.kind).sort()).toEqual([
|
||||
"HEALTH_GOALS",
|
||||
"TOBACCO_DETAIL",
|
||||
]);
|
||||
expect(dr.mentalHealthAssessments.map((a) => a.instrument)).toEqual([
|
||||
"AUDIT_C",
|
||||
]);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await restoreProfileData(tx, user.id, dr);
|
||||
await restoreSensitiveData(tx, user.id, dr);
|
||||
});
|
||||
const restored = await prisma.userHealthProfile.findUniqueOrThrow({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
expect(restored.aiIncludedSections).toEqual([
|
||||
"ABOUT_ME",
|
||||
"HABITS",
|
||||
"GOALS",
|
||||
]);
|
||||
expect(restored.lifeConfirmedJson).toEqual({
|
||||
work: "2026-09-01T10:00:00.000Z",
|
||||
});
|
||||
const audit = await prisma.mentalHealthAssessment.findUniqueOrThrow({
|
||||
where: { id: "audit-c-entry" },
|
||||
});
|
||||
expect(audit.instrument).toBe("AUDIT_C");
|
||||
expect(audit.totalScore).toBe(4);
|
||||
|
||||
// Portable: the values travel readable and come back sealed and valid.
|
||||
const portable = parseBackupPayload(
|
||||
JSON.parse(
|
||||
JSON.stringify((await buildFullBackupPayload(prisma, user.id)).payload),
|
||||
),
|
||||
);
|
||||
const goalsEntry = portable.healthProfileFacts.find(
|
||||
(f) => f.kind === "HEALTH_GOALS",
|
||||
);
|
||||
expect(JSON.parse(goalsEntry!.value!)).toEqual(goals);
|
||||
await prisma.$transaction((tx) =>
|
||||
restoreProfileData(tx, user.id, portable),
|
||||
);
|
||||
const { decryptLifeFactValue } = await import("@/lib/profile/health-facts");
|
||||
const row = await prisma.healthProfileFactRevision.findFirstOrThrow({
|
||||
where: { userId: user.id, kind: "TOBACCO_DETAIL" },
|
||||
});
|
||||
expect(
|
||||
decryptLifeFactValue("TOBACCO_DETAIL", row.valueEncrypted).value,
|
||||
).toEqual(tobacco);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,523 @@
|
||||
/**
|
||||
* 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("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("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("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);
|
||||
});
|
||||
});
|
||||
@@ -282,7 +282,7 @@ const ROUTE_IMPORTS = import.meta.glob<Record<string, unknown>>(
|
||||
|
||||
describe("the complete MANAGE handler matrix", () => {
|
||||
it("starts with a non-empty, exact handler inventory", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(89);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(92);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1169,6 +1169,59 @@ manageContract("POST /api/biomarkers/[id]/merge", {
|
||||
})) === 1,
|
||||
});
|
||||
|
||||
manageContract("POST /api/anamnesis/audit-c", {
|
||||
contract: admittedContract("/api/anamnesis/audit-c", "POST"),
|
||||
prepare: async (ownerId) => ({ ownerId }),
|
||||
act: async () => {
|
||||
const { POST } = await import("@/app/api/anamnesis/audit-c/route");
|
||||
return call(POST as Handler, "POST", "/api/anamnesis/audit-c", {
|
||||
items: [1, 1, 0],
|
||||
source: "WEB",
|
||||
});
|
||||
},
|
||||
ok: 201,
|
||||
auditAction: "anamnesis.audit_c.create",
|
||||
applied: async ({ ownerId }) =>
|
||||
(await getPrismaClient().mentalHealthAssessment.count({
|
||||
where: { userId: ownerId, instrument: "AUDIT_C", deletedAt: null },
|
||||
})) === 1,
|
||||
});
|
||||
|
||||
manageContract("DELETE /api/anamnesis/audit-c/[id]", {
|
||||
contract: admittedContract("/api/anamnesis/audit-c/[id]", "DELETE"),
|
||||
prepare: async (ownerId) =>
|
||||
getPrismaClient().mentalHealthAssessment.create({
|
||||
data: {
|
||||
userId: ownerId,
|
||||
instrument: "AUDIT_C",
|
||||
locale: "en",
|
||||
responsesEncrypted: Buffer.from("sealed"),
|
||||
totalScore: 2,
|
||||
severityBand: "unbanded",
|
||||
takenAt: new Date("2026-09-01T08:00:00.000Z"),
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
act: async (row) => {
|
||||
const { DELETE } = await import("@/app/api/anamnesis/audit-c/[id]/route");
|
||||
return call(
|
||||
DELETE as Handler,
|
||||
"DELETE",
|
||||
`/api/anamnesis/audit-c/${row.id}`,
|
||||
undefined,
|
||||
{ id: row.id },
|
||||
);
|
||||
},
|
||||
ok: 200,
|
||||
auditAction: "anamnesis.audit_c.delete",
|
||||
applied: async (row) =>
|
||||
(
|
||||
await getPrismaClient().mentalHealthAssessment.findUnique({
|
||||
where: { id: row.id },
|
||||
})
|
||||
)?.deletedAt != null,
|
||||
});
|
||||
|
||||
manageContract("POST /api/allergies", {
|
||||
contract: ADMITTED_MUTATING_HANDLERS.find(
|
||||
(entry) => entry.route === "/api/allergies" && entry.action === "POST",
|
||||
@@ -2953,13 +3006,13 @@ describe("the conditions the admissions were granted on", () => {
|
||||
describe("the complete MANAGE handler matrix", () => {
|
||||
it("registers one strict actor-and-effect driver for every admission", () => {
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
|
||||
expect(STRICT_DRIVER_KEYS.size).toBe(89);
|
||||
expect(STRICT_DRIVER_KEYS.size).toBe(92);
|
||||
expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected);
|
||||
});
|
||||
|
||||
it("executes every registered driver through its owned effect and actor audit", () => {
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
|
||||
expect(REAL_EFFECT_KEYS.size).toBe(89);
|
||||
expect(REAL_EFFECT_KEYS.size).toBe(92);
|
||||
expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user