test(analytics): pin the analytics response before moving its builder

A parity walk over GET /api/analytics on an account with a full record
(record-fixture.ts, reusable by the other read routes): an unknown slice,
the summaries slice and the default body cold and warm, the modules off, a
second language with a personal blood-pressure target, an empty account and
an anonymous call. Background work a request leaves running is settled
before the next one. The blood-pressure fast path's rollup row counts are
left out of the pinned wide event: they follow the rollup tier's coverage,
not the code, while the answer they fed stays pinned in the body.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 13:49:24 +02:00
1 parent 905d4b167c
commit d24c544e98
11 files changed
+13631

No files matched your search

@@ -0,0 +1,39 @@
{
"status": 422,
"cacheControl": null,
"body": {
"data": null,
"error": "Validation failed",
"details": {
"issues": [
{
"path": "slice",
"code": "invalid_value",
"message": "Invalid input: expected \"summaries\""
}
]
},
"meta": {
"errorCode": "analytics.invalid_query",
"requestId": "<requestId>"
}
},
"events": [
{
"status": 422,
"action": {
"name": "analytics.get"
},
"meta": {
"outcome": "invalid_query"
},
"warnings": null,
"error": null
}
],
"effects": [],
"stored": {
"healthScores": [],
"patterns": []
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,51 @@
{
"status": 401,
"cacheControl": null,
"body": {
"data": null,
"error": "Not authenticated",
"meta": {
"errorCode": "auth.missing",
"requestId": "<requestId>"
}
},
"events": [
{
"status": 401,
"action": null,
"meta": null,
"warnings": null,
"error": {
"type": "HttpError",
"message": "Not authenticated"
}
}
],
"effects": [],
"stored": {
"healthScores": [
{
"dayKey": "2026-10-15",
"timezone": "Europe/Berlin",
"composite": 86,
"band": "green",
"scoreVersion": 4,
"composition": [
"BLOOD_PRESSURE",
"GLYCAEMIA",
"ACTIVITY",
"SLEEP"
],
"pillarScores": {
"SLEEP": 92,
"ACTIVITY": 78,
"GLYCAEMIA": 100,
"BLOOD_PRESSURE": 73
},
"inputFingerprint": "5653cd8c051404182d6c6f813a48b1c2ebc3abe1116901466ab7cc1ab70cb276",
"computedAt": "<now>"
}
],
"patterns": []
}
}
@@ -0,0 +1,251 @@
/**
* Parity for `GET /api/analytics` while the response builder moves out of
* the route into `src/lib/analytics/response/`.
*
* One account with a full record (see `record-fixture.ts`) walks through the
* cases the route tells apart, in order, each request seeing the cache the
* earlier ones filled: a `slice` outside the closed set, the summaries slice
* cold and warm, the default body cold and warm, the summaries slice after
* the glucose, sleep and recovery modules were switched off (gated after the
* cache read), and the default body in another language with a personal
* blood-pressure target (a fresh cache cell, so the builder runs against the
* clinical band a second time and with glucose off). An account with no
* profile and no data, and an anonymous call, close the walk. After each
* request the snapshot holds the response and its cache header, the wide
* event, the rollup refreshes the request handed off, and the stored health
* scores and correlation patterns.
*
* The clock is pinned (`Date` only). The record's rollups are built once
* before the walk; the fire-and-forget refresh the route starts on every
* request is recorded instead of run, so the wide event never depends on
* whether it finished first.
*/
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { GET } from "@/app/api/analytics/route";
import { settleBackgroundTasks } from "@/lib/logging/background-tasks";
import { anonymous, callRoute, type RouteAuth } from "../../../kit/call-route";
import { makeSession, makeUser } from "../../../kit/factories";
import {
effectRecorder,
eventFacts,
expectParity,
stable,
wideEventRecorder,
} from "../../../kit/parity";
import { getPrismaClient, truncateAllTables } from "../../setup";
import { parityId, seedParityRecord } from "./record-fixture";
vi.mock("@/lib/logging/transports", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/logging/transports")>();
const { wideEventRecorder: recorder } = await import("../../../kit/parity");
return {
...actual,
emitIfSampled: (event: (typeof recorder.events)[number]) => {
recorder.events.push(event);
},
};
});
vi.mock("@/lib/rollups/measurement-rollups", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/rollups/measurement-rollups")>();
const { effectRecorder } = await import("../../../kit/parity");
return {
...actual,
ensureUserRollupsFresh: async (...args: unknown[]) => {
effectRecorder.record("ensureUserRollupsFresh", args);
},
};
});
const prisma = getPrismaClient();
const NOW = new Date("2026-10-15T12:00:00.000Z");
const TZ = "Europe/Berlin";
const OWNER_ID = parityId("analytics", 1);
const EMPTY_ID = parityId("analytics", 2);
let owner: RouteAuth;
let empty: RouteAuth;
async function stored(userId: string) {
const [healthScores, patterns] = await Promise.all([
prisma.healthScoreRecord.findMany({
where: { userId },
orderBy: { dayKey: "asc" },
select: {
dayKey: true,
timezone: true,
composite: true,
band: true,
scoreVersion: true,
composition: true,
pillarScores: true,
inputFingerprint: true,
computedAt: true,
},
}),
prisma.correlationPattern.findMany({
where: { userId },
orderBy: { canonicalKey: "asc" },
select: {
canonicalKey: true,
family: true,
factorKey: true,
outcomeKey: true,
lagDays: true,
sampleSize: true,
effectSize: true,
pValue: true,
qValue: true,
evidenceHash: true,
isCurrent: true,
lastComputedAt: true,
},
}),
]);
return { healthScores, patterns };
}
async function step(
name: string,
options: {
auth?: RouteAuth;
query?: Record<string, string>;
userId?: string;
} = {},
): Promise<void> {
wideEventRecorder.take();
effectRecorder.take();
const res = await callRoute(GET, {
method: "GET",
route: "/api/analytics",
query: options.query,
auth: options.auth ?? owner,
});
const events = wideEventRecorder.take();
expect(events.length).toBeGreaterThan(0);
// Work the request left running (a rollup fill behind a read) finishes
// before the next request, so each case starts from the same store.
await settleBackgroundTasks();
await expectParity(
`analytics.${name}`,
stable(
{
status: res.status,
cacheControl: res.headers.get("cache-control"),
body: res.body,
events: events.map(eventFacts).map(withoutRollupCoverage),
effects: effectRecorder.take(),
stored: await stored(options.userId ?? OWNER_ID),
},
{ since: NOW },
),
);
}
/**
* The blood-pressure fast path reports how many rollup rows it read. That
* count follows the rollup tier's coverage at the moment of the request,
* which background refills outside this route decide, so it differs between
* two runs of the same code while the answer it fed does not. The answer
* stays pinned in `body`; only the three diagnostic counts are dropped.
*/
function withoutRollupCoverage(facts: unknown): unknown {
const meta = (facts as { meta?: { analytics?: { bp_in_target?: object } } })
.meta;
const bp = meta?.analytics?.bp_in_target as
Record<string, unknown> | undefined;
if (bp) {
delete bp.row_count;
delete bp.sys_rows;
delete bp.dia_rows;
}
return facts;
}
beforeAll(async () => {
vi.useFakeTimers({ toFake: ["Date"], now: NOW });
await truncateAllTables(prisma);
const ownerRow = await makeUser({
id: OWNER_ID,
timezone: TZ,
locale: "en",
heightCm: 178,
dateOfBirth: new Date("1975-04-02T00:00:00.000Z"),
gender: "male",
});
await seedParityRecord({ userId: OWNER_ID, now: NOW, tz: TZ, tag: "an" });
const rollups = await vi.importActual<
typeof import("@/lib/rollups/measurement-rollups")
>("@/lib/rollups/measurement-rollups");
await rollups.ensureUserRollupsFresh(OWNER_ID);
await settleBackgroundTasks();
owner = await makeSession(ownerRow);
const emptyRow = await makeUser({ id: EMPTY_ID, timezone: "UTC" });
empty = await makeSession(emptyRow);
});
afterAll(() => {
vi.useRealTimers();
});
describe("GET /api/analytics parity", () => {
it("01 a slice outside the closed set", async () => {
await step("01-invalid-slice", { query: { slice: "summary" } });
});
it("02 the summaries slice, cold", async () => {
await step("02-summaries-cold", { query: { slice: "summaries" } });
});
it("03 the summaries slice, warm", async () => {
await step("03-summaries-warm", { query: { slice: "summaries" } });
});
it("04 the default body, cold", async () => {
await step("04-default-cold");
});
it("05 the default body, warm", async () => {
await step("05-default-warm");
});
it("06 the summaries slice with modules off", async () => {
await prisma.user.update({
where: { id: OWNER_ID },
data: {
modulePreferencesJson: {
glucose: false,
sleep: false,
recovery: false,
},
},
});
await step("06-summaries-modules-off", { query: { slice: "summaries" } });
});
it("07 the default body in another language with a personal target", async () => {
await prisma.user.update({
where: { id: OWNER_ID },
data: {
locale: "de",
thresholdsJson: {
BLOOD_PRESSURE_SYS: { min: 100, max: 125 },
BLOOD_PRESSURE_DIA: { min: 60, max: 80 },
},
},
});
await step("07-default-de-personal-target");
});
it("08 an account with no profile and no data", async () => {
await step("08-empty-account", { auth: empty, userId: EMPTY_ID });
});
it("09 an anonymous call", async () => {
await step("09-anonymous", { auth: anonymous });
});
});
@@ -0,0 +1,294 @@
/**
* One representative health record for the read-route parity walks.
*
* The aggregate routes (`/api/analytics`, `/api/dashboard/summary`,
* `/api/insights/comprehensive`, `/api/insights/generate`) shape their body
* from many domains at once, so a parity walk over them needs a record in
* which every block has something to say: daily weight and blood pressure
* long enough for the 90-day windows, a resting-pulse series, glucose in
* three meal contexts (one untagged), staged sleep nights with a nap, steps,
* oxygen saturation, HRV, a mood diary, a blood-pressure medication with an
* intake history that has skips and gaps, a second medication, and an open
* illness episode.
*
* Every value is a function of the day offset from `now`, so the record is
* the same on every run. Medications carry fixed ids, because some payloads
* hash or echo them; measurement ids are generated and blanked by `stable`.
*
* Not a test file. Only for tests under `tests/integration/`.
*/
import type { Prisma } from "@/generated/prisma/client";
import { getPrismaClient } from "../../setup";
const DAY_MS = 24 * 60 * 60 * 1000;
/** A cuid-shaped fixed id: `c` and 24 lower-case alphanumerics. */
export function parityId(tag: string, n: number): string {
return `c${`${tag}${n.toString(36)}`.padStart(24, "0")}`;
}
/** A local `YYYY-MM-DD` key of `at` in `tz`. */
function dayKey(at: Date, tz: string): string {
return new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(at);
}
/** The UTC instant `hh:mm` on the day `d` days before `now`. */
function at(now: Date, d: number, hh: number, mm = 0): Date {
const base = new Date(now.getTime() - d * DAY_MS);
base.setUTCHours(hh, mm, 0, 0);
return base;
}
export interface RecordFixture {
bpMedicationId: string;
otherMedicationId: string;
}
/**
* Write the record onto an existing account. `tag` keeps the fixed ids of
* two records in one database apart.
*/
export async function seedParityRecord(options: {
userId: string;
now: Date;
tz: string;
tag: string;
}): Promise<RecordFixture> {
const { userId, now, tz, tag } = options;
const prisma = getPrismaClient();
const rows: Prisma.MeasurementCreateManyInput[] = [];
const push = (row: Omit<Prisma.MeasurementCreateManyInput, "userId">) =>
rows.push({ userId, createdAt: row.measuredAt, ...row });
for (let d = 0; d < 60; d++) {
push({
type: "WEIGHT",
value: Math.round((82 + d * 0.04) * 10) / 10,
unit: "kg",
source: "WITHINGS",
deviceType: "scale",
measuredAt: at(now, d, 6, 30),
});
push({
type: "PULSE",
value: 64 + (d % 6),
unit: "bpm",
measuredAt: at(now, d, 7, 1),
});
if (d % 7 === 0) {
push({
type: "BODY_FAT",
value: Math.round((24 + d * 0.02) * 10) / 10,
unit: "%",
source: "WITHINGS",
measuredAt: at(now, d, 6, 31),
});
}
}
for (let d = 0; d < 120; d++) {
push({
type: "BLOOD_PRESSURE_SYS",
value: 123 + ((d * 7) % 15),
unit: "mmHg",
measuredAt: at(now, d, 7, 0),
});
push({
type: "BLOOD_PRESSURE_DIA",
value: 78 + ((d * 5) % 9),
unit: "mmHg",
measuredAt: at(now, d, 7, 0),
});
}
for (let d = 0; d < 40; d++) {
push({
type: "RESTING_HEART_RATE",
value: 58 + (d % 4),
unit: "bpm",
source: "APPLE_HEALTH",
measuredAt: at(now, d, 5, 0),
});
}
for (let d = 0; d < 20; d++) {
push({
type: "BLOOD_GLUCOSE",
value: 92 + (d % 7),
unit: "mg/dL",
glucoseContext: "FASTING",
measuredAt: at(now, d, 6, 15),
});
push({
type: "BLOOD_GLUCOSE",
value: 128 + ((d * 3) % 20),
unit: "mg/dL",
glucoseContext: "POSTPRANDIAL",
measuredAt: at(now, d, 11, 0),
});
push({
type: "BLOOD_GLUCOSE",
value: 105 + (d % 4),
unit: "mg/dL",
measuredAt: at(now, d, 10, 0),
});
}
// Staged nights ending on the morning of each wake day, and one nap.
for (let d = 0; d < 21; d++) {
const stage = (
sleepStage: "CORE" | "DEEP" | "REM" | "AWAKE",
hh: number,
mm: number,
minutes: number,
) =>
push({
type: "SLEEP_DURATION",
value: minutes,
unit: "min",
source: "APPLE_HEALTH",
deviceType: "watch",
sleepStage,
measuredAt: at(now, d, hh, mm),
});
stage("CORE", 1, 0, 200 + (d % 5) * 10);
stage("DEEP", 2, 30, 70 + (d % 3) * 5);
stage("REM", 4, 0, 85 + (d % 4) * 5);
stage("AWAKE", 4, 15, 12);
}
push({
type: "SLEEP_DURATION",
value: 35,
unit: "min",
source: "APPLE_HEALTH",
deviceType: "watch",
sleepStage: "CORE",
measuredAt: at(now, 2, 13, 40),
});
for (let d = 0; d < 30; d++) {
push({
type: "ACTIVITY_STEPS",
value: 6000 + ((d * 373) % 4000),
unit: "count",
source: "APPLE_HEALTH",
measuredAt: at(now, d, 9, 0),
});
push({
type: "HEART_RATE_VARIABILITY",
value: 45 + (d % 8),
unit: "ms",
source: "APPLE_HEALTH",
measuredAt: at(now, d, 4, 30),
});
}
for (let d = 0; d < 10; d++) {
push({
type: "OXYGEN_SATURATION",
value: 96 + (d % 3),
unit: "%",
source: "APPLE_HEALTH",
measuredAt: at(now, d, 4, 40),
});
}
await prisma.measurement.createMany({ data: rows });
const moods = ["LAUSIG", "SCHLECHT", "OKAY", "GUT", "SUPER_GUT"];
const moodRows: Prisma.MoodEntryCreateManyInput[] = [];
for (let d = 0; d < 45; d++) {
const score = 1 + ((d * 3) % 5);
const loggedAt = at(now, d, 9, 30);
moodRows.push({
userId,
date: dayKey(loggedAt, tz),
mood: moods[score - 1],
score,
moodLoggedAt: loggedAt,
tz,
createdAt: loggedAt,
});
}
await prisma.moodEntry.createMany({ data: moodRows });
const bpMedicationId = parityId(`${tag}med`, 1);
const otherMedicationId = parityId(`${tag}med`, 2);
const created = new Date(now.getTime() - 90 * DAY_MS);
await prisma.medication.create({
data: {
id: bpMedicationId,
userId,
name: "Ramipril",
dose: "5mg",
createdAt: created,
schedules: {
create: [
{
id: parityId(`${tag}sch`, 1),
windowStart: "08:00",
windowEnd: "10:00",
},
],
},
categoryAssignment: { create: { category: "BLOOD_PRESSURE" } },
},
});
await prisma.medication.create({
data: {
id: otherMedicationId,
userId,
name: "Vitamin D",
dose: "1000 IU",
createdAt: created,
schedules: {
create: [
{
id: parityId(`${tag}sch`, 2),
windowStart: "20:00",
windowEnd: "21:00",
},
],
},
},
});
const intakes: Prisma.MedicationIntakeEventCreateManyInput[] = [];
for (let d = 1; d <= 45; d++) {
if (d % 9 === 0) continue;
const scheduledFor = at(now, d, 6, 0);
const skipped = d % 7 === 0;
intakes.push({
id: parityId(`${tag}ie`, d),
userId,
medicationId: bpMedicationId,
scheduledFor,
takenAt: skipped ? null : at(now, d, 6, 20),
skipped,
createdAt: scheduledFor,
});
if (d % 2 === 0) {
const evening = at(now, d, 18, 0);
intakes.push({
id: parityId(`${tag}ie`, 100 + d),
userId,
medicationId: otherMedicationId,
scheduledFor: evening,
takenAt: at(now, d, 18, 30),
createdAt: evening,
});
}
}
await prisma.medicationIntakeEvent.createMany({ data: intakes });
await prisma.illnessEpisode.create({
data: {
id: parityId(`${tag}ill`, 1),
userId,
label: "Common cold",
type: "INFECTION",
onsetAt: new Date(now.getTime() - 3 * DAY_MS),
createdAt: new Date(now.getTime() - 3 * DAY_MS),
},
});
return { bpMedicationId, otherMedicationId };
}