Files
HealthLog/src/lib/cache/__tests__/invalidate.test.ts
T

380 lines
15 KiB
TypeScript

/**
* Unit tests for the per-user invalidation helpers — every helper
* evicts the matching `caches.*` bucket and leaves unrelated entries
* intact.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { __resetAllCachesForTests, cached, caches } from "../server-cache";
import {
invalidateUserDashboardWidgets,
invalidateUserDashboardSnapshot,
invalidateUserInsights,
invalidateUserHealthScore,
invalidateUserMeasurements,
invalidateUserMedications,
invalidateUserMood,
invalidateUserProfile,
dashboardSnapshotCacheKey,
} from "../invalidate";
const USER_A = "user-a";
const USER_B = "user-b";
beforeEach(() => {
__resetAllCachesForTests();
});
afterEach(() => {
__resetAllCachesForTests();
});
async function primeAllCaches(): Promise<void> {
// Analytics keys carry a slice suffix; the prefix-match must include
// the pipe so user-1 doesn't match user-10 in production traffic.
await cached(caches.analytics, `${USER_A}|default`, async () => ({ a: 1 }));
await cached(caches.analytics, `${USER_A}|summaries`, async () => ({ a: 2 }));
await cached(caches.analytics, `${USER_B}|default`, async () => ({ b: 1 }));
await cached(caches.medications, USER_A, async () => ({ m: 1 }));
await cached(caches.medications, USER_B, async () => ({ m: 2 }));
await cached(
caches.medicationsIntake,
`${USER_A}|compliance|30`,
async () => ({
c: 1,
}),
);
await cached(
caches.medicationsIntake,
`${USER_B}|compliance|30`,
async () => ({
c: 2,
}),
);
// v1.15.20 — per-medication compliance payload cache.
await cached(
caches.medicationCompliance,
`${USER_A}|med-1|compliance`,
async () => ({ mc: 1 }),
);
await cached(
caches.medicationCompliance,
`${USER_B}|med-1|compliance`,
async () => ({ mc: 2 }),
);
await cached(caches.achievements, USER_A, async () => ({ ach: 1 }));
await cached(caches.achievements, USER_B, async () => ({ ach: 2 }));
await cached(caches.workouts, `${USER_A}|3|0||`, async () => ({
canonical: [],
rawCount: 1,
}));
await cached(caches.workouts, `${USER_B}|3|0||`, async () => ({
canonical: [],
rawCount: 2,
}));
await cached(caches.moodAnalytics, USER_A, async () => ({ mood: 1 }));
await cached(caches.moodAnalytics, USER_B, async () => ({ mood: 2 }));
// v1.12.1 — mood-insights aggregate cache (SWR bucket).
await cached(caches.moodInsights, USER_A, async () => ({ mi: 1 }));
await cached(caches.moodInsights, USER_B, async () => ({ mi: 2 }));
await cached(caches.dashboardWidgets, USER_A, async () => ({ d: 1 }));
await cached(caches.dashboardWidgets, USER_B, async () => ({ d: 2 }));
// v1.7.0 W6 — the unified dashboard snapshot lives under the analytics
// bucket. Since v1.21.2 the real cell is keyed WITH the resolved locale
// (`${userId}|dashboard-snapshot|${locale}`) because the briefing prose is
// locale-specific. Seed the locale-suffixed cell (the production shape) so
// the snapshot invalidators are exercised against the key they must actually
// reach — a point-delete of the locale-less key silently misses it.
await cached(caches.analytics, snapshotCell(USER_A), async () => ({
snap: 1,
}));
await cached(caches.analytics, snapshotCell(USER_B), async () => ({
snap: 2,
}));
}
/**
* The real snapshot cache cell — the locale-less base key plus the resolved
* locale segment. The invalidators must sweep by prefix to reach it.
*/
function snapshotCell(userId: string, locale = "en"): string {
return `${dashboardSnapshotCacheKey(userId)}|${locale}`;
}
describe("invalidateUserHealthScore", () => {
it("evicts analytics, dashboard snapshots, and derived batches for one user", async () => {
await cached(caches.analytics, `${USER_A}|default|en`, async () => ({}));
await cached(caches.analytics, snapshotCell(USER_A), async () => ({}));
await cached(
caches.insightsDerived,
`${USER_A}|batch|HEALTH_SCORE|en`,
async () => ({}),
);
await cached(caches.analytics, `${USER_B}|default|en`, async () => ({}));
await cached(
caches.insightsDerived,
`${USER_B}|batch|HEALTH_SCORE|en`,
async () => ({}),
);
invalidateUserHealthScore(USER_A);
expect(caches.analytics.get(`${USER_A}|default|en`)).toBeNull();
expect(caches.analytics.get(snapshotCell(USER_A))).toBeNull();
expect(
caches.insightsDerived.get(`${USER_A}|batch|HEALTH_SCORE|en`),
).toBeNull();
expect(caches.analytics.get(`${USER_B}|default|en`)).not.toBeNull();
expect(
caches.insightsDerived.get(`${USER_B}|batch|HEALTH_SCORE|en`),
).not.toBeNull();
});
});
describe("invalidateUserMeasurements", () => {
it("evicts analytics + achievements + workouts for the target user only", async () => {
await primeAllCaches();
invalidateUserMeasurements(USER_A);
expect(caches.analytics.get(`${USER_A}|default`)).toBeNull();
expect(caches.analytics.get(`${USER_A}|summaries`)).toBeNull();
// v1.7.0 W6 — the `${userId}|` prefix sweep covers the snapshot key.
expect(caches.analytics.get(snapshotCell(USER_A))).toBeNull();
expect(caches.achievements.get(USER_A)).toBeNull();
expect(caches.workouts.get(`${USER_A}|3|0||`)).toBeNull();
// User B's caches untouched.
expect(caches.analytics.get(`${USER_B}|default`)).not.toBeNull();
expect(caches.analytics.get(snapshotCell(USER_B))).not.toBeNull();
expect(caches.achievements.get(USER_B)).not.toBeNull();
expect(caches.workouts.get(`${USER_B}|3|0||`)).not.toBeNull();
// Mood-analytics not touched by a measurement write.
expect(caches.moodAnalytics.get(USER_A)).not.toBeNull();
});
it("marks the analytics bucket stale (SWR-serveable) by default — background sync posture", async () => {
await primeAllCaches();
invalidateUserMeasurements(USER_A);
// v1.16.7 — the default (batch sync) path marks stale: the entry is
// gone for plain `get()` (asserted above) but stays serveable for
// the `cachedSwr` readers, so a high-frequency sync never busts the
// snapshot / comprehensive into an inline cold rebuild.
expect(caches.analytics.getAllowStale(`${USER_A}|default`)).toEqual({
value: { a: 1 },
stale: true,
});
expect(caches.analytics.getAllowStale(snapshotCell(USER_A))).toEqual({
value: { snap: 1 },
stale: true,
});
});
it("hard-evicts the analytics bucket with { evict: true } — interactive write posture", async () => {
await primeAllCaches();
invalidateUserMeasurements(USER_A, { evict: true });
// v1.16.7 — an interactive single-entry write must NOT leave a
// stale-serveable body behind: the SWR readers would hand the user
// back the pre-write payload. The evict drops the entries entirely.
expect(caches.analytics.getAllowStale(`${USER_A}|default`)).toBeNull();
expect(caches.analytics.getAllowStale(`${USER_A}|summaries`)).toBeNull();
expect(caches.analytics.getAllowStale(snapshotCell(USER_A))).toBeNull();
// User B stays warm; the non-analytics buckets evict as before.
expect(caches.analytics.getAllowStale(`${USER_B}|default`)).not.toBeNull();
expect(caches.achievements.get(USER_A)).toBeNull();
expect(caches.workouts.get(`${USER_A}|3|0||`)).toBeNull();
});
});
describe("invalidateUserMood", () => {
it("evicts mood-analytics + achievements + analytics", async () => {
await primeAllCaches();
invalidateUserMood(USER_A);
expect(caches.moodAnalytics.get(USER_A)).toBeNull();
expect(caches.achievements.get(USER_A)).toBeNull();
expect(caches.analytics.get(`${USER_A}|default`)).toBeNull();
expect(caches.analytics.get(snapshotCell(USER_A))).toBeNull();
expect(caches.moodAnalytics.get(USER_B)).not.toBeNull();
expect(caches.analytics.get(`${USER_B}|default`)).not.toBeNull();
// v1.12.1 — mood-insights is marked stale, not hard-evicted: the
// prior value is still serveable within the SWR window so the next
// reader pays no cold compute. (A plain `get()` would treat the
// collapsed TTL as expired and drop it, so assert via getAllowStale
// first — it must not consume the entry.) User B stays fresh.
expect(caches.moodInsights.getAllowStale(USER_A)).toEqual({
value: { mi: 1 },
stale: true,
});
expect(caches.moodInsights.getAllowStale(USER_B)).toEqual({
value: { mi: 2 },
stale: false,
});
});
});
describe("invalidateUserMedications", () => {
it("evicts medications + medications-intake + achievements", async () => {
await primeAllCaches();
invalidateUserMedications(USER_A);
expect(caches.medications.get(USER_A)).toBeNull();
expect(caches.medicationsIntake.get(`${USER_A}|compliance|30`)).toBeNull();
expect(caches.achievements.get(USER_A)).toBeNull();
// v1.7.0 W6 — medication writes sweep the analytics bucket, which
// covers the snapshot key.
expect(caches.analytics.get(snapshotCell(USER_A))).toBeNull();
expect(caches.medications.get(USER_B)).not.toBeNull();
expect(
caches.medicationsIntake.get(`${USER_B}|compliance|30`),
).not.toBeNull();
});
it("evicts the per-medication compliance payload for the target user only", async () => {
await primeAllCaches();
invalidateUserMedications(USER_A);
// v1.15.20 — the compliance route caches 15 min; an intake write must
// flush the `${userId}|` prefix so the next card read is fresh.
expect(
caches.medicationCompliance.get(`${USER_A}|med-1|compliance`),
).toBeNull();
expect(
caches.medicationCompliance.get(`${USER_B}|med-1|compliance`),
).not.toBeNull();
});
it("marks the SWR buckets stale by default — background sync posture", async () => {
await primeAllCaches();
invalidateUserMedications(USER_A);
// v1.16.8 — the default (bulk sync / cron) path marks stale: the
// entries are gone for plain `get()` (asserted above) but stay
// serveable for the `cachedSwr` readers (medications list, both
// compliance routes, the dashboard snapshot), so a high-frequency
// iOS sync never busts every card into an inline cold rebuild.
expect(caches.medications.getAllowStale(USER_A)).toEqual({
value: { m: 1 },
stale: true,
});
expect(
caches.medicationCompliance.getAllowStale(`${USER_A}|med-1|compliance`),
).toEqual({ value: { mc: 1 }, stale: true });
expect(caches.analytics.getAllowStale(snapshotCell(USER_A))).toEqual({
value: { snap: 1 },
stale: true,
});
// User B stays fresh.
expect(caches.medications.getAllowStale(USER_B)).toEqual({
value: { m: 2 },
stale: false,
});
});
it("hard-evicts the SWR buckets with { evict: true } — interactive write posture", async () => {
await primeAllCaches();
invalidateUserMedications(USER_A, { evict: true });
// v1.16.8 — an interactive take / skip / CRUD write must NOT leave a
// stale-serveable body behind: the SWR readers would hand the user
// back the pre-write card rates. The evict drops the entries.
expect(caches.medications.getAllowStale(USER_A)).toBeNull();
expect(
caches.medicationCompliance.getAllowStale(`${USER_A}|med-1|compliance`),
).toBeNull();
expect(caches.analytics.getAllowStale(snapshotCell(USER_A))).toBeNull();
// User B stays warm; the non-SWR buckets evict as before.
expect(caches.medications.getAllowStale(USER_B)).not.toBeNull();
expect(
caches.medicationCompliance.getAllowStale(`${USER_B}|med-1|compliance`),
).not.toBeNull();
expect(caches.medicationsIntake.get(`${USER_A}|compliance|30`)).toBeNull();
expect(caches.achievements.get(USER_A)).toBeNull();
});
});
describe("invalidateUserDashboardWidgets", () => {
it("evicts the dashboardWidgets bucket + the snapshot key for the target user", async () => {
await primeAllCaches();
invalidateUserDashboardWidgets(USER_A);
expect(caches.dashboardWidgets.get(USER_A)).toBeNull();
expect(caches.dashboardWidgets.get(USER_B)).not.toBeNull();
// v1.7.0 W6 — the layout rides inside the snapshot now, so a tile
// reorder must drop the snapshot too. The widget invalidator does
// NOT sweep the whole analytics bucket, so the slim/default cells
// stay warm — only the point-keyed snapshot is dropped.
expect(caches.analytics.get(snapshotCell(USER_A))).toBeNull();
expect(caches.analytics.get(`${USER_A}|default`)).not.toBeNull();
expect(caches.analytics.get(snapshotCell(USER_B))).not.toBeNull();
});
});
describe("invalidateUserDashboardSnapshot", () => {
it("drops only the snapshot key, leaving the slim / default analytics cells warm", async () => {
await primeAllCaches();
invalidateUserDashboardSnapshot(USER_A);
expect(caches.analytics.get(snapshotCell(USER_A))).toBeNull();
expect(caches.analytics.get(`${USER_A}|default`)).not.toBeNull();
expect(caches.analytics.get(`${USER_A}|summaries`)).not.toBeNull();
expect(caches.analytics.get(snapshotCell(USER_B))).not.toBeNull();
});
});
describe("invalidateUserInsights", () => {
it("drops the snapshot key so a fresh briefing is re-embedded", async () => {
await primeAllCaches();
invalidateUserInsights(USER_A);
expect(caches.analytics.get(snapshotCell(USER_A))).toBeNull();
expect(caches.analytics.get(snapshotCell(USER_B))).not.toBeNull();
});
});
describe("invalidateUserProfile", () => {
it("hard-evicts targets + derived + analytics for the target user only", async () => {
await primeAllCaches();
await cached(caches.insightsTargets, USER_A, async () => ({ t: 1 }));
await cached(caches.insightsTargets, USER_B, async () => ({ t: 2 }));
await cached(caches.insightsDerived, `${USER_A}|batch|x|en`, async () => ({
d: 1,
}));
await cached(caches.insightsDerived, `${USER_B}|batch|x|en`, async () => ({
d: 2,
}));
invalidateUserProfile(USER_A);
// Profile-derived payloads drop — including the SWR stale window
// (a Settings save is interactive; the pre-edit grid must not serve).
expect(caches.insightsTargets.getAllowStale(USER_A)).toBeNull();
expect(
caches.insightsDerived.getAllowStale(`${USER_A}|batch|x|en`),
).toBeNull();
expect(caches.analytics.getAllowStale(snapshotCell(USER_A))).toBeNull();
expect(caches.analytics.getAllowStale(`${USER_A}|default`)).toBeNull();
// The other user's cells stay warm.
expect(caches.insightsTargets.get(USER_B)).not.toBeNull();
expect(caches.insightsDerived.get(`${USER_B}|batch|x|en`)).not.toBeNull();
expect(caches.analytics.get(`${USER_B}|default`)).not.toBeNull();
// Buckets a profile edit does not feed stay untouched.
expect(caches.medications.get(USER_A)).not.toBeNull();
});
});