mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
46f2cfb9e7
commit
78186ba549
1 file changed
+282
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user