Files
HealthLog/tests/integration/timezone-per-user.test.ts
T
Marc-André Bombeck eeaa563026 feat(charts): x-axis ticks render in user displayTimezone
Four chart components (HealthChart, MoodChart, MedicationComplianceChart,
ComplianceLineChart) now accept a userTimezone prop, default
"Europe/Berlin" for backwards compat. Inside each chart, the X-axis
tick formatter, tooltip date label, and per-day bucketing all run
through a tz-aware makeFormatters(locale, userTz) instance instead of
the legacy formatDateShort() which pinned to Europe/Berlin.

Mount sites that already pull useAuth() pass user?.timezone:
dashboard /page.tsx, trends-row, recommendation-card,
insight-advisor-card. Ad-hoc usages without a user (storybook-style
or test harnesses) keep the default Europe/Berlin rendering
bit-for-bit.

The HealthChart's daily-aggregation key (toDayKey) was also rewritten
to depend on the userTimezone prop so a 23:50 Pacific/Auckland reading
buckets to the Auckland day instead of the Berlin day for chart data
display. The TanStack Query cache key gains userTimezone so a tz
change inside a session re-fetches and re-buckets correctly.

Tests: 109 chart-component tests stay green.
2026-05-14 13:04:12 +02:00

380 lines
14 KiB
TypeScript

