Files
HealthLog/tests/integration/cache-invalidation-coverage.test.ts
T
Marc-André Bombeck 9715831f82 v1.4.34.5 — audit follow-on: critical-path tests + iOS textarea zoom
Follow-on to v1.4.34.4.

Tests:
  24 new integration tests across 5 files closing the highest
  critical-path gaps the test-coverage audit flagged:
    - auth-password-change.test.ts pins the v1.4.34.3+
      destroyAllSessions three-transport revocation contract.
    - admin-reset-password.test.ts mirrors the same shape for
      admin resets.
    - withings-oauth.test.ts covers connect / callback / disconnect
      + state-token replay rejection.
    - passkey-register.test.ts covers register-options +
      register-verify + tampered-attestation + challenge single-use.
    - cache-invalidation-coverage.test.ts grids the IW-G
      invalidation matrix across 5 write surfaces.
  Integration suite: 197/50 → 222/55. tsc + lint clean.

Mobile:
  Five textareas no longer trigger iOS Safari zoom-on-focus. The
  Coach composer, bug-report description, admin feedback note,
  medication-side-effects notes, and medication-intake JSON
  import all shipped text-sm (14 px). iOS Safari zooms whenever
  an input renders below 16 px. Switched each to
  `text-base sm:text-sm` so mobile clears the floor while desktop
  keeps the compact look.
2026-05-17 00:53:03 +02:00

268 lines
9.4 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);
}
async function loginAsAdmin() {
const prisma = getPrismaClient();
const admin = await prisma.user.create({
data: {
username: "cache-admin",
email: "cache-admin@example.test",
role: "ADMIN",
},
});
const session = await prisma.session.create({
data: {
userId: admin.id,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.clear();
cookieJar.set("healthlog_session", session.id);
return admin.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();
});
it("admin app-settings PUT evicts the bugreportStatus singleton cache", async () => {
await loginAsAdmin();
const { caches } = await import("@/lib/cache/server-cache");
caches.bugreportStatus.set("singleton", { fake: "status" });
expect(caches.bugreportStatus.get("singleton")).not.toBeNull();
const { PUT } = await import("@/app/api/admin/settings/route");
const res = await PUT(
new NextRequest("http://localhost/api/admin/settings", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ bugReportRepo: "owner/repo" }),
}),
);
expect(res.status).toBe(200);
// The singleton bucket is gone — the next bugreport.status read
// will rebuild from app_settings.
expect(caches.bugreportStatus.get("singleton")).toBeNull();
});
});