mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites). See CHANGELOG.md for the full per-section delta.
444 lines
16 KiB
TypeScript
444 lines
16 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 reading at HH:13:00 UTC sits at
|
||
// 01:00 NZST on the *next* Auckland day (NZST = UTC+12 in May).
|
||
// The snapshot must pin the reading to the Auckland day, not the
|
||
// (incidentally-equal) UTC day a Berlin user would see.
|
||
//
|
||
// The instant must stay inside the snapshot's 14-day `recent`
|
||
// window across every CI run, so we anchor it to "today − 2d at
|
||
// 13:00 UTC" rather than a hard-coded May-2026 calendar date.
|
||
// The − 2d cushion keeps the reading clear of `Date.now()` clock
|
||
// jitter and the late-evening DST cutover in late September
|
||
// (Auckland flips NZST → NZDT, which shifts the day-key for
|
||
// 13:00 UTC by an extra hour but never crosses the date line).
|
||
const prisma = getPrismaClient();
|
||
const me = await seedAucklandUser();
|
||
|
||
const anchor = new Date();
|
||
anchor.setUTCDate(anchor.getUTCDate() - 2);
|
||
anchor.setUTCHours(13, 0, 0, 0);
|
||
|
||
const dayKey = (tz: string) =>
|
||
new Intl.DateTimeFormat("en-CA", {
|
||
timeZone: tz,
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
}).format(anchor);
|
||
const aucklandDay = dayKey("Pacific/Auckland");
|
||
const berlinDay = dayKey("Europe/Berlin");
|
||
// Sanity check: 13:00 UTC means Auckland is on a different
|
||
// calendar day than Berlin. Without this, the test couldn't
|
||
// assert the tz divergence we care about.
|
||
expect(aucklandDay).not.toBe(berlinDay);
|
||
|
||
await prisma.measurement.create({
|
||
data: {
|
||
userId: me.id,
|
||
type: "WEIGHT",
|
||
value: 80,
|
||
unit: "kg",
|
||
measuredAt: anchor,
|
||
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);
|
||
// Bucket lands on the Auckland day, not the Berlin day.
|
||
expect(recent.some((r) => r.date === aucklandDay)).toBe(true);
|
||
expect(recent.some((r) => r.date === berlinDay)).toBe(false);
|
||
});
|
||
});
|