/**
* v1.4.25 W7 — per-user-timezone end-to-end guard.
*
* Creates a Pacific/Auckland user (UTC+12 in May, UTC+13 with DST
* later in the year), inserts a measurement at a UTC instant that
* maps to "today" in Auckland but "yesterday" in Berlin, then
* verifies:
*
* 1. Withing the Auckland user's session, the CSV export emits
* `+12:00` (or `+13:00` if DST is active) on the timestamp
* column — NOT the trailing `Z`. This is the issue #167 fix
* from the user's perspective.
*
* 2. The `PUT /api/auth/me/timezone` route correctly persists a
* new zone and the next read picks it up via the resolver
* cache (write-time invalidation).
*
* 3. The signup payload's browser-detected timezone is captured
* onto `User.timezone`, and an invalid value falls back to
* the server default.
*
* The Coach snapshot itself buckets by UTC and is not yet
* user-tz-aware (proposal §3 symptom 9 — deferred to a separate
* v1.5 wave). That assertion is intentionally NOT in this file.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
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();
});
async function seedAucklandUser() {
const prisma = getPrismaClient();
const user = await prisma.user.create({
data: {
username: "akl-user",
email: "akl@example.test",
role: "USER",
timezone: "Pacific/Auckland",
},
});
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("per-user timezone — Pacific/Auckland end-to-end", () => {
it("CSV export emits the user's offset (not Z) on measuredAt", async () => {
const prisma = getPrismaClient();
const me = await seedAucklandUser();
// 12:00 UTC on May 15 → 00:00 Auckland on May 16 (UTC+12 in
// May, NZST). Auckland switches to NZDT (UTC+13) only in
// late September → early April; pick a May instant to keep
// the offset deterministic for the test snapshot.
await prisma.measurement.create({
data: {
userId: me.id,
type: "WEIGHT",
value: 75.5,
unit: "kg",
measuredAt: new Date("2026-05-15T12:00:00.000Z"),
source: "MANUAL",
},
});
const { GET } = await import("@/app/api/export/measurements/route");
const res = await GET(
new Request("http://localhost/api/export/measurements", {
method: "GET",
}) as Parameters<typeof GET>[0],
);
expect(res.status).toBe(200);
const body = await res.text();
// Header row + one data row.
expect(body).toContain(
"type,value,unit,measuredAt,source,notes,glucoseContext",
);
// The Auckland offset in May = +12:00 (NZST). The CSV row
// should carry it verbatim, never the bare Z.
expect(body).toContain("2026-05-16T00:00:00+12:00");
expect(body).not.toContain("2026-05-15T12:00:00.000Z");
});
it("PUT /api/auth/me/timezone writes the new zone and the resolver picks it up immediately", async () => {
const prisma = getPrismaClient();
const me = await seedAucklandUser();
const { PUT } = await import("@/app/api/auth/me/timezone/route");
const req = new Request("http://localhost/api/auth/me/timezone", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ timezone: "Asia/Tokyo" }),
});
const res = await PUT(req as Parameters<typeof PUT>[0]);
expect(res.status).toBe(200);
const fresh = await prisma.user.findUnique({
where: { id: me.id },
select: { timezone: true },
});
expect(fresh?.timezone).toBe("Asia/Tokyo");
// Resolver returns the new value without waiting for the 60s
// TTL — the route's invalidateUserTimezone() call evicts the
// cache on write.
const { resolveUserTimezone } = await import("@/lib/tz/resolver");
expect(await resolveUserTimezone(me.id)).toBe("Asia/Tokyo");
});
it("PUT /api/auth/me/timezone rejects an invalid IANA zone with 422", async () => {
await seedAucklandUser();
const { PUT } = await import("@/app/api/auth/me/timezone/route");
const req = new Request("http://localhost/api/auth/me/timezone", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ timezone: "Mars/Olympus_Mons" }),
});
const res = await PUT(req as Parameters<typeof PUT>[0]);
expect(res.status).toBe(422);
});
it("CSV export for an Asia/Tokyo user shows +09:00 (no DST)", async () => {
const prisma = getPrismaClient();
const me = await prisma.user.create({
data: {
username: "tokyo-user",
email: "tokyo@example.test",
role: "USER",
timezone: "Asia/Tokyo",
},
});
const session = await prisma.session.create({
data: { userId: me.id, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
await prisma.measurement.create({
data: {
userId: me.id,
type: "WEIGHT",
value: 70,
unit: "kg",
measuredAt: new Date("2026-05-15T03:00:00.000Z"), // 12:00 Tokyo
source: "MANUAL",
},
});
const { GET } = await import("@/app/api/export/measurements/route");
const res = await GET(
new Request("http://localhost/api/export/measurements", {
method: "GET",
}) as Parameters<typeof GET>[0],
);
const body = await res.text();
expect(body).toContain("2026-05-15T12:00:00+09:00");
});
it("CSV export with no userTz (legacy callers) emits the Z suffix", async () => {
// This protects the canonical-backup-on-disk contract — the
// export library has a backward-compatible no-userTz path.
const { formatMeasurementsForExport, toCSV } = await import("@/lib/export");
const csv = toCSV(
formatMeasurementsForExport([
{
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: new Date("2026-05-15T12:00:00.000Z"),
source: "MANUAL",
notes: null,
},
]),
);
expect(csv).toContain("2026-05-15T12:00:00.000Z");
});
it("registration captures the browser-detected timezone", async () => {
const prisma = getPrismaClient();
const { POST } = await import("@/app/api/auth/register/route");
const req = new Request("http://localhost/api/auth/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
email: "fresh-akl@example.test",
username: "fresh-akl",
password: "S3cure-Passw0rd-12345",
timezone: "Pacific/Auckland",
}),
});
const res = await POST(req as Parameters<typeof POST>[0]);
// The first ever signup becomes admin → 201; subsequent signups
// succeed too. Either way it's a 2xx.
expect(res.status).toBeGreaterThanOrEqual(200);
expect(res.status).toBeLessThan(300);
const stored = await prisma.user.findFirst({
where: { username: "fresh-akl" },
select: { timezone: true },
});
expect(stored?.timezone).toBe("Pacific/Auckland");
});
it("registration with an invalid timezone falls back to Europe/Berlin", async () => {
// No admin server default is set in this test, so the resolver
// chain bottoms out at the hard-coded "Europe/Berlin". This
// covers the worst-case fallback path. Setting an admin default
// and observing the new value would require the testcontainer
// migrations to carry every AppSettings column the running
// schema requires; the integration suite is intentionally
// tolerant of unmigrated app_settings columns, so we exercise
// the unset-default path here.
const prisma = getPrismaClient();
// Make sure no leftover row pins a default.
const { invalidateServerDefaultTimezone } =
await import("@/lib/tz/resolver");
invalidateServerDefaultTimezone();
const { POST } = await import("@/app/api/auth/register/route");
const req = new Request("http://localhost/api/auth/register", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
email: "bogus@example.test",
username: "bogus-tz",
password: "S3cure-Passw0rd-12345",
timezone: "Mars/Olympus_Mons",
}),
});
const res = await POST(req as Parameters<typeof POST>[0]);
expect(res.status).toBeGreaterThanOrEqual(200);
expect(res.status).toBeLessThan(300);
const stored = await prisma.user.findFirst({
where: { username: "bogus-tz" },
select: { timezone: true },
});
expect(stored?.timezone).toBe("Europe/Berlin");
});
it("dashboard streak counts the Auckland-day for a 23:50 NZST reading", async () => {
// v1.4.25 W7b — surface 1. A Pacific/Auckland user logs a
// reading at 11:50 UTC, which is 23:50 NZST (May, UTC+12). In
// Berlin that's 13:50, well clear of midnight either way — but
// the test pins the principle: streak day-keys honour the user's
// tz. Without the fix, the legacy Berlin bucketing would pin
// this reading to the same Berlin day for every user regardless
// of where their personal "today" actually is.
const prisma = getPrismaClient();
const me = await seedAucklandUser();
// 11:50 UTC on May 14 → 23:50 NZST May 14 (UTC+12 in May).
await prisma.measurement.create({
data: {
userId: me.id,
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: new Date("2026-05-14T11:50:00.000Z"),
source: "MANUAL",
},
});
const { userDayKey } = await import("@/lib/tz/resolver");
// The Auckland day-key for this reading is "2026-05-14"; the
// Berlin day-key would be "2026-05-14" too in May because Berlin
// is UTC+2, but Auckland users at the day boundary depend on
// their own tz. Pin the principle directly: the key the
// dashboard would store for this reading IS the user's tz day.
expect(
userDayKey(new Date("2026-05-14T11:50:00.000Z"), "Pacific/Auckland"),
).toBe("2026-05-14");
expect(
userDayKey(new Date("2026-05-14T11:50:00.000Z"), "Europe/Berlin"),
).toBe("2026-05-14");
// The actual boundary case: 13:00 UTC on May 14 → 01:00 NZST
// May 15 in Auckland, but still 15:00 on May 14 in Berlin. The
// dashboard's streak for an Auckland user should pin this to
// May 15 (their tomorrow); a Berlin user would pin it to May 14.
expect(
userDayKey(new Date("2026-05-14T13:00:00.000Z"), "Pacific/Auckland"),
).toBe("2026-05-15");
expect(
userDayKey(new Date("2026-05-14T13:00:00.000Z"), "Europe/Berlin"),
).toBe("2026-05-14");
});
it("dashboard summary aggregates streaks in the user's tz", async () => {
// Hit the actual /api/dashboard/summary route as the Auckland
// user and confirm the streak counts days using Auckland's
// calendar, not Berlin's. We seed three consecutive Auckland
// days of activity; each measurement is timestamped late-evening
// UTC so a Berlin-bucketed pipeline would group two of them
// into one day and report a shorter streak.
const prisma = getPrismaClient();
const me = await seedAucklandUser();
// Three consecutive Auckland days, each at 23:30 NZST (11:30 UTC).
// For Auckland (UTC+12 in May): 2026-05-12, 2026-05-13, 2026-05-14.
// For Berlin (UTC+2 in May): 13:30 on the same UTC day; the
// legacy bucketing would still see three days, so this test
// doesn't trigger a tz divergence on its own. The point is to
// confirm the route returns a non-zero streak and goes through
// the user-tz path without crashing.
for (let i = 0; i < 3; i++) {
const dt = new Date(`2026-05-${12 + i}T11:30:00.000Z`);
await prisma.measurement.create({
data: {
userId: me.id,
type: "WEIGHT",
value: 80 + i,
unit: "kg",
measuredAt: dt,
source: "MANUAL",
},
});
}
const { GET } = await import("@/app/api/dashboard/summary/route");
// The handler is typed `() => Promise<Response>`, but the
// `apiHandler` wrapper unconditionally reads `args[0]` as the
// Next.js Request — cast through unknown to push the test fixture
// through without weakening the runtime type.
const handler = GET as unknown as (req: Request) => Promise<Response>;
const res = await handler(
new Request("http://localhost/api/dashboard/summary", {
method: "GET",
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { streak: { currentDays: number; longest: number } };
};
// The longest streak across the 3 seeded days is 3.
expect(body.data.streak.longest).toBeGreaterThanOrEqual(3);
});
});