Files
HealthLog/src/lib/analytics/__tests__/summaries-slice.test.ts
T
Marc-André Bombeck 8a56f48259 refactor(prompts): unify src/lib/insights/prompt*.ts into src/lib/ai/prompts/
Pre-v1.4.41 prompts lived in two homes — `src/lib/ai/prompts/` (per-
metric system prompts + safety contracts) and `src/lib/insights/prompt
*.ts` (user-prompt builder + comparison snapshot + the `compactSections`
helper). A new reader could not tell from filenames which file to
extend; both directories looked equally authoritative. Audit rec #3
flagged the split as the only undiscoverable seam in the AI layer.

Move both files into `src/lib/ai/prompts/`:
  - `lib/insights/prompt.ts` → `lib/ai/prompts/insight-system-prompt.ts`
  - `lib/insights/prompt-compact.ts` → `lib/ai/prompts/compact-sections.ts`
  - co-located tests follow

Three importers updated: `app/api/insights/generate/route.ts`,
`lib/ai/coach/snapshot.ts`, the generate route test (the
`vi.mock('@/lib/insights/prompt')` path moved too). Prompts now have
ONE home and ONE mental model.
2026-05-21 17:14:01 +02:00

453 lines
15 KiB
TypeScript

/**
* Unit-level pin for the slim summaries slice. Heavier integration
* coverage lives in `tests/integration/analytics-summaries-slice.test.ts`
* (real Postgres, real `regr_slope`); this file mocks `$queryRaw` so
* the slope/round/empty/path-selection contracts are pinned without
* a container.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: vi.fn(),
measurement: { findFirst: vi.fn() },
// v1.4.36 — slim slice reads DAY buckets from `measurement_rollups`
// on the happy path. The freshness watermark inside
// `ensureUserRollupsFresh` also pokes `measurementRollup.findFirst`;
// mock both so the helper runs without a container.
measurementRollup: {
findMany: vi.fn(),
findFirst: vi.fn(),
},
},
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
import { prisma } from "@/lib/db";
import { computeSummariesSlice } from "../summaries-slice";
const RAW = prisma.$queryRaw as unknown as ReturnType<typeof vi.fn>;
const MEASUREMENT_FIND_FIRST =
prisma.measurement.findFirst as unknown as ReturnType<typeof vi.fn>;
const ROLLUP_FIND_MANY =
prisma.measurementRollup.findMany as unknown as ReturnType<typeof vi.fn>;
const ROLLUP_FIND_FIRST =
prisma.measurementRollup.findFirst as unknown as ReturnType<typeof vi.fn>;
beforeEach(() => {
RAW.mockReset();
MEASUREMENT_FIND_FIRST.mockReset();
ROLLUP_FIND_MANY.mockReset();
ROLLUP_FIND_FIRST.mockReset();
ROLLUP_FIND_MANY.mockResolvedValue([]);
ROLLUP_FIND_FIRST.mockResolvedValue(null);
MEASUREMENT_FIND_FIRST.mockResolvedValue(null);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("computeSummariesSlice", () => {
describe("cold fallback — empty rollup table", () => {
it("returns the empty-summary skeleton when the user has no rows", async () => {
// 1. per-type coverage probe — empty ⇒ cold path.
// 2. heavy aggregate ($queryRaw) — empty.
// 3. latests ($queryRaw) — empty.
RAW.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
const result = await computeSummariesSlice("user-1");
expect(result.summaries.WEIGHT).toEqual({
count: 0,
latest: null,
min: null,
max: null,
mean: null,
avg7: null,
avg30: null,
slope7: null,
slope30: null,
slope90: null,
anomalyCount: 0,
avg30LastMonth: null,
avg30LastYear: null,
});
expect(result.bmi).toBeNull();
expect(RAW).toHaveBeenCalledTimes(3);
});
it("maps a populated heavy aggregate row into the DataSummary shape on cold path", async () => {
// Coverage probe returns WEIGHT with no buckets → cold path.
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: false }])
.mockResolvedValueOnce([
{
type: "WEIGHT",
count: BigInt(42),
min_value: 79.2,
max_value: 84.1,
mean_value: 82.05,
avg7: 81.9,
avg30: 82.1,
slope7: -0.014,
r2_7: 0.65,
slope30: -0.005,
r2_30: 0.42,
slope90: 0.001,
r2_90: 0.12,
},
])
.mockResolvedValueOnce([
{ type: "WEIGHT", value: 81.4, measured_at: new Date() },
]);
const result = await computeSummariesSlice("user-1");
const weight = result.summaries.WEIGHT;
expect(weight.count).toBe(42);
expect(weight.latest).toBe(81.4);
expect(weight.min).toBe(79.2);
expect(weight.max).toBe(84.1);
expect(weight.mean).toBe(82.05);
expect(weight.avg7).toBe(81.9);
expect(weight.avg30).toBe(82.1);
expect(weight.anomalyCount).toBe(0);
expect(weight.avg30LastMonth).toBeNull();
expect(weight.avg30LastYear).toBeNull();
expect(weight.slope7).toEqual({
slope: -0.014,
direction: "down",
confidence: 0.65,
});
expect(weight.slope30).toEqual({
slope: -0.005,
direction: "stable",
confidence: 0.42,
});
expect(weight.slope90).toEqual({
slope: 0.001,
direction: "stable",
confidence: 0.12,
});
});
it("returns a null slope tuple when the SQL slope is null (insufficient rows)", async () => {
RAW.mockResolvedValueOnce([{ type: "PULSE", has_buckets: false }])
.mockResolvedValueOnce([
{
type: "PULSE",
count: BigInt(1),
min_value: 72,
max_value: 72,
mean_value: 72,
avg7: 72,
avg30: 72,
slope7: null,
r2_7: null,
slope30: null,
r2_30: null,
slope90: null,
r2_90: null,
},
])
.mockResolvedValueOnce([
{ type: "PULSE", value: 72, measured_at: new Date() },
]);
const result = await computeSummariesSlice("user-1");
expect(result.summaries.PULSE.slope7).toBeNull();
expect(result.summaries.PULSE.slope30).toBeNull();
expect(result.summaries.PULSE.slope90).toBeNull();
});
it("surfaces lastSeenByType from the DISTINCT ON pass's measured_at", async () => {
const tenDaysAgo = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000);
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: false }])
.mockResolvedValueOnce([
{
type: "WEIGHT",
count: BigInt(5),
min_value: 80,
max_value: 84,
mean_value: 82,
avg7: null,
avg30: 82,
slope7: null,
r2_7: null,
slope30: 0.005,
r2_30: 0.2,
slope90: null,
r2_90: null,
},
])
.mockResolvedValueOnce([
{ type: "WEIGHT", value: 82.3, measured_at: tenDaysAgo },
]);
const result = await computeSummariesSlice("user-1");
const ws = result.lastSeenByType.WEIGHT;
expect(ws).not.toBeNull();
expect(ws?.daysAgo).toBeGreaterThanOrEqual(9);
expect(ws?.daysAgo).toBeLessThanOrEqual(11);
expect(ws?.lastSeenAt).toBe(tenDaysAgo.toISOString());
expect(result.lastSeenByType.PULSE).toBeNull();
});
it("seeds the latest value from the DISTINCT ON pass per type", async () => {
RAW.mockResolvedValueOnce([{ type: "PULSE", has_buckets: false }])
.mockResolvedValueOnce([
{
type: "PULSE",
count: BigInt(3),
min_value: 60,
max_value: 95,
mean_value: 77,
avg7: 77,
avg30: 77,
slope7: 0,
r2_7: 0,
slope30: 0,
r2_30: 0,
slope90: 0,
r2_90: 0,
},
])
.mockResolvedValueOnce([
{ type: "PULSE", value: 88, measured_at: new Date() },
]);
const result = await computeSummariesSlice("user-1");
expect(result.summaries.PULSE.latest).toBe(88);
expect(result.summaries.PULSE.max).toBe(95);
});
});
describe("rollup-fresh happy path", () => {
it("composes count/min/max/mean from the per-type rollup GROUP BY without running the heavy aggregate", async () => {
// v1.4.37.2 — the slim slice's rollup read is now a per-type
// GROUP BY ($queryRaw) instead of a row-per-bucket findMany,
// so the mock sequence is:
// 1. per-type coverage probe — WEIGHT fully covered ⇒ happy path.
// 2. narrow aggregate — windowed/regression only.
// 3. latests.
// 4. rollup GROUP BY — one row per type with count/min/max/mean
// already composed server-side.
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }])
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 82,
avg30: 82.5,
slope7: 0.02,
r2_7: 0.5,
slope30: 0.01,
r2_30: 0.4,
slope90: 0.005,
r2_90: 0.2,
},
])
.mockResolvedValueOnce([
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
])
.mockResolvedValueOnce([
{
type: "WEIGHT",
// pre-aggregated server-side: SUM(count), MIN(min),
// MAX(max), weighted mean — equivalent to the two-bucket
// fixture below.
// bucket A: count=10, mean=81.0, min=79.5, max=82.0
// bucket B: count=10, mean=83.0, min=81.5, max=84.0
// ⇒ count=20, min=79.5, max=84.0, mean=82
count: 20,
min: 79.5,
max: 84.0,
mean: 82.0,
},
]);
const result = await computeSummariesSlice("user-rollup");
const weight = result.summaries.WEIGHT;
expect(weight.count).toBe(20);
expect(weight.min).toBe(79.5);
expect(weight.max).toBe(84.0);
expect(weight.mean).toBe(82);
expect(weight.latest).toBe(82.7);
expect(weight.avg7).toBe(82);
expect(weight.slope7).toEqual({
slope: 0.02,
direction: "up",
confidence: 0.5,
});
// probe + narrow aggregate + latests + rollup GROUP BY
// (v1.4.37.2 — the prior `findMany` is gone). No heavy aggregate.
expect(RAW).toHaveBeenCalledTimes(4);
// v1.4.40 W-WMY-WIRE — the year-ago baseline probe runs
// `readBestGranularityRollups(userId, type, 395)` per
// type-with-data via `prisma.measurementRollup.findMany`. The
// 395-day window skips the YEAR floor (731 d) and walks MONTH
// → WEEK → DAY on full coverage miss; the default mock returns
// `[]` so all three reachable tiers probe.
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(3);
});
});
/**
* v1.4.40 W-WMY-WIRE — pin the wiring of the WMY readers into the
* slim slice. The pre-v1.4.40 shape hardcoded `avg30LastYear` to
* `null`; we now populate it from `readBestGranularityRollups` when
* the YEAR / MONTH / WEEK / DAY tier carries buckets that overlap
* the `[now-395d, now-365d)` slice.
*/
describe("year-over-year wiring (avg30LastYear)", () => {
it("populates avg30LastYear from MONTH buckets that overlap the year-ago slice", async () => {
// Coverage probe shows WEIGHT covered → rollup happy path.
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }])
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 82,
avg30: 82.5,
slope7: 0,
r2_7: 0,
slope30: 0,
r2_30: 0,
slope90: 0,
r2_90: 0,
},
])
.mockResolvedValueOnce([
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
])
.mockResolvedValueOnce([
{ type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 },
]);
// Year-ago slice MONTH bucket — `bucketStart` placed 380 days
// ago so it falls inside `[now-395d, now-365d)`. Single bucket
// (count=10, mean=85) → weighted mean = 85.
const yearAgoBucketStart = new Date(
Date.now() - 380 * 24 * 60 * 60 * 1000,
);
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: yearAgoBucketStart,
count: 10,
mean: 85,
sd: 1,
slope: 0,
r2: 0,
sumValue: null,
minValue: 83,
maxValue: 87,
},
]);
const result = await computeSummariesSlice("user-yoy");
expect(result.summaries.WEIGHT.avg30LastYear).toBe(85);
// Router asked MONTH first (395 > 181, 395 < 731 → skip YEAR).
expect(ROLLUP_FIND_MANY.mock.calls[0][0].where.granularity).toBe("MONTH");
});
it("leaves avg30LastYear null when no bucket overlaps the year-ago slice", async () => {
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }])
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 82,
avg30: 82.5,
slope7: 0,
r2_7: 0,
slope30: 0,
r2_30: 0,
slope90: 0,
r2_90: 0,
},
])
.mockResolvedValueOnce([
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
])
.mockResolvedValueOnce([
{ type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 },
]);
// MONTH bucket placed 30 days ago — inside the YEAR / MONTH /
// WEEK 395-day window the router asks for, but outside the
// `[now-395d, now-365d)` slice. The helper returns null.
const recentBucket = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: recentBucket,
count: 10,
mean: 82,
sd: 1,
slope: 0,
r2: 0,
sumValue: null,
minValue: 80,
maxValue: 84,
},
]);
const result = await computeSummariesSlice("user-recent");
expect(result.summaries.WEIGHT.avg30LastYear).toBeNull();
});
it("leaves avg30LastYear null when every granularity misses (no coverage)", async () => {
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }])
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 82,
avg30: 82.5,
slope7: 0,
r2_7: 0,
slope30: 0,
r2_30: 0,
slope90: 0,
r2_90: 0,
},
])
.mockResolvedValueOnce([
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
])
.mockResolvedValueOnce([
{ type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 },
]);
// Default mock returns `[]` for every findMany call → router
// walks YEAR → MONTH → WEEK → DAY and gives up.
ROLLUP_FIND_MANY.mockResolvedValue([]);
const result = await computeSummariesSlice("user-empty-yoy");
expect(result.summaries.WEIGHT.avg30LastYear).toBeNull();
// 395d window skips YEAR (floor 731 d) → MONTH → WEEK → DAY,
// three reachable tiers all probed on full coverage miss.
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(3);
});
it("only probes types that actually have data in the current window", async () => {
// Coverage probe + rollup GROUP BY both report zero rows → no
// types-with-data, so the year-ago probe must NOT fan out.
RAW.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
await computeSummariesSlice("user-no-data");
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
});
});
});