Files
HealthLog/tests/integration/symptoms.test.ts
T
Marc-André Bombeck 9d9e1f153c feat(insights): make each person-defined symptom its own correlation channel
Every active symptom definition enters pattern discovery as an outcome
channel SYMPTOM:<id>, labelled with its decrypted name (the data label
the interpretation already prefers over the key). The daily value is the
day's highest intensity in the person's zone; days without an occurrence
count as 0 only between the first and the last logged day. At most eight,
the active-definition cap. The SYMPTOM: prefix resolves to the illness
module in the surface map, so the family leaves the matrix with the
module, and the discovery guard lists the new fetcher.
2026-10-03 21:14:08 +02:00

579 lines
18 KiB
TypeScript

/**
* Person-defined symptoms against real Postgres and through the real routes
* (v1.40, migration 0369).
*
* What only a real database can answer:
*
* - the two tables exist, the label and the note are ciphertext on disk, and
* the intensity CHECK holds;
* - the eight-definition cap counts active definitions only, and showing a
* hidden one again re-checks it;
* - an occurrence can be filed against the record's own OPEN episode and
* against nothing else: not a resolved one, not another account's;
* - each active definition reaches the discovery matrix as its own
* `SYMPTOM:<id>` channel, labelled with its name, and leaves it when the
* illness module is off;
* - a portable export carries the names readable and restores them under
* the instance's key, dropping (and naming) a link to an episode the file
* does not carry.
*
* Mutation checks (each run, each seen red):
* - drop `resolvedAt: null` from `isLinkableEpisode` → "refuses a resolved
* episode" goes red (the occurrence is linked to a closed episode);
* - drop `userId` from the same lookup → "refuses another account's episode"
* goes red;
* - remove the cap check in the definitions POST → "holds eight active
* definitions" goes red.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { NextRequest } from "next/server";
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { assembleDiscoveryMatrix } from "@/lib/insights/discovery-matrix";
import { buildSymptomsBackupSection } from "@/lib/export/symptoms-backup";
import { restoreSymptomsData } from "@/lib/export/symptoms-backup";
import type { RestoreSkipLog } from "@/lib/export/restore-skips";
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),
}));
const OWNER_ID = "symptoms-owner";
const OTHER_ID = "symptoms-other";
type Handler = (
req: NextRequest,
ctx: { params: Promise<Record<string, string>> },
) => Promise<Response>;
async function call(
handler: unknown,
method: string,
path: string,
body?: unknown,
params: Record<string, string> = {},
): Promise<Response> {
const request = new NextRequest(`http://localhost${path}`, {
method,
headers: { "content-type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
return (handler as Handler)(request, { params: Promise.resolve(params) });
}
async function json<T>(response: Response): Promise<T> {
return (await response.json()) as T;
}
interface Envelope<T> {
data: T;
error: string | null;
meta?: { errorCode?: string };
}
async function seedUsers() {
const prisma = getPrismaClient();
for (const id of [OWNER_ID, OTHER_ID]) {
await prisma.user.create({
data: {
id,
username: id,
email: `${id}@example.test`,
timezone: "Europe/Berlin",
locale: "en",
},
});
}
}
async function signIn(userId: string) {
const session = await getPrismaClient().session.create({
data: {
userId,
expiresAt: new Date(Date.now() + 86_400_000),
mfaVerifiedAt: new Date(),
},
});
cookieJar.set("healthlog_session", session.id);
}
async function defineSymptom(label: string): Promise<string> {
const { POST } = await import("@/app/api/symptoms/definitions/route");
const response = await call(POST, "POST", "/api/symptoms/definitions", {
label,
icon: "Zap",
});
expect(response.status, label).toBe(201);
return (await json<Envelope<{ id: string }>>(response)).data.id;
}
async function logEvent(body: Record<string, unknown>): Promise<Response> {
const { POST } = await import("@/app/api/symptoms/events/route");
return call(POST, "POST", "/api/symptoms/events", body);
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await seedUsers();
await signIn(OWNER_ID);
});
describe("symptom definitions", () => {
it("stores the name as ciphertext and reads it back with its summary", async () => {
const id = await defineSymptom("Aura");
const row = await getPrismaClient().symptomDefinition.findUniqueOrThrow({
where: { id },
});
expect(Buffer.from(row.labelEncrypted).toString("utf8")).not.toContain(
"Aura",
);
expect(decryptFromBytes(row.labelEncrypted)).toBe("Aura");
await logEvent({ definitionId: id, intensity: 3 });
await logEvent({ definitionId: id, intensity: 7 });
const { GET } = await import("@/app/api/symptoms/definitions/route");
const listed = await json<
Envelope<{
definitions: Array<{
id: string;
label: string;
recent: { count30d: number; maxIntensity30d: number | null };
}>;
limit: number;
}>
>(await call(GET, "GET", "/api/symptoms/definitions"));
expect(listed.data.limit).toBe(8);
expect(listed.data.definitions).toMatchObject([
{ id, label: "Aura", recent: { count30d: 2, maxIntensity30d: 7 } },
]);
});
it("holds eight active definitions, and a hidden one frees its slot", async () => {
const ids: string[] = [];
for (let i = 0; i < 8; i += 1) ids.push(await defineSymptom(`S${i}`));
const { POST } = await import("@/app/api/symptoms/definitions/route");
const ninth = await call(POST, "POST", "/api/symptoms/definitions", {
label: "Ninth",
});
expect(ninth.status).toBe(422);
expect((await json<Envelope<null>>(ninth)).meta?.errorCode).toBe(
"symptoms.definition.limitReached",
);
const { DELETE, PATCH } =
await import("@/app/api/symptoms/definitions/[id]/route");
const hidden = await call(
DELETE,
"DELETE",
`/api/symptoms/definitions/${ids[0]}`,
undefined,
{ id: ids[0] },
);
expect(hidden.status).toBe(200);
expect(await defineSymptom("Ninth")).toBeTruthy();
// Showing the hidden one again would make nine.
const reshown = await call(
PATCH,
"PATCH",
`/api/symptoms/definitions/${ids[0]}`,
{ isActive: true },
{ id: ids[0] },
);
expect(reshown.status).toBe(422);
expect(
(
await getPrismaClient().symptomDefinition.findUniqueOrThrow({
where: { id: ids[0] },
})
).isActive,
).toBe(false);
});
it("purges a definition with every occurrence of it", async () => {
const id = await defineSymptom("Aura");
await logEvent({ definitionId: id, intensity: 4 });
const { DELETE } =
await import("@/app/api/symptoms/definitions/[id]/route");
const response = await call(
DELETE,
"DELETE",
`/api/symptoms/definitions/${id}?purge=true`,
undefined,
{ id },
);
expect(response.status).toBe(200);
expect(
(await json<Envelope<{ eventsDeleted: number }>>(response)).data
.eventsDeleted,
).toBe(1);
expect(
await getPrismaClient().symptomEvent.count({
where: { userId: OWNER_ID },
}),
).toBe(0);
});
it("is a 404 for another account's definition", async () => {
const id = await defineSymptom("Aura");
await signIn(OTHER_ID);
const { PATCH } = await import("@/app/api/symptoms/definitions/[id]/route");
const response = await call(
PATCH,
"PATCH",
`/api/symptoms/definitions/${id}`,
{ label: "Mine now" },
{ id },
);
expect(response.status).toBe(404);
expect(
(
await json<Envelope<null>>(
await logEvent({ definitionId: id, intensity: 2 }),
)
).meta?.errorCode,
).toBe("symptoms.definition.notFound");
});
});
describe("symptom occurrences", () => {
it("logs, lists, edits and deletes one, with the note encrypted", async () => {
const id = await defineSymptom("Headache");
const created = await logEvent({
definitionId: id,
intensity: 8,
occurredAt: "2026-09-01T09:30:00.000Z",
note: "behind the left eye",
});
expect(created.status).toBe(201);
const event = (await json<Envelope<{ id: string; note: string }>>(created))
.data;
expect(event.note).toBe("behind the left eye");
const row = await getPrismaClient().symptomEvent.findUniqueOrThrow({
where: { id: event.id },
});
expect(decryptFromBytes(row.noteEncrypted!)).toBe("behind the left eye");
const { GET } = await import("@/app/api/symptoms/events/route");
const listed = await json<Envelope<{ events: Array<{ id: string }> }>>(
await call(GET, "GET", `/api/symptoms/events?definitionId=${id}`),
);
expect(listed.data.events.map((e) => e.id)).toEqual([event.id]);
const { PATCH, DELETE } =
await import("@/app/api/symptoms/events/[id]/route");
const patched = await call(
PATCH,
"PATCH",
`/api/symptoms/events/${event.id}`,
{ intensity: 5, note: null },
{ id: event.id },
);
expect(patched.status).toBe(200);
const after = await getPrismaClient().symptomEvent.findUniqueOrThrow({
where: { id: event.id },
});
expect(after.intensity).toBe(5);
expect(after.noteEncrypted).toBeNull();
const deleted = await call(
DELETE,
"DELETE",
`/api/symptoms/events/${event.id}`,
undefined,
{ id: event.id },
);
expect(deleted.status).toBe(200);
expect(
await getPrismaClient().symptomEvent.count({ where: { id: event.id } }),
).toBe(0);
});
it("refuses an intensity outside 0-10 and a time in the future", async () => {
const id = await defineSymptom("Aura");
expect((await logEvent({ definitionId: id, intensity: 11 })).status).toBe(
422,
);
expect(
(
await logEvent({
definitionId: id,
intensity: 3,
occurredAt: new Date(Date.now() + 3_600_000).toISOString(),
})
).status,
).toBe(422);
});
it("holds the 0-10 bound in the database too", async () => {
const id = await defineSymptom("Aura");
await expect(
getPrismaClient().symptomEvent.create({
data: {
userId: OWNER_ID,
definitionId: id,
occurredAt: new Date(),
intensity: 12,
},
}),
).rejects.toThrow();
});
it("links an occurrence to the record's own open episode", async () => {
const id = await defineSymptom("Aura");
const episode = await getPrismaClient().illnessEpisode.create({
data: {
userId: OWNER_ID,
label: "Migraine",
type: "CHRONIC",
lifecycle: "CHRONIC_ONGOING",
onsetAt: new Date("2026-01-01T00:00:00.000Z"),
},
});
const response = await logEvent({
definitionId: id,
intensity: 6,
episodeId: episode.id,
});
expect(response.status).toBe(201);
expect(
(await json<Envelope<{ episodeId: string }>>(response)).data.episodeId,
).toBe(episode.id);
});
it("refuses a resolved episode, and nothing is written", async () => {
const id = await defineSymptom("Aura");
const episode = await getPrismaClient().illnessEpisode.create({
data: {
userId: OWNER_ID,
label: "Cold",
type: "INFECTION",
onsetAt: new Date("2026-08-01T00:00:00.000Z"),
resolvedAt: new Date("2026-08-08T00:00:00.000Z"),
},
});
const response = await logEvent({
definitionId: id,
intensity: 6,
episodeId: episode.id,
});
expect(response.status).toBe(422);
expect((await json<Envelope<null>>(response)).meta?.errorCode).toBe(
"symptoms.episode.notOpen",
);
expect(
await getPrismaClient().symptomEvent.count({
where: { episodeId: episode.id },
}),
).toBe(0);
});
it("refuses another account's episode, and nothing is written", async () => {
const id = await defineSymptom("Aura");
const foreign = await getPrismaClient().illnessEpisode.create({
data: {
userId: OTHER_ID,
label: "Flu",
type: "INFECTION",
onsetAt: new Date("2026-09-01T00:00:00.000Z"),
},
});
const response = await logEvent({
definitionId: id,
intensity: 6,
episodeId: foreign.id,
});
expect(response.status).toBe(422);
expect(
await getPrismaClient().symptomEvent.count({
where: { episodeId: foreign.id },
}),
).toBe(0);
});
it("answers illness.disabled with the module off", async () => {
await getPrismaClient().user.update({
where: { id: OWNER_ID },
data: { modulePreferencesJson: { illness: false } },
});
const { GET } = await import("@/app/api/symptoms/definitions/route");
const response = await call(GET, "GET", "/api/symptoms/definitions");
expect(response.status).toBe(403);
expect((await json<Envelope<null>>(response)).meta?.errorCode).toBe(
"illness.disabled",
);
});
});
describe("the SYMPTOM:<id> correlation channel", () => {
it("reaches the matrix labelled with its name, and leaves it with the module off", async () => {
const prisma = getPrismaClient();
const definition = await prisma.symptomDefinition.create({
data: { userId: OWNER_ID, labelEncrypted: encryptToBytes("Aura") },
});
const hidden = await prisma.symptomDefinition.create({
data: {
userId: OWNER_ID,
labelEncrypted: encryptToBytes("Old"),
isActive: false,
},
});
for (const [id, at, intensity] of [
[definition.id, "2026-09-01T08:00:00.000Z", 3],
[definition.id, "2026-09-01T18:00:00.000Z", 6],
[definition.id, "2026-09-03T08:00:00.000Z", 2],
[hidden.id, "2026-09-02T08:00:00.000Z", 9],
] as const) {
await prisma.symptomEvent.create({
data: {
userId: OWNER_ID,
definitionId: id,
occurredAt: new Date(at),
intensity,
},
});
}
const options = {
tz: "Europe/Berlin",
since: new Date("2026-08-01T00:00:00.000Z"),
fetchMode: "raw" as const,
};
const on = await assembleDiscoveryMatrix(OWNER_ID, {
...options,
modules: {},
});
const channels = on.series.filter((s) => s.key.startsWith("SYMPTOM:"));
expect(channels).toEqual([
{
key: `SYMPTOM:${definition.id}`,
label: "Aura",
role: "outcome",
points: [
{ day: "2026-09-01", value: 6 },
{ day: "2026-09-02", value: 0 },
{ day: "2026-09-03", value: 2 },
],
},
]);
const off = await assembleDiscoveryMatrix(OWNER_ID, {
...options,
modules: { illness: false },
});
expect(off.series.some((s) => s.key.startsWith("SYMPTOM:"))).toBe(false);
});
});
describe("the portable backup", () => {
it("carries the names readable and restores them, dropping a link to an episode the file lacks", async () => {
const prisma = getPrismaClient();
const kept = await prisma.illnessEpisode.create({
data: {
userId: OWNER_ID,
label: "Migraine",
type: "CHRONIC",
onsetAt: new Date("2026-01-01T00:00:00.000Z"),
},
});
const gone = await prisma.illnessEpisode.create({
data: {
userId: OWNER_ID,
label: "Cold",
type: "INFECTION",
onsetAt: new Date("2026-07-01T00:00:00.000Z"),
},
});
const definition = await prisma.symptomDefinition.create({
data: {
userId: OWNER_ID,
labelEncrypted: encryptToBytes("Aura"),
icon: "Zap",
},
});
for (const [episodeId, intensity] of [
[kept.id, 4],
[gone.id, 6],
[null, 2],
] as const) {
await prisma.symptomEvent.create({
data: {
userId: OWNER_ID,
definitionId: definition.id,
occurredAt: new Date(`2026-07-0${intensity}T08:00:00.000Z`),
intensity,
episodeId,
noteEncrypted: intensity === 2 ? encryptToBytes("mild") : null,
},
});
}
const section = await buildSymptomsBackupSection(prisma, OWNER_ID);
const text = JSON.stringify(section);
expect(text).toContain('"label":"Aura"');
expect(text).toContain('"note":"mild"');
expect(text).not.toContain("labelEncrypted");
// The restore puts back only the kept episode.
const skips: RestoreSkipLog = [];
await prisma.$transaction((tx) =>
restoreSymptomsData(
tx,
OWNER_ID,
JSON.parse(text),
new Set([kept.id]),
skips,
),
);
const restored = await prisma.symptomDefinition.findMany({
where: { userId: OWNER_ID },
include: { events: { orderBy: { intensity: "asc" } } },
});
expect(restored).toHaveLength(1);
expect(decryptFromBytes(restored[0].labelEncrypted)).toBe("Aura");
expect(
restored[0].events.map((e) => ({
intensity: e.intensity,
episodeId: e.episodeId,
note: e.noteEncrypted ? decryptFromBytes(e.noteEncrypted) : null,
})),
).toEqual([
{ intensity: 2, episodeId: null, note: "mild" },
{ intensity: 4, episodeId: kept.id, note: null },
{ intensity: 6, episodeId: null, note: null },
]);
expect(skips).toEqual([
{ catalogue: "symptomEpisodeReference", key: gone.id, links: 1 },
]);
});
});