mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
The medication list fired one compliance request per card every 30 seconds, and each request scanned the medication's full intake history and expanded the recurrence calendar five to six times. The endpoint now mints bands once per request, serves the sub-windows from that single ledger, bounds the event read to a year of selected columns and sits behind a write-invalidated 15-minute server cache; the cards poll at five minutes. The dashboard drops two redundant checklist queries by reading counts from the snapshot, the BMI chart derives from the weight series instead of refetching it, the detail page no longer serialises behind the auth probe and seeds its query from the list row. Expensive analytics reads gain a shared per-user rate limit, and the service worker stops serving cross-version shells, enables navigation preload and trims caches in one pass.
231 lines
8.5 KiB
TypeScript
231 lines
8.5 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 {
|
|
invalidateAppSettings,
|
|
invalidateUserDashboardWidgets,
|
|
invalidateUserDashboardSnapshot,
|
|
invalidateUserInsights,
|
|
invalidateUserMeasurements,
|
|
invalidateUserMedications,
|
|
invalidateUserMood,
|
|
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 () => ({ w: 1 }));
|
|
await cached(caches.workouts, `${USER_B}|3|0||`, async () => ({ w: 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 keyed `${userId}|dashboard-snapshot`.
|
|
await cached(
|
|
caches.analytics,
|
|
dashboardSnapshotCacheKey(USER_A),
|
|
async () => ({ snap: 1 }),
|
|
);
|
|
await cached(
|
|
caches.analytics,
|
|
dashboardSnapshotCacheKey(USER_B),
|
|
async () => ({ snap: 2 }),
|
|
);
|
|
|
|
await cached(caches.bugreportStatus, "singleton", async () => ({ s: 1 }));
|
|
}
|
|
|
|
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(dashboardSnapshotCacheKey(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(dashboardSnapshotCacheKey(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();
|
|
});
|
|
});
|
|
|
|
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(dashboardSnapshotCacheKey(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(dashboardSnapshotCacheKey(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();
|
|
});
|
|
});
|
|
|
|
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(dashboardSnapshotCacheKey(USER_A))).toBeNull();
|
|
expect(caches.analytics.get(`${USER_A}|default`)).not.toBeNull();
|
|
expect(
|
|
caches.analytics.get(dashboardSnapshotCacheKey(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(dashboardSnapshotCacheKey(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(dashboardSnapshotCacheKey(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(dashboardSnapshotCacheKey(USER_A))).toBeNull();
|
|
expect(
|
|
caches.analytics.get(dashboardSnapshotCacheKey(USER_B)),
|
|
).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("invalidateAppSettings", () => {
|
|
it("evicts the entire bug-report status cache (singleton row)", async () => {
|
|
await primeAllCaches();
|
|
invalidateAppSettings();
|
|
expect(caches.bugreportStatus.get("singleton")).toBeNull();
|
|
// Per-user caches untouched.
|
|
expect(caches.analytics.get(`${USER_A}|default`)).not.toBeNull();
|
|
});
|
|
});
|