fix(medication-compliance): tighten coverage probe to count rolled days vs expected

QA F-H-01 (v1.4.39): the previous "any row exists" probe would short-circuit
to the rollup path while only the first few days of a multi-medication
account had been backfilled, exposing zero-filled tiles for the un-rolled
days until the boot fold completed. The fix compares the count of DISTINCT
rolled days against the count of DISTINCT days that hold an intake event in
the trailing window via a single SQL aggregate; partial coverage now falls
through to the legacy aggregator until the fold catches up. Pinned with a
new unit test covering the partial-coverage case alongside the zero-events
and full-coverage cases.
This commit is contained in:
Marc-André Bombeck committed 2026-05-18 12:46:16 +02:00
1 parent bbcb09e879
commit 5e45791771
2 files changed
+81 -15

No files matched your search

@@ -312,8 +312,10 @@ describe("readMedicationCompliance", () => {
});
describe("hasMedicationComplianceCoverage", () => {
it("returns true when at least one row exists inside the window", async () => {
rollupFindFirst.mockResolvedValue({ day: "2026-05-17" });
it("returns true when rolled-day count meets the event-day count in window", async () => {
queryRaw.mockResolvedValue([
{ rolled_days: BigInt(7), event_days: BigInt(7) },
]);
await expect(
hasMedicationComplianceCoverage(
"user-1",
@@ -324,8 +326,40 @@ describe("hasMedicationComplianceCoverage", () => {
).resolves.toBe(true);
});
it("returns false when the window is empty", async () => {
rollupFindFirst.mockResolvedValue(null);
it("returns true when the user has zero events in window (trivially covered)", async () => {
// Zero events → the read path returns a trailing-window zero-fill
// from an empty rollup table; covered semantically.
queryRaw.mockResolvedValue([
{ rolled_days: BigInt(0), event_days: BigInt(0) },
]);
await expect(
hasMedicationComplianceCoverage("user-1", 7, "Europe/Berlin"),
).resolves.toBe(true);
});
it("returns false on partial coverage (boot backfill mid-fold)", async () => {
// QA F-H-01 (v1.4.39): the legacy "any row exists" probe would
// flip true here and serve zero-filled buckets for days N..days-1
// that the backfill hasn't reached yet. With the tightened probe
// partial coverage forces the route into the legacy aggregator
// until the fold completes.
queryRaw.mockResolvedValue([
{ rolled_days: BigInt(2), event_days: BigInt(7) },
]);
await expect(
hasMedicationComplianceCoverage(
"user-1",
7,
"Europe/Berlin",
new Date("2026-05-18T12:00:00.000Z"),
),
).resolves.toBe(false);
});
it("returns false on zero rollups when events exist (legacy account cold start)", async () => {
queryRaw.mockResolvedValue([
{ rolled_days: BigInt(0), event_days: BigInt(7) },
]);
await expect(
hasMedicationComplianceCoverage("user-1", 7, "Europe/Berlin"),
).resolves.toBe(false);
+43 -11
View File
@@ -363,12 +363,18 @@ export async function readMedicationCompliance(
}
/**
* Returns true when the user has at least one
* `medication_compliance_rollups` row inside the trailing `days`
* window. The route uses this as the coverage probe — when the
* trailing window is empty but the live `medication_intake_events`
* table has rows, the read path falls through to the legacy live
* aggregator + fires a boot backfill in the background.
* Returns true when the rollup tier covers every day inside the
* trailing `days` window that has at least one intake event. Coverage
* is full when the count of DISTINCT rolled days equals the count of
* DISTINCT days that hold a `medication_intake_events` row in window —
* partial coverage (boot backfill mid-fold on a multi-medication
* account) returns false so the route falls through to the legacy
* aggregator instead of serving zero-filled tiles for un-rolled days.
*
* v1.4.39 hotfix (QA F-H-01): the previous "any row exists" probe
* could short-circuit to the rollup path while only the first few days
* had been backfilled, exposing zero-filled tiles for the un-rolled
* days until the boot fold completed.
*/
export async function hasMedicationComplianceCoverage(
userId: string,
@@ -381,11 +387,37 @@ export async function hasMedicationComplianceCoverage(
new Date(now.getTime() - (days - 1) * 86_400_000),
safeTz,
);
const row = await prisma.medicationComplianceRollup.findFirst({
where: { userId, day: { gte: oldestKey } },
select: { day: true },
});
return row !== null;
const oldestStart = startOfDayUtcInTz(oldestKey, safeTz);
// Single SQL aggregate: count DISTINCT rolled days vs DISTINCT
// event-days in window. The event side is anchored on the same
// user-tz day-key (`to_char(... AT TIME ZONE $tz, 'YYYY-MM-DD')`) so
// the comparison stays apples-to-apples even on DST boundaries.
const result = await prisma.$queryRaw<
Array<{ rolled_days: bigint; event_days: bigint }>
>`
SELECT
(
SELECT COUNT(DISTINCT "day")::bigint
FROM "medication_compliance_rollups"
WHERE "user_id" = ${userId}
AND "day" >= ${oldestKey}
) AS rolled_days,
(
SELECT COUNT(DISTINCT to_char("scheduled_for" AT TIME ZONE ${safeTz}, 'YYYY-MM-DD'))::bigint
FROM "medication_intake_events"
WHERE "user_id" = ${userId}
AND "scheduled_for" >= ${oldestStart}
) AS event_days
`;
if (result.length === 0) return true;
const { rolled_days, event_days } = result[0];
const rolled = Number(rolled_days);
const events = Number(event_days);
// Zero events in window → trivially covered (the read path produces
// a zero-filled trailing window from the empty rollup table).
if (events === 0) return true;
return rolled >= events;
}
/**