Files
HealthLog/tests/integration/series-source-ladder.test.ts
T
Marc-André Bombeck d1ec6715d9 fix(series): pick one source per day for both halves of a blood-pressure reading
The systolic and diastolic reads each took the ladder's source per day on
their own. Whenever a source held only half of a reading on a day (a deleted
diastolic, an orphaned sample, a sub-floor systolic), the two picks diverged
and the pairing joined one source's systolic to another source's diastolic.
A sub-floor systolic could also claim a day and then contribute nothing,
leaving the day empty although a lower-ranked source had a valid reading.

The source is now picked per day from the systolic rows that pass the floor,
both halves are read from it in one statement, and a pair is only made
within one source, which also covers a reading that straddles midnight.
2026-10-04 14:14:22 +02:00

379 lines
14 KiB
TypeScript

/**
* The per-kind series follows the source-priority ladder.
*
* The rollup tier and sleep resolve every (type, day) to the source the
* person's ladder picks before they aggregate. The dense and raw reads of
* `/api/measurements/series` filtered on user and type only, so with a second
* provider a day's value blended the two, a reading present in both showed
* twice, and the ladder had no effect on those charts.
*
* What is asserted, against a real Postgres:
* - the ladder decides which source a day shows, in both directions;
* - the rule is per day, so a day only the second source covers still shows;
* - a reading mirrored into two sources appears once;
* - the day-bucket, hour-bucket and raw reads all follow it, and the stats
* strip counts the canonical rows;
* - a type with no ladder keeps every source (the collapse would otherwise
* break the tie by source name and hide one of two real sources);
* - a single-source account reads exactly what is stored.
*/
import { NextRequest } from "next/server";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import type {
MeasurementSource,
MeasurementType,
Prisma,
} from "@/generated/prisma/client";
import { invalidateUserTimezone } from "@/lib/tz/resolver";
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 prisma = getPrismaClient();
const TZ = "Europe/Berlin";
const DUAL = "ladder-dual";
const DENSE = "ladder-dense";
const SOLO = "ladder-solo";
const BP_MIX = "ladder-bp-mix";
const NOW = new Date(Math.floor(Date.now() / 60_000) * 60_000 - 60_000);
const DAY = 86_400_000;
/**
* Noon UTC of the n-th day back (plus a few minutes), so a reading falls on one
* UTC day and one Berlin day whenever the suite runs. Anchoring on "now" would
* let a reading cross midnight and make the per-day source pick depend on the
* clock.
*/
const TODAY_NOON_UTC = Date.UTC(
NOW.getUTCFullYear(),
NOW.getUTCMonth(),
NOW.getUTCDate(),
12,
);
const at = (daysBack: number, minuteOffset = 0) =>
new Date(TODAY_NOON_UTC - daysBack * DAY + minuteOffset * 60_000);
let seq = 0;
const row = (
userId: string,
type: MeasurementType,
unit: string,
source: MeasurementSource,
value: number,
measuredAt: Date,
): Prisma.MeasurementCreateManyInput => ({
id: `ladder-${++seq}`,
userId,
type,
unit,
source,
value,
measuredAt,
});
async function setLadder(userId: string, ladder: Record<string, string[]>) {
await prisma.user.update({
where: { id: userId },
data: { sourcePriorityJson: ladder },
});
}
async function series(userId: string, kind: string, days: number) {
const s = await prisma.session.create({
data: { userId, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.clear();
cookieJar.set("healthlog_session", s.id);
const { GET } = await import("@/app/api/measurements/series/route");
const res = await GET(
new NextRequest(
`http://localhost/api/measurements/series?kind=${kind}&days=${days}`,
),
);
expect(res.status).toBe(200);
return (
(await res.json()) as {
data: {
points: Array<{
id: string;
at: string;
value: number;
secondary: number | null;
}>;
stats: { count: number; mean: number; min: number; max: number };
};
}
).data;
}
beforeAll(async () => {
await truncateAllTables(prisma);
for (const id of [DUAL, DENSE, SOLO, BP_MIX]) {
await prisma.user.create({ data: { id, username: id, timezone: TZ } });
invalidateUserTimezone(id);
}
// Pulse: Apple Health (60-64) covers days 1-3, Fitbit (90-94) covers days 3-5,
// so day 3 has both sources and days 4-5 only Fitbit.
const dual: Prisma.MeasurementCreateManyInput[] = [];
for (const d of [1, 2, 3]) {
[60, 62, 64].forEach((v, i) =>
dual.push(row(DUAL, "PULSE", "bpm", "APPLE_HEALTH", v, at(d, i))),
);
}
for (const d of [3, 4, 5]) {
[90, 92, 94].forEach((v, i) =>
dual.push(row(DUAL, "PULSE", "bpm", "FITBIT", v, at(d, i + 3))),
);
}
// Weight: the same weighing recorded by hand AND by the scale, three days.
for (const d of [1, 2, 3]) {
dual.push(row(DUAL, "WEIGHT", "kg", "MANUAL", 80 + d, at(d)));
dual.push(row(DUAL, "WEIGHT", "kg", "WITHINGS", 80 + d, at(d)));
}
// Blood pressure: Apple Health (120/80, 122/82) on days 1-2, Withings
// (140/90, 142/92) on days 2-3. Day 2 has both sources; day 1 only Apple,
// day 3 only Withings. A sub-floor systolic (0) on day 1 must stay out.
const bp = (
source: MeasurementSource,
d: number,
sys: number,
dia: number,
) => {
dual.push(row(DUAL, "BLOOD_PRESSURE_SYS", "mmHg", source, sys, at(d, 10)));
dual.push(row(DUAL, "BLOOD_PRESSURE_DIA", "mmHg", source, dia, at(d, 10)));
};
bp("APPLE_HEALTH", 1, 120, 80);
bp("APPLE_HEALTH", 2, 122, 82);
bp("WITHINGS", 2, 142, 92);
bp("WITHINGS", 3, 140, 90);
dual.push(
row(DUAL, "BLOOD_PRESSURE_SYS", "mmHg", "APPLE_HEALTH", 0, at(1, 20)),
);
// Glucose has no ladder: both sources must stay.
for (const d of [1, 2]) {
dual.push(row(DUAL, "BLOOD_GLUCOSE", "mg/dL", "APPLE_HEALTH", 100, at(d)));
dual.push(row(DUAL, "BLOOD_GLUCOSE", "mg/dL", "NIGHTSCOUT", 110, at(d, 1)));
}
await prisma.measurement.createMany({ data: dual });
// A dense two-source pulse stream: past the 10 000-row cap, so it is read
// as hour buckets. Apple is a steady 70, Fitbit a steady 100.
for (const [source, value] of [
["APPLE_HEALTH", 70],
["FITBIT", 100],
] as const) {
await prisma.$executeRawUnsafe(
`INSERT INTO measurements (
id, user_id, type, value, unit, source, measured_at,
created_at, updated_at, sync_version)
SELECT $4 || lpad(g::text, 7, '0'), $1, 'PULSE'::measurement_type,
$5, 'bpm', $6::measurement_source,
($2::timestamptz AT TIME ZONE 'UTC') - (g * interval '1 minute'),
now(), now(), 1
FROM generate_series(0, $3::int - 1) g`,
DENSE,
NOW.toISOString(),
6_000,
`dn-${source}-`,
value,
source,
);
}
// Blood pressure where one source holds only half of a reading. Day 1: the
// cuff's systolic 150 is live but its diastolic was deleted, while Apple
// Health holds the same moment as a full 125/85. Day 2: the cuff's only
// systolic that day is a sub-floor 0 next to a diastolic 80, while Apple
// Health holds 126/86. A per-type pick takes the cuff's systolic and Apple's
// diastolic on day 1 and pairs them across sources, and on day 2 picks the
// cuff for a systolic the floor then removes, which empties the day.
await prisma.measurement.createMany({
data: [
row(BP_MIX, "BLOOD_PRESSURE_SYS", "mmHg", "WITHINGS", 150, at(1, 10)),
{
...row(BP_MIX, "BLOOD_PRESSURE_DIA", "mmHg", "WITHINGS", 95, at(1, 10)),
deletedAt: new Date(),
},
row(BP_MIX, "BLOOD_PRESSURE_SYS", "mmHg", "APPLE_HEALTH", 125, at(1, 10)),
row(BP_MIX, "BLOOD_PRESSURE_DIA", "mmHg", "APPLE_HEALTH", 85, at(1, 10)),
row(BP_MIX, "BLOOD_PRESSURE_SYS", "mmHg", "WITHINGS", 0, at(2, 10)),
row(BP_MIX, "BLOOD_PRESSURE_DIA", "mmHg", "WITHINGS", 80, at(2, 10)),
row(BP_MIX, "BLOOD_PRESSURE_SYS", "mmHg", "APPLE_HEALTH", 126, at(2, 12)),
row(BP_MIX, "BLOOD_PRESSURE_DIA", "mmHg", "APPLE_HEALTH", 86, at(2, 12)),
],
});
// A single-source account.
await prisma.measurement.createMany({
data: [1, 2, 3, 4].map((d) =>
row(SOLO, "WEIGHT", "kg", "MANUAL", 70 + d, at(d)),
),
});
await prisma.$executeRawUnsafe(`ANALYZE measurements`);
}, 120_000);
describe("pulse follows the ladder (raw read, 30 days)", () => {
it("shows Apple Health when it ranks first, and fills the days only Fitbit covers", async () => {
await setLadder(DUAL, { pulse: ["APPLE_HEALTH", "FITBIT"] });
const data = await series(DUAL, "pulse", 30);
// Days 1-3: Apple (60-64). Day 3 is shared, so Fitbit's day-3 rows are out.
// Days 4-5: only Fitbit exists, so Fitbit shows.
const values = data.points.map((p) => p.value).sort((a, b) => a - b);
expect(values).toEqual([
60, 60, 60, 62, 62, 62, 64, 64, 64, 90, 90, 92, 92, 94, 94,
]);
expect(data.stats.count).toBe(15);
});
it("shows Fitbit when it ranks first, and fills the days only Apple covers", async () => {
await setLadder(DUAL, { pulse: ["FITBIT", "APPLE_HEALTH"] });
const data = await series(DUAL, "pulse", 30);
const values = data.points.map((p) => p.value).sort((a, b) => a - b);
// Days 3-5: Fitbit. Days 1-2: only Apple exists.
expect(values).toEqual([
60, 60, 62, 62, 64, 64, 90, 90, 90, 92, 92, 92, 94, 94, 94,
]);
expect(data.stats.count).toBe(15);
});
it("does not blend the two on a shared day (day-bucket read, 365 days)", async () => {
await setLadder(DUAL, { pulse: ["APPLE_HEALTH", "FITBIT"] });
const data = await series(DUAL, "pulse", 365);
const byDay = data.points.map((p) => p.value).sort((a, b) => a - b);
// One mean per local day: Apple's days 1-3 are 62, Fitbit's days 4-5 are 92.
expect(byDay).toEqual([62, 62, 62, 92, 92]);
expect(data.stats.count).toBe(15);
});
});
describe("blood pressure follows the ladder", () => {
const bpSeries = async () => (await series(DUAL, "bloodPressure", 30)).points;
it("shows the ladder's source on a day both cover, and the other where only it exists", async () => {
await setLadder(DUAL, { bloodPressure: ["APPLE_HEALTH", "WITHINGS"] });
const points = await bpSeries();
// Day 1 Apple, day 2 Apple (Withings' 142/92 is out), day 3 only Withings.
expect(points.map((p) => [p.value, p.secondary])).toEqual([
[140, 90],
[122, 82],
[120, 80],
]);
});
it("flips with the ladder", async () => {
await setLadder(DUAL, { bloodPressure: ["WITHINGS", "APPLE_HEALTH"] });
const points = await bpSeries();
expect(points.map((p) => [p.value, p.secondary])).toEqual([
[140, 90],
[142, 92],
[120, 80],
]);
});
it("keeps a non-physiological systolic out of the series", async () => {
await setLadder(DUAL, { bloodPressure: ["APPLE_HEALTH", "WITHINGS"] });
expect((await bpSeries()).every((p) => p.value >= 40)).toBe(true);
});
});
describe("blood pressure keeps a reading's two halves from one source", () => {
it("never pairs one source's systolic with another source's diastolic", async () => {
await setLadder(BP_MIX, { bloodPressure: ["WITHINGS", "APPLE_HEALTH"] });
const points = (await series(BP_MIX, "bloodPressure", 30)).points;
// Day 1 is the cuff's day: its systolic stands alone rather than borrowing
// Apple's diastolic. Day 2 is Apple's, because the cuff has no systolic
// that passes the floor there.
expect(points.map((p) => [p.value, p.secondary])).toEqual([
[126, 86],
[150, null],
]);
});
});
describe("a reading mirrored into two sources", () => {
it("appears once", async () => {
await setLadder(DUAL, { weight: ["WITHINGS", "MANUAL"] });
const data = await series(DUAL, "weight", 30);
expect(data.points).toHaveLength(3);
expect(data.points.map((p) => p.value).sort()).toEqual([81, 82, 83]);
expect(data.stats.count).toBe(3);
});
it("follows the ladder for which copy it keeps", async () => {
await setLadder(DUAL, { weight: ["MANUAL", "WITHINGS"] });
const manual = await series(DUAL, "weight", 30);
const rows = await prisma.measurement.findMany({
where: { userId: DUAL, type: "WEIGHT", source: "MANUAL" },
select: { id: true },
});
expect(new Set(manual.points.map((p) => p.id))).toEqual(
new Set(rows.map((r) => r.id)),
);
});
});
describe("an hour-bucket read (past the 10 000-row cap)", () => {
it("shows only the ladder's source in every bucket", async () => {
await setLadder(DENSE, { pulse: ["APPLE_HEALTH", "FITBIT"] });
const apple = await series(DENSE, "pulse", 30);
expect(apple.points.length).toBeGreaterThan(50);
expect(apple.points.every((p) => p.id.startsWith("hour:"))).toBe(true);
expect(apple.points.every((p) => p.value === 70)).toBe(true);
expect(apple.stats.count).toBe(6_000);
await setLadder(DENSE, { pulse: ["FITBIT", "APPLE_HEALTH"] });
const fitbit = await series(DENSE, "pulse", 30);
expect(fitbit.points.every((p) => p.value === 100)).toBe(true);
expect(fitbit.stats.count).toBe(6_000);
});
});
describe("a type with no ladder", () => {
it("keeps every source (glucose)", async () => {
const data = await series(DUAL, "glucose", 30);
expect(data.points).toHaveLength(4);
expect(data.stats.count).toBe(4);
});
});
describe("a single-source account", () => {
it("reads exactly what is stored", async () => {
const data = await series(SOLO, "weight", 30);
const raw = await prisma.measurement.findMany({
where: { userId: SOLO, type: "WEIGHT" },
orderBy: { measuredAt: "asc" },
select: { id: true, value: true, measuredAt: true },
});
expect(data.points).toEqual(
raw.map((r) => ({
id: r.id,
at: r.measuredAt.toISOString(),
value: r.value,
secondary: null,
})),
);
});
});