diff --git a/src/lib/medications/__tests__/research-mode-staleness.test.ts b/src/lib/medications/__tests__/research-mode-staleness.test.ts deleted file mode 100644 index 424a107e3..000000000 --- a/src/lib/medications/__tests__/research-mode-staleness.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -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 deleted file mode 100644 index 088268309..000000000 --- a/src/lib/medications/research-mode-staleness.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * 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; -} diff --git a/src/lib/personal-records/__tests__/pr-detection-worker.test.ts b/src/lib/personal-records/__tests__/pr-detection-worker.test.ts index ce22475c5..a44098448 100644 --- a/src/lib/personal-records/__tests__/pr-detection-worker.test.ts +++ b/src/lib/personal-records/__tests__/pr-detection-worker.test.ts @@ -700,3 +700,59 @@ describe("compareToCurrentBest — pure helper", () => { ); }); }); + +describe("detectPersonalRecordsForUser — null-slot dup regression", () => { + // Regression for Fix-O / senior-M3 + L2: the personal_records unique + // index is `(userId, metricType, metricSlot, achievedAt)` with NULLS + // NOT DISTINCT. Measurement-type PRs (ACTIVITY_STEPS, RESTING_HEART_RATE, + // …) have `metricSlot = NULL`. Two back-to-back invocations on the + // exact same input — same userId, same metricType, null slot, same + // achievedAt — must coalesce to a single row. A future refactor that + // forgets to compare NULL slot values via the NULLS-NOT-DISTINCT + // semantics would silently double-insert; this test pins the + // contract so the regression surfaces immediately. + it("does not double-insert on back-to-back invocations with a NULL slot and the same achievedAt", async () => { + const samples: FakeMeasurementRow[] = []; + for (let i = 0; i < PR_DETECTION_WARMUP_THRESHOLD; i++) { + samples.push( + measurement("ACTIVITY_STEPS", 4000 + i * 250, i + 2, `seed-${i}`), + ); + } + // The candidate row that drives the PR write — pinned to a + // specific UTC instant so both invocations see the same achievedAt. + samples.push({ + id: "m-best", + type: "ACTIVITY_STEPS", + value: 19000, + unit: "x", + measuredAt: new Date(Date.UTC(2026, 4, 13, 8, 30, 0)), + source: "APPLE_HEALTH", + externalId: null, + userId: USER, + }); + + const state = { + measurements: samples, + workouts: [], + personalRecords: [] as FakePersonalRecordRow[], + }; + const prisma = makeFakePrisma(state); + + await detectPersonalRecordsForUser(USER, { prisma }); + const stepRowsAfterFirst = state.personalRecords.filter( + (r) => r.metricType === "ACTIVITY_STEPS" && r.metricSlot === null, + ); + expect(stepRowsAfterFirst).toHaveLength(1); + + // Second invocation — same state, no new measurement rows. The + // dedup index must coalesce; the row count must stay at 1. + await detectPersonalRecordsForUser(USER, { prisma }); + const stepRowsAfterSecond = state.personalRecords.filter( + (r) => r.metricType === "ACTIVITY_STEPS" && r.metricSlot === null, + ); + expect(stepRowsAfterSecond).toHaveLength(1); + expect(stepRowsAfterSecond[0].achievedAt.getTime()).toBe( + stepRowsAfterFirst[0].achievedAt.getTime(), + ); + }); +});