Files
HealthLog/tests/integration/cache-invalidation-coverage.test.ts
T
Marc-André Bombeck de33f1ad4c chore: remove the bug-report integration
Drop the in-app bug-report surface end to end: the user submission page,
the feedback intake and admin inbox, the optional GitHub-issue
escalation, the operator config, and the dedicated achievement badges.

- delete the /bugreport page and the bugreport / feedback / admin-feedback API routes
- drop the Feedback model, its enums, and the AppSettings GitHub-issue and enable columns (migration 0205)
- remove the admin Feedback section, the Bug Report integration card, and the system-status row
- drop the bug-report nav entry and the app-settings provider that only carried its flag
- remove the bug-hunter achievement badges and their metric
- prune the related i18n keys across every locale and the encrypted-column and key-rotation registries
2026-06-27 20:37:35 +02:00

225 lines
8.1 KiB
TypeScript
Raw 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);
},
})),
};
});
// `ensureDbCompatibility` is intentionally LEFT REAL for this file —
// the admin app-settings PUT touches the `app_settings` table whose
// columns land via that helper rather than the migration set in
// `prisma/migrations/`. Mocking it out (as the other integration tests
// do for speed) would make the upsert reference a `default_locale`
// column the test container doesn't carry. Letting the real helper
// run inside the route handler keeps the schema consistent for the
// admin-settings test case in this file.
//
// `isolate: false` in `vitest.integration.config.mts` shares module
// state across files, so a sibling test file that mocks db-compat
// would leak its mock here. The beforeEach below explicitly imports
// the actual implementation through `vi.importActual` and runs it on
// each test setup to defeat that bleed-through.
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();
// Run the real db-compat helper directly so a sibling test file's
// `vi.mock("@/lib/db-compat", ...)` cannot leave the `app_settings`
// table missing columns the admin-settings PUT writes through.
const realDbCompat =
await vi.importActual<typeof import("@/lib/db-compat")>("@/lib/db-compat");
await realDbCompat.ensureDbCompatibility();
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();
});
});