mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
test(insights): cover the batched status generator
Prove one provider call covers every present metric, absent metrics are omitted from the prompt and never fabricated, an all-served cycle makes no call (the per-metric hash gate still short-circuits), and the batch degrades gracefully: an omitted metric or a provider error falls each affected card back to its single-card path, invalid JSON retries once, and a no-provider cycle serves each card's precomputed no-key result.
This commit is contained in:
1 parent
b3b6720723
commit
e6d48e2de5
1 file changed
+362
@@ -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<unknown> = 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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user