mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
- getUserTodayBounds: a 25h fall-back day keeps a 23:30 dose inside today, a 23h spring-forward day does not bleed tomorrow's 00:30 dose, ordinary days stay 24h, and a negative-offset zone anchors on the local day. - dailyComplianceRatesFromLedger: a Los-Angeles user's two same-local-day doses land in ONE bucket where the old fixed-Berlin key split them. - classifyIntakeTiming: an 08:00-local dose reads on_time with the user's zone and very_late without it. - hourInTz: reads the wall-clock hour in the given zone. - hero-strip greeting: resolves the hour in the stored zone, not the browser clock; existing greeting fixtures re-pinned to explicit UTC instants mapped to Berlin wall-clock.
340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
/**
|
|
* v1.4.25 W7 — resolver tests.
|
|
*
|
|
* The cache-hit / cache-miss assertions assume the test file is run
|
|
* in isolation. Vitest's default per-file isolation gives us that.
|
|
* Each test imports the module fresh, so the module-level caches
|
|
* don't leak across cases.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
// Mock prisma BEFORE importing the resolver.
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: {
|
|
user: { findUnique: vi.fn() },
|
|
appSettings: { findUnique: vi.fn() },
|
|
},
|
|
}));
|
|
|
|
import { prisma } from "@/lib/db";
|
|
import {
|
|
DEFAULT_TIMEZONE,
|
|
detectBrowserTimezone,
|
|
formatInUserTz,
|
|
invalidateServerDefaultTimezone,
|
|
invalidateUserTimezone,
|
|
hourInTz,
|
|
isNearUtc,
|
|
isValidTimezone,
|
|
resolveServerDefaultTimezone,
|
|
resolveUserTimezone,
|
|
userDayKey,
|
|
} from "../resolver";
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
invalidateUserTimezone("user-1");
|
|
invalidateUserTimezone("user-2");
|
|
invalidateServerDefaultTimezone();
|
|
});
|
|
|
|
describe("isValidTimezone", () => {
|
|
it("accepts canonical IANA zones", () => {
|
|
expect(isValidTimezone("Europe/Berlin")).toBe(true);
|
|
expect(isValidTimezone("America/New_York")).toBe(true);
|
|
expect(isValidTimezone("Pacific/Auckland")).toBe(true);
|
|
expect(isValidTimezone("UTC")).toBe(true);
|
|
});
|
|
|
|
it("rejects obvious nonsense", () => {
|
|
expect(isValidTimezone("")).toBe(false);
|
|
expect(isValidTimezone("Not/A_Zone")).toBe(false);
|
|
expect(isValidTimezone("Europe/Berlin' OR 1=1")).toBe(false);
|
|
});
|
|
|
|
it("rejects strings longer than 64 chars", () => {
|
|
expect(isValidTimezone("A".repeat(65))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("resolveServerDefaultTimezone", () => {
|
|
it("returns Europe/Berlin when AppSettings is empty", async () => {
|
|
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
|
expect(await resolveServerDefaultTimezone()).toBe("Europe/Berlin");
|
|
});
|
|
|
|
it("returns the configured value when set", async () => {
|
|
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
|
defaultUserTimezone: "Asia/Tokyo",
|
|
} as never);
|
|
expect(await resolveServerDefaultTimezone()).toBe("Asia/Tokyo");
|
|
});
|
|
|
|
it("falls back when the configured value is invalid", async () => {
|
|
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
|
defaultUserTimezone: "Mars/Olympus_Mons",
|
|
} as never);
|
|
expect(await resolveServerDefaultTimezone()).toBe("Europe/Berlin");
|
|
});
|
|
|
|
it("hits the cache on the second call", async () => {
|
|
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
|
defaultUserTimezone: "Asia/Tokyo",
|
|
} as never);
|
|
await resolveServerDefaultTimezone();
|
|
await resolveServerDefaultTimezone();
|
|
await resolveServerDefaultTimezone();
|
|
expect(prisma.appSettings.findUnique).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("re-reads after invalidation", async () => {
|
|
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
|
defaultUserTimezone: "Asia/Tokyo",
|
|
} as never);
|
|
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
|
|
defaultUserTimezone: "America/New_York",
|
|
} as never);
|
|
|
|
expect(await resolveServerDefaultTimezone()).toBe("Asia/Tokyo");
|
|
invalidateServerDefaultTimezone();
|
|
expect(await resolveServerDefaultTimezone()).toBe("America/New_York");
|
|
});
|
|
});
|
|
|
|
describe("resolveUserTimezone", () => {
|
|
it("returns User.timezone when present", async () => {
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
|
timezone: "Pacific/Auckland",
|
|
} as never);
|
|
expect(await resolveUserTimezone("user-1")).toBe("Pacific/Auckland");
|
|
});
|
|
|
|
it("falls back to the server default when the user row is missing", async () => {
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
|
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
|
|
defaultUserTimezone: "Asia/Tokyo",
|
|
} as never);
|
|
expect(await resolveUserTimezone("user-1")).toBe("Asia/Tokyo");
|
|
});
|
|
|
|
it("falls back to Europe/Berlin when neither user nor server default exist", async () => {
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
|
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
|
expect(await resolveUserTimezone("user-1")).toBe("Europe/Berlin");
|
|
});
|
|
|
|
it("hits the cache on the second call", async () => {
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
|
timezone: "Pacific/Auckland",
|
|
} as never);
|
|
await resolveUserTimezone("user-1");
|
|
await resolveUserTimezone("user-1");
|
|
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("re-reads after invalidation", async () => {
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
|
timezone: "Pacific/Auckland",
|
|
} as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
|
timezone: "America/New_York",
|
|
} as never);
|
|
|
|
expect(await resolveUserTimezone("user-1")).toBe("Pacific/Auckland");
|
|
invalidateUserTimezone("user-1");
|
|
expect(await resolveUserTimezone("user-1")).toBe("America/New_York");
|
|
});
|
|
|
|
it("caches per-userId so two users do not collide", async () => {
|
|
// Two distinct findUnique calls; we use mockResolvedValueOnce
|
|
// queued ahead of time. The cache hit on the third call
|
|
// bypasses the mock altogether.
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
|
timezone: "Pacific/Auckland",
|
|
} as never);
|
|
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
|
timezone: "America/New_York",
|
|
} as never);
|
|
|
|
expect(await resolveUserTimezone("user-1")).toBe("Pacific/Auckland");
|
|
expect(await resolveUserTimezone("user-2")).toBe("America/New_York");
|
|
expect(await resolveUserTimezone("user-1")).toBe("Pacific/Auckland");
|
|
expect(prisma.user.findUnique).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("returns the server default for empty userId", async () => {
|
|
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
|
expect(await resolveUserTimezone("")).toBe("Europe/Berlin");
|
|
});
|
|
});
|
|
|
|
describe("detectBrowserTimezone", () => {
|
|
it("returns a non-empty string", () => {
|
|
expect(detectBrowserTimezone().length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe("formatInUserTz", () => {
|
|
// 2026-05-11T09:05:00Z — a Monday morning in May (CEST in Berlin,
|
|
// NZST in Auckland is UTC+12 in May with DST off).
|
|
const instant = new Date("2026-05-11T09:05:00.000Z");
|
|
|
|
it("emits ISO-8601 with offset for Europe/Berlin (CEST in May)", () => {
|
|
expect(formatInUserTz(instant, "Europe/Berlin", "iso-with-offset")).toBe(
|
|
"2026-05-11T11:05:00+02:00",
|
|
);
|
|
});
|
|
|
|
it("emits ISO-8601 with offset for Pacific/Auckland (NZST in May)", () => {
|
|
// May 2026 = NZST = UTC+12 (DST ends in early April for NZ).
|
|
expect(formatInUserTz(instant, "Pacific/Auckland", "iso-with-offset")).toBe(
|
|
"2026-05-11T21:05:00+12:00",
|
|
);
|
|
});
|
|
|
|
it("emits ISO-8601 with offset for America/New_York (EDT in May)", () => {
|
|
// May = EDT = UTC-04:00.
|
|
expect(formatInUserTz(instant, "America/New_York", "iso-with-offset")).toBe(
|
|
"2026-05-11T05:05:00-04:00",
|
|
);
|
|
});
|
|
|
|
it("emits ISO-8601 for UTC with +00:00", () => {
|
|
expect(formatInUserTz(instant, "UTC", "iso-with-offset")).toBe(
|
|
"2026-05-11T09:05:00+00:00",
|
|
);
|
|
});
|
|
|
|
it("falls back to Europe/Berlin when handed an invalid tz", () => {
|
|
expect(formatInUserTz(instant, "Mars/Olympus", "iso-with-offset")).toBe(
|
|
"2026-05-11T11:05:00+02:00",
|
|
);
|
|
});
|
|
|
|
it("formats wall-clock without timezone marker", () => {
|
|
expect(formatInUserTz(instant, "Pacific/Auckland", "wall-clock")).toBe(
|
|
"21:05",
|
|
);
|
|
});
|
|
|
|
it("formats datetime as YYYY-MM-DD HH:MM", () => {
|
|
expect(formatInUserTz(instant, "Pacific/Auckland", "datetime")).toBe(
|
|
"2026-05-11 21:05",
|
|
);
|
|
});
|
|
|
|
it("formats date-only as YYYY-MM-DD", () => {
|
|
expect(formatInUserTz(instant, "Pacific/Auckland", "date")).toBe(
|
|
"2026-05-11",
|
|
);
|
|
});
|
|
|
|
it("round-trips Berlin vs Auckland — same instant, different days", () => {
|
|
// 2026-05-10T22:30:00Z — 23 May in Auckland already, still 10 May
|
|
// in Berlin (00:30 next day actually, so 11 May Berlin).
|
|
// Pick a clearer case: 12:30 UTC = 14:30 Berlin = 00:30 Auckland (next day).
|
|
const lateNight = new Date("2026-05-10T12:30:00.000Z");
|
|
expect(formatInUserTz(lateNight, "Europe/Berlin", "date")).toBe(
|
|
"2026-05-10",
|
|
);
|
|
expect(formatInUserTz(lateNight, "Pacific/Auckland", "date")).toBe(
|
|
"2026-05-11",
|
|
);
|
|
});
|
|
|
|
it("honours DST: Berlin in January is CET = +01:00", () => {
|
|
const jan = new Date("2026-01-15T09:05:00.000Z");
|
|
expect(formatInUserTz(jan, "Europe/Berlin", "iso-with-offset")).toBe(
|
|
"2026-01-15T10:05:00+01:00",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("userDayKey", () => {
|
|
it("matches the Berlin reference when tz=Europe/Berlin", () => {
|
|
const instant = new Date("2025-10-25T21:30:00Z"); // Sat 23:30 Berlin
|
|
expect(userDayKey(instant, "Europe/Berlin")).toBe("2025-10-25");
|
|
});
|
|
|
|
it("returns the next day when the user is east of UTC by enough", () => {
|
|
const instant = new Date("2026-05-10T14:50:00Z"); // 23:50 Tokyo
|
|
expect(userDayKey(instant, "Asia/Tokyo")).toBe("2026-05-10");
|
|
expect(userDayKey(instant, "Pacific/Auckland")).toBe("2026-05-11");
|
|
});
|
|
});
|
|
|
|
describe("hourInTz", () => {
|
|
it("reads the wall-clock hour in the user's zone, not UTC", () => {
|
|
// 2026-06-15T03:00Z is 20:00 the previous day in Los Angeles (UTC-7),
|
|
// 05:00 in Berlin (UTC+2), 12:00 in Tokyo (UTC+9).
|
|
const instant = new Date("2026-06-15T03:00:00.000Z");
|
|
expect(hourInTz(instant, "UTC")).toBe(3);
|
|
expect(hourInTz(instant, "America/Los_Angeles")).toBe(20);
|
|
expect(hourInTz(instant, "Europe/Berlin")).toBe(5);
|
|
expect(hourInTz(instant, "Asia/Tokyo")).toBe(12);
|
|
});
|
|
|
|
it("normalises local midnight to 0, never 24", () => {
|
|
const instant = new Date("2026-06-14T22:00:00.000Z"); // 00:00 Berlin
|
|
expect(hourInTz(instant, "Europe/Berlin")).toBe(0);
|
|
});
|
|
|
|
it("falls back to the default zone for an unusable tz string", () => {
|
|
// Mirrors formatInUserTz / userDayKey: an invalid zone degrades to
|
|
// DEFAULT_TIMEZONE (Europe/Berlin), which is UTC+2 in summer.
|
|
const instant = new Date("2026-06-15T09:00:00.000Z");
|
|
expect(hourInTz(instant, "Mars/Olympus")).toBe(11);
|
|
});
|
|
});
|
|
|
|
describe("isNearUtc", () => {
|
|
// Pin a summer-side instant so DST is on for Berlin (+2) and off for
|
|
// most southern-hemisphere zones — keeps the assertions readable.
|
|
const summer = new Date("2026-07-15T12:00:00.000Z");
|
|
// Winter-side instant so Berlin is on CET (+1).
|
|
const winter = new Date("2026-01-15T12:00:00.000Z");
|
|
|
|
it("returns true for UTC", () => {
|
|
expect(isNearUtc("UTC", summer)).toBe(true);
|
|
});
|
|
|
|
it("returns true for Europe/Berlin in both summer and winter", () => {
|
|
expect(isNearUtc("Europe/Berlin", summer)).toBe(true);
|
|
expect(isNearUtc("Europe/Berlin", winter)).toBe(true);
|
|
});
|
|
|
|
it("returns true for the ±3h boundary zones", () => {
|
|
// Europe/Moscow = +3 year-round.
|
|
expect(isNearUtc("Europe/Moscow", summer)).toBe(true);
|
|
// Atlantic/Azores = -1 in winter, 0 in summer.
|
|
expect(isNearUtc("Atlantic/Azores", summer)).toBe(true);
|
|
});
|
|
|
|
it("returns false for zones more than 3 hours from UTC", () => {
|
|
// Pacific/Honolulu = -10 year-round.
|
|
expect(isNearUtc("Pacific/Honolulu", summer)).toBe(false);
|
|
// Asia/Tokyo = +9 year-round.
|
|
expect(isNearUtc("Asia/Tokyo", summer)).toBe(false);
|
|
// America/New_York = -4 in summer, -5 in winter.
|
|
expect(isNearUtc("America/New_York", summer)).toBe(false);
|
|
// Pacific/Auckland = +13 in southern summer (Jan), +12 in May.
|
|
expect(isNearUtc("Pacific/Auckland", winter)).toBe(false);
|
|
});
|
|
|
|
it("defaults to near-UTC when the zone string is invalid", () => {
|
|
// Invalid zones fall back to Europe/Berlin, which is near-UTC.
|
|
expect(isNearUtc("Mars/Olympus_Mons", summer)).toBe(true);
|
|
});
|
|
|
|
it("uses the current instant when `now` is omitted", () => {
|
|
// Pure smoke — exercises the default parameter branch.
|
|
expect(typeof isNearUtc("Europe/Berlin")).toBe("boolean");
|
|
});
|
|
});
|
|
|
|
describe("DEFAULT_TIMEZONE", () => {
|
|
it("is Europe/Berlin", () => {
|
|
expect(DEFAULT_TIMEZONE).toBe("Europe/Berlin");
|
|
});
|
|
});
|