Files
HealthLog/src/lib/insights/__tests__/feature-cache.test.ts
T
Marc-André Bombeck dfa4533c7c fix(insights): roll the daily card cache over at the user's own midnight
The eleven per-metric/derived-score status helpers (pulse, weight,
bmi, mood, blood-pressure, medication-compliance, general, the generic
metric card, biomarker, the shared feature cache, the derived-score AI
warm layer) computed todayKey from toBerlinDayKey(new Date()) before
resolving the user's own timezone — several fetched the profile only
for downstream day-bucketing, after the cache read had already run on
the Berlin key. A non-Berlin user's card regenerated at their local
evening (Berlin midnight) and, for biomarker series and the
derived-score prompt label, mis-keyed data or displayed a hardcoded
"(Europe/Berlin)" regardless of their actual zone.

Threads resolveUserTimezone()/userDayKey() ahead of every cache read
so the day-key and the data bucketing agree, and reuses the one
resolved value everywhere downstream instead of re-deriving it.
2026-07-18 06:47:32 +02:00

255 lines
7.2 KiB
TypeScript

import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { groupBy: vi.fn() },
},
}));
// The user-tz day key is deterministic input to the cache key; pin it, but
// keep `resolveUserTimezone` spy-able so QA F5 (tz threading, not a
// hardcoded Berlin day) has a wiring assertion below.
const { resolveUserTimezoneMock } = vi.hoisted(() => ({
resolveUserTimezoneMock: vi.fn(async () => "Europe/Berlin"),
}));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: resolveUserTimezoneMock,
userDayKey: () => "2026-06-21",
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
import { prisma } from "@/lib/db";
import { getCachedFeatures, withFeatureCacheScope } from "../feature-cache";
const T0 = new Date("2026-06-20T08:00:00.000Z");
function fingerprintRows(weightCount: number) {
return [
{ type: "WEIGHT", _count: { _all: weightCount }, _max: { measuredAt: T0 } },
];
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurement.groupBy).mockResolvedValue(
fingerprintRows(10) as never,
);
resolveUserTimezoneMock.mockResolvedValue("Europe/Berlin");
});
describe("getCachedFeatures (P3)", () => {
it("computes once and reuses within a scope for identical key + inputs", async () => {
const compute = vi.fn().mockResolvedValue({ marker: "features-v1" });
const { first, second } = await withFeatureCacheScope(async () => {
const first = await getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
});
const second = await getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
});
return { first, second };
});
expect(compute).toHaveBeenCalledTimes(1);
// The same object reference is handed back — a true reuse, not a re-run.
expect(second).toBe(first);
expect(first).toEqual({ marker: "features-v1" });
});
it("recomputes when the salient input fingerprint changes mid-scope", async () => {
const compute = vi
.fn()
.mockResolvedValueOnce({ marker: "before" })
.mockResolvedValueOnce({ marker: "after" });
const out = await withFeatureCacheScope(async () => {
const before = await getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
});
// A new reading lands mid-scope — the fingerprint probe now sees a
// higher count, so the gate misses and the read is recomputed.
vi.mocked(prisma.measurement.groupBy).mockResolvedValue(
fingerprintRows(11) as never,
);
const after = await getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
});
return { before, after };
});
expect(compute).toHaveBeenCalledTimes(2);
expect(out.before).toEqual({ marker: "before" });
expect(out.after).toEqual({ marker: "after" });
});
it("keys separately on includeRaw and sinceDays", async () => {
const compute = vi
.fn()
.mockResolvedValueOnce({ k: "agg-400" })
.mockResolvedValueOnce({ k: "raw-400" })
.mockResolvedValueOnce({ k: "agg-90" });
await withFeatureCacheScope(async () => {
await getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
});
await getCachedFeatures({
userId: "u1",
includeRaw: true,
sinceDays: 400,
compute,
});
await getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 90,
compute,
});
});
// Three distinct keys → three computes (no cross-key collision).
expect(compute).toHaveBeenCalledTimes(3);
});
it("keys separately per user (no cross-user leak)", async () => {
const compute = vi
.fn()
.mockResolvedValueOnce({ u: "u1" })
.mockResolvedValueOnce({ u: "u2" });
const out = await withFeatureCacheScope(async () => {
const a = await getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
});
const b = await getCachedFeatures({
userId: "u2",
includeRaw: false,
sinceDays: 400,
compute,
});
return { a, b };
});
expect(compute).toHaveBeenCalledTimes(2);
expect(out.a).toEqual({ u: "u1" });
expect(out.b).toEqual({ u: "u2" });
});
it("passes straight through (no caching, no probe) outside any scope", async () => {
const compute = vi.fn().mockResolvedValue({ marker: "x" });
const a = await getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
});
const b = await getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
});
// No scope → no memoisation → each call computes; and the fingerprint
// probe is never even issued (it only runs inside a scope).
expect(compute).toHaveBeenCalledTimes(2);
expect(prisma.measurement.groupBy).not.toHaveBeenCalled();
expect(a).toEqual({ marker: "x" });
expect(b).toEqual({ marker: "x" });
});
it("does not share a cache across sibling scopes", async () => {
const compute = vi.fn().mockResolvedValue({ marker: "y" });
await withFeatureCacheScope(() =>
getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
}),
);
await withFeatureCacheScope(() =>
getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
}),
);
// Each scope has its own store, so the second scope recomputes.
expect(compute).toHaveBeenCalledTimes(2);
});
it("resolves the day-key in the CALLING USER's own tz, not a hardcoded Berlin (QA F5)", async () => {
const compute = vi.fn().mockResolvedValue({ marker: "z" });
await withFeatureCacheScope(() =>
getCachedFeatures({
userId: "u-ny",
includeRaw: false,
sinceDays: 400,
compute,
}),
);
expect(resolveUserTimezoneMock).toHaveBeenCalledWith("u-ny");
});
it("recomputes on a fingerprint probe failure rather than serving a stale object", async () => {
const compute = vi
.fn()
.mockResolvedValueOnce({ marker: "1" })
.mockResolvedValueOnce({ marker: "2" });
vi.mocked(prisma.measurement.groupBy).mockRejectedValue(
new Error("db down"),
);
const out = await withFeatureCacheScope(async () => {
const first = await getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
});
const second = await getCachedFeatures({
userId: "u1",
includeRaw: false,
sinceDays: 400,
compute,
});
return { first, second };
});
// A failed probe yields a unique sentinel hash each call → the key never
// matches → the read is recomputed (fail-safe, never a wrong reuse).
expect(compute).toHaveBeenCalledTimes(2);
expect(out.first).toEqual({ marker: "1" });
expect(out.second).toEqual({ marker: "2" });
});
});