Files
HealthLog/tests/integration/analytics-bp-aggregate-paged.test.ts
T
Marc-André Bombeck 394f0b0795 chore(release): v1.5.0 — native iOS client public-beta + per-day stats overwrite + compliance fix
v1.5.0 cut. The native iOS client is now publicly available via TestFlight (https://testflight.apple.com/join/bucuTBpa), and three issues that surfaced during the iOS handoff land alongside it.

## Features

- **stats:* upsert** (closes #213) — `POST /api/measurements/batch` recognises `externalId` values starting with `stats:` (per-day cumulative HK metrics: Steps, Active Energy, Sleep Duration, Walking/Running Distance, Flights Climbed) and treats a duplicate on those as an overwrite of the existing row's `value` rather than dropping the new payload. Sample-class externalIds keep the strict immutable duplicate contract because each sample is a canonical reading. New per-entry status `"updated"` on the response envelope, new `measurement.batch.stats-overwrite` wide-event annotation, audit-log entry extended with the overwrite count. Three integration tests pin the contract.

## Fixes

- **Compliance honour daysOfWeek + intervalWeeks** (closes #214) — `calculateCompliance` was computing `totalExpected = schedules.length * days`, silently ignoring `MedicationSchedule.daysOfWeek` and `intervalWeeks`. Weekly Ozempic reported ~13% adherence instead of ~100%; weekday-only 3×/day metformin reported ~73% instead of ~100%. Rewrote as a thin adapter on top of the cadence-aware `buildCadenceTimeline` path that already powers `/api/medications/[id]/cadence`. The wire shape (`{ totalExpected, taken, skipped, missed, rate, streak }`) is unchanged so every consumer (Health Score, AI Coach prompt, per-medication card, BP-status compliance gate, insight targets, `/api/insights/comprehensive`) keeps reading the same fields. Parameterised test matrix with 35 cases covers daily / weekly / bi-weekly / weekday-only / multi-dose-per-day / DST-boundary / createdAt-truncation. Health-Score regression test pinned.

## Tests

- 8 stale integration assertions retired across `withings-oauth.test.ts`, `withings-oauth-flow.test.ts`, `analytics-bp-aggregate-paged.test.ts`, `analytics-sleep-stages.test.ts`, `apns-dispatch.test.ts` — drift from the v1.4.47.x OAuth fine-grained reason tags, the v1.4.47.2 ES256 PEM verify guard, and the v1.4.49.1 analytics slim-slice annotation rename. Three `source-priority-two-axis` cases skipped with inline TODO referencing the v1.4.49.1 commit and the relocation candidate.
- New cadence test matrix anchored explicitly in UTC so the suite stays deterministic across CI (UTC) and local (Europe/Berlin) hosts.

## Docs

- README rewrite for the v1.5 cut: TestFlight badge in the badge row + iOS TestFlight link in Website / Demo / Docs row + footer. Buy Me A Coffee badge added. Status block updated, new "Heavily developed" advisory directs self-hosters to pin a tag and back up before each upgrade. Tech-Stack flags the iOS app as TestFlight-available. Roadmap promotes v1.5 from "in active development" to "current". The "How it works" diagram cluster removed — the four SVGs continue to live under `docs/diagrams/` and are surfaced through docs.healthlog.dev where they render reliably across themes.

## Final verify

- typecheck clean
- lint clean (one pre-existing warning in `withings/resume/__tests__`)
- pnpm test: 5285 passed / 1 skipped
- pnpm test:integration: 253 passed / 3 skipped
- GHA CI on PR: build amd64 + build arm64 + lint/typecheck/test + integration + e2e + dependency audit + secret scanning + dead-code gate + env check → all green
2026-05-24 22:22:30 +02:00

175 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.4.23 Sr-H1 — chunked-paged measurement reads in `GET /api/analytics`.
*
* The senior-dev review (W6) flagged the analytics route as fanning
* out 17+ unbounded `findMany` queries per request, each pulling the
* user's entire history for a single MeasurementType. The fix routes
* every per-type read through `fetchMeasurementSeriesChunked()` —
* page size 5 000, cursor on `(measuredAt asc, id asc)` — so the
* working set stays bounded even for a multi-year HealthKit tenant.
*
* This test pins two contracts:
*
* 1. **Response shape unchanged.** A 6 000-row PULSE dataset spans
* two chunks; the resulting `summaries.PULSE` must still report
* the correct count and latest value. The chunked helper must
* not duplicate or skip rows on the page boundary.
*
* 2. **Wide-event annotation present.** The route adds an
* `analytics.bp_aggregate.row_count` meta key carrying the total
* number of rows read across every chunked per-type query, so ops
* can attribute slow requests to outlier users without re-running
* a DB query.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
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),
}));
// Read wide-events back out of the in-memory ring buffer the
// `emitEvent` transport already populates — avoids coupling to the
// Loki/stdout plumbing and works regardless of `LOG_SAMPLE_RATE`.
import { clearLogBuffer, readLogBuffer } from "@/lib/logging/in-memory-buffer";
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
clearLogBuffer();
});
afterEach(() => {
vi.restoreAllMocks();
});
interface AnalyticsEnvelope {
data: {
summaries: Record<
string,
{
count: number;
latest: number | null;
}
>;
};
}
async function seedSession(username: string) {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username,
email: `${username}@example.test`,
role: "USER",
},
});
const session = await prisma.session.create({
data: {
userId: user.id,
expiresAt: new Date(Date.now() + 60_000),
},
});
cookieJar.set("healthlog_session", session.id);
return user;
}
describe("GET /api/analytics — chunked per-type reads (Sr-H1)", () => {
it("returns the correct summary across a 6 000-row dataset spanning two chunks", async () => {
const prisma = getPrismaClient();
const user = await seedSession("analytics-paged-user");
// 6 000 PULSE rows — one above MEASUREMENT_CHUNK_SIZE (5 000) so
// the chunked helper has to advance its cursor at least once. Each
// row carries a unique `measuredAt` so the cursor's stable
// `(measuredAt, id)` order produces a deterministic chronological
// sequence that `summarize()` consumes the same way it would have
// consumed a single-shot read.
//
// v1.4.39 W-SINCE — the route caps the live-fallback per-type read
// at the trailing 425 days. The seed packs 6 000 rows × 15 min ≈
// 62.5 days backwards from a fixed anchor; QA F-M-04 (v1.4.39):
// re-anchor the youngest row to `nowMs - 30 * 86_400_000` so the
// seed lives ~30-92 days ago regardless of when the test runs.
// Pre-fix the seed used `nowMs` directly so 28 days from now the
// 90-day cap would expire it — now 425-day, but the same anchor
// makes the test stable across long-running CI gaps.
const ROW_COUNT = 6000;
const STEP_MS = 15 * 60 * 1000;
const nowMs = Date.now();
const youngestRowAtMs = nowMs - 30 * 86_400_000;
const rows = Array.from({ length: ROW_COUNT }, (_, i) => ({
userId: user.id,
type: "PULSE" as const,
value: 60 + (i % 40), // deterministic 60..99
unit: "bpm",
source: "MANUAL" as const,
// i = ROW_COUNT - 1 is the freshest row (30 days before `now`);
// i = 0 is the oldest. The chunked helper reads
// `(measuredAt asc, id asc)` so the latest value is the last
// entry in the sorted stream — which the test asserts below.
measuredAt: new Date(
youngestRowAtMs - (ROW_COUNT - 1 - i) * STEP_MS,
),
}));
// createMany batches efficiently; default Postgres parameter limit
// accommodates this volume in a single statement.
await prisma.measurement.createMany({ data: rows });
const { GET } = await import("@/app/api/analytics/route");
const response = await (
GET as unknown as (req: Request) => Promise<Response>
)(new Request("http://localhost/api/analytics"));
expect(response.status).toBe(200);
const envelope = (await response.json()) as AnalyticsEnvelope;
// Response shape unchanged: count matches every row inserted, the
// latest value is the final row's value (the last index, i = 5999,
// gives 60 + (5999 % 40) = 60 + 39 = 99). If the cursor skipped or
// duplicated a row across the chunk boundary either of these
// assertions fails loudly.
expect(envelope.data.summaries.PULSE.count).toBe(ROW_COUNT);
expect(envelope.data.summaries.PULSE.latest).toBe(99);
// Wide-event annotation surfaces the row count for slow-query
// attribution. Total reads = 6 000 PULSE rows; every other type
// is empty (count 0) for this user. The field shape mirrors the
// existing `analytics.bp_in_target.row_count` slot.
const buffered = readLogBuffer({ limit: 50 });
const httpEvent = buffered.find((e) => e.http?.path === "/api/analytics");
expect(httpEvent).toBeDefined();
expect(httpEvent!.meta).toBeDefined();
// v1.4.49.1 — analytics route delegates to `computeSummariesSlice`,
// which writes the row-count slot under `slim_summaries` rather than
// the old `bp_aggregate.row_count`.
const analytics = httpEvent!.meta!.analytics as
| { slim_summaries?: { row_count?: number } }
| undefined;
expect(analytics?.slim_summaries?.row_count).toBe(ROW_COUNT);
});
});