Files
HealthLog/tests/integration/cycle-crosstab-day-aggregates.test.ts
T

111 lines
4.6 KiB
TypeScript

/**
* The cycle-phase crosstab reads per-day, per-source aggregates instead of a
* year of raw readings (#1023). This pins that the day values it tests are
* the ones the raw readings give: for every crosstab metric, `metricDayMap`
* over the aggregates equals `metricDayMap` over the raw rows, on a fixture
* with two sources on the same days, two device types within one source,
* dense CGM glucose, summed steps and readings next to Berlin midnight.
*/
import { beforeAll, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import { readSourceDayAggregates } from "@/lib/measurements/day-aggregates";
import { metricDayMap } from "@/lib/insights/mood-crosstab";
import { PHASE_CROSSTAB_METRIC_TYPES } from "@/lib/cycle/phase-crosstab";
import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates";
const prisma = getPrismaClient();
const USER = "cycle-crosstab-agg";
const SINCE = new Date("2026-06-01T00:00:00Z");
beforeAll(async () => {
await truncateAllTables(prisma);
await prisma.user.create({ data: { id: USER, username: USER } });
// CGM glucose every 5 minutes for 60 days from two sources.
await prisma.$executeRawUnsafe(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at, sync_version)
SELECT 'cg' || s || g, $1, 'BLOOD_GLUCOSE'::measurement_type, 80 + (g % 60), 'mg/dL',
(CASE WHEN s = 0 THEN 'APPLE_HEALTH' ELSE 'NIGHTSCOUT' END)::measurement_source,
timestamp '2026-06-02 00:00:00' + (g * interval '5 minutes') + (s * interval '1 second'),
now(), now(), 1
FROM generate_series(0, 60 * 288 - 1) g, generate_series(0, 1) s`,
USER,
);
// Steps every 15 minutes from a watch and a phone, plus Google totals on
// some days; resting heart rate once a day near Berlin midnight.
await prisma.$executeRawUnsafe(
`INSERT INTO measurements (id, user_id, type, value, unit, source, device_type, measured_at, created_at, updated_at, sync_version)
SELECT 'st' || d || g, $1, 'ACTIVITY_STEPS'::measurement_type, 10 + (g % 7), 'count',
'APPLE_HEALTH'::measurement_source, CASE WHEN d = 0 THEN 'watch' ELSE 'phone' END,
timestamp '2026-06-02 00:00:00' + (g * interval '15 minutes') + (d * interval '2 seconds'),
now(), now(), 1
FROM generate_series(0, 60 * 96 - 1) g, generate_series(0, 1) d
WHERE d = 0 OR g % 3 = 0`,
USER,
);
await prisma.$executeRawUnsafe(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at, sync_version)
SELECT 'gs' || g, $1, 'ACTIVITY_STEPS'::measurement_type, 5000 + g, 'count',
'GOOGLE_HEALTH'::measurement_source,
timestamp '2026-06-02 12:00:00' + (g * interval '1 day'), now(), now(), 1
FROM generate_series(0, 59, 4) g`,
USER,
);
await prisma.$executeRawUnsafe(
`INSERT INTO measurements (id, user_id, type, value, unit, source, measured_at, created_at, updated_at, sync_version)
SELECT 'rh' || g, $1, 'RESTING_HEART_RATE'::measurement_type, 55 + (g % 9), 'bpm',
'WITHINGS'::measurement_source,
timestamp '2026-06-02 22:30:00' + (g * interval '1 day'), now(), now(), 1
FROM generate_series(0, 59) g`,
USER,
);
}, 120_000);
describe("cycle crosstab day values (#1023)", () => {
it("per-source day aggregates give the raw rows' day values for every metric", async () => {
const raw = await prisma.measurement.findMany({
where: {
userId: USER,
deletedAt: null,
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
measuredAt: { gte: SINCE },
},
select: {
type: true,
value: true,
measuredAt: true,
source: true,
deviceType: true,
},
});
const agg = await readSourceDayAggregates({
userId: USER,
types: PHASE_CROSSTAB_METRIC_TYPES,
since: SINCE,
timeZone: "Europe/Berlin",
});
const aggRows: CrossMetricMeasurement[] = agg.map((a) => ({
type: a.type,
value: a.sum,
count: a.n,
measuredAt: a.firstAt,
source: a.source,
deviceType: a.deviceType,
}));
expect(raw.length).toBeGreaterThan(40_000);
expect(aggRows.length).toBeLessThan(500);
for (const type of PHASE_CROSSTAB_METRIC_TYPES) {
const expected = metricDayMap(raw, type, null, "Europe/Berlin");
const actual = metricDayMap(aggRows, type, null, "Europe/Berlin");
expect([...actual.keys()].sort(), type).toEqual(
[...expected.keys()].sort(),
);
for (const [day, value] of expected) {
expect(actual.get(day), `${type} ${day}`).toBeCloseTo(value, 9);
}
}
}, 60_000);
});