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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 10:02:50 +02:00
1 parent e37263e8cc
commit 8821fdeb54
2 files changed
+342

No files matched your search

+290
View File
@@ -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<typeof import("@/lib/modules/gate")>();
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<Response>;
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);
});
});
});
+52
View File
@@ -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
});
});