From 8821fdeb54a380d2d74370c50f3c4d2be4916278 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 10:02:50 +0200 Subject: [PATCH] test(tz): run core day paths with the host at both edges of the clock The suite and the production image both sit near UTC, so a UTC day and a local day agree for most of every day and day-boundary defects kept passing. The harness sets the host zone in code to Los Angeles and to Kiritimati, gives the account the same zone, and checks a schedule slot, a course's last day, a mood entry, the dashboard summary's mood instant and a document date round trip at the instant where the local and the UTC day differ. Unit tests cover the new date-only and key helpers. --- src/__tests__/tz-day-paths-harness.test.ts | 290 +++++++++++++++++++++ src/lib/tz/__tests__/date-only.test.ts | 52 ++++ 2 files changed, 342 insertions(+) create mode 100644 src/__tests__/tz-day-paths-harness.test.ts create mode 100644 src/lib/tz/__tests__/date-only.test.ts diff --git a/src/__tests__/tz-day-paths-harness.test.ts b/src/__tests__/tz-day-paths-harness.test.ts new file mode 100644 index 000000000..7adf653da --- /dev/null +++ b/src/__tests__/tz-day-paths-harness.test.ts @@ -0,0 +1,290 @@ +/** + * Core day paths under a host zone far from UTC. + * + * The suite pins the host to UTC (`vitest.config.mts`), and the production + * image to Europe/Berlin. Both sit close enough to UTC that a UTC day and a + * local day agree for most of every day, which is how day-boundary defects + * kept passing: the evening west of UTC and the small hours east of it are + * exactly the hours no fixture lived in. This harness sets the HOST zone + * (`process.env.TZ`, in code; the `TZ=` prefix on the command line does not + * reach Vitest's workers here) to one zone at each edge, gives the account + * the same zone, and checks that a handful of the paths people hit name the + * local day at the edge of it: + * + * - a medication schedule slot, and a course on its last day + * - a mood entry's day + * - the dashboard summary's mood instant (the iOS home tile) + * - a document date from the stated string to the stored instant to the + * DTO + * + * Each zone is tested at the instant where its local day and the UTC day + * differ: 23:30 in Los Angeles (already tomorrow in UTC) and 00:30 on + * Kiritimati (still yesterday in UTC). + */ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/modules/gate", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveModuleMap: vi.fn(async () => + Object.fromEntries(actual.MODULE_KEYS.map((k) => [k, true])), + ), + }; +}); + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { groupBy: vi.fn(), findMany: vi.fn() }, + medicationIntakeEvent: { + findMany: vi.fn(), + createMany: vi.fn(), + groupBy: vi.fn(), + }, + medication: { findMany: vi.fn() }, + medicationScheduleRevision: { groupBy: vi.fn() }, + user: { findUnique: vi.fn() }, + appSettings: { findUnique: vi.fn(async () => null) }, + $queryRaw: vi.fn(), + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/rollups/measurement-read", () => ({ + loadUserSourcePriority: vi.fn(async () => null), +})); +vi.mock("@/lib/analytics/mood-series", () => ({ + buildMoodDailySeries: vi.fn(), +})); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET as dashboardSummary } from "@/app/api/dashboard/summary/route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { buildMoodDailySeries } from "@/lib/analytics/mood-series"; +import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; +import { serialiseDocument } from "@/lib/documents/store"; +import { resolveIntakeActionability } from "@/lib/medications/intake-actionable"; +import { moodDateKey } from "@/lib/mood/date-key"; +import { dayKeyForScheduledFor } from "@/lib/rollups/medication-compliance-rollups"; +import { + dateOnlyAtNoonUtc, + dayKeyAsUtcMidnight, + statedDateKey, +} from "@/lib/tz/date-only"; +import { userDayKey } from "@/lib/tz/format"; +import { localHmAsUtc } from "@/lib/tz/local-day"; + +interface EdgeCase { + zone: string; + /** An instant where the local day and the UTC day differ. */ + now: Date; + /** The local calendar day at `now`. */ + localDay: string; + /** The UTC calendar day at `now`, for the record. */ + utcDay: string; +} + +const EDGES: EdgeCase[] = [ + { + zone: "America/Los_Angeles", + now: new Date("2026-07-03T06:30:00.000Z"), // 2 July, 23:30 local + localDay: "2026-07-02", + utcDay: "2026-07-03", + }, + { + zone: "Pacific/Kiritimati", + now: new Date("2026-07-02T10:30:00.000Z"), // 3 July, 00:30 local + localDay: "2026-07-03", + utcDay: "2026-07-02", + }, +]; + +describe.each(EDGES)("day paths with the host in $zone", (edge) => { + let previousTz: string | undefined; + + beforeAll(() => { + previousTz = process.env.TZ; + process.env.TZ = edge.zone; + }); + afterAll(() => { + if (previousTz === undefined) delete process.env.TZ; + else process.env.TZ = previousTz; + }); + + it("runs with the host clock in the zone (the harness itself)", () => { + // If the zone did not take, every assertion below would be testing UTC. + expect(edge.now.getDate()).toBe(Number(edge.localDay.slice(8, 10))); + expect(edge.localDay).not.toBe(edge.utcDay); + }); + + describe("medication schedule day", () => { + it("keys a slot on the local day it falls on", () => { + expect(dayKeyForScheduledFor(edge.now, edge.zone)).toBe(edge.localDay); + }); + + it("places today's 08:00 slot on the local day", () => { + const slot = localHmAsUtc(edge.now, edge.zone, 8, 0); + expect(userDayKey(slot, edge.zone)).toBe(edge.localDay); + }); + + it("keeps a course running through its last local day", () => { + const status = resolveIntakeActionability( + { + active: true, + trackIntake: true, + startsOn: null, + // `endsOn` is a @db.Date column: the calendar date as UTC midnight. + endsOn: dayKeyAsUtcMidnight(edge.localDay), + }, + edge.now, + edge.zone, + ); + expect(status.courseStatus).toBe("CURRENT"); + expect(status.intakeActionable).toBe(true); + }); + }); + + describe("mood day", () => { + it("files an entry under the local day it was logged", () => { + expect(moodDateKey(edge.now, edge.zone)).toBe(edge.localDay); + }); + }); + + describe("dashboard summary day", () => { + beforeEach(() => { + vi.resetAllMocks(); + __resetAllCachesForTests(); + vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.createMany).mockResolvedValue({ + count: 0, + } as never); + vi.mocked(prisma.medicationIntakeEvent.groupBy).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationScheduleRevision.groupBy).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + dateOfBirth: null, + gender: null, + heightCm: null, + } as never); + vi.mocked(getSession).mockResolvedValue({ + session: { id: "s", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "tester", + role: "USER", + displayName: null, + timezone: edge.zone, + }, + } as never); + }); + + it("puts the mood card's instant on the entry's local day", async () => { + vi.mocked(buildMoodDailySeries).mockResolvedValue({ + entries: [{ date: edge.localDay, score: 4, samples: 1 }], + summary: null, + entryCount: 1, + source: "rollup", + } as never); + const call = dashboardSummary as unknown as ( + req: NextRequest, + ) => Promise; + const res = await call( + new NextRequest("http://localhost/api/dashboard/summary"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { metrics: Array<{ kind: string; updatedAt: string | null }> }; + }; + const mood = body.data.metrics.find((m) => m.kind === "mood"); + expect(mood?.updatedAt).toBeTruthy(); + const at = new Date(mood!.updatedAt!); + // Read the way a client in the zone reads it: the host clock and the + // account zone agree here. + expect(userDayKey(at, edge.zone)).toBe(edge.localDay); + expect( + `${at.getFullYear()}-${String(at.getMonth() + 1).padStart(2, "0")}-${String(at.getDate()).padStart(2, "0")}`, + ).toBe(edge.localDay); + }); + }); + + describe("document date round trip", () => { + function documentRow(documentDate: Date) { + return { + id: "doc-1", + userId: "user-1", + kind: "OTHER", + title: null, + filename: "report.pdf", + mimeType: "application/pdf", + byteSize: 1, + status: "STORED", + providerType: null, + reportDate: documentDate, + documentDate, + errorReason: null, + lastIndexAttemptAt: null, + lastIndexOutcome: null, + sourceSystem: null, + sourceId: null, + createdAt: edge.now, + updatedAt: edge.now, + } as never; + } + + it("reads a written-out date as the date it states", () => { + // Parsed as local time in the host zone; read back in UTC this was the + // previous day east of UTC. + expect(statedDateKey("July 3, 2026")).toBe("2026-07-03"); + expect(statedDateKey("2026-07-03")).toBe("2026-07-03"); + expect(statedDateKey("2026-07-03T08:00:00+09:00")).toBe("2026-07-03"); + }); + + it("stores a stated date and serves the same date back", () => { + const stored = dateOnlyAtNoonUtc(edge.localDay); + const dto = serialiseDocument(documentRow(stored), { + factCount: 0, + pendingCount: 0, + }); + expect(dto.documentDate).toBe(edge.localDay); + expect(dto.reportDate).toBe(edge.localDay); + }); + + it("serves a row written before the noon anchor on its date too", () => { + const legacy = dayKeyAsUtcMidnight(edge.localDay); + const dto = serialiseDocument(documentRow(legacy), { + factCount: 0, + pendingCount: 0, + }); + expect(dto.documentDate).toBe(edge.localDay); + }); + }); +}); diff --git a/src/lib/tz/__tests__/date-only.test.ts b/src/lib/tz/__tests__/date-only.test.ts new file mode 100644 index 000000000..6c09a06b2 --- /dev/null +++ b/src/lib/tz/__tests__/date-only.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + dateOnlyAtNoonUtc, + dateOnlyKey, + dayKeyAsUtcMidnight, + isCalendarDateKey, + isDateOnlyKey, + statedDateKey, +} from "../date-only"; +import { daysBetweenDateKeys, weekdayOfDateKey } from "../format"; + +describe("date-only values", () => { + it("stores a date at noon UTC and reads it back", () => { + const stored = dateOnlyAtNoonUtc("2026-06-10"); + expect(stored.toISOString()).toBe("2026-06-10T12:00:00.000Z"); + expect(dateOnlyKey(stored)).toBe("2026-06-10"); + }); + + it("reads a row stored at UTC midnight as the same date", () => { + expect(dateOnlyKey(dayKeyAsUtcMidnight("2026-06-10"))).toBe("2026-06-10"); + }); + + it("tells a shape from a real calendar date", () => { + expect(isDateOnlyKey("2026-02-30")).toBe(true); + expect(isCalendarDateKey("2026-02-30")).toBe(false); + expect(isCalendarDateKey("2028-02-29")).toBe(true); + expect(isCalendarDateKey("2026-6-1")).toBe(false); + }); + + it("reads the date a string states", () => { + expect(statedDateKey("2026-06-10")).toBe("2026-06-10"); + expect(statedDateKey("2026-06-10T23:30:00-07:00")).toBe("2026-06-10"); + expect(statedDateKey(" 2026-06-10 ")).toBe("2026-06-10"); + expect(statedDateKey("June 10, 2026")).toBe("2026-06-10"); + expect(statedDateKey("2026-02-30")).toBeNull(); + expect(statedDateKey("not a date")).toBeNull(); + }); +}); + +describe("calendar key arithmetic", () => { + it("counts whole days between keys across a DST change", () => { + expect(daysBetweenDateKeys("2026-03-28", "2026-03-30")).toBe(2); + expect(daysBetweenDateKeys("2026-03-30", "2026-03-28")).toBe(-2); + expect(daysBetweenDateKeys("2025-12-31", "2026-01-01")).toBe(1); + expect(daysBetweenDateKeys("bad", "2026-01-01")).toBeNaN(); + }); + + it("names the weekday of a key", () => { + expect(weekdayOfDateKey("2026-10-01")).toBe(4); // Thursday + expect(weekdayOfDateKey("2026-10-04")).toBe(0); // Sunday + }); +});