Files
HealthLog/tests/integration/mood-context-linkage.test.ts
T
Marc-André Bombeck 88f83104e0 fix(mood): the linked day read crosses sections, so it declares record
The route answered sleep minutes, steps, active energy, resting heart rate,
heart-rate variability and an illness day-log with its symptom keys, and it
declared `mind`. A delegate given only the mind section could walk arbitrary
dates through the one URL and read two sections the owner had declined to
share; no mood entry had to exist for any of those dates.

`record` is the convention for a read that crosses sections, and a scoped
grant never reaches one, so the scoped delegate loses the linked block rather
than receiving a filtered day. A block that silently dropped the sections a
grant does not cover would show a day that looks empty where the truth is that
it is none of the reader's business.

The reason is written at the declaration and in the frozen surface entry,
whose previous justification held only for whole-record grants.
2026-08-08 09:58:22 +02:00

543 lines
17 KiB
TypeScript

/**
* v1.38 — the linked day block, proven against real Postgres to be a read of
* the owning module rather than a copy of it.
*
* The claim the whole design rests on cannot be made by a shape test. "The
* response has a `sleep` key" would stay green over an implementation that
* copied the figure onto the mood row at write time and never looked at it
* again — and that implementation is exactly the failure mode this feature
* exists to avoid. So the test changes the SOURCE and reads the mood surface
* again: write a sleep session, read the block, correct the session, read
* again, assert the block moved.
*
* Beside it, the two absences that are easy to get wrong:
* - a module switched off blanks its block rather than answering zero;
* - a day with no rows answers `{ present: false }`, which is a different
* fact from a day of no sleep.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup";
const TEST_USER_ID = "user-mood-linkage";
const DAY = "2026-06-11";
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),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
await getPrismaClient().user.create({
data: {
id: TEST_USER_ID,
username: "mood-linkage",
email: "mood-linkage@example.test",
timezone: "Europe/Berlin",
},
});
const session = await getPrismaClient().session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
});
interface LinkedFigure {
present: boolean;
value?: number;
unit?: string;
}
interface LinkedBody {
available: boolean;
reason?: string;
day: string;
sleep: { available: boolean; asleep?: LinkedFigure; inBed?: LinkedFigure };
activity: {
available: boolean;
reason?: string;
steps?: LinkedFigure;
activeEnergy?: LinkedFigure;
};
vitals: {
available: boolean;
restingHeartRate?: LinkedFigure;
heartRateVariability?: LinkedFigure;
};
body: {
available: boolean;
reason?: string;
logged?: boolean;
symptoms?: string[];
episodeId?: string | null;
};
}
async function readLinked(day = DAY): Promise<LinkedBody> {
const { GET } = await import("@/app/api/mood/linked-context/route");
const res = await GET(
new NextRequest(
`http://localhost/api/mood/linked-context?date=${day}&tz=Europe%2FBerlin`,
),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: LinkedBody };
return body.data;
}
/** One night's sleep, as the sleep module stores it: minutes per stage. */
async function writeSleep(minutes: number) {
return getPrismaClient().measurement.create({
data: {
userId: TEST_USER_ID,
type: "SLEEP_DURATION",
value: minutes,
unit: "min",
// A night that ends on the morning of DAY: the reconstruction keys a
// night on the day it woke up on.
measuredAt: new Date("2026-06-11T05:30:00.000Z"),
sleepStage: "ASLEEP",
source: "MANUAL",
},
});
}
describe("mood context linkage (real Postgres)", () => {
it("a corrected sleep session moves the linked figure, because nothing was copied", async () => {
const row = await writeSleep(400);
const before = await readLinked();
expect(before.sleep.available).toBe(true);
expect(before.sleep.asleep).toEqual({
present: true,
value: 400,
unit: "min",
});
// The correction, made where the fact lives.
await getPrismaClient().measurement.update({
where: { id: row.id },
data: { value: 455 },
});
const after = await readLinked();
expect(after.sleep.asleep).toEqual({
present: true,
value: 455,
unit: "min",
});
});
it("a day with no rows answers absence, not zero", async () => {
const linked = await readLinked();
expect(linked.sleep.available).toBe(true);
expect(linked.sleep.asleep).toEqual({ present: false });
expect(linked.activity.steps).toEqual({ present: false });
expect(linked.vitals.restingHeartRate).toEqual({ present: false });
// Never a zero: a night nobody recorded and a night of no sleep are
// different facts, and only one of them belongs in a health record.
expect(JSON.stringify(linked)).not.toContain('"value":0');
});
it("sums the day's activity and takes the day's latest vital", async () => {
await getPrismaClient().measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 4000,
unit: "steps",
measuredAt: new Date("2026-06-11T08:00:00.000Z"),
source: "MANUAL",
},
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 2500,
unit: "steps",
measuredAt: new Date("2026-06-11T18:00:00.000Z"),
source: "MANUAL",
},
{
userId: TEST_USER_ID,
type: "RESTING_HEART_RATE",
value: 61,
unit: "bpm",
measuredAt: new Date("2026-06-11T07:00:00.000Z"),
source: "MANUAL",
},
{
userId: TEST_USER_ID,
type: "RESTING_HEART_RATE",
value: 58,
unit: "bpm",
measuredAt: new Date("2026-06-11T21:00:00.000Z"),
source: "MANUAL",
},
// The day before, to prove the window does not bleed.
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 9999,
unit: "steps",
measuredAt: new Date("2026-06-10T12:00:00.000Z"),
source: "MANUAL",
},
],
});
const linked = await readLinked();
expect(linked.activity.steps).toEqual({
present: true,
value: 6500,
unit: "steps",
});
expect(linked.vitals.restingHeartRate).toEqual({
present: true,
value: 58,
unit: "bpm",
});
});
it("blanks the sleep block when the sleep module is off, rather than answering zero", async () => {
await writeSleep(400);
expect((await readLinked()).sleep.available).toBe(true);
await getPrismaClient().user.update({
where: { id: TEST_USER_ID },
data: { modulePreferencesJson: { sleep: false } },
});
const linked = await readLinked();
expect(linked.sleep).toEqual({
available: false,
reason: "module-disabled",
});
// The block is gone, not zeroed and not filtered down to an empty figure.
expect(linked.sleep).not.toHaveProperty("asleep");
// And the other blocks are untouched by that choice.
expect(linked.activity.available).toBe(true);
expect(linked.vitals.available).toBe(true);
});
it("links the illness day-log read-only, and blanks it when the module is off", async () => {
const episode = await getPrismaClient().illnessEpisode.create({
data: {
userId: TEST_USER_ID,
label: "cold",
type: "INFECTION",
onsetAt: new Date("2026-06-10T08:00:00.000Z"),
},
});
// The symptom catalogue is seeded reference data every instance has, so
// this upserts rather than creating: the point of the test is the link,
// not the row.
const symptom = await getPrismaClient().illnessSymptom.upsert({
where: { key: "cough" },
create: { key: "cough", labelKey: "illness.symptom.cough" },
update: {},
});
await getPrismaClient().illnessDayLog.create({
data: {
userId: TEST_USER_ID,
episodeId: episode.id,
date: DAY,
functionalImpact: 2,
symptomLinks: { create: [{ symptomId: symptom.id, severity: 2 }] },
},
});
const linked = await readLinked();
expect(linked.body.available).toBe(true);
expect(linked.body.logged).toBe(true);
expect(linked.body.symptoms).toEqual(["cough"]);
expect(linked.body.episodeId).toBe(episode.id);
await getPrismaClient().user.update({
where: { id: TEST_USER_ID },
data: { modulePreferencesJson: { illness: false } },
});
expect((await readLinked()).body).toEqual({
available: false,
reason: "module-disabled",
});
});
it("sums one source per day, not both, when two report the same day", async () => {
// Two sources reporting the same day is the ordinary case for anyone
// syncing a phone and a watch, and summing both doubles the number on the
// mood sheet. The repo's cure is the canonical-source picker every other
// steps/energy read already runs through.
await getPrismaClient().measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 6000,
unit: "steps",
measuredAt: new Date("2026-06-11T08:00:00.000Z"),
source: "APPLE_HEALTH",
},
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 5900,
unit: "steps",
measuredAt: new Date("2026-06-11T09:00:00.000Z"),
source: "FITBIT",
},
{
userId: TEST_USER_ID,
type: "ACTIVE_ENERGY_BURNED",
value: 500,
unit: "kcal",
measuredAt: new Date("2026-06-11T08:00:00.000Z"),
source: "APPLE_HEALTH",
},
{
userId: TEST_USER_ID,
type: "ACTIVE_ENERGY_BURNED",
value: 480,
unit: "kcal",
measuredAt: new Date("2026-06-11T09:00:00.000Z"),
source: "FITBIT",
},
],
});
const linked = await readLinked();
// One stream, whichever the ladder picks — never the two added together.
expect([6000, 5900]).toContain(linked.activity.steps?.value);
expect([500, 480]).toContain(linked.activity.activeEnergy?.value);
expect(linked.activity.steps?.value).not.toBe(11900);
expect(linked.activity.activeEnergy?.value).not.toBe(980);
});
it("takes one source's vital for the day rather than whichever row is last", async () => {
// The default resting-heart-rate ladder ranks FITBIT above APPLE_HEALTH,
// and the fixture puts the LOWER-ranked source last in the day on purpose:
// a source-blind "latest of the day" answers 71, the canonical stream
// answers 62. Without that ordering the two rules agree and the case
// proves nothing.
await getPrismaClient().measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "RESTING_HEART_RATE",
value: 60,
unit: "bpm",
measuredAt: new Date("2026-06-11T07:00:00.000Z"),
source: "FITBIT",
},
{
userId: TEST_USER_ID,
type: "RESTING_HEART_RATE",
value: 62,
unit: "bpm",
measuredAt: new Date("2026-06-11T12:00:00.000Z"),
source: "FITBIT",
},
{
userId: TEST_USER_ID,
type: "RESTING_HEART_RATE",
value: 71,
unit: "bpm",
// 21:00 Berlin, deliberately still inside the same local day: at
// 22:00 UTC in June this row would belong to the NEXT Berlin day and
// the case would pass without proving anything.
measuredAt: new Date("2026-06-11T19:00:00.000Z"),
source: "APPLE_HEALTH",
},
],
});
const linked = await readLinked();
// The picked source's latest, not the day's latest.
expect(linked.vitals.restingHeartRate).toEqual({
present: true,
value: 62,
unit: "bpm",
});
});
it("keeps ambient movement available when the workouts module is off", async () => {
// `workouts` gates workout SESSIONS. Steps and active energy are ambient
// movement and are deliberately unscoped — the module ownership table says
// so, and gating them here made the mood sheet stricter than every other
// surface in the product.
await getPrismaClient().measurement.create({
data: {
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 3000,
unit: "steps",
measuredAt: new Date("2026-06-11T08:00:00.000Z"),
source: "MANUAL",
},
});
await getPrismaClient().user.update({
where: { id: TEST_USER_ID },
data: { modulePreferencesJson: { workouts: false } },
});
const linked = await readLinked();
expect(linked.activity.available).toBe(true);
expect(linked.activity.steps).toEqual({
present: true,
value: 3000,
unit: "steps",
});
});
it("blanks the vitals block when the recovery module is off", async () => {
// Resting heart rate and heart-rate variability are owned by `recovery`,
// which the ownership table has said since v1.30.22. The block claimed to
// be core and answered them regardless, which made the release note's
// "a module you have switched off leaves its block out entirely" false.
await getPrismaClient().measurement.create({
data: {
userId: TEST_USER_ID,
type: "RESTING_HEART_RATE",
value: 58,
unit: "bpm",
measuredAt: new Date("2026-06-11T07:00:00.000Z"),
source: "MANUAL",
},
});
expect((await readLinked()).vitals.available).toBe(true);
await getPrismaClient().user.update({
where: { id: TEST_USER_ID },
data: { modulePreferencesJson: { recovery: false } },
});
const linked = await readLinked();
expect(linked.vitals).toEqual({
available: false,
reason: "module-disabled",
});
expect(linked.vitals).not.toHaveProperty("restingHeartRate");
});
it("refuses a delegate whose grant does not open the whole record", async () => {
// The finding this case exists for: the route reads sleep, activity,
// vitals and the illness journal, and it used to declare `mind`. A
// delegate given the mind section alone could therefore walk arbitrary
// dates and read four other sections of the owner's record — no mood
// entry even had to exist for the date.
//
// The convention for a read that crosses sections is `record`, and a
// scoped grant never reaches one. So the scoped delegate loses the linked
// block entirely, which is the honest answer rather than a filtered one.
await getPrismaClient().measurement.createMany({
data: [
{
userId: TEST_USER_ID,
type: "ACTIVITY_STEPS",
value: 7777,
unit: "steps",
measuredAt: new Date("2026-06-11T08:00:00.000Z"),
source: "MANUAL",
},
{
userId: TEST_USER_ID,
type: "RESTING_HEART_RATE",
value: 55,
unit: "bpm",
measuredAt: new Date("2026-06-11T07:00:00.000Z"),
source: "MANUAL",
},
],
});
const episode = await getPrismaClient().illnessEpisode.create({
data: {
userId: TEST_USER_ID,
label: "cold",
type: "INFECTION",
onsetAt: new Date("2026-06-10T08:00:00.000Z"),
},
});
await getPrismaClient().illnessDayLog.create({
data: {
userId: TEST_USER_ID,
episodeId: episode.id,
date: DAY,
functionalImpact: 3,
},
});
const delegate = await getPrismaClient().user.create({
data: {
id: "user-mood-linkage-delegate",
username: "mood-linkage-delegate",
email: "mood-linkage-delegate@example.test",
timezone: "Europe/Berlin",
},
});
await getPrismaClient().accountGrant.create({
data: {
grantorId: TEST_USER_ID,
granteeId: delegate.id,
access: "READ",
acceptedAt: new Date(),
// Only the mind section. Everything this route reads is outside it.
scopeJson: ["mind"] as never,
},
});
const session = await getPrismaClient().session.create({
data: {
userId: delegate.id,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
await switchSessionTo(session.id, TEST_USER_ID);
const { GET } = await import("@/app/api/mood/linked-context/route");
const res = await GET(
new NextRequest(
`http://localhost/api/mood/linked-context?date=${DAY}&tz=Europe%2FBerlin`,
),
);
expect(res.status).toBe(403);
const raw = await res.text();
// Asserted on the bytes, not on a parsed field: the point is that not one
// of the owner's figures reached the response.
expect(raw).not.toContain("7777");
expect(raw).not.toContain("55");
expect(raw).not.toContain("functionalImpact");
});
it("refuses a malformed day", async () => {
const { GET } = await import("@/app/api/mood/linked-context/route");
const res = await GET(
new NextRequest(
"http://localhost/api/mood/linked-context?date=yesterday",
),
);
expect(res.status).toBe(422);
});
});