From 68a9f114a558a14fe5e68f3835bf29f0c689d8f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 31 May 2026 21:47:16 +0200 Subject: [PATCH] test(insights): cover graded compression and stub-skip Pin the snapshot payload budget: a 2-year daily weigher (weight + daily BP) and a six-type dense account both serialise under the 24 000-char cap, proving the graded rewrite holds the line on the worst-case accounts. The stub-skip and timeout-no-persist behaviour is covered by the per-generator cache-read tests + the shared status-cache suite. --- .../insights/__tests__/payload-budget.test.ts | 137 +++++++++++++++++ .../insights-status-real-text.test.ts | 142 ++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 src/lib/insights/__tests__/payload-budget.test.ts create mode 100644 tests/integration/insights-status-real-text.test.ts diff --git a/src/lib/insights/__tests__/payload-budget.test.ts b/src/lib/insights/__tests__/payload-budget.test.ts new file mode 100644 index 000000000..000d9a20b --- /dev/null +++ b/src/lib/insights/__tests__/payload-budget.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +/** + * Wave 1D — payload-budget verification. A dense daily weigher (2 years + * of daily WEIGHT + BP, plus a many-type Apple-Health account for + * general-status) used to embed the full ~360-day daily array per + * metric, running ~20-100 K tokens. After the graded-series rewrite the + * snapshot the prompt carries must sit well under the ~24 000-char + * (~6 000-token) cap. + * + * The snapshot JSON is the `{ … }` block embedded in the user prompt; + * `runStatusCompletion` is stubbed to capture it. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + auditLog: { findFirst: vi.fn(), create: vi.fn() }, + measurement: { findMany: vi.fn() }, + medicationIntakeEvent: { 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"; +import { generateGeneralStatusForUser } from "../general-status"; + +const dayMs = 24 * 60 * 60 * 1000; +const BUDGET_CHARS = 24_000; + +let capturedPrompt: string | null = null; + +function stubCapture() { + vi.mocked(runStatusCompletion).mockImplementation( + async (args: { userPrompt: string }) => { + capturedPrompt = args.userPrompt; + return { + kind: "ok", + content: '{"summary":"OK"}', + providerType: "anthropic", + model: "x", + tokensUsed: 1, + } as never; + }, + ); +} + +function snapshotChars(): number { + const match = capturedPrompt!.match(/\{[\s\S]*\}/); + return match![0].length; +} + +beforeEach(() => { + vi.resetAllMocks(); + capturedPrompt = null; + vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); + vi.mocked(prisma.auditLog.create).mockResolvedValue({ + createdAt: new Date(), + } as never); + vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + heightCm: 175, + } as never); + stubCapture(); +}); + +describe("status snapshot payload budget", () => { + it("weight-status: a 2-year daily weigher + daily BP stays under the char cap", async () => { + const now = new Date(); + const rows: Array<{ type: string; value: number; measuredAt: Date }> = []; + for (let day = 0; day < 730; day++) { + const measuredAt = new Date(now.getTime() - day * dayMs); + rows.push({ type: "WEIGHT", value: 80 + (day % 5), measuredAt }); + rows.push({ + type: "BLOOD_PRESSURE_SYS", + value: 120 + (day % 10), + measuredAt, + }); + rows.push({ + type: "BLOOD_PRESSURE_DIA", + value: 80 + (day % 5), + measuredAt, + }); + } + vi.mocked(prisma.measurement.findMany).mockResolvedValue(rows as never); + + await generateWeightStatusForUser("user-1", { locale: "en" }); + + const chars = snapshotChars(); + // Pre-rewrite this ran ~80-110 KB for this exact account; the + // graded rewrite lands it around ~15 KB. + expect(chars).toBeLessThanOrEqual(BUDGET_CHARS); + }); + + it("general-status: a many-type dense account stays under the char cap", async () => { + const now = new Date(); + const types = [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "BODY_FAT", + "STEPS", + ]; + const rows: Array<{ type: string; value: number; measuredAt: Date }> = []; + for (let day = 0; day < 730; day++) { + const measuredAt = new Date(now.getTime() - day * dayMs); + for (const type of types) { + rows.push({ type, value: 50 + (day % 30), measuredAt }); + } + } + vi.mocked(prisma.measurement.findMany).mockResolvedValue(rows as never); + + await generateGeneralStatusForUser("user-1", { locale: "en" }); + + const chars = snapshotChars(); + // Pre-rewrite a many-type dense account could exceed 100 K tokens; + // the graded rewrite lands it around ~12 KB. + expect(chars).toBeLessThanOrEqual(BUDGET_CHARS); + }); +}); diff --git a/tests/integration/insights-status-real-text.test.ts b/tests/integration/insights-status-real-text.test.ts new file mode 100644 index 000000000..3beede888 --- /dev/null +++ b/tests/integration/insights-status-real-text.test.ts @@ -0,0 +1,142 @@ +/** + * Wave 1D — the per-card status path delivers the REAL provider text. + * + * Pins two contracts against a real Postgres + a seeded rich account: + * + * 1. With a provider chain stubbed to return a known assessment, the + * status generator returns that text (the real path is reached, not + * the generic no-key fallback). This is the regression the + * sticky-stub bug used to break. + * + * 2. A timeout-stub cache row keyed to today is NOT served — the + * generator skips it and re-generates, proving the stub no longer + * pins the fallback for the day. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +// Stub the provider resolution so the chain runner has a deterministic +// provider to walk. `runRawCompletionWithFallback` runs for real on top +// of this — exercising the same plumbing the live status path uses. +const KNOWN_TEXT = "Your weight has trended down steadily over the past month."; +vi.mock("@/lib/ai/provider", () => ({ + resolveProviderChain: vi.fn().mockResolvedValue([ + { + providerType: "anthropic", + instance: { + type: "anthropic", + generateCompletion: vi.fn().mockResolvedValue({ + content: JSON.stringify({ summary: KNOWN_TEXT }), + model: "test-model", + tokensUsed: 42, + }), + }, + }, + ]), + resolveProvider: vi.fn().mockResolvedValue({ type: "none" }), +})); + +import { toBerlinDayKey } from "@/lib/tz/resolver"; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function seedRichWeightUser(username: string) { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username, + email: `${username}@example.test`, + role: "USER", + heightCm: 180, + dateOfBirth: new Date("1985-01-01"), + }, + }); + const now = Date.now(); + const DAY_MS = 24 * 60 * 60 * 1000; + const rows = Array.from({ length: 120 }, (_, i) => ({ + userId: user.id, + type: "WEIGHT" as const, + value: 82 - i * 0.02, + unit: "kg", + source: "MANUAL" as const, + measuredAt: new Date(now - i * DAY_MS), + })); + await prisma.measurement.createMany({ data: rows }); + return user; +} + +describe("status path delivers real provider text", () => { + it("returns the provider's assessment, not the no-key fallback", async () => { + const user = await seedRichWeightUser("status-real-text-user"); + const { generateWeightStatusForUser } = await import( + "@/lib/insights/weight-status" + ); + + const result = await generateWeightStatusForUser(user.id, { + locale: "en", + }); + + expect(result.hasProvider).toBe(true); + expect(result.cached).toBe(false); + expect(result.text).toBe(KNOWN_TEXT); + + // The real assessment was persisted (not a stub). + const prisma = getPrismaClient(); + const cached = await prisma.auditLog.findFirst({ + where: { userId: user.id, action: "insights.weight-status.en" }, + orderBy: { createdAt: "desc" }, + select: { details: true }, + }); + const parsed = JSON.parse(cached?.details ?? "{}") as { + text: string; + model: string; + timeout?: boolean; + }; + expect(parsed.text).toBe(KNOWN_TEXT); + expect(parsed.model).toBe("test-model"); + expect(parsed.timeout).toBeUndefined(); + }); + + it("skips a timeout-stub cache row and regenerates the real text", async () => { + const user = await seedRichWeightUser("status-stub-skip-user"); + const prisma = getPrismaClient(); + + // Pre-seed a stub row keyed to today — the bug used to serve this. + await prisma.auditLog.create({ + data: { + userId: user.id, + action: "insights.weight-status.en", + details: JSON.stringify({ + dateKey: toBerlinDayKey(new Date()), + locale: "en", + text: "Generic fallback advice that must not be served.", + model: "timeout-stub", + timeout: true, + }), + }, + }); + + const { generateWeightStatusForUser } = await import( + "@/lib/insights/weight-status" + ); + + const result = await generateWeightStatusForUser(user.id, { + locale: "en", + }); + + // The stub is skipped; the real provider text is returned + cached. + expect(result.text).toBe(KNOWN_TEXT); + expect(result.cached).toBe(false); + }); +});