Files
HealthLog/tests/integration/server-cache-routes.test.ts
T
Marc-André Bombeck affede31a4 v1.4.34 — apple-health import + reliability + web-freeze
The final functional web release before the iOS native client lands.
Headline work: a streaming Apple Health `export.zip` importer for
prospective iOS-migrating accounts, a server-side aggregation cache
that collapses the three hottest dashboard reads onto in-process LRU
slots with single-flight coalescing and per-user invalidation, a
broader compliance classifier with a dedicated `early` bucket so
ahead-of-window doses count as compliant, the Coach launch surface
hoisted onto every authed page with a dashboard-hero CTA, and a
trimmer Settings shelf that folds Sources into Targets. The release
also lights up the `cache.<name>.outcome` annotation on the active
wide event so production logs carry the cache hit-ratio signal
without leaking userIds.

### 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
  `apple-health-import` worker, per-`MeasurementType` ingestion
  stats). Streams the upload straight to disk so a multi-gigabyte
  export never lands in V8 heap, hashes the bytes inline for
  content-based idempotency, and unpacks `apple_health_export/export.xml`
  with a hand-rolled ZIP central-directory walker that handles Zip64
  for archives past the 4 GB barrier. The parser folds every
  `<Record>`, `<Workout>`, `<Correlation>`, and `<ClinicalRecord>`
  into the existing `Measurement` and `Workout` row shapes — spot
  rows keyed by `HKMetadataKeyExternalUUID` (or a deterministic
  `sample:<sha256>` fallback), cumulative HK types collapsed into
  one `stats:<HKType>:<YYYY-MM-DD>` row per user-local day to match
  the iOS daily-aggregation convention. Live per-stage progress
  surfaces through `GET /api/import/apple-health-export/{jobId}/status`.
  An admin variant at `POST /api/admin/import-apple-health-export`
  imports on behalf of a target user (cookie-only `requireAdmin()`
  gate; Bearer tokens never elevate).
- **`ImportJob` schema model + migration.** New Prisma model captures
  the per-upload state machine (`queued | unpacking | parsing |
  upserting | done | failed`), the content-hash for idempotency
  short-circuits, and the per-`MeasurementType` ingestion counters
  the status route surfaces back to the client.
- **`lastSeenByType` on `/api/analytics`.** Both the slim
  (`?slice=summaries`) and default slices return a per-type
  `{ lastSeenAt, daysAgo }` map. The dashboard trend tiles wire 12
  mounts to a new `tileStaleDays()` helper so an "X days / weeks /
  months ago" hint paints under any per-metric tile whose last
  sample crosses the 7-day floor. Six locales gained additive
  week / month plural keys; Polish uses the genitive-plural form
  that covers every non-1 count the bucket math can produce.
- **Dashboard "Ask the coach" hero CTA.** A new pill next to the
  existing "Hinzufügen" / "Add" launches the Coach drawer directly
  from the dashboard hero. The `<CoachLaunchProvider>` hoisted from
  the insights layout to the auth shell so the drawer is reachable
  from every authed route; the floating action button stays scoped
  to `/insights/**` so it cannot distract on the dashboard.
- **Shared achievements query hook.** `useAchievementsQuery()`
  centralises the three previous consumers — recent-achievements
  card, the `/achievements` mother page, and the unlock notifier —
  onto one TanStack queryKey + cache slot so dashboard cold mount
  fires the endpoint once instead of twice.
- **Typed authed Cache-Control presets.** New
  `src/lib/http/cache-headers.ts` module exports
  `NO_STORE_BUT_BFCACHE = "private, max-age=0, must-revalidate"`,
  `SHORT_LIVED_PUBLIC`, and an `applyAuthedHeaders()` helper. The
  presets land at the framework level via a `next.config.ts`
  `headers()` rule that stamps the bfcache-friendly directive on
  every authed HTML response, restoring Chromium bfcache eligibility
  for in-app back / forward navigation.
- **`scripts/print-bundle-report.mjs`.** Restores the at-a-glance
  "top client chunks by parsed size" signal Turbopack dropped from
  `next build`; reads `.next/analyze/client.json` (written by
  `@next/bundle-analyzer` when `pnpm analyze` runs with `ANALYZE=1`)
  and prints a sorted table plus totals.

### Changed

- **Settings sidebar: "Sources" folded into "Targets & Sources".**
  Per-metric threshold ranges and per-metric source priority now
  share one `/settings/thresholds` page so the same metric's
  threshold and device-source preference sit together instead of
  one sidebar entry away. `/settings/sources` keeps a
  `permanentRedirect` so external bookmarks and docs links follow
  through unchanged. Section count: 11 → 10.
- **Insights tab strip: vital pills collapse under a "Vitals"
  parent.** Five vital pills (HRV, Resting HR, Oxygen, Body
  Temperature, Active Energy) hide behind one parent pill that
  opens a popover sub-list. Parent-pill active state mirrors the
  URL so spatial orientation survives the collapse; each sub-page
  keeps its own URL and bookmark resolves unchanged. Strip
  footprint: 14 → 10 entries when every metric has data.
