Files
HealthLog/src/lib/insights/__tests__/status-cache.test.ts
T
Marc-André Bombeck f790e14edd fix(api,lib): consent before reuse, tighter inputs and caches that tell on themselves
Per-status texts check AI consent before the unchanged-data refresh can
re-stamp them, the comprehensive fingerprint folds in the comparison
baseline and generation language so toggling either regenerates once,
and profile, threshold and source-priority writes evict the analytics,
targets and derived caches they feed. CSV export neutralises spreadsheet
formula prefixes on free-text cells while numeric columns stay typed,
the derived batch validates its type token against the measurement enum
and carries a per-user rate limit, and a dose synced from the phone app
counts as the interactive write it is. Background cache refresh
failures log a structured event instead of vanishing, the warm queue
runs visit-triggered jobs alongside the nightly walk, the formatter
memo evicts one entry instead of flushing wholesale with its option
objects frozen, the targets cold build bounds its fan-out, and time in
bed unions every writer's envelope while stages stay with the richest
recorder.
2026-06-11 23:20:38 +02:00

403 lines
13 KiB
TypeScript

import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { findFirst: vi.fn(), findMany: vi.fn(), create: vi.fn() },
},
}));
const hasUsableStatusProvider = vi.fn();
const statusConsentBlocksGeneration = vi.fn();
vi.mock("@/lib/insights/status-provider", () => ({
hasUsableStatusProvider: (...a: unknown[]) => hasUsableStatusProvider(...a),
statusConsentBlocksGeneration: (...a: unknown[]) =>
statusConsentBlocksGeneration(...a),
}));
const enqueueStatusGeneration = vi.fn();
vi.mock("@/lib/jobs/insight-status-generate-shared", () => ({
enqueueStatusGeneration: (...a: unknown[]) => enqueueStatusGeneration(...a),
}));
import { prisma } from "@/lib/db";
import {
isTimeoutStub,
readFreshStatusText,
refreshUnchangedStatusInsight,
resolveReadOnlyStatusMiss,
} from "../status-cache";
const TODAY = "2026-05-31";
function cacheRow(details: Record<string, unknown>, createdAt = new Date()) {
return { createdAt, details: JSON.stringify(details) };
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("isTimeoutStub", () => {
it("flags the timeout-stub model marker", () => {
expect(isTimeoutStub({ model: "timeout-stub" })).toBe(true);
});
it("flags the timeout:true marker", () => {
expect(isTimeoutStub({ timeout: true })).toBe(true);
});
it("does not flag a real assessment", () => {
expect(isTimeoutStub({ model: "gpt-4o-mini", timeout: false })).toBe(false);
expect(isTimeoutStub({})).toBe(false);
});
});
describe("readFreshStatusText", () => {
it("returns today's real assessment text", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
dateKey: TODAY,
text: "Your weight trend is stable.",
model: "gpt-4o-mini",
}) as never,
);
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: false,
});
expect(hit?.text).toBe("Your weight trend is stable.");
});
it("skips a timeout-stub row keyed to today", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
dateKey: TODAY,
text: "Generic fallback advice.",
model: "timeout-stub",
timeout: true,
}) as never,
);
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: false,
});
expect(hit).toBeNull();
});
it("skips a stale-day row", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({ dateKey: "2026-05-30", text: "Yesterday." }) as never,
);
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: false,
});
expect(hit).toBeNull();
});
it("skips an empty-text row", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({ dateKey: TODAY, text: " " }) as never,
);
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: false,
});
expect(hit).toBeNull();
});
it("does not read the cache under force", async () => {
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: true,
});
expect(hit).toBeNull();
expect(prisma.auditLog.findFirst).not.toHaveBeenCalled();
});
it("treats a malformed payload as a miss", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue({
createdAt: new Date(),
details: "{not json",
} as never);
const hit = await readFreshStatusText({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
force: false,
});
expect(hit).toBeNull();
});
});
describe("resolveReadOnlyStatusMiss", () => {
beforeEach(() => {
// Default: no prior assessment to serve stale (readLastGoodStatusText).
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never);
});
it("returns no-provider without enqueuing when the user has no provider", async () => {
hasUsableStatusProvider.mockResolvedValue(false);
const outcome = await resolveReadOnlyStatusMiss({
userId: "u1",
metric: "weight",
locale: "en",
});
expect(outcome.kind).toBe("no-provider");
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
});
it("enqueues generation and returns preparing on a clean miss", async () => {
hasUsableStatusProvider.mockResolvedValue(true);
// No negative stub present.
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
const outcome = await resolveReadOnlyStatusMiss({
userId: "u1",
metric: "pulse",
locale: "de",
});
expect(outcome.kind).toBe("preparing");
// No last-good text to show, so nothing to revalidate against — the card
// polls on `preparing` alone.
expect(outcome).toEqual({
kind: "preparing",
lastGood: null,
revalidating: false,
});
expect(enqueueStatusGeneration).toHaveBeenCalledWith({
userId: "u1",
metric: "pulse",
locale: "de",
});
});
it("serves the last good assessment stale-while-revalidate on a clean miss", async () => {
hasUsableStatusProvider.mockResolvedValue(true);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
// A prior (e.g. yesterday's) real assessment is on record.
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([
cacheRow(
{
dateKey: "2026-05-30",
text: "Steady upward trend.",
model: "gpt-4o-mini",
},
new Date("2026-05-30T04:30:00.000Z"),
),
] as never);
const outcome = await resolveReadOnlyStatusMiss({
userId: "u1",
metric: "weight",
locale: "en",
});
expect(outcome.kind).toBe("preparing");
if (outcome.kind !== "preparing") throw new Error("expected preparing");
expect(outcome.lastGood?.text).toBe("Steady upward trend.");
// v1.9.0 — stale text served AND a refresh enqueued → revalidating so the
// open card keeps polling until the fresh assessment lands.
expect(outcome.revalidating).toBe(true);
// A refresh is still enqueued behind the stale serve.
expect(enqueueStatusGeneration).toHaveBeenCalledTimes(1);
});
it("suppresses re-enqueue while a fresh timeout stub exists", async () => {
hasUsableStatusProvider.mockResolvedValue(true);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
dateKey: TODAY,
timeout: true,
model: "timeout-stub",
retryAt: new Date(Date.now() + 60_000).toISOString(),
}) as never,
);
const outcome = await resolveReadOnlyStatusMiss({
userId: "u1",
metric: "mood",
locale: "en",
});
expect(outcome.kind).toBe("preparing");
if (outcome.kind !== "preparing") throw new Error("expected preparing");
// No enqueue on the suppressed branch → nothing in flight to revalidate.
expect(outcome.revalidating).toBe(false);
expect(enqueueStatusGeneration).not.toHaveBeenCalled();
});
it("re-enqueues once the negative stub's retryAt has passed", async () => {
hasUsableStatusProvider.mockResolvedValue(true);
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
dateKey: TODAY,
timeout: true,
model: "timeout-stub",
retryAt: new Date(Date.now() - 60_000).toISOString(),
}) as never,
);
const outcome = await resolveReadOnlyStatusMiss({
userId: "u1",
metric: "bmi",
locale: "de",
});
expect(outcome.kind).toBe("preparing");
expect(enqueueStatusGeneration).toHaveBeenCalledTimes(1);
});
});
describe("refreshUnchangedStatusInsight (v1.16.8)", () => {
const HASH = "a".repeat(64);
beforeEach(() => {
// Default: consent does not block (BYOK / consented chains).
statusConsentBlocksGeneration.mockResolvedValue(false);
});
it("misses (and writes nothing) when the server-managed consent is revoked, even on a hash match", async () => {
statusConsentBlocksGeneration.mockResolvedValue(true);
const hit = await refreshUnchangedStatusInsight({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
snapshotHash: HASH,
});
expect(hit).toBeNull();
// The gate must not even read the cache row — a revoked consent can
// never re-stamp old AI text as today's assessment.
expect(prisma.auditLog.findFirst).not.toHaveBeenCalled();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
expect(statusConsentBlocksGeneration).toHaveBeenCalledWith(
"u1",
"insights",
);
});
it("re-persists the row under today's dateKey and returns the text on a hash match", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
dateKey: "2026-05-30",
locale: "en",
text: "Stable weight, no concerns.",
providerType: "openai",
model: "gpt-4o-mini",
tokensUsed: 321,
snapshotHash: HASH,
}) as never,
);
vi.mocked(prisma.auditLog.create).mockResolvedValue({
createdAt: new Date("2026-05-31T04:30:00.000Z"),
} as never);
const hit = await refreshUnchangedStatusInsight({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
snapshotHash: HASH,
});
expect(hit?.text).toBe("Stable weight, no concerns.");
expect(hit?.updatedAt).toBe("2026-05-31T04:30:00.000Z");
// The refresh row carries the same payload re-keyed to today, so the
// read path and the ingest debounce both see a current assessment.
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const arg = vi.mocked(prisma.auditLog.create).mock.calls[0][0] as {
data: { userId: string; action: string; details: string };
};
expect(arg.data.userId).toBe("u1");
expect(arg.data.action).toBe("insights.weight-status.en");
const details = JSON.parse(arg.data.details) as Record<string, unknown>;
expect(details.dateKey).toBe(TODAY);
expect(details.text).toBe("Stable weight, no concerns.");
expect(details.snapshotHash).toBe(HASH);
});
it("misses (and writes nothing) when the stored hash differs", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
dateKey: "2026-05-30",
text: "Older text.",
model: "gpt-4o-mini",
snapshotHash: "b".repeat(64),
}) as never,
);
const hit = await refreshUnchangedStatusInsight({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
snapshotHash: HASH,
});
expect(hit).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
it("misses when the latest row carries no hash (pre-gate rows)", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
dateKey: "2026-05-30",
text: "Older text.",
model: "gpt-4o-mini",
}) as never,
);
const hit = await refreshUnchangedStatusInsight({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
snapshotHash: HASH,
});
expect(hit).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
it("never refreshes off a timeout stub, even with a matching hash", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(
cacheRow({
dateKey: "2026-05-30",
text: "Generic fallback advice.",
model: "timeout-stub",
timeout: true,
snapshotHash: HASH,
}) as never,
);
const hit = await refreshUnchangedStatusInsight({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
snapshotHash: HASH,
});
expect(hit).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
it("misses on no prior row and on malformed payloads", async () => {
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce(null as never);
expect(
await refreshUnchangedStatusInsight({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
snapshotHash: HASH,
}),
).toBeNull();
vi.mocked(prisma.auditLog.findFirst).mockResolvedValueOnce({
details: "{not json",
} as never);
expect(
await refreshUnchangedStatusInsight({
userId: "u1",
cacheAction: "insights.weight-status.en",
todayKey: TODAY,
snapshotHash: HASH,
}),
).toBeNull();
expect(prisma.auditLog.create).not.toHaveBeenCalled();
});
});