mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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
175 lines
6.7 KiB
TypeScript
175 lines
6.7 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|