Files
HealthLog/src/lib/insights/__tests__/weight-status.test.ts
T
Marc-André Bombeck eee6ab2e7e refactor(insights): wire the WMY rollup tier into status snapshots and share generator helpers
Read the MONTH / YEAR rollup tier for the monthly / yearly slices of the
focused single-metric status snapshots (weight, pulse, bmi,
blood-pressure) via `buildGradedSeriesWithRollups`, so the coarse tiers
finally earn their write amplification. BMI has no tier of its own, so it
scales the WEIGHT tier by 1 / heightCm² (`scaleGradedSeries`) — an exact
linear transform. The multi-metric general overview and the tier-less
mood / adherence series keep their bounded in-memory fold.

Fix the coverage-miss path that returned an always-empty monthly / yearly
slice: the bounded ~90-day recent read can never supply those slices, so
on a cold-tier miss the builder now folds the full history in memory
rather than shipping empty coarse arrays when years of raw history exist.
Excludes tombstoned rows from both reads.

Lift the byte-identical `round`, `normalizeSummaryText`,
`normalizeLocale`, `summarizeSeries`, the `{summary}`-envelope parse, and
the cache-row persist block out of the seven generators into
`status-shared.ts`. Drop the per-medication expectation in the compliance
generator: the prompt returns only `{summary}`, so the per-med cards are
server-side placeholders rather than a dead model-authored branch. Remove
the unused `summarizeGradedSeries` export.

Behaviour is unchanged on the in-memory path; the rollup path is exact
where the tier composes (count / min / max / mean) and the payload stays
under the snapshot char cap.
2026-05-31 22:43:24 +02:00

198 lines
6.6 KiB
TypeScript

import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { findFirst: vi.fn(), create: vi.fn() },
measurement: { findMany: vi.fn() },
measurementRollup: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
vi.mock("@/lib/insights/memory", () => ({
getPreviousInsightContext: vi.fn().mockResolvedValue(null),
formatPreviousContextForPrompt: vi.fn().mockReturnValue(""),
}));
import { prisma } from "@/lib/db";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { generateWeightStatusForUser } from "../weight-status";
const dayMs = 24 * 60 * 60 * 1000;
function stubCompletion(
content: string,
capture?: { userPrompt: string | null },
) {
vi.mocked(runStatusCompletion).mockImplementation(
async (args: { userPrompt: string }) => {
if (capture) capture.userPrompt = args.userPrompt;
return {
kind: "ok",
content,
providerType: "anthropic",
model: "x",
tokensUsed: 1,
} as never;
},
);
}
beforeEach(() => {
vi.resetAllMocks();
// Cold rollup tier: the graded builder folds monthly/yearly from the
// full-history `measurement.findMany` fallback the test already mocks.
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
});
describe("generateWeightStatusForUser — graded payload", () => {
it("emits a graded {recent, weekly, monthly} weight series, not the full daily array", async () => {
const now = new Date();
const records: Array<{
type: string;
value: number;
measuredAt: Date;
}> = [];
for (let day = 0; day < 1000; day++) {
records.push({
type: "WEIGHT",
value: 80 + (day % 5),
measuredAt: new Date(now.getTime() - day * dayMs),
});
}
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue(records as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
const captured: { userPrompt: string | null } = { userPrompt: null };
stubCompletion('{"summary":"OK"}', captured);
await generateWeightStatusForUser("user-1", { locale: "en" });
const match = captured.userPrompt!.match(/\{[\s\S]*\}/);
const snapshot = JSON.parse(match![0]);
const weight = snapshot.weight.series;
expect(weight).toHaveProperty("recent");
expect(weight).toHaveProperty("weekly");
expect(weight).toHaveProperty("monthly");
expect(weight).toHaveProperty("yearly");
// No raw daily array beyond the bounded recent window.
expect(weight.recent.length).toBeLessThanOrEqual(21);
expect(weight.recent[0]).toHaveProperty("date");
expect(weight.recent[0]).toHaveProperty("mean");
expect(weight.recent[0]).toHaveProperty("min");
expect(weight.recent[0]).toHaveProperty("max");
expect(weight.monthly[0]).toHaveProperty("month");
expect(weight.monthly[0]).toHaveProperty("mean");
// The whole graded series collapses 1000 daily readings to a tiny
// bucket count.
const total =
weight.recent.length +
weight.weekly.length +
weight.monthly.length +
weight.yearly.length;
expect(total).toBeLessThanOrEqual(50);
});
});
describe("generateWeightStatusForUser — timeout/error never persists", () => {
it("serves the fallback without writing a cache row on timeout", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 82, measuredAt: new Date() },
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
vi.mocked(runStatusCompletion).mockResolvedValue({
kind: "timeout",
} as never);
const result = await generateWeightStatusForUser("user-1", {
locale: "en",
});
expect(result.text).toBeTruthy();
expect(result.cached).toBe(true);
expect(result.updatedAt).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});
describe("generateWeightStatusForUser — cache-read skips a stub", () => {
it("regenerates when the only cached row is a timeout stub", async () => {
const now = new Date();
const todayKey = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
}).format(now);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: now,
details: JSON.stringify({
dateKey: todayKey,
locale: "en",
text: "Generic stub fallback.",
model: "timeout-stub",
timeout: true,
}),
} as never);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 82, measuredAt: now },
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: now,
} as never);
stubCompletion('{"summary":"Fresh real assessment."}');
const result = await generateWeightStatusForUser("user-1", {
locale: "en",
});
expect(runStatusCompletion).toHaveBeenCalledTimes(1);
expect(result.text).toBe("Fresh real assessment.");
expect(result.cached).toBe(false);
});
});
describe("generateWeightStatusForUser — token-leak hardening (v1.4.27 F16)", () => {
it("strips metric: tokens out of the cached text before persisting", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null);
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ type: "WEIGHT", value: 82, measuredAt: new Date() },
] as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date(),
} as never);
stubCompletion(
'{"summary":"Weight trended down 0.4 kg last week. metric:WEIGHT"}',
);
const result = await generateWeightStatusForUser("user-1", {
locale: "en",
});
expect(result.text).toBeTruthy();
expect(result.text).not.toContain("metric:");
const createCalls = vi.mocked(prisma.auditLog.create).mock.calls;
expect(createCalls.length).toBeGreaterThan(0);
const details = (createCalls[0][0] as { data: { details: string } }).data
.details;
const parsed = JSON.parse(details) as { text: string };
expect(parsed.text).not.toContain("metric:");
});
});