diff --git a/src/lib/insights/__tests__/status-batch.test.ts b/src/lib/insights/__tests__/status-batch.test.ts new file mode 100644 index 000000000..fdd930c57 --- /dev/null +++ b/src/lib/insights/__tests__/status-batch.test.ts @@ -0,0 +1,362 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +// Mock the seven prepare modules. Each test sets a prepare's return to a +// `served` or `pending` card. The pending card's `finalize` / `timeout` are +// vi.fn so we can assert the fan-out hit the right closure. +vi.mock("@/lib/insights/blood-pressure-status", () => ({ + prepareBloodPressureStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/weight-status", () => ({ + prepareWeightStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/pulse-status", () => ({ + preparePulseStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/bmi-status", () => ({ + prepareBmiStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/mood-status", () => ({ + prepareMoodStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/medication-compliance-status", () => ({ + prepareMedicationComplianceStatusForUser: vi.fn(), +})); +vi.mock("@/lib/insights/general-status", () => ({ + prepareGeneralStatusForUser: vi.fn(), +})); + +vi.mock("@/lib/insights/status-provider", () => ({ + runStatusCompletion: vi.fn(), +})); + +// The single-card fallback path — assert it is invoked for omitted / failed +// metrics, but don't re-drive a real completion. +vi.mock("@/lib/insights/status-card-generation", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@/lib/insights/status-card-generation") + >(); + return { ...actual, runPreparedStatusCard: vi.fn(async () => ({})) }; +}); + +import { prepareBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status"; +import { prepareWeightStatusForUser } from "@/lib/insights/weight-status"; +import { preparePulseStatusForUser } from "@/lib/insights/pulse-status"; +import { prepareBmiStatusForUser } from "@/lib/insights/bmi-status"; +import { prepareMoodStatusForUser } from "@/lib/insights/mood-status"; +import { prepareMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status"; +import { prepareGeneralStatusForUser } from "@/lib/insights/general-status"; +import { runStatusCompletion } from "@/lib/insights/status-provider"; +import { runPreparedStatusCard } from "@/lib/insights/status-card-generation"; +import { generateStatusBatchForUser } from "../status-batch"; + +function servedCard() { + return { + phase: "served" as const, + result: { hasProvider: true, text: "cached", cached: true, updatedAt: "t" }, + }; +} + +function pendingCard( + metric: string, + finalize: (arg: { content: string }) => Promise = vi.fn( + async (_arg: { content: string }) => ({}), + ), +) { + const timeout = vi.fn(() => ({ + hasProvider: true, + text: "stub", + cached: true, + updatedAt: null, + })); + return { + phase: "pending" as const, + metric, + userId: "u1", + cacheAction: `insights.${metric}-status.en`, + systemPrompt: "sys", + userPrompt: `snapshot for ${metric}`, + snapshotHash: "h", + temperature: 0.45, + noProvider: { hasProvider: false, text: "nokey", cached: true, updatedAt: null }, + timeout, + finalize, + }; +} + +const ALL_PREPARES = [ + prepareBloodPressureStatusForUser, + prepareWeightStatusForUser, + preparePulseStatusForUser, + prepareBmiStatusForUser, + prepareMoodStatusForUser, + prepareMedicationComplianceStatusForUser, + prepareGeneralStatusForUser, +]; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +describe("generateStatusBatchForUser — one call covers all present metrics", () => { + it("issues exactly ONE provider call for the seven pending cards and fans each summary into its finalize", async () => { + const mkFinalize = () => vi.fn(async (_arg: { content: string }) => ({})); + const finalizers = { + "blood-pressure": mkFinalize(), + weight: mkFinalize(), + pulse: mkFinalize(), + bmi: mkFinalize(), + mood: mkFinalize(), + "medication-compliance": mkFinalize(), + general: mkFinalize(), + } as const; + vi.mocked(prepareBloodPressureStatusForUser).mockResolvedValue( + pendingCard("blood-pressure", finalizers["blood-pressure"]) as never, + ); + vi.mocked(prepareWeightStatusForUser).mockResolvedValue( + pendingCard("weight", finalizers.weight) as never, + ); + vi.mocked(preparePulseStatusForUser).mockResolvedValue( + pendingCard("pulse", finalizers.pulse) as never, + ); + vi.mocked(prepareBmiStatusForUser).mockResolvedValue( + pendingCard("bmi", finalizers.bmi) as never, + ); + vi.mocked(prepareMoodStatusForUser).mockResolvedValue( + pendingCard("mood", finalizers.mood) as never, + ); + vi.mocked(prepareMedicationComplianceStatusForUser).mockResolvedValue( + pendingCard( + "medication-compliance", + finalizers["medication-compliance"], + ) as never, + ); + vi.mocked(prepareGeneralStatusForUser).mockResolvedValue( + pendingCard("general", finalizers.general) as never, + ); + + vi.mocked(runStatusCompletion).mockResolvedValue({ + kind: "ok", + content: JSON.stringify({ + perMetric: { + bp: "BP assessment.", + weight: "Weight assessment.", + pulse: "Pulse assessment.", + bmi: "BMI assessment.", + mood: "Mood assessment.", + compliance: "Adherence assessment.", + general: "Overview assessment.", + }, + }), + providerType: "anthropic", + model: "x", + tokensUsed: 100, + } as never); + + const result = await generateStatusBatchForUser("u1", { locale: "en" }); + + // ONE provider round-trip for all seven metrics. + expect(runStatusCompletion).toHaveBeenCalledTimes(1); + expect(result.batched).toBe(7); + expect(result.fellBack).toBe(0); + // Each metric's finalize got its own summary, wrapped in the { summary } + // envelope each card's finalize parses. + for (const f of Object.values(finalizers)) { + expect(f).toHaveBeenCalledTimes(1); + } + const bpArg = finalizers["blood-pressure"].mock.calls[0][0]; + expect(JSON.parse(bpArg.content)).toEqual({ summary: "BP assessment." }); + // No card fell back to a single-card call. + expect(runPreparedStatusCard).not.toHaveBeenCalled(); + }); +}); + +describe("generateStatusBatchForUser — absent metrics are omitted, not fabricated", () => { + it("only prompts for the metrics with data and never finalizes an absent metric", async () => { + // Five metrics resolve `served` (cache hit / unchanged / no data); only + // weight + pulse are pending. + vi.mocked(prepareBloodPressureStatusForUser).mockResolvedValue( + servedCard() as never, + ); + const weightFinalize = vi.fn(async () => ({})); + const pulseFinalize = vi.fn(async () => ({})); + vi.mocked(prepareWeightStatusForUser).mockResolvedValue( + pendingCard("weight", weightFinalize) as never, + ); + vi.mocked(preparePulseStatusForUser).mockResolvedValue( + pendingCard("pulse", pulseFinalize) as never, + ); + vi.mocked(prepareBmiStatusForUser).mockResolvedValue(servedCard() as never); + vi.mocked(prepareMoodStatusForUser).mockResolvedValue(servedCard() as never); + vi.mocked(prepareMedicationComplianceStatusForUser).mockResolvedValue( + servedCard() as never, + ); + vi.mocked(prepareGeneralStatusForUser).mockResolvedValue( + servedCard() as never, + ); + + let capturedSystem = ""; + vi.mocked(runStatusCompletion).mockImplementation( + async (args: { systemPrompt: string }) => { + capturedSystem = args.systemPrompt; + return { + kind: "ok", + content: JSON.stringify({ + perMetric: { weight: "W.", pulse: "P." }, + }), + providerType: "anthropic", + model: "x", + tokensUsed: 10, + } as never; + }, + ); + + const result = await generateStatusBatchForUser("u1", { locale: "en" }); + + expect(result.served).toBe(5); + expect(result.batched).toBe(2); + // The prompt names ONLY the present keys (in prepare order: pulse before + // weight, since the batch runs [bp, pulse, weight, …]). + expect(capturedSystem).toContain("keys present: pulse, weight"); + expect(capturedSystem).not.toContain('"bp"'); + expect(weightFinalize).toHaveBeenCalledTimes(1); + expect(pulseFinalize).toHaveBeenCalledTimes(1); + }); +}); + +describe("generateStatusBatchForUser — hash gate short-circuits unchanged data", () => { + it("makes NO provider call when every card resolved served (cache / unchanged)", async () => { + for (const prepare of ALL_PREPARES) { + vi.mocked(prepare).mockResolvedValue(servedCard() as never); + } + + const result = await generateStatusBatchForUser("u1", { locale: "en" }); + + expect(runStatusCompletion).not.toHaveBeenCalled(); + expect(result.served).toBe(7); + expect(result.batched).toBe(0); + expect(result.batchCallMade).toBe(false); + }); +}); + +describe("generateStatusBatchForUser — graceful partial degradation", () => { + it("falls a metric the batch omitted back to its single-card path", async () => { + const weightFinalize = vi.fn(async () => ({})); + const pulseFinalize = vi.fn(async () => ({})); + vi.mocked(prepareWeightStatusForUser).mockResolvedValue( + pendingCard("weight", weightFinalize) as never, + ); + vi.mocked(preparePulseStatusForUser).mockResolvedValue( + pendingCard("pulse", pulseFinalize) as never, + ); + for (const prepare of [ + prepareBloodPressureStatusForUser, + prepareBmiStatusForUser, + prepareMoodStatusForUser, + prepareMedicationComplianceStatusForUser, + prepareGeneralStatusForUser, + ]) { + vi.mocked(prepare).mockResolvedValue(servedCard() as never); + } + + // The batch returns weight but OMITS pulse. + vi.mocked(runStatusCompletion).mockResolvedValue({ + kind: "ok", + content: JSON.stringify({ perMetric: { weight: "W." } }), + providerType: "anthropic", + model: "x", + tokensUsed: 10, + } as never); + + const result = await generateStatusBatchForUser("u1", { locale: "en" }); + + expect(weightFinalize).toHaveBeenCalledTimes(1); + // pulse was omitted → fell back to the single-card path, NOT finalized + // from the batch. + expect(pulseFinalize).not.toHaveBeenCalled(); + expect(runPreparedStatusCard).toHaveBeenCalledTimes(1); + expect(result.batched).toBe(1); + expect(result.fellBack).toBe(1); + }); + + it("degrades every pending metric to its single-card path on a batch provider error", async () => { + const finalizers = ALL_PREPARES.map(() => vi.fn(async () => ({}))); + ALL_PREPARES.forEach((prepare, i) => { + vi.mocked(prepare).mockResolvedValue( + pendingCard(`m${i}`, finalizers[i]) as never, + ); + }); + vi.mocked(runStatusCompletion).mockResolvedValue({ + kind: "error", + } as never); + + const result = await generateStatusBatchForUser("u1", { locale: "en" }); + + // No finalize from the batch; every metric fell back. + for (const f of finalizers) expect(f).not.toHaveBeenCalled(); + expect(runPreparedStatusCard).toHaveBeenCalledTimes(7); + expect(result.fellBack).toBe(7); + expect(result.batched).toBe(0); + }); + + it("retries once on invalid JSON, then succeeds", async () => { + const wf = vi.fn(async () => ({})); + const pf = vi.fn(async () => ({})); + vi.mocked(prepareWeightStatusForUser).mockResolvedValue( + pendingCard("weight", wf) as never, + ); + vi.mocked(preparePulseStatusForUser).mockResolvedValue( + pendingCard("pulse", pf) as never, + ); + for (const prepare of [ + prepareBloodPressureStatusForUser, + prepareBmiStatusForUser, + prepareMoodStatusForUser, + prepareMedicationComplianceStatusForUser, + prepareGeneralStatusForUser, + ]) { + vi.mocked(prepare).mockResolvedValue(servedCard() as never); + } + + vi.mocked(runStatusCompletion) + .mockResolvedValueOnce({ + kind: "ok", + content: "not json at all", + providerType: "anthropic", + model: "x", + tokensUsed: 1, + } as never) + .mockResolvedValueOnce({ + kind: "ok", + content: JSON.stringify({ perMetric: { weight: "W.", pulse: "P." } }), + providerType: "anthropic", + model: "x", + tokensUsed: 1, + } as never); + + const result = await generateStatusBatchForUser("u1", { locale: "en" }); + + expect(runStatusCompletion).toHaveBeenCalledTimes(2); + expect(wf).toHaveBeenCalledTimes(1); + expect(pf).toHaveBeenCalledTimes(1); + expect(result.batched).toBe(2); + }); + + it("serves the no-key result for every pending card when no provider is configured", async () => { + const finalizers = ALL_PREPARES.map(() => vi.fn(async () => ({}))); + ALL_PREPARES.forEach((prepare, i) => { + vi.mocked(prepare).mockResolvedValue( + pendingCard(`m${i}`, finalizers[i]) as never, + ); + }); + vi.mocked(runStatusCompletion).mockResolvedValue({ kind: "none" } as never); + + const result = await generateStatusBatchForUser("u1", { locale: "en" }); + + // No finalize, no re-issued completion (the no-key result is precomputed). + for (const f of finalizers) expect(f).not.toHaveBeenCalled(); + expect(runPreparedStatusCard).not.toHaveBeenCalled(); + expect(result.fellBack).toBe(7); + expect(result.batchCallMade).toBe(false); + }); +});