mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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.
This commit is contained in:
1 parent
73c4dda224
commit
7be05608f8
8 files changed
+263
-15
No files matched your search
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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<string, { max: number; reason: string }> = {
|
||||
// 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<string, AgingFinding[]>();
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,9 @@ async function seedConnection(userId: string): Promise<void> {
|
||||
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),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user