mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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.
403 lines
13 KiB
TypeScript
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();
|
|
});
|
|
});
|