From bcdab48e968d81aed3c870a5622b47005df9bf65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 21:11:37 +0200 Subject: [PATCH] test(cache): integration coverage + CHANGELOG entry for server cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three integration tests against the real Postgres container pin the v1.4.34 IW-G contract: 1. Ten concurrent /api/analytics reads against a cold cache fan into a single builder call (misses === 1; remaining 9 split between stampede joiners and warm-cache hits, with no second miss). 2. Priming analytics then writing a measurement evicts the cache — the next read misses again and the response reflects the new row. 3. Compliance buckets re-read after an intake POST flush the cache and surface the new taken-count. Drops the unused _request parameter from buildAnalyticsResponse() so the analytics route lints clean. CHANGELOG carries one Added line per the marathon's user-facing voice rules. --- CHANGELOG.md | 14 + src/app/api/analytics/route.ts | 7 +- tests/integration/server-cache-routes.test.ts | 271 ++++++++++++++++++ 3 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 tests/integration/server-cache-routes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fceed982..0f51d169b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ ### Added +- **Server-side aggregation cache.** A new `ServerCache` primitive + (`src/lib/cache/server-cache.ts`) extends the v1.4.33 Coach snapshot + LRU shape into a reusable per-route layer with TTL expiry, + capacity-bounded LRU eviction, single-flight read coalescing, and + per-instance hit / miss / eviction / stampede counters. Wires the + three hottest dashboard reads — `/api/analytics` (slim + default), + `/api/gamification/achievements`, `/api/medications/intake?scope=compliance` + — and bolts per-user invalidation onto every measurement / mood / + medication / workout / dashboard-widget write endpoint so the next + read paints fresh data instead of waiting out the TTL. Every cache + hit / miss surfaces on the active wide event as + `cache..outcome` + `cache..key_hash` so production logs + carry the hit-ratio signal without leaking userIds. + - **Apple Health `export.zip` import.** New endpoint `POST /api/import/apple-health-export` (synchronous multipart upload, asynchronous ingest via a dedicated pg-boss diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index 63b4acf47..984fd59d8 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -91,7 +91,7 @@ export const GET = apiHandler(async (request?: Request) => { const body = await cached( caches.analytics as ServerCache>>, `${user.id}|default`, - () => buildAnalyticsResponse(user, request), + () => buildAnalyticsResponse(user), annotate, ); @@ -116,10 +116,7 @@ type AuthedUser = Awaited>["user"]; * matches the v1.4.33 snapshot LRU's `buildCoachSnapshot` → * `buildCoachSnapshotImpl` shape. */ -async function buildAnalyticsResponse( - user: AuthedUser, - _request: Request | undefined, -) { +async function buildAnalyticsResponse(user: AuthedUser) { // v1.4.25 W7b — every day-bucket call inside this route now honours // the user's display timezone. The legacy `berlinDayKey()` import // remains for sleep-stage and correlation paths that share their diff --git a/tests/integration/server-cache-routes.test.ts b/tests/integration/server-cache-routes.test.ts new file mode 100644 index 000000000..f3841a71c --- /dev/null +++ b/tests/integration/server-cache-routes.test.ts @@ -0,0 +1,271 @@ +/** + * v1.4.34 IW-G — integration coverage for the server-cache layer. + * + * Pins two contracts the unit suite cannot cover end-to-end: + * + * 1. **Single-flight under concurrency.** Ten concurrent requests to + * `/api/analytics` on a cold cache must fan into a single builder + * call — the `pending` map in `ServerCache.wrap()` coalesces every + * caller after the first onto the same promise. The Postgres-side + * row count for the per-type chunked reads is the witness: the + * builder reads ~30 measurement types × 1 chunked findMany each + * per call; a busted single-flight would multiply that by 10. + * + * 2. **Invalidation closes the loop.** Priming the analytics cache, + * then POSTing a new measurement, then re-reading must yield a + * MISS — the write endpoint's `invalidateUserMeasurements` call + * flushes the prior entry. The `cache.analytics.outcome` annotation + * reaches the route's wide-event meta; we read it back through + * the response envelope by tagging the first request as `miss` and + * the post-write request as `miss` again (same userId + the same + * slice key, but the cache was evicted). + * + * Test wiring uses the same Postgres testcontainer the other + * integration suites share; cache state is reset between cases via the + * registry escape hatch so each test starts cold. + */ +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); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + const { __resetAllCachesForTests } = await import( + "@/lib/cache/server-cache" + ); + __resetAllCachesForTests(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function seedSession(username: string) { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username, + email: `${username}@example.test`, + role: "USER", + }, + }); + const session = await prisma.session.create({ + data: { + userId: user.id, + expiresAt: new Date(Date.now() + 60_000), + }, + }); + cookieJar.set("healthlog_session", session.id); + return user; +} + +describe("v1.4.34 IW-G — server-cache route integration", () => { + it("coalesces 10 concurrent /api/analytics reads into a single builder call", async () => { + await seedSession("cache-stampede-user"); + + const { GET } = await import("@/app/api/analytics/route"); + const { caches } = await import("@/lib/cache/server-cache"); + + // Fire 10 concurrent reads against a cold cache. The first sets up + // the `pending` promise; the other nine join it. The cache's + // counter snapshot witnesses the fan-in: misses === 1, stampedes + // covers the remaining nine reads. + const requests = Array.from( + { length: 10 }, + () => + (GET as unknown as (req: Request) => Promise)( + new Request("http://localhost/api/analytics"), + ), + ); + const responses = await Promise.all(requests); + + expect(responses.every((r) => r.status === 200)).toBe(true); + // One miss (the builder fires for the first caller). Every other + // caller MUST land on either the in-flight `pending` promise (a + // stampede) or — if the builder resolved between two `Promise.all` + // microtasks — the now-warm cache (a hit). What we forbid is a + // second miss: that would mean the single-flight join failed and a + // duplicate builder fired. + const stats = caches.analytics.stats(); + expect(stats.misses).toBe(1); + expect(stats.stampedes + stats.hits).toBe(9); + // Subsequent serial reads inside the TTL hit the warm cache. + const warm = await ( + GET as unknown as (req: Request) => Promise + )(new Request("http://localhost/api/analytics")); + expect(warm.status).toBe(200); + // Plus at least one extra hit from this serial read. + expect(caches.analytics.stats().hits).toBeGreaterThanOrEqual(1); + }); + + it("invalidates the analytics cache after a measurement write", async () => { + const user = await seedSession("cache-invalidate-user"); + + const { GET } = await import("@/app/api/analytics/route"); + const { POST: postMeasurement } = await import( + "@/app/api/measurements/route" + ); + const { caches } = await import("@/lib/cache/server-cache"); + + // Prime the cache — first read is a miss; second is a hit. + const first = await ( + GET as unknown as (req: Request) => Promise + )(new Request("http://localhost/api/analytics")); + expect(first.status).toBe(200); + expect(caches.analytics.stats().misses).toBe(1); + expect(caches.analytics.stats().hits).toBe(0); + + const second = await ( + GET as unknown as (req: Request) => Promise + )(new Request("http://localhost/api/analytics")); + expect(second.status).toBe(200); + expect(caches.analytics.stats().hits).toBe(1); + + // Write a new measurement — the POST handler calls + // `invalidateUserMeasurements(user.id)` after its DB commit. + const writeRes = await ( + postMeasurement as unknown as (req: Request) => Promise + )( + new Request("http://localhost/api/measurements", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "WEIGHT", + value: 82.4, + measuredAt: new Date().toISOString(), + }), + }), + ); + expect([200, 201]).toContain(writeRes.status); + + // The next read MUST miss — the cache for this userId|default key + // was evicted by the write's invalidation call. + const third = await ( + GET as unknown as (req: Request) => Promise + )(new Request("http://localhost/api/analytics")); + expect(third.status).toBe(200); + expect(caches.analytics.stats().misses).toBe(2); + + // Sanity: the body reflects the new write. + const body = (await third.json()) as { + data: { summaries: { WEIGHT: { count: number; latest: number | null } } }; + }; + expect(body.data.summaries.WEIGHT.count).toBe(1); + expect(body.data.summaries.WEIGHT.latest).toBeCloseTo(82.4, 1); + + // The achievements cache for the same user must also be empty — + // the measurements helper evicts that bucket too. + expect(caches.achievements.get(user.id)).toBeNull(); + }); + + it("re-reads compliance buckets after an intake POST evicts the cache", async () => { + const user = await seedSession("cache-intake-invalidate-user"); + const prisma = getPrismaClient(); + + // Seed a medication + scheduled intake so the compliance branch + // has something to aggregate. + const med = await prisma.medication.create({ + data: { + userId: user.id, + name: "Test Med", + dose: "1mg", + active: true, + schedules: { + create: [ + { windowStart: "08:00", windowEnd: "10:00", daysOfWeek: null }, + ], + }, + }, + include: { schedules: true }, + }); + const event = await prisma.medicationIntakeEvent.create({ + data: { + userId: user.id, + medicationId: med.id, + scheduledFor: new Date(), + takenAt: null, + skipped: false, + }, + }); + + const { GET: intakeGet, POST: intakePost } = await import( + "@/app/api/medications/intake/route" + ); + const { NextRequest } = await import("next/server"); + const { caches } = await import("@/lib/cache/server-cache"); + + // Prime — first compliance read is a miss. + const first = await intakeGet( + new NextRequest( + "http://localhost/api/medications/intake?scope=compliance&days=30", + ), + ); + expect(first.status).toBe(200); + expect(caches.medicationsIntake.stats().misses).toBe(1); + + // Second read inside the TTL — hit. + const second = await intakeGet( + new NextRequest( + "http://localhost/api/medications/intake?scope=compliance&days=30", + ), + ); + expect(second.status).toBe(200); + expect(caches.medicationsIntake.stats().hits).toBe(1); + + // Mark the dose taken — POST handler calls invalidateUserMedications + // which flushes the compliance bucket too. + const writeRes = await intakePost( + new NextRequest("http://localhost/api/medications/intake", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ intakeId: event.id, status: "taken" }), + }), + ); + expect(writeRes.status).toBe(200); + + // Next compliance read is a miss again. + const third = await intakeGet( + new NextRequest( + "http://localhost/api/medications/intake?scope=compliance&days=30", + ), + ); + expect(third.status).toBe(200); + expect(caches.medicationsIntake.stats().misses).toBe(2); + + // And the new taken state shows in the bucket aggregate. + const body = (await third.json()) as { + data: Array<{ date: string; scheduled: number; taken: number }>; + }; + const todayBucket = body.data.find((b) => b.taken > 0); + expect(todayBucket).toBeDefined(); + expect(todayBucket!.taken).toBeGreaterThanOrEqual(1); + }); +});