feat(medications): 90-day research-mode acknowledgment staleness gate

This commit is contained in:
Marc-André Bombeck committed 2026-05-14 20:45:00 +02:00
1 parent 8502e7b11f
commit bfd129fc7f
2 files changed
+190

No files matched your search

@@ -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);
});
});
@@ -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;
}