Files
HealthLog/tests/integration/series-source-ladder.test.ts
T
BernhardBuckel bb1acfab64 fix(series): follow the source priority ladder in the dense and raw series reads
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 configured ladder had no effect on those charts.

Both reads now take the ladder-winning source per day through the shared
canonicalMeasurementsCte helper, as the other live readers do:

- the day and hour bucket read of the dense kinds (pulse, glucose, HRV, SpO2),
  and the stats strip over its rows;
- the generic raw read (weight, body fat, steps, resting heart rate, ...).

The collapse is per day for the hour grain too, as everywhere else. The helper
gains an optional single-type scope so a series scans one metric, not every row
the person has in the window.

Only a type that has a ladder is collapsed. For a type without one (glucose,
body water, bone mass) the helper breaks the tie by source name, which would
silently hide one of two real sources, so those keep every source as before.
A single-source account reads exactly what is stored.

Tests: a real-database test covering the ladder in both directions, the
per-day fallback, a reading mirrored into two sources, the day-bucket, hour-
bucket and raw reads, a type with no ladder and a single-source account. The
route's unit test file reads its fixtures through a small adapter for the new
raw statements, and the dense-read guard drops the exception for the removed
findMany. The OpenAPI description states the rule.

Refs #1108
2026-10-03 20:18:50 +02:00

287 lines
9.9 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 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 }>;
stats: { count: number; mean: number; min: number; max: number };
};
}
).data;
}
beforeAll(async () => {
await truncateAllTables(prisma);
for (const id of [DUAL, DENSE, SOLO]) {
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)));
}
// 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,
);
}
// 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("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,
})),
);
});
});