diff --git a/src/app/api/anamnesis/facts/[id]/route.ts b/src/app/api/anamnesis/facts/[id]/route.ts index 0e7f4d227..5551cd635 100644 --- a/src/app/api/anamnesis/facts/[id]/route.ts +++ b/src/app/api/anamnesis/facts/[id]/route.ts @@ -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)) { diff --git a/src/app/api/anamnesis/facts/route.ts b/src/app/api/anamnesis/facts/route.ts index 31f91b2f2..5d912b023 100644 --- a/src/app/api/anamnesis/facts/route.ts +++ b/src/app/api/anamnesis/facts/route.ts @@ -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 { const now = new Date(); const validFrom = parsed.data.validFrom - ? dayStartUtc(parsed.data.validFrom) + ? dateOnlyAtNoonUtc(parsed.data.validFrom) : undefined; try { const input = parsed.data; diff --git a/src/lib/ai/coach/about-me.ts b/src/lib/ai/coach/about-me.ts index bb6f0395b..4b5423721 100644 --- a/src/lib/ai/coach/about-me.ts +++ b/src/lib/ai/coach/about-me.ts @@ -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, }; diff --git a/src/lib/profile/life-derived.ts b/src/lib/profile/life-derived.ts index ac5ca21c2..3d01fe58c 100644 --- a/src/lib/profile/life-derived.ts +++ b/src/lib/profile/life-derived.ts @@ -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 ( diff --git a/src/lib/validations/life-circumstances.ts b/src/lib/validations/life-circumstances.ts index ae5287b62..c7a6e5ece 100644 --- a/src/lib/validations/life-circumstances.ts +++ b/src/lib/validations/life-circumstances.ts @@ -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.", ); /** diff --git a/tests/integration/health-profile-backup-roundtrip.test.ts b/tests/integration/health-profile-backup-roundtrip.test.ts index abe77d5ae..be2ce8241 100644 --- a/tests/integration/health-profile-backup-roundtrip.test.ts +++ b/tests/integration/health-profile-backup-roundtrip.test.ts @@ -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); + }); }); diff --git a/tests/integration/life-circumstances.test.ts b/tests/integration/life-circumstances.test.ts new file mode 100644 index 000000000..fa5088705 --- /dev/null +++ b/tests/integration/life-circumstances.test.ts @@ -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; + +let counter = 0; + +async function makeUser(overrides: Record = {}) { + 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(response: Response, status = 200): Promise { + 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 { + 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 { + 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 { + 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 { + const { GET } = await import("@/app/api/anamnesis/life-circumstances/route"); + return data(await (GET as unknown as () => Promise)()); +} + +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; + history: Array<{ kind: string }>; + }>(await (GET as unknown as () => Promise)()); + 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 { + 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)( + 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 }>( + 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)(), + ); + 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); + }); +}); diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts index 28686b708..12debf6d2 100644 --- a/tests/integration/sharing-manage-handler-matrix.test.ts +++ b/tests/integration/sharing-manage-handler-matrix.test.ts @@ -282,7 +282,7 @@ const ROUTE_IMPORTS = import.meta.glob>( 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); }); });