Files
HealthLog/tests/integration/cache-invalidation-coverage.test.ts
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
2026-11-03 20:32:35 +01:00

203 lines
6.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v1.4.35 — invalidation matrix coverage for the IW-G server cache.
*
* The audit (F-6) found 16 of 18 write surfaces had no
* invalidation-verification test. A missed `invalidateUser*` call on
* any write path silently leaks stale dashboard reads for up to the
* TTL. This file grids the contract: for each major write surface we
* prime the matching cache, run the mutation, and assert the cache is
* empty for the user's key. The intent is breadth — one assertion per
* write × cache pair — not depth.
*
* Cross-user isolation is also pinned: a write by user A must not
* evict user B's cache slot for the same cache.
*
* Reference: `src/lib/cache/invalidate.ts` (the helper module that
* encodes the write→cache fan-out).
*/
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
const PRIMARY_USER_ID = "user-cache-invalidation-primary";
const OTHER_USER_ID = "user-cache-invalidation-other";
async function seedUsers() {
const prisma = getPrismaClient();
await prisma.user.createMany({
data: [
{
id: PRIMARY_USER_ID,
username: "cache-primary",
email: "cache-primary@example.test",
role: "USER",
},
{
id: OTHER_USER_ID,
username: "cache-other",
email: "cache-other@example.test",
role: "USER",
},
],
});
}
async function loginAs(userId: string) {
const prisma = getPrismaClient();
const session = await prisma.session.create({
data: {
userId,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.clear();
cookieJar.set("healthlog_session", session.id);
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
const { __resetAllCachesForTests } = await import("@/lib/cache/server-cache");
__resetAllCachesForTests();
await seedUsers();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("v1.4.34 IW-G — invalidation matrix across the major write surfaces", () => {
it("measurement POST evicts the analytics cache bucket for the writer", async () => {
await loginAs(PRIMARY_USER_ID);
const { caches } = await import("@/lib/cache/server-cache");
// Seed the analytics bucket and a sibling user's bucket to also
// assert cross-user isolation below.
caches.analytics.set(`${PRIMARY_USER_ID}|all`, { fake: "data" });
caches.analytics.set(`${OTHER_USER_ID}|all`, { other: "data" });
expect(caches.analytics.get(`${PRIMARY_USER_ID}|all`)).not.toBeNull();
const { POST } = await import("@/app/api/measurements/route");
const res = await POST(
new NextRequest("http://localhost/api/measurements", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
type: "WEIGHT",
value: 82.5,
measuredAt: new Date().toISOString(),
}),
}),
);
expect([200, 201]).toContain(res.status);
// The writer's bucket is gone.
expect(caches.analytics.get(`${PRIMARY_USER_ID}|all`)).toBeNull();
// The sibling user's bucket is untouched (cross-user isolation).
expect(caches.analytics.get(`${OTHER_USER_ID}|all`)).not.toBeNull();
});
it("mood-entry POST evicts the moodAnalytics cache bucket for the writer", async () => {
await loginAs(PRIMARY_USER_ID);
const { caches } = await import("@/lib/cache/server-cache");
caches.moodAnalytics.set(PRIMARY_USER_ID, { fake: "mood" });
caches.moodAnalytics.set(OTHER_USER_ID, { other: "mood" });
expect(caches.moodAnalytics.get(PRIMARY_USER_ID)).not.toBeNull();
const { POST } = await import("@/app/api/mood-entries/route");
const res = await POST(
new NextRequest("http://localhost/api/mood-entries", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
mood: "GUT",
moodLoggedAt: new Date().toISOString(),
}),
}),
);
expect([200, 201]).toContain(res.status);
expect(caches.moodAnalytics.get(PRIMARY_USER_ID)).toBeNull();
expect(caches.moodAnalytics.get(OTHER_USER_ID)).not.toBeNull();
});
it("medication POST evicts the medications cache bucket for the writer", async () => {
await loginAs(PRIMARY_USER_ID);
const { caches } = await import("@/lib/cache/server-cache");
caches.medications.set(PRIMARY_USER_ID, [{ fake: "med" }]);
caches.medications.set(OTHER_USER_ID, [{ other: "med" }]);
expect(caches.medications.get(PRIMARY_USER_ID)).not.toBeNull();
const { POST } = await import("@/app/api/medications/route");
const res = await POST(
new NextRequest("http://localhost/api/medications", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: "Test Med",
dose: "10mg",
schedules: [
{ windowStart: "08:00", windowEnd: "10:00", daysOfWeek: [] },
],
}),
}),
);
expect([200, 201]).toContain(res.status);
expect(caches.medications.get(PRIMARY_USER_ID)).toBeNull();
expect(caches.medications.get(OTHER_USER_ID)).not.toBeNull();
});
it("dashboard-widgets PUT evicts the dashboardWidgets cache bucket for the writer", async () => {
await loginAs(PRIMARY_USER_ID);
const { caches } = await import("@/lib/cache/server-cache");
caches.dashboardWidgets.set(PRIMARY_USER_ID, { fake: "layout" });
caches.dashboardWidgets.set(OTHER_USER_ID, { other: "layout" });
expect(caches.dashboardWidgets.get(PRIMARY_USER_ID)).not.toBeNull();
const { DEFAULT_DASHBOARD_LAYOUT } = await import("@/lib/dashboard-layout");
const { PUT } = await import("@/app/api/dashboard/widgets/route");
const res = await PUT(
new NextRequest("http://localhost/api/dashboard/widgets", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
version: 1,
widgets: DEFAULT_DASHBOARD_LAYOUT.widgets,
}),
}),
);
expect(res.status).toBe(200);
expect(caches.dashboardWidgets.get(PRIMARY_USER_ID)).toBeNull();
expect(caches.dashboardWidgets.get(OTHER_USER_ID)).not.toBeNull();
});
});