Files
HealthLog/tests/integration/analytics-bp-aggregate-paged.test.ts
T
Marc-André Bombeck 823764a914 perf(analytics): cap live-fallback findMany at trailing 90 days
The per-type loop inside `buildAnalyticsResponse` calls
`fetchMeasurementSeriesChunked` without a `since` filter, so a single
default-slice request pulls every measurement ever for the user across
15 chunked rounds. The v1.4.38.8 per-type fast-path gate made this
unreachable in the common case, but a regression on those gates would
re-trigger Marc's 74.6 s cold full-slice mount.

- Add `ANALYTICS_LIVE_WINDOW_DAYS = 90` + `liveSince` constant.
- Thread `since: liveSince` into every per-type chunked read.
- Annotate `meta.analytics.bp_aggregate.live_since` (ISO) so the next
  perf-verify can prove the cap is in effect on a live request.

Defense-in-depth only — the rollup fast-path is unchanged because it
never reaches the per-type loop. Worst-case bound drops from ~347 k
rows to ~5 k rows on Marc's tenant (matching the
comprehensive-aggregator + summaries-slice 90-day windows).

Trade-off: `summarize().avg30LastYear` returns null on the fall-through
path; the dashboard's year-over-year overlay shows "no prior data"
until the rollup tier warms. Acceptable on a degraded path that
v1.4.38.8 already made rare.

Tests: new `src/__tests__/api/analytics/since-cap.test.ts` pins the
90-day floor on every per-type findMany, pins the `live_since`
annotate, and pins that the slim slice is untouched. Existing chunked
paging integration test reseeded with timestamps inside the 90-day
window so it still exercises the two-chunk boundary.
2026-05-18 12:22:11 +02:00

166 lines
6.2 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 now caps the live-fallback per-type
// read at the trailing 90 days. To keep this paging contract test
// exercising both chunks the rows are seeded backwards from `now`
// at 15-minute intervals (6 000 × 15 min = 62.5 days, comfortably
// inside the 90-day floor).
const ROW_COUNT = 6000;
const STEP_MS = 15 * 60 * 1000;
const nowMs = Date.now();
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 (just 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(nowMs - (ROW_COUNT - 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();
const analytics = httpEvent!.meta!.analytics as
| { bp_aggregate?: { row_count?: number } }
| undefined;
expect(analytics?.bp_aggregate?.row_count).toBe(ROW_COUNT);
});
});