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, 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; 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(); }); });