Files
HealthLog/tests/integration/intake-late-take-future-slot.test.ts
T
Marc-André Bombeck ad503c45c0 fix(medications): keep a late take off a future slot the card supplied
A twice-daily card advances its display-due to the evening slot once the
morning slot's catch-up window has lapsed, so a late-morning "Genommen"
tap posts `scheduledFor = <evening slot>` with `takenAt = now`. Band
attribution correctly refuses the take (it lands in no window), but the
intake routes' source-agnostic convergence probe then bound it to the
evening slot's pending REMINDER row — recording the morning dose as the
evening one, silently consuming a slot the user had not reached, and
jumping the dashboard "next intake" to the following day.

Gate the convergence probe with `mayConvergeOntoSuppliedSlot`: a taken
write may not converge onto a slot whose anchor is in the future relative
to the take (the same forward-grace invariant `resolveCanonicalSlotInstant`
already enforces). Off-window late takes now record standalone (ad-hoc),
leaving both real slots intact; skips and pending echoes still name their
slot deliberately. Applied to both the per-medication and bulk intake
routes.
2026-07-07 15:55:50 +02:00

337 lines
10 KiB
TypeScript

/**
* Regression: a late-morning take must not consume the evening slot.
*
* The medication card threads the DISPLAYED dose's slot onto the intake POST
* as `scheduledFor`. Once a morning slot's catch-up window has lapsed the card
* advances its display-due to the EVENING slot, so a "Genommen" tap for the
* (missed) morning dose posts `scheduledFor = 21:00` with `takenAt = now`
* (13:08). Band attribution correctly refuses the take (it lands in no
* window), but the route's source-agnostic convergence probe used to bind it
* to the 21:00 pending REMINDER row — recording the morning dose as the
* evening one, silently consuming a slot the user had not reached, and jumping
* the dashboard "next intake" to tomorrow.
*
* These tests drive the SAME shipped helpers the per-medication intake route
* runs for a taken write (`resolveSlotForWriteByBand` → the
* `mayConvergeOntoSuppliedSlot` guard → the convergence probe /
* `applyCanonicalSlotWrite` / standalone insert) against real Postgres for a
* twice-daily 09:00 / 21:00 medication, then assert the read model the
* dashboard composes (`computeDisplayDue` + the taken/scheduled tally) reflects
* the correct state.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import type { PrismaClient } from "@/generated/prisma/client";
import {
applyCanonicalSlotWrite,
mayConvergeOntoSuppliedSlot,
resolveSlotForWriteByBand,
} from "@/lib/medications/scheduling/slot-upsert";
import {
computeDisplayDue,
toResolvedSlotMark,
} from "@/lib/medications/scheduling/next-due";
import { localHmAsUtc } from "@/lib/tz/local-day";
const TEST_USER_ID = "user-late-take-future-slot";
const TZ = "Europe/Berlin";
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
// Fixed non-DST-boundary weekday so the slot instants are deterministic.
const DAY = new Date("2026-03-10T12:00:00.000Z");
const slot0900 = () => localHmAsUtc(DAY, TZ, 9, 0);
const slot2100 = () => localHmAsUtc(DAY, TZ, 21, 0);
/** The real recording moment: 13:08, i.e. 8 minutes past the 09:00 slot's
* +4h catch-up cutoff (09:00 on-time ±1h → 10:00, overdue tail → 13:00). */
const takenAt1308 = () => localHmAsUtc(DAY, TZ, 13, 8);
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
await prisma.user.create({
data: {
id: TEST_USER_ID,
username: "late-take-future-slot",
email: "late-take-future-slot@example.test",
timezone: TZ,
},
});
});
async function createTwiceDailyMed(): Promise<string> {
const prisma = getPrismaClient();
const med = await prisma.medication.create({
data: {
userId: TEST_USER_ID,
name: "Ramipril",
dose: "5mg",
active: true,
startsOn: new Date("2026-01-01T00:00:00.000Z"),
schedules: {
create: {
windowStart: "09:00",
windowEnd: "21:00",
timesOfDay: ["09:00", "21:00"],
daysOfWeek: null,
scheduleType: "SCHEDULED",
},
},
},
});
return med.id;
}
/** Mint the day's two pending REMINDER rows exactly as the projector would. */
async function projectPendingSlots(medId: string): Promise<void> {
const prisma = getPrismaClient();
await prisma.medicationIntakeEvent.createMany({
data: [
{
userId: TEST_USER_ID,
medicationId: medId,
scheduledFor: slot0900(),
takenAt: null,
skipped: false,
source: "REMINDER",
},
{
userId: TEST_USER_ID,
medicationId: medId,
scheduledFor: slot2100(),
takenAt: null,
skipped: false,
source: "REMINDER",
},
],
});
}
/**
* Replicate the per-medication intake route's taken-write branch for a card
* "Genommen" tap: band attribution on `takenAt`, then the ad-hoc fallback
* (the `mayConvergeOntoSuppliedSlot` guard gating the convergence probe, else
* a standalone insert). Uses the real shipped helpers so the guard under test
* is the one shipped in the route.
*/
async function recordViaCard(
prisma: PrismaClient,
medId: string,
suppliedSlot: Date,
takenAt: Date,
): Promise<void> {
const attribution = await resolveSlotForWriteByBand({
userId: TEST_USER_ID,
medicationId: medId,
userTz: TZ,
takenAt,
now: takenAt,
});
const canonicalSlot = attribution.slotInstant;
if (canonicalSlot) {
await applyCanonicalSlotWrite({
client: prisma,
userId: TEST_USER_ID,
medicationId: medId,
canonicalSlot,
takenAt,
skipped: false,
isExplicitTaken: true,
isExplicitSkip: false,
idempotencyKey: null,
createSource: "WEB",
attributionSource: "AUTO",
});
return;
}
const existingSlotRow = mayConvergeOntoSuppliedSlot({
skipped: false,
takenAt,
suppliedSlot,
})
? await prisma.medicationIntakeEvent.findFirst({
where: {
userId: TEST_USER_ID,
medicationId: medId,
scheduledFor: suppliedSlot,
deletedAt: null,
},
select: { id: true },
})
: null;
if (existingSlotRow) {
await applyCanonicalSlotWrite({
client: prisma,
userId: TEST_USER_ID,
medicationId: medId,
canonicalSlot: suppliedSlot,
takenAt,
skipped: false,
isExplicitTaken: true,
isExplicitSkip: false,
idempotencyKey: null,
createSource: "WEB",
attributionSource: "AUTO",
});
} else {
// Genuinely standalone (ad-hoc): anchor on the intake instant.
await prisma.medicationIntakeEvent.create({
data: {
userId: TEST_USER_ID,
medicationId: medId,
scheduledFor: takenAt,
takenAt,
skipped: false,
source: "WEB",
},
});
}
}
async function readDisplayDue(medId: string, now: Date): Promise<Date | null> {
const prisma = getPrismaClient();
const med = await prisma.medication.findUniqueOrThrow({
where: { id: medId },
include: { schedules: true },
});
// Mirror `buildMedsTodayBlock`: resolved slots = taken / skipped / autoMissed
// rows, each carrying its anchoring shape.
const resolvedEvents = await prisma.medicationIntakeEvent.findMany({
where: {
userId: TEST_USER_ID,
medicationId: medId,
deletedAt: null,
OR: [{ takenAt: { not: null } }, { skipped: true }, { autoMissed: true }],
},
select: { scheduledFor: true, takenAt: true },
});
const display = computeDisplayDue({
medication: {
id: med.id,
startsOn: med.startsOn,
endsOn: med.endsOn,
oneShot: med.oneShot,
createdAt: med.createdAt,
},
schedules: med.schedules,
now,
userTz: TZ,
lastIntakeAt: null,
resolvedSlots: resolvedEvents.map(toResolvedSlotMark),
});
return display?.at ?? null;
}
describe("late-morning take does not consume the evening slot", () => {
it("records the 13:08 take standalone; the 21:00 slot stays pending", async () => {
const prisma = getPrismaClient();
const medId = await createTwiceDailyMed();
await projectPendingSlots(medId);
// Card shows 21:00 (09:00 catch-up lapsed); the tap posts scheduledFor=21:00.
await recordViaCard(prisma, medId, slot2100(), takenAt1308());
const eveningRow = await prisma.medicationIntakeEvent.findFirstOrThrow({
where: {
userId: TEST_USER_ID,
medicationId: medId,
scheduledFor: slot2100(),
deletedAt: null,
},
});
// The evening slot must NOT have been consumed by the morning take.
expect(eveningRow.takenAt).toBeNull();
expect(eveningRow.skipped).toBe(false);
// Exactly one taken row, anchored ad-hoc on its own 13:08 instant.
const takenRows = await prisma.medicationIntakeEvent.findMany({
where: {
userId: TEST_USER_ID,
medicationId: medId,
deletedAt: null,
takenAt: { not: null },
},
});
expect(takenRows).toHaveLength(1);
expect(takenRows[0]!.takenAt!.getTime()).toBe(takenAt1308().getTime());
// Ad-hoc contract: scheduledFor === takenAt (not a slot anchor).
expect(takenRows[0]!.scheduledFor.getTime()).toBe(takenAt1308().getTime());
// The morning slot stays open too (nothing filled it).
const morningRow = await prisma.medicationIntakeEvent.findFirstOrThrow({
where: {
userId: TEST_USER_ID,
medicationId: medId,
scheduledFor: slot0900(),
deletedAt: null,
},
});
expect(morningRow.takenAt).toBeNull();
});
it("next intake stays 21:00 today — it does not jump to tomorrow", async () => {
const prisma = getPrismaClient();
const medId = await createTwiceDailyMed();
await projectPendingSlots(medId);
await recordViaCard(prisma, medId, slot2100(), takenAt1308());
const due = await readDisplayDue(medId, takenAt1308());
expect(due).not.toBeNull();
// The still-open evening slot today, NOT tomorrow's 09:00.
expect(due!.getTime()).toBe(slot2100().getTime());
});
it("the pre-fix convergence would have consumed 21:00 and jumped to tomorrow (mechanism)", async () => {
const prisma = getPrismaClient();
const medId = await createTwiceDailyMed();
await projectPendingSlots(medId);
// Reproduce the OLD behaviour explicitly: converge the 13:08 take onto the
// 21:00 pending row (what the ungated probe did).
await applyCanonicalSlotWrite({
client: prisma,
userId: TEST_USER_ID,
medicationId: medId,
canonicalSlot: slot2100(),
takenAt: takenAt1308(),
skipped: false,
isExplicitTaken: true,
isExplicitSkip: false,
idempotencyKey: null,
createSource: "WEB",
attributionSource: "AUTO",
});
const eveningRow = await prisma.medicationIntakeEvent.findFirstOrThrow({
where: {
userId: TEST_USER_ID,
medicationId: medId,
scheduledFor: slot2100(),
deletedAt: null,
},
});
// Confirms the bug shape: the evening slot carries the morning take.
expect(eveningRow.takenAt!.getTime()).toBe(takenAt1308().getTime());
// And the dashboard next-due jumps past today to tomorrow's 09:00.
const due = await readDisplayDue(medId, takenAt1308());
expect(due).not.toBeNull();
expect(due!.getTime()).toBeGreaterThan(slot2100().getTime());
const tomorrow0900 = localHmAsUtc(
new Date(DAY.getTime() + 24 * 60 * 60 * 1000),
TZ,
9,
0,
);
expect(due!.getTime()).toBe(tomorrow0900.getTime());
});
});