Files
HealthLog/tests/integration/analytics-summaries-slice.test.ts
T
Marc-André Bombeck fea1d1e0b5 chore: strip planning tags from comments and enable the rule
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
2026-10-20 09:08:40 +02:00

221 lines
8.0 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.33 — `?slice=summaries` integration coverage.
*
* Pins the slim summaries slice against the real Postgres + the real
* `regr_slope` / `regr_r2` functions, not a unit-level `$queryRaw`
* mock. The slice replaces the route's 30-type × Promise.all chunked
* findMany walk with 2 SQL passes; the integration test runs against
* the test container so any drift in the SQL (alias names, casts,
* `FILTER` semantics) surfaces here instead of in production.
*
* Three contracts pinned:
*
* 1. **Empty user.** A brand-new account with zero rows returns the
* empty `DataSummary` skeleton for every `MeasurementType` and
* `bmi: null`. No JS-side spread anywhere along the chain — the
* v1.4.33 P0 RangeError won't reach this path.
*
* 2. **Populated WEIGHT series.** A 14-day descending-weight series
* lands `count`, `latest`, `avg7`, `avg30`, the negative-slope
* `direction: "down"` tuple from `regr_slope`, plus min/max/mean
* with the same 2-decimal rounding the `summarize()` helper uses.
*
* 3. **Default slice unchanged.** Hitting the route WITHOUT
* `?slice=summaries` still returns the thick `healthScore` /
* `correlations` / `bpInTargetPct` / `sleepStages` envelope. C1
* is additive — no behaviour change for the existing consumers.
*/
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),
}));
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
interface DataSummaryShape {
count: number;
latest: number | null;
min: number | null;
max: number | null;
mean: number | null;
avg7: number | null;
avg30: number | null;
slope7: { slope: number; direction: string; confidence: number } | null;
slope30: { slope: number; direction: string; confidence: number } | null;
slope90: { slope: number; direction: string; confidence: number } | null;
anomalyCount: number;
avg30LastMonth: number | null;
avg30LastYear: number | null;
}
interface SlimEnvelope {
data: {
summaries: Record<string, DataSummaryShape>;
bmi: number | null;
};
error: 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?slice=summaries (C1)", () => {
it("returns the empty-summary skeleton for a brand-new user", async () => {
await seedSession("slim-empty-user");
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?slice=summaries"));
expect(response.status).toBe(200);
const envelope = (await response.json()) as SlimEnvelope;
// Every enum option is present with the deterministic empty
// shape so consumers can read `summaries.WEIGHT.count > 0`
// without an undefined guard.
expect(envelope.data.summaries.WEIGHT.count).toBe(0);
expect(envelope.data.summaries.WEIGHT.latest).toBeNull();
expect(envelope.data.summaries.WEIGHT.slope30).toBeNull();
expect(envelope.data.summaries.BLOOD_PRESSURE_SYS.count).toBe(0);
expect(envelope.data.summaries.PULSE.count).toBe(0);
// The slim slice never carries BMI — consumers re-derive from
// `summaries.WEIGHT.latest` + `user.heightCm` client-side or
// fetch the default slice.
expect(envelope.data.bmi).toBeNull();
});
it("reports count + latest + slope + windowed averages from real SQL", async () => {
const prisma = getPrismaClient();
const user = await seedSession("slim-populated-user");
// 14 descending-WEIGHT entries spaced one day apart. Slope per
// day = (84.0 - 80.0) / 13 ≈ -0.308 — solidly inside the "down"
// band (|slope| >= 0.01 units/day threshold).
const now = Date.now();
const DAY_MS = 24 * 60 * 60 * 1000;
const N = 14;
const rows = Array.from({ length: N }, (_, i) => ({
userId: user.id,
type: "WEIGHT" as const,
// Most-recent reading is the smallest — slope must come out
// negative when regressing value against time.
value: 80 + i * (4 / (N - 1)),
unit: "kg",
source: "MANUAL" as const,
measuredAt: new Date(now - i * DAY_MS),
}));
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?slice=summaries"));
expect(response.status).toBe(200);
const envelope = (await response.json()) as SlimEnvelope;
const weight = envelope.data.summaries.WEIGHT;
expect(weight.count).toBe(N);
// Most-recent row in DESCENDING construction = index 0 = value 80.
expect(weight.latest).toBeCloseTo(80, 1);
// Min / max across the 14 rows.
expect(weight.min).toBeCloseTo(80, 1);
expect(weight.max).toBeCloseTo(84, 1);
// 7-day window covers the first 7 entries (descending dates).
expect(weight.avg7).not.toBeNull();
expect(weight.avg30).not.toBeNull();
// Slope direction must be "down" — value decreases with time.
expect(weight.slope30).not.toBeNull();
expect(weight.slope30!.direction).toBe("down");
expect(weight.slope30!.slope).toBeLessThan(0);
// R² confidence is in [0, 1].
expect(weight.slope30!.confidence).toBeGreaterThanOrEqual(0);
expect(weight.slope30!.confidence).toBeLessThanOrEqual(1);
// Sanity-check: PULSE is empty on this fixture.
expect(envelope.data.summaries.PULSE.count).toBe(0);
});
it("default slice still carries the thick envelope (additive contract)", async () => {
await seedSession("slim-default-still-thick");
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);
// Default slice still produces every block the route v1.4.32
// shipped — proving C1 added a branch instead of mutating the
// existing path.
const body = (await response.json()) as {
data: {
summaries: unknown;
bmi: unknown;
bpInTargetPct: unknown;
correlations: unknown;
healthScore: unknown;
sleepStages: unknown;
glucoseByContext: unknown;
};
};
expect(body.data.summaries).toBeDefined();
// Every thick-slice key must still be on the envelope (value can
// be null for an empty user — what we care about is presence).
expect("bpInTargetPct" in body.data).toBe(true);
expect("correlations" in body.data).toBe(true);
expect("healthScore" in body.data).toBe(true);
expect("sleepStages" in body.data).toBe(true);
expect("glucoseByContext" in body.data).toBe(true);
});
});