diff --git a/src/lib/medications/__tests__/research-mode-staleness.test.ts b/src/lib/medications/__tests__/research-mode-staleness.test.ts new file mode 100644 index 000000000..424a107e3 --- /dev/null +++ b/src/lib/medications/__tests__/research-mode-staleness.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; + +import { + RESEARCH_MODE_ACK_MAX_AGE_DAYS, + isAcknowledgmentStale, +} from "../research-mode-staleness"; + +/** + * v1.4.25 W19c-Safety — 90-day staleness gate unit tests. + * + * The helper is pure (no I/O, no Date.now()) so every test pins both + * `acknowledgedAt` and `asOf` explicitly. The threshold is exclusive: + * day 90 is still inside the window, day 91 falls out. + */ + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const ASOF = new Date("2026-05-14T12:00:00Z"); + +function daysAgo(n: number): Date { + return new Date(ASOF.getTime() - n * MS_PER_DAY); +} + +describe("RESEARCH_MODE_ACK_MAX_AGE_DAYS", () => { + it("defaults to 90 days", () => { + expect(RESEARCH_MODE_ACK_MAX_AGE_DAYS).toBe(90); + }); +}); + +describe("isAcknowledgmentStale — null and invalid inputs", () => { + it("null acknowledgment is stale", () => { + expect(isAcknowledgmentStale(null, ASOF)).toBe(true); + }); + + it("non-finite acknowledgedAt is stale", () => { + expect(isAcknowledgmentStale(new Date(NaN), ASOF)).toBe(true); + }); + + it("non-finite asOf is stale (defensive — caller should never pass NaN)", () => { + expect(isAcknowledgmentStale(daysAgo(1), new Date(NaN))).toBe(true); + }); +}); + +describe("isAcknowledgmentStale — within window", () => { + it("acknowledgment today is fresh", () => { + expect(isAcknowledgmentStale(ASOF, ASOF)).toBe(false); + }); + + it("acknowledgment 1 day ago is fresh", () => { + expect(isAcknowledgmentStale(daysAgo(1), ASOF)).toBe(false); + }); + + it("acknowledgment 30 days ago is fresh", () => { + expect(isAcknowledgmentStale(daysAgo(30), ASOF)).toBe(false); + }); + + it("acknowledgment 89 days ago is fresh", () => { + expect(isAcknowledgmentStale(daysAgo(89), ASOF)).toBe(false); + }); + + it("acknowledgment exactly 90 days ago is still fresh (threshold is exclusive)", () => { + expect(isAcknowledgmentStale(daysAgo(90), ASOF)).toBe(false); + }); +}); + +describe("isAcknowledgmentStale — outside window", () => { + it("acknowledgment 91 days ago is stale", () => { + expect(isAcknowledgmentStale(daysAgo(91), ASOF)).toBe(true); + }); + + it("acknowledgment 180 days ago is stale", () => { + expect(isAcknowledgmentStale(daysAgo(180), ASOF)).toBe(true); + }); + + it("acknowledgment 365 days ago is stale", () => { + expect(isAcknowledgmentStale(daysAgo(365), ASOF)).toBe(true); + }); +}); + +describe("isAcknowledgmentStale — custom maxAgeDays", () => { + it("respects a 30-day override", () => { + expect(isAcknowledgmentStale(daysAgo(29), ASOF, 30)).toBe(false); + expect(isAcknowledgmentStale(daysAgo(30), ASOF, 30)).toBe(false); + expect(isAcknowledgmentStale(daysAgo(31), ASOF, 30)).toBe(true); + }); + + it("respects a 365-day override", () => { + expect(isAcknowledgmentStale(daysAgo(180), ASOF, 365)).toBe(false); + expect(isAcknowledgmentStale(daysAgo(366), ASOF, 365)).toBe(true); + }); + + it("treats zero or negative maxAgeDays as stale (defensive)", () => { + expect(isAcknowledgmentStale(daysAgo(1), ASOF, 0)).toBe(true); + expect(isAcknowledgmentStale(daysAgo(1), ASOF, -10)).toBe(true); + }); + + it("treats non-finite maxAgeDays as stale (defensive)", () => { + expect(isAcknowledgmentStale(daysAgo(1), ASOF, NaN)).toBe(true); + expect(isAcknowledgmentStale(daysAgo(1), ASOF, Infinity)).toBe(true); + }); +}); + +describe("isAcknowledgmentStale — clock-skew defence", () => { + it("acknowledgment in the future relative to asOf is treated as fresh", () => { + const future = new Date(ASOF.getTime() + 5 * MS_PER_DAY); + expect(isAcknowledgmentStale(future, ASOF)).toBe(false); + }); + + it("acknowledgment one millisecond ahead is treated as fresh", () => { + const slightlyAhead = new Date(ASOF.getTime() + 1); + expect(isAcknowledgmentStale(slightlyAhead, ASOF)).toBe(false); + }); +}); diff --git a/src/lib/medications/research-mode-staleness.ts b/src/lib/medications/research-mode-staleness.ts new file mode 100644 index 000000000..088268309 --- /dev/null +++ b/src/lib/medications/research-mode-staleness.ts @@ -0,0 +1,78 @@ +/** + * v1.4.25 W19c-Safety — Research-mode acknowledgment staleness helper. + * + * W19c-Frontend ships a version-mismatch re-prompt that fires whenever + * `acknowledgedVersion !== currentDisclaimerVersion`. The version + * constant only ratchets when Marc bumps `RESEARCH_MODE_DISCLAIMER_VERSION` + * in `glp1-pk.ts` (copy change, new drug, new EMA EPAR version) — so + * a user who acknowledged the current disclaimer 89 days ago and 360 + * days ago looks identical to that gate. + * + * This helper layers a second gate: an acknowledgment older than 90 + * days is "stale" regardless of whether the version still matches. + * The chart can then re-prompt the user on a calendar boundary + * (annual-ish if the version never ratchets, sooner otherwise) so + * consent stays freshly given. + * + * Why a sibling module rather than an addition to `glp1-pk.ts`: + * `glp1-pk.ts` is the W19c-Backend pure-math surface; it owns the PK + * compute and the disclaimer-version constant, but adding a date-math + * helper to it would dilute its responsibility. This file is the + * thin staleness gate; importers compose it with `glp1-pk.ts` as + * needed. + * + * The helper is pure: it accepts the acknowledgment timestamp + the + * `asOf` date (caller-supplied, never `Date.now()` inside the + * function) and returns a boolean. No I/O, no Prisma, no fetch. + * + * Note: W19c-Frontend has shipped without this gate wired into the + * `DrugLevelChart` placeholder. Wiring it into the chart's gating + * decision tree is a v1.4.26 cleanup item — see the W19c-Safety + * phase report for the deferral rationale. + */ + +/** Default staleness threshold — 90 days from acknowledgment. */ +export const RESEARCH_MODE_ACK_MAX_AGE_DAYS = 90; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +/** + * Returns true when the supplied acknowledgment is older than + * `maxAgeDays` (default 90) relative to `asOf`. + * + * Behaviour: + * - `acknowledgedAt === null` => `true` (no acknowledgment is, by + * definition, stale). + * - `acknowledgedAt` is a `Date` that's exactly `maxAgeDays` old => + * `false` (the threshold is exclusive; day 90 is still inside the + * window, day 91 falls out). + * - `acknowledgedAt` in the FUTURE relative to `asOf` => `false` + * (treat as freshly given; defensive against clock skew or a + * stale `asOf` from caller). + * - Non-positive `maxAgeDays` => `true` whenever `acknowledgedAt` + * is non-null (callers should not pass zero or negative, but the + * helper degrades safely toward "force re-acknowledge"). + * + * @param acknowledgedAt — the `User.researchModeAcknowledgedAt` + * timestamp persisted by W19c-Backend. `null` when the user has + * never acknowledged the current disclaimer. + * @param asOf — the caller-supplied "now". Pass `new Date()` from + * call-sites; tests pin this for determinism. + * @param maxAgeDays — staleness threshold in days. Defaults to + * {@link RESEARCH_MODE_ACK_MAX_AGE_DAYS}. + */ +export function isAcknowledgmentStale( + acknowledgedAt: Date | null, + asOf: Date, + maxAgeDays: number = RESEARCH_MODE_ACK_MAX_AGE_DAYS, +): boolean { + if (acknowledgedAt === null) return true; + if (!Number.isFinite(acknowledgedAt.getTime())) return true; + if (!Number.isFinite(asOf.getTime())) return true; + // Future acknowledgment (clock skew) — treat as fresh. + if (acknowledgedAt.getTime() > asOf.getTime()) return false; + if (!Number.isFinite(maxAgeDays) || maxAgeDays <= 0) return true; + const ageMs = asOf.getTime() - acknowledgedAt.getTime(); + const thresholdMs = maxAgeDays * MS_PER_DAY; + return ageMs > thresholdMs; +}