mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the login, register, token and session paths, mirroring columns every migration under prisma/migrations already creates. The entrypoint applies those migrations before the server listens and stops the boot when one fails, so the mirror never had work to do, and DDL on a request path needs the table's owner: a connection that may only read and write rows fails every login on it. Drop the module, its call sites and the mocks that stubbed it out.
180 lines
7.0 KiB
TypeScript
180 lines
7.0 KiB
TypeScript
/**
|
||
* v1.4.23 — 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 { dayValue } from "@/lib/measurements/day-mean";
|
||
import { userDayKey } from "@/lib/tz/format";
|
||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||
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);
|
||
},
|
||
})),
|
||
};
|
||
});
|
||
|
||
// 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 — 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 pulse is the value of its latest local day (the mean of that
|
||
// day's hour means, the statistic the day view shows as "Pulse"), so it
|
||
// is computed here from the rows of that day through the same helper.
|
||
// If the cursor skipped or duplicated a row across the chunk boundary
|
||
// the count fails loudly.
|
||
expect(envelope.data.summaries.PULSE.count).toBe(ROW_COUNT);
|
||
const tz = await resolveUserTimezone(user.id);
|
||
const lastDay = userDayKey(rows.at(-1)!.measuredAt, tz);
|
||
const sameDay = rows.filter(
|
||
(r) => userDayKey(r.measuredAt, tz) === lastDay,
|
||
);
|
||
expect(envelope.data.summaries.PULSE.latest).toBeCloseTo(
|
||
dayValue("PULSE", sameDay, tz)!,
|
||
9,
|
||
);
|
||
|
||
// 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);
|
||
});
|
||
});
|