From 050ddbecb2ac4c11f0e85cb67cdaec3afbba32ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 8 Jun 2026 20:09:17 +0200 Subject: [PATCH] test(medications): make compliance and analytics tests timezone-stable These tests passed in Europe/Berlin but failed under UTC (or against the shipped engine) because they leant on the host clock: - intake route test: the mocked user had no timezone, so slot resolution fell back to the host TZ and the 07:00 schedule slot only matched in Berlin. Pin the user to Europe/Berlin. - health-score: 30 daily takes seeded at an unaligned now-i*DAY shifted their Berlin hour with the runner TZ, dropping them out of the 08:00-10:00 on-time band. Anchor each take to a fixed 07:30 UTC. - sleep-stages: the night sat on the trailing-30-day window edge for a fixed calendar instant. Anchor it to now-5d. - weekly-compliance: not a timezone issue. The dose-window engine treats a weekly dose inside the 4-day overdue tail as still-takeable, not missed, so skipping the most-recent Monday no longer lowers the rate. Skip an older, settled-miss Monday instead; the gap assertion stands. --- .../medications/[id]/intake/__tests__/route.test.ts | 9 ++++++++- tests/integration/analytics-health-score.test.ts | 10 ++++++++++ tests/integration/analytics-sleep-stages.test.ts | 6 +++++- .../medication-weekly-compliance.test.ts | 13 +++++++++---- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index fc87a09b3..984f5fb73 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -96,7 +96,14 @@ import { getSession } from "@/lib/auth/session"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "marc", role: "USER" as const }, + user: { + id: "user-1", + username: "marc", + role: "USER" as const, + // Pin the user timezone so slot-instant resolution is deterministic + // regardless of the host TZ (CI runs in UTC, local in Europe/Berlin). + timezone: "Europe/Berlin", + }, }; const MED_OK = { id: "med-1", userId: "user-1" }; diff --git a/tests/integration/analytics-health-score.test.ts b/tests/integration/analytics-health-score.test.ts index b6e8a5cea..cfaec655b 100644 --- a/tests/integration/analytics-health-score.test.ts +++ b/tests/integration/analytics-health-score.test.ts @@ -163,7 +163,12 @@ describe("GET /api/analytics — Health Score", () => { }, }); for (let i = 0; i < 30; i++) { + // Anchor the take to a fixed wall-clock instant that lands inside the + // 08:00–10:00 Berlin window (07:30 UTC ≈ 09:30 Berlin) so the slot + // matcher reads "on-time" deterministically regardless of host TZ — + // an unaligned `now - i*DAY` shifts its Berlin hour with the runner TZ. const scheduledFor = new Date(now - i * DAY); + scheduledFor.setUTCHours(7, 30, 0, 0); await prisma.medicationIntakeEvent.create({ data: { userId: user.id, @@ -283,7 +288,12 @@ describe("GET /api/analytics — Health Score", () => { }, }); for (let i = 0; i < 30; i++) { + // Anchor the take to a fixed wall-clock instant that lands inside the + // 08:00–10:00 Berlin window (07:30 UTC ≈ 09:30 Berlin) so the slot + // matcher reads "on-time" deterministically regardless of host TZ — + // an unaligned `now - i*DAY` shifts its Berlin hour with the runner TZ. const scheduledFor = new Date(now - i * DAY); + scheduledFor.setUTCHours(7, 30, 0, 0); await prisma.medicationIntakeEvent.create({ data: { userId: user.id, diff --git a/tests/integration/analytics-sleep-stages.test.ts b/tests/integration/analytics-sleep-stages.test.ts index 983d06b6f..2b8fdd13d 100644 --- a/tests/integration/analytics-sleep-stages.test.ts +++ b/tests/integration/analytics-sleep-stages.test.ts @@ -100,7 +100,11 @@ describe("GET /api/analytics — sleep-stage aggregation", () => { // One night with the four iOS-16+ stages tagged. The analytics // route should surface a single Measurement-summary datapoint // (~480 minutes total) plus a per-stage breakdown. - const baseDate = new Date("2026-05-09T01:00:00.000Z"); + // Anchor the night comfortably inside the route's trailing 30-day + // window (relative to `now`) so the test never drifts onto the + // window edge as the calendar advances or the host TZ shifts. + const baseDate = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000); + baseDate.setUTCHours(1, 0, 0, 0); const stages: Array<{ stage: "IN_BED" | "CORE" | "DEEP" | "REM"; min: number; diff --git a/tests/integration/medication-weekly-compliance.test.ts b/tests/integration/medication-weekly-compliance.test.ts index 6ba684e23..11a9004c7 100644 --- a/tests/integration/medication-weekly-compliance.test.ts +++ b/tests/integration/medication-weekly-compliance.test.ts @@ -212,15 +212,20 @@ describe("weekly injectable compliance (Mounjaro 0% regression)", () => { rrule: "FREQ=WEEKLY;BYDAY=MO", windowStart: "07:30", }); - // Log only 12 of the trailing 13 Mondays (skip the most recent). + // Take the most-recent Monday on time, then skip ONE older Monday + // (`lastMonday − 7d`, which sits 9 days before the pinned now — past + // the weekly 4-day overdue tail, so it counts as definitively missed + // rather than still-takeable) and take the 11 Mondays before it. The + // gap is a real, settled miss, not a head-of-window overdue slot. + await logWeeklyIntakes(id, lastMonday, 1); await logWeeklyIntakes( id, - new Date(lastMonday.getTime() - 7 * DAY_MS), - 12, + new Date(lastMonday.getTime() - 14 * DAY_MS), + 11, ); const { compliance90 } = await readCompliance(id); - // 12 of 13 expected → ~92%, definitively non-zero and below 100. + // 12 taken of 13 expected → ~92%, definitively non-zero and below 100. expect(compliance90.rate).toBeGreaterThan(80); expect(compliance90.rate).toBeLessThan(100); expect(compliance90.missed).toBeGreaterThanOrEqual(1);