mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user