mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The backup schedule case assumed the shared database had no queue schema, but files that drive the queue create it and one leaves a finished backup run behind, so the case failed whenever it ran after them. It now clears that queue's history first. The delegate refusal case scanned the raw response for a digit run that the random request id can contain; the id is taken out before the scan.
545 lines
18 KiB
TypeScript
545 lines
18 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. The request id is random
|
|
// and can contain any digit run, so it is taken out before the check.
|
|
const bytes = raw.replace(/"requestId":"[^"]*"/, "");
|
|
expect(bytes).not.toContain("7777");
|
|
expect(bytes).not.toContain("55");
|
|
expect(bytes).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);
|
|
});
|
|
});
|