chore(medications): delete unused research-mode-staleness module + add PR worker null-slot dup regression

`research-mode-staleness.ts` shipped in W19c-Safety as a sibling helper
for a future 90-day re-acknowledgment gate on the GLP-1 drug-level
chart. The wiring step never landed — the helper has zero production
importers (only its co-located test consumed it). The version-bump
re-prompt path in `/api/auth/me/research-mode/route.ts` already
covers the regulatory contract (a copy or EMA-EPAR ratchet forces
the dialog), so the calendar-staleness gate is intentionally deferred
to v1.4.26 and the unused module ships off-codebase rather than
sitting orphaned through the release.

Also pin the PR-detection worker's null-slot dedup contract against
a back-to-back invocation regression. The personal_records unique
index uses NULLS NOT DISTINCT on `(userId, metricType, metricSlot,
achievedAt)` — measurement-type PRs (slot = NULL) must coalesce
across consecutive worker invocations even when the candidate row
sits at the exact same achievedAt. A future refactor that drops the
NULLS-NOT-DISTINCT semantics would silently double-insert; this test
fails fast if that happens.

Fix-O / code-M5 + dead-H4 + senior-M3 + L2.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 22:43:53 +02:00
1 parent 2a65f2b179
commit 5f5b8dfb49
3 files changed
+56 -190

No files matched your search

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