mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Every status note reader and writer moves to InsightStatusCache: the generators, the unchanged-data gates, the stale-while-revalidate read, the ingest debounce, the previous-note prompt block and the medication-compliance card with its per-medication lines. A stall now opens a negative-cache window next to the note instead of a stub row that shadowed it. Stored model text is never served while its capability is unavailable, whatever the reason: the status readers and gates check statusText, the period narrative hides a model-written row while periodNarrative is unavailable (the deterministic narrative is data and always shown), and the daily digest drops reaction lines while reactionLines is unavailable. A read-only miss now reports why (`unavailable` with the capability's reason) and keeps hasProvider meaning provider presence, so a withdrawn consent no longer reads as "no provider". runStatusCompletion takes the capability as a required argument and re-checks it before the provider chain is resolved, so a job queued before a switch flipped still stops at the wire. The comprehensive briefing re-checks briefing the same way. hasUsableStatusProvider is the one presence probe. Refs #975
158 lines
5.0 KiB
TypeScript
158 lines
5.0 KiB
TypeScript
/**
|
|
* 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 negative-cache window opened by an earlier stall never stands in
|
|
* for a note: with no note stored for today, the generator regenerates
|
|
* and stores the real text, and writing it closes the window.
|
|
*/
|
|
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", async (importOriginal) => ({
|
|
// The presence probe the capability reads stays real; only the chain the
|
|
// generation walks is replaced.
|
|
...(await importOriginal<typeof import("@/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 { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
|
|
|
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"),
|
|
// Presence of a provider for the capability; never decrypted here.
|
|
aiProviderChain: [{ providerType: "anthropic", enabled: true }],
|
|
aiAnthropicKeyEncrypted: "key-present",
|
|
},
|
|
});
|
|
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 stored, encrypted, as the weight note.
|
|
const prisma = getPrismaClient();
|
|
const stored = await prisma.insightStatusCache.findUniqueOrThrow({
|
|
where: {
|
|
userId_metric_locale: {
|
|
userId: user.id,
|
|
metric: "weight",
|
|
locale: "en",
|
|
},
|
|
},
|
|
});
|
|
expect(decryptFromBytes(stored.textEncrypted!)).toBe(KNOWN_TEXT);
|
|
expect(stored.retryAt).toBeNull();
|
|
// Nothing about the note lands in the audit log any more.
|
|
expect(
|
|
await prisma.auditLog.count({
|
|
where: { userId: user.id, action: { contains: "-status." } },
|
|
}),
|
|
).toBe(0);
|
|
});
|
|
|
|
it("regenerates past a negative-cache window and closes it", async () => {
|
|
const user = await seedRichWeightUser("status-stub-skip-user");
|
|
const prisma = getPrismaClient();
|
|
|
|
// An earlier stall opened a window, with no note stored.
|
|
await prisma.insightStatusCache.create({
|
|
data: {
|
|
userId: user.id,
|
|
metric: "weight",
|
|
locale: "en",
|
|
dateKey: "2026-01-01",
|
|
retryAt: new Date(Date.now() + 60_000),
|
|
negativeReason: "timeout",
|
|
},
|
|
});
|
|
|
|
const { generateWeightStatusForUser } =
|
|
await import("@/lib/insights/weight-status");
|
|
|
|
const result = await generateWeightStatusForUser(user.id, {
|
|
locale: "en",
|
|
});
|
|
|
|
expect(result.text).toBe(KNOWN_TEXT);
|
|
expect(result.cached).toBe(false);
|
|
const stored = await prisma.insightStatusCache.findUniqueOrThrow({
|
|
where: {
|
|
userId_metric_locale: {
|
|
userId: user.id,
|
|
metric: "weight",
|
|
locale: "en",
|
|
},
|
|
},
|
|
});
|
|
expect(stored.retryAt).toBeNull();
|
|
expect(stored.negativeReason).toBeNull();
|
|
});
|
|
});
|