mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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.
579 lines
18 KiB
TypeScript
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 },
|
|
]);
|
|
});
|
|
});
|