mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Up to v1.4.24 the snapshot's day-key and weekday labels ran through utcDayKey() / utcWeekday() / isoWeekKey(), so a 23:50 Pacific/Auckland reading was filed under the next UTC day and the Coach replied with the wrong calendar. The pipeline now reads User.timezone (alongside the existing coachPrefsJson read so the read budget is unchanged) and threads it through every bucketing helper (dailyMeans, bucketWeekly, buildDailyValueRows, buildDailyBpRows) plus the medication-compliance day-roll-up and the Apple Health additive blocks. The prompt-budget calculator (src/lib/ai/coach/budget.ts) is unaffected. The ledger still uses UTC midnight for spend roll-over because the operator's LLM bill cycles on UTC; only the SNAPSHOT itself shifts to the user's calendar. The provenance counts are row counts, not day counts, so no provenance drift either. Integration test pins the boundary: a 13:00 UTC May 14 reading (= 01:00 NZST May 15) appears under "2026-05-15" in the Coach snapshot for a Pacific/Auckland user. The same instant in Berlin would land on May 14; the test asserts the snapshot does NOT bucket there. Existing 88-test coach unit suite stays green (the mocked user row returns no timezone, so the snapshot falls back to Europe/Berlin and the v1.4.24 byte-shape holds).
424 lines
15 KiB
TypeScript
424 lines
15 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);
|
|
});
|
|
|
|
it("coach snapshot anchors timeline.recent to the user's tz", async () => {
|
|
// v1.4.25 W7b — surface 3. A 23:50 NZST reading on May 14 (=
|
|
// 11:50 UTC May 14, well into the Auckland evening) must appear
|
|
// in the snapshot under the May 14 Auckland day, not the
|
|
// (incidentally-equal) UTC day. The boundary case is 13:00 UTC
|
|
// May 14 = 01:00 NZST May 15, where the snapshot must pin the
|
|
// reading to May 15 (the user's "today") even though UTC
|
|
// (and Berlin) would call it May 14.
|
|
const prisma = getPrismaClient();
|
|
const me = await seedAucklandUser();
|
|
|
|
// Boundary instant: 13:00 UTC May 14 → 01:00 NZST May 15.
|
|
await prisma.measurement.create({
|
|
data: {
|
|
userId: me.id,
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
unit: "kg",
|
|
measuredAt: new Date("2026-05-14T13:00:00.000Z"),
|
|
source: "MANUAL",
|
|
},
|
|
});
|
|
|
|
const { buildCoachSnapshot } = await import("@/lib/ai/coach/snapshot");
|
|
const out = await buildCoachSnapshot(me.id, {
|
|
window: "last30days",
|
|
sources: ["weight"],
|
|
});
|
|
const parsed = JSON.parse(out.snapshotJson) as {
|
|
weight?: {
|
|
timeline?: {
|
|
recent?: Array<{ date: string; weekday: string; value: number }>;
|
|
};
|
|
};
|
|
};
|
|
const recent = parsed.weight?.timeline?.recent ?? [];
|
|
expect(recent.length).toBeGreaterThanOrEqual(1);
|
|
// Auckland day for 13:00 UTC May 14 is May 15.
|
|
expect(recent.some((r) => r.date === "2026-05-15")).toBe(true);
|
|
// The same UTC instant in Berlin would land on May 14 — verify
|
|
// we did NOT bucket to that.
|
|
expect(recent.some((r) => r.date === "2026-05-14")).toBe(false);
|
|
});
|
|
});
|