- **Coolify env-var audit.** `mcp__coolify-apps01__env_vars`
  inspection captured the section-1 / section-2 duplicates that
  have accumulated under apps-01 since v1.3.1. Audit pinned at
  `.planning/round-v1434-iwa-coolify-env-audit.md`; no env-var
  deletes performed (operator action).

### Fixed

- **Compliance classifier: early intakes count as compliant.** The
  classifier picked up a dedicated `early` bucket (window-start
  minus three hours through window-start), the `on_time` post-window
  tolerance widened from one hour to three hours, and the `late`
  band sits between the new `on_time` ceiling and the configurable
  `lateMinutes` knob. Heatmap consumers route the new bucket
  through the compliant path; the dedicated `early` counter rides
  on `DailyComplianceEntry` for downstream consumers that want to
  distinguish ahead-of-window from on-window intakes.
- **Compliance heatmap fallback retired.** The v1.4.33 defensive
  `looksClassifierBug` fallthrough is gone now that the underlying
  classifier widening removes the every-dose-very-late mode the
  fallback covered.

### Performance

- **`/api/gamification/achievements` consumer collapse.** Two of
  three previous consumers shared the same TanStack queryKey
  literal; the third carried a per-user discriminator so the cache
  treated it as a fresh slot. Dashboard cold mount fired the
  endpoint twice. Collapsing onto the shared hook trims that to a
  single request; with the new server-side cache slot warm, the
  duplicate-mount worst case rides single-flight coalescing.
- **GHCR build fires once per release tag.** The
  `docker-publish.yml` workflow lost its `push.branches: [main]`
  trigger; the `:latest` raw-tag enable rule moved onto the tag
  ref so each release produces exactly one multi-arch build that
  refreshes both the semver tags and the `:latest` alias.
- **NFT-trace warnings silenced.** `next.config.ts` gained an
  `outputFileTracingExcludes` block that narrows the Turbopack
  tracer away from the `MAXMIND_LICENSE_KEY` env-access path so
  the trace-report no longer warns on every dependent route file.

### Refactor

- **Dashboard analytics consumers.** The two cards that previously
  decided their own freshness copy now lean on the typed
  `tileStaleDays()` helper and the additive `lastSeenByType` field
  on the analytics response. Mood and BD-Zielbereich tiles keep the
  default `null` because they have no underlying per-type freshness
  signal.

### Accessibility

- **Dashboard hero CTA hits the WCAG 2.5.5 touch-target floor.**
  The new "Ask the coach" pill carries `min-h-11` on mobile and
  the matching `sm:min-h-9` desktop floor so the touch target
  stays above 44 px on the Pixel 5 boundary.
- **Insights tab-strip Vitals parent reads correctly to assistive
  tech.** Parent pill carries `aria-current="page"` whenever the
  current URL matches one of its child vital sub-pages, plus a
  `data-slot="insights-tab-strip-group"` hook for visual-regression
  testing.

### Internal

- **Two e2e flake windows tightened.** `onboarding-flicker` swapped
  its 12-sample 50 ms poll loop for a single Playwright
  auto-retrying `toBeHidden({ timeout: 700 })` assertion so the
  1-2 ms race window between the analytics-pending shell and
  `useAuth().user` resolving collapses to a single retry slot.
  `mobile-viewport` dropped `nav a[href]` from the touch-target
  sweep (the bottom-nav owns its own WCAG spec) and gated the
  44-px floor on `matchMedia('(min-width: 640px)').matches === false`
  so the Pixel 5 viewport never tripped into the `sm:` tier during
  WebKit render commits.
- **Server-cache observability.** Every `cached()` invocation passes
  an `annotate` callback that lands two keys on the wide event:
  `cache.<name>.outcome` ∈ `{ hit | miss | stampede }` and
  `cache.<name>.key_hash` (non-reversible djb2 32-bit hash). Ops
  can grep `cache.analytics.outcome` over any time window to
  compute the hit ratio per deployment.
- **AASA followups verified.** The v1.4.33 `/.well-known/apple-app-site-association`
  handler serves a direct 200 on every fronting origin; Apple's
  CDN has ingested matching bodies for all three domains.

### Web freeze

v1.4.34 marks the last functional release of the web codebase before
the iOS native client launches. Subsequent v1.4.x tags carry security
fixes, dependency bumps, and hotfix-only corrections. New feature work
is paused until the iOS app clears Apple review, at which point a
v1.5.0 version-bump-only release tags the milestone. The Prisma
schema head comment and `.planning/v15-strategic-plan.md` §5
decision-log row pin the freeze trigger in-tree.
2026-05-16 21:25:26 +02:00

272 lines
9.7 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.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);
});
});