mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
test(cache): integration coverage + CHANGELOG entry for server cache
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.
This commit is contained in:
1 parent
757387ca83
commit
bcdab48e96
3 files changed
+287
-5
No files matched your search
@@ -4,6 +4,20 @@
|
||||
|
||||
### Added
|
||||
|
||||
- **Server-side aggregation cache.** A new `ServerCache<T>` 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.<name>.outcome` + `cache.<name>.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
|
||||
|
||||
@@ -91,7 +91,7 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
const body = await cached(
|
||||
caches.analytics as ServerCache<Awaited<ReturnType<typeof buildAnalyticsResponse>>>,
|
||||
`${user.id}|default`,
|
||||
() => buildAnalyticsResponse(user, request),
|
||||
() => buildAnalyticsResponse(user),
|
||||
annotate,
|
||||
);
|
||||
|
||||
@@ -116,10 +116,7 @@ type AuthedUser = Awaited<ReturnType<typeof requireAuth>>["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
|
||||
|
||||
@@ -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<Response>)(
|
||||
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<Response>
|
||||
)(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<Response>
|
||||
)(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<Response>
|
||||
)(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<Response>
|
||||
)(
|
||||
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<Response>
|
||||
)(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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user