mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
One-shot prettier pass so the new format:check gate starts from a clean baseline. Formatting only — no behaviour change. Generator-owned and vendored artifacts stay untouched via .prettierignore.
266 lines
9.7 KiB
TypeScript
266 lines
9.7 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|