test(integration): per-user timezone end-to-end (Pacific/Auckland)

v1.4.25 W7 phase 7 — proposal §6.4 test strategy applied.

Seven cases cover the full per-user-tz pipeline against the
integration testcontainer:

  1. CSV export for an Auckland user emits '+12:00' on measuredAt,
     never the bare 'Z'. This is the issue #167 fix from the user's
     end-to-end perspective: an Auckland user's exported spreadsheet
     carries unambiguous local time.

  2. CSV export for an Asia/Tokyo user emits '+09:00' (no DST in
     Japan, deterministic offset year-round).

  3. CSV export with no userTz (legacy library callers — admin tools,
     canonical backup-on-disk) keeps the 'Z' suffix. Protects the
     backward-compatibility path.

  4. PUT /api/auth/me/timezone writes the new zone, the user row
     reflects it, AND the in-process resolver cache returns the new
     value immediately (the route invalidates on write — proves the
     evict path works without waiting 60s for the TTL to roll).

  5. PUT /api/auth/me/timezone rejects an invalid IANA zone with
     422 and does not touch the column.

  6. POST /api/auth/register captures a browser-detected zone from
     the signup payload onto User.timezone.

  7. POST /api/auth/register falls back to Europe/Berlin when the
     payload's zone is invalid. The 'admin server default'
     intermediate fallback layer is exercised by the unit suite —
     the integration test runs against a testcontainer whose
     migration history doesn't carry every AppSettings column,
     so setting a non-NULL default would error before the route
     runs. Documented inline.

The Coach snapshot's UTC bucketing (proposal §3 symptom 9) is
intentionally NOT asserted — that surface is deferred to a
follow-up wave because the snapshot's day/week bucketing pipeline
is a separate refactor that touches the prompt budget calculator.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 12:11:37 +02:00
1 parent 46f2cfb9e7
commit 78186ba549
1 file changed
+282
+282
View File
@@ -0,0 +1,282 @@
/**
* 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");
});
});