From 7be05608f8e678fc7e06dc8db7cbdc538ab281dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 12:23:09 +0200 Subject: [PATCH] test: date fixtures that feed a clock-relative window relative to now A fixture dated near the day it was written passes until the date slides out of a window the route counts back from the clock. Five did: a snooze until a fixed date that becomes the past in January, a prognosis read against the wall clock over days seeded at a fixed date, sleep rows and a weight streak read through windows that end now, and a sync token that would expire on a fixed date. The e2e mood heatmap mock and a timeline URL are dated from today as well. A guard scans the integration and e2e trees for near-now absolute dates within reach of a window parameter, with an allowlist for files whose windows are fixed on both ends. --- e2e/timeline.spec.ts | 2 +- e2e/utils/mock-mood-insights.ts | 12 +- .../aging-fixture-dates-guard.test.ts | 203 ++++++++++++++++++ .../google-health-sync-progress.test.ts | 4 +- .../integration/mood-prognosis-ladder.test.ts | 18 +- .../sharing-manage-handler-matrix.test.ts | 7 +- .../source-priority-two-axis.test.ts | 23 +- tests/integration/timezone-per-user.test.ts | 9 +- 8 files changed, 263 insertions(+), 15 deletions(-) create mode 100644 src/__tests__/aging-fixture-dates-guard.test.ts diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 39b4d40fb..37a03977c 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -560,7 +560,7 @@ test("a chosen range lives in the URL: reload keeps it, Back returns to it", asy // A parameter it cannot read opens the same default as no zoom: this // record has entries in the last three months, so three months. - await page.goto("/timeline?zoom=range&from=nonsense&to=2026-01-01"); + await page.goto(`/timeline?zoom=range&from=nonsense&to=${TODAY}`); await expect(zoom("quarter")).toHaveAttribute("aria-checked", "true", { timeout: 20_000, }); diff --git a/e2e/utils/mock-mood-insights.ts b/e2e/utils/mock-mood-insights.ts index 36fe5c144..a85ceb457 100644 --- a/e2e/utils/mock-mood-insights.ts +++ b/e2e/utils/mock-mood-insights.ts @@ -23,13 +23,21 @@ function correlation(r: number, strength: string, n: number) { }; } +/** + * A calendar day this many days back, so the heatmap cells stay inside its + * 90-day window whenever the suite runs. + */ +function dayKeyDaysAgo(days: number): string { + return new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10); +} + const MOOD_INSIGHTS_PAYLOAD = { summary: { totalEntries: 269, inTargetPct: 62 }, heatmap: { windowDays: 90, cells: [ - { date: "2026-07-01", score: 4, samples: 2 }, - { date: "2026-07-02", score: 3, samples: 1 }, + { date: dayKeyDaysAgo(14), score: 4, samples: 2 }, + { date: dayKeyDaysAgo(13), score: 3, samples: 1 }, ], }, distribution: [], diff --git a/src/__tests__/aging-fixture-dates-guard.test.ts b/src/__tests__/aging-fixture-dates-guard.test.ts new file mode 100644 index 000000000..b4db923dd --- /dev/null +++ b/src/__tests__/aging-fixture-dates-guard.test.ts @@ -0,0 +1,203 @@ +/** + * Guard: an integration or e2e fixture dated near "now" must not feed a time + * window. + * + * A fixture written today with an absolute date (`new Date("2026-07-15…")`, + * `?date=2026-07-02`) next to a window parameter passes on the day it is + * written and fails months later, when the date slides out of a window the + * route counts back from the clock. The suite then goes red on a release + * branch that changed nothing near it, and the fix is archaeology. That has + * happened more than once (chart tabs, the 90-day share-link window). + * + * The rule: an absolute ISO date within the last 400 days (or in the future) + * that sits within two lines of a window parameter (`days=`, `from=`, `to=`, + * `since`, `until`, `date=`, `range=`, `rangeDays`, `windowDays`, + * `lookbackDays`) is a + * finding. Fix it by dating the fixture relative to now + * (`new Date(Date.now() - 3 * DAY_MS)`, a `daysAgo(n)` helper), or, when the + * window is fixed on both ends and the route never measures it against the + * clock, allowlist the file below with that reason and the number of findings + * it may carry, so a new one in the same file still fails. + * + * What it cannot see: a fixture date and a relative window that are further + * apart than two lines (a seed helper at the top of the file, `days=90` in a + * request at the bottom). It is a textual net for the common shape, not a + * proof; the clock-shifted run described in the allowlist reasons is what + * proved the allowlisted files fixed. + */ +import fs from "node:fs"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +const ROOT = path.resolve(__dirname, "..", ".."); +const SCAN_ROOTS = ["tests/integration", "e2e"]; +const DAY_MS = 86_400_000; +/** How far back an absolute date still counts as "near now". */ +const HORIZON_DAYS = 400; +/** Lines either side of a date searched for a window parameter. */ +const NEIGHBOURHOOD = 2; + +const ISO_DATE = /\b(20\d\d)-(\d\d)-(\d\d)/g; +const WINDOW_PARAM = + /[?&](?:days|from|to|since|until|date|range|rangeDays|windowDays)=|\b(?:since|until|rangeDays|windowDays|lookbackDays)\b\s*[:=]/; + +/** + * Files whose flagged dates feed windows fixed on both ends, which the routes + * never compare with the clock. `max` is the number of findings the file may + * carry; one more fails. + */ +const ALLOWLIST: Record = { + // Each entry passed with the test clock moved a year forward, which is what + // an aging fixture fails under; the reason says why it cannot age. + "tests/integration/day-view.test.ts": { + max: 21, + reason: + "day index, timeline and notable reads are asked for explicit from/to windows; the reminder since/until params are fixture data, not a window counted from now", + }, + "tests/integration/sharing-delegable-routes.test.ts": { + max: 5, + reason: + "day-log reads name one explicit calendar day (?date=), never a window counted from now", + }, + "tests/integration/measurements-aggregate-daily.test.ts": { + max: 5, + reason: "every series read passes both from and to", + }, + "tests/integration/cycle-routes.test.ts": { + max: 5, + reason: + "day-log reads name one explicit day and the calendar read passes both from and to", + }, + "tests/integration/medication-auto-mark-taken.test.ts": { + max: 2, + reason: "the cases pin the clock with fake timers around the fixed dates", + }, + "tests/integration/symptoms.test.ts": { + max: 1, + reason: + "the discovery matrix reads from a fixed since forward; the fixture days stay inside it as now moves on", + }, +}; + +export interface AgingFinding { + file: string; + line: number; + text: string; +} + +/** Every near-now absolute date within reach of a window parameter. */ +export function findAgingFixtureDates( + files: ReadonlyArray<{ file: string; source: string }>, + now: number, +): AgingFinding[] { + const floor = now - HORIZON_DAYS * DAY_MS; + const findings: AgingFinding[] = []; + for (const { file, source } of files) { + const lines = source.split("\n"); + lines.forEach((text, index) => { + for (const match of text.matchAll(ISO_DATE)) { + const at = Date.UTC( + Number(match[1]), + Number(match[2]) - 1, + Number(match[3]), + ); + if (Number.isNaN(at) || at < floor) continue; + const around = lines + .slice(Math.max(0, index - NEIGHBOURHOOD), index + NEIGHBOURHOOD + 1) + .join("\n"); + if (WINDOW_PARAM.test(around)) { + findings.push({ file, line: index + 1, text: text.trim() }); + break; + } + } + }); + } + return findings; +} + +function walk(dir: string): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return walk(full); + return /\.(ts|tsx|mts)$/.test(entry.name) ? [full] : []; + }); +} + +function scannedFiles() { + return SCAN_ROOTS.flatMap((root) => walk(path.join(ROOT, root))).map( + (full) => ({ + file: path.relative(ROOT, full), + source: fs.readFileSync(full, "utf8"), + }), + ); +} + +describe("aging fixture dates", () => { + const files = scannedFiles(); + + it("scans the integration and e2e trees (never vacuously)", () => { + expect(files.length).toBeGreaterThan(300); + expect(files.some((f) => f.file.startsWith("e2e/"))).toBe(true); + }); + + it("no near-now absolute date feeds a window outside the allowlist", () => { + const findings = findAgingFixtureDates(files, Date.now()); + const byFile = new Map(); + for (const finding of findings) { + const list = byFile.get(finding.file) ?? []; + list.push(finding); + byFile.set(finding.file, list); + } + const offenders: string[] = []; + for (const [file, list] of byFile) { + const allowed = ALLOWLIST[file]?.max ?? 0; + if (list.length > allowed) { + offenders.push( + ...list.map( + (f) => + `${f.file}:${f.line} ${f.text}\n -> date it relative to now (Date.now() - n * DAY_MS), or allowlist a fixed window with its reason`, + ), + ); + } + } + expect(offenders, offenders.join("\n")).toEqual([]); + }); + + it("every allowlist entry still names a file with findings", () => { + // A stale entry would quietly allow the next finding in that file. + const findings = findAgingFixtureDates(files, Date.now()); + for (const file of Object.keys(ALLOWLIST)) { + expect( + findings.some((f) => f.file === file), + `${file} has no findings left; drop its allowlist entry`, + ).toBe(true); + } + }); + + it("flags a near-now fixture next to a window, and nothing older", () => { + const now = Date.UTC(2026, 9, 15); + const findings = findAgingFixtureDates( + [ + { + file: "a.test.ts", + source: [ + 'const takenAt = new Date("2026-07-15T08:00:00Z");', + 'await get("/api/series?kind=pulse&days=90");', + ].join("\n"), + }, + { file: "b.test.ts", source: 'get("/api/day-logs?date=2026-07-02");' }, + { + file: "c.test.ts", + source: 'get("/api/labs?from=2019-04-01T00:00:00Z&to=2019-05-01");', + }, + { file: "d.test.ts", source: 'const at = new Date("2026-07-15");' }, + ], + now, + ); + expect(findings.map((f) => `${f.file}:${f.line}`)).toEqual([ + "a.test.ts:1", + "b.test.ts:1", + ]); + }); +}); diff --git a/tests/integration/google-health-sync-progress.test.ts b/tests/integration/google-health-sync-progress.test.ts index 0a80f416c..45f857cd6 100644 --- a/tests/integration/google-health-sync-progress.test.ts +++ b/tests/integration/google-health-sync-progress.test.ts @@ -42,7 +42,9 @@ async function seedConnection(userId: string): Promise { googleUserId: `google-${userId}`, accessToken: "encrypted-access", refreshToken: "encrypted-refresh", - tokenExpiresAt: new Date("2027-01-01T00:00:00.000Z"), + // Valid for a year from whenever the suite runs: a fixed date would + // expire and send the sync down the refresh path. + tokenExpiresAt: new Date(Date.now() + 365 * 86_400_000), }, }); } diff --git a/tests/integration/mood-prognosis-ladder.test.ts b/tests/integration/mood-prognosis-ladder.test.ts index 888e69581..7176734b4 100644 --- a/tests/integration/mood-prognosis-ladder.test.ts +++ b/tests/integration/mood-prognosis-ladder.test.ts @@ -15,7 +15,7 @@ * tooltip. */ import { NextRequest } from "next/server"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar } from "./mock-next-headers"; import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup"; @@ -46,7 +46,14 @@ vi.mock("@/lib/db-compat", () => ({ })); /** Fixed clock: a ladder test that slides with the calendar is a date bomb. */ -const NOW = new Date("2026-08-08T09:00:00.000Z"); +const FIXED_NOW = new Date("2026-08-08T09:00:00.000Z"); +/** + * The instant the seed and the job are dated against. The ladder cases keep + * the fixed clock; the route cases move it to the real one, because the route + * reads the forecast against the wall clock and a forecast seeded at a fixed + * date goes stale under it as the calendar moves on. + */ +let NOW = FIXED_NOW; function dayKey(daysAgo: number): string { return new Date(NOW.getTime() - daysAgo * DAY_MS).toISOString().slice(0, 10); @@ -271,6 +278,13 @@ describe("the minimum-n ladder, end to end", () => { }); describe("GET /api/mood/prognosis", () => { + beforeEach(() => { + NOW = new Date(); + }); + afterEach(() => { + NOW = FIXED_NOW; + }); + /** * A signed-in browser session. * diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts index 34583f305..6b7d83fce 100644 --- a/tests/integration/sharing-manage-handler-matrix.test.ts +++ b/tests/integration/sharing-manage-handler-matrix.test.ts @@ -795,7 +795,12 @@ manageContract("POST /api/measurement-reminders/[id]/snooze", { POST as Handler, "POST", `/api/measurement-reminders/${row.id}/snooze`, - { until: "2027-01-15" }, + // A snooze must end in the future; a fixed date stops being one. + { + until: new Date(Date.now() + 90 * 86_400_000) + .toISOString() + .slice(0, 10), + }, { id: row.id }, ); }, diff --git a/tests/integration/source-priority-two-axis.test.ts b/tests/integration/source-priority-two-axis.test.ts index b90ef2bd7..4710e0740 100644 --- a/tests/integration/source-priority-two-axis.test.ts +++ b/tests/integration/source-priority-two-axis.test.ts @@ -34,6 +34,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; +/** + * The night the fixtures sleep, ten days back from whenever the suite runs. + * The analytics route summarises a window that ends now; a fixed date slides + * out of it as the calendar moves on. + */ +const SLEEP_DAY = new Date(Date.now() - 10 * 86_400_000) + .toISOString() + .slice(0, 10); + vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); return { @@ -130,7 +139,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 420, unit: "minutes", source: "WITHINGS", - measuredAt: new Date("2026-05-12T07:00:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T07:00:00.000Z`), }, }); @@ -162,7 +171,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 480, unit: "minutes", source: "APPLE_HEALTH", - measuredAt: new Date("2026-05-12T06:00:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T06:00:00.000Z`), externalId: "ah-sleep-1", }, }); @@ -174,7 +183,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 410, unit: "minutes", source: "WITHINGS", - measuredAt: new Date("2026-05-12T07:00:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T07:00:00.000Z`), }, }); @@ -200,7 +209,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 380, unit: "minutes", source: "APPLE_HEALTH", - measuredAt: new Date("2026-05-12T06:00:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T06:00:00.000Z`), externalId: "ah-sleep-watch", deviceType: "watch", }, @@ -212,7 +221,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 480, unit: "minutes", source: "APPLE_HEALTH", - measuredAt: new Date("2026-05-12T06:30:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T06:30:00.000Z`), externalId: "ah-sleep-phone", deviceType: "phone", }, @@ -240,7 +249,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 480, unit: "minutes", source: "APPLE_HEALTH", - measuredAt: new Date("2026-05-12T06:00:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T06:00:00.000Z`), externalId: "ah-sleep-watch", deviceType: "watch", }, @@ -252,7 +261,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", () value: 380, unit: "minutes", source: "APPLE_HEALTH", - measuredAt: new Date("2026-05-12T06:30:00.000Z"), + measuredAt: new Date(`${SLEEP_DAY}T06:30:00.000Z`), externalId: "ah-sleep-phone", deviceType: "phone", }, diff --git a/tests/integration/timezone-per-user.test.ts b/tests/integration/timezone-per-user.test.ts index 8a191e52a..218764d21 100644 --- a/tests/integration/timezone-per-user.test.ts +++ b/tests/integration/timezone-per-user.test.ts @@ -420,8 +420,15 @@ describe("per-user timezone — Pacific/Auckland end-to-end", () => { // 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. + // Three consecutive days ending a week back: the summary reads its + // streaks from a window that ends now, so fixed dates would slide out. + const firstDay = Date.UTC( + new Date().getUTCFullYear(), + new Date().getUTCMonth(), + new Date().getUTCDate() - 9, + ); for (let i = 0; i < 3; i++) { - const dt = new Date(`2026-05-${12 + i}T11:30:00.000Z`); + const dt = new Date(firstDay + i * 86_400_000 + 11.5 * 3_600_000); await prisma.measurement.create({ data: { userId: me.id,