test(medications): cover the auto-take pass

This commit is contained in:
Marc-André Bombeck committed 2026-10-11 13:52:22 +02:00
1 parent 4386ac820e
commit 5affa9142c
1 file changed
+713
@@ -0,0 +1,713 @@
/**
* v1.42.2 — the auto-mark-taken pass.
*
* Pins:
* * which slots are due, across cadences (daily, several times a day,
* legacy weekdays, every-other-week RRULE, rolling, cyclic), honouring
* start / end dates and pause eras, and across the Europe/Berlin DST
* changes (the wall-clock time stays, the UTC instant moves);
* * the floor: never a slot before the option was switched on, before the
* medication existed, or beyond the catch-up window;
* * the precedence on one slot: a take / skip / auto-miss / removal by the
* person always wins, a take logged near the slot claims it, a pending
* placeholder is converted in place, otherwise a row is created;
* * the pass end to end over a fake client: one row per slot, a second
* pass records nothing, a hand-skipped slot is left alone, the follow-up
* hooks run once per recorded dose.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/medications/inventory/consumption", () => ({
consumeForIntake: vi.fn(async () => undefined),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({
recomputeMedicationComplianceForEvent: vi.fn(async () => undefined),
dayKeyForScheduledFor: (at: Date, tz: string) =>
at.toLocaleDateString("sv-SE", { timeZone: tz }),
}));
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
queueMedicationIntakeSync: vi.fn(),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/medications/lifecycle", () => ({
reconcileOneShotState: vi.fn(async () => "noop"),
}));
import { consumeForIntake } from "@/lib/medications/inventory/consumption";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import {
INTAKE_AUTO_TAKE_CRON,
INTAKE_AUTO_TAKE_LOOKBACK_MS,
INTAKE_AUTO_TAKE_QUEUE,
autoTakeFloor,
decideSlot,
dueAutoTakeSlots,
isInPauseEra,
runIntakeAutoTakePass,
} from "../intake-auto-take";
import { autoTakeOwnedFrom } from "../intake-auto-skip";
const TZ = "Europe/Berlin";
const DAY = 24 * 60 * 60 * 1000;
interface ScheduleInput {
id?: string;
timesOfDay?: string[];
rrule?: string | null;
daysOfWeek?: string | null;
rollingIntervalDays?: number | null;
scheduleType?: "SCHEDULED" | "PRN" | "CYCLIC";
cyclicOnWeeks?: number | null;
cyclicOffWeeks?: number | null;
}
function schedule(input: ScheduleInput = {}) {
const times = input.timesOfDay ?? ["19:00"];
return {
id: input.id ?? "s1",
windowStart: times[0],
windowEnd: times[0],
daysOfWeek: input.daysOfWeek ?? null,
timesOfDay: times,
reminderGraceMinutes: null,
rrule:
input.rrule === undefined
? input.rollingIntervalDays
? null
: "FREQ=DAILY"
: input.rrule,
rollingIntervalDays: input.rollingIntervalDays ?? null,
scheduleType: input.scheduleType ?? "SCHEDULED",
cyclicOnWeeks: input.cyclicOnWeeks ?? null,
cyclicOffWeeks: input.cyclicOffWeeks ?? null,
doseWindows: null,
};
}
function medication(
overrides: Partial<{
id: string;
userId: string;
startsOn: Date | null;
endsOn: Date | null;
oneShot: boolean;
createdAt: Date;
autoMarkTakenSince: Date | null;
schedules: ReturnType<typeof schedule>[];
pauseEras: { pausedAt: Date; resumedAt: Date | null }[];
}> = {},
) {
return {
id: overrides.id ?? "m1",
userId: overrides.userId ?? "u1",
startsOn: overrides.startsOn ?? null,
endsOn: overrides.endsOn ?? null,
oneShot: overrides.oneShot ?? false,
createdAt: overrides.createdAt ?? new Date("2026-01-01T00:00:00Z"),
autoMarkTakenSince:
overrides.autoMarkTakenSince === undefined
? new Date("2026-01-01T00:00:00Z")
: overrides.autoMarkTakenSince,
schedules: overrides.schedules ?? [schedule()],
scheduleRevisions: [],
pauseEras: overrides.pauseEras ?? [],
user: { timezone: TZ },
};
}
function slotsIso(
med: ReturnType<typeof medication>,
from: string,
to: string,
lastIntakeAt: Date | null = null,
): string[] {
return dueAutoTakeSlots({
medication: med,
userTz: TZ,
floor: new Date(from),
now: new Date(to),
lastIntakeAt,
}).map((o) => o.at.toISOString());
}
describe("intake-auto-take — contract", () => {
it("runs on its own queue, every 15 minutes off the other passes", () => {
expect(INTAKE_AUTO_TAKE_QUEUE).toBe("intake-auto-take");
expect(INTAKE_AUTO_TAKE_CRON).toBe("10,25,40,55 * * * *");
});
});
describe("dueAutoTakeSlots — cadences", () => {
it("daily 19:00: one slot per evening whose time has passed", () => {
// 2026-06-10 is CEST (UTC+2): 19:00 local = 17:00Z.
expect(
slotsIso(medication(), "2026-06-08T00:00:00Z", "2026-06-10T16:59:00Z"),
).toEqual(["2026-06-08T17:00:00.000Z", "2026-06-09T17:00:00.000Z"]);
expect(
slotsIso(medication(), "2026-06-08T00:00:00Z", "2026-06-10T17:00:00Z"),
).toContain("2026-06-10T17:00:00.000Z");
});
it("several times a day: each time is its own slot", () => {
const med = medication({
schedules: [schedule({ timesOfDay: ["08:00", "13:00", "19:00"] })],
});
expect(
slotsIso(med, "2026-06-10T00:00:00Z", "2026-06-10T12:00:00Z"),
).toEqual(["2026-06-10T06:00:00.000Z", "2026-06-10T11:00:00.000Z"]);
});
it("legacy weekdays (Mon/Wed/Fri) skip the other days", () => {
const med = medication({
schedules: [schedule({ rrule: null, daysOfWeek: "1,3,5" })],
});
// Mon 2026-06-08 .. Sun 2026-06-14.
expect(
slotsIso(med, "2026-06-08T00:00:00Z", "2026-06-14T23:00:00Z"),
).toEqual([
"2026-06-08T17:00:00.000Z",
"2026-06-10T17:00:00.000Z",
"2026-06-12T17:00:00.000Z",
]);
});
it("every other week (RRULE INTERVAL=2) honours the stride", () => {
const med = medication({
startsOn: new Date("2026-06-01T00:00:00Z"),
schedules: [schedule({ rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO" })],
});
expect(
slotsIso(med, "2026-06-01T00:00:00Z", "2026-06-30T00:00:00Z"),
).toEqual([
"2026-06-01T17:00:00.000Z",
"2026-06-15T17:00:00.000Z",
"2026-06-29T17:00:00.000Z",
]);
});
it("cyclic 1 week on / 1 week off drops the off week", () => {
const med = medication({
startsOn: new Date("2026-06-01T00:00:00Z"),
schedules: [
schedule({
scheduleType: "CYCLIC",
cyclicOnWeeks: 1,
cyclicOffWeeks: 1,
}),
],
});
const slots = slotsIso(med, "2026-06-01T00:00:00Z", "2026-06-15T00:00:00Z");
expect(slots).toHaveLength(7);
expect(slots[6]).toBe("2026-06-07T17:00:00.000Z");
});
it("rolling every 7 days counts from the last taken dose", () => {
const med = medication({
schedules: [schedule({ rollingIntervalDays: 7, timesOfDay: ["09:00"] })],
});
const last = new Date("2026-06-01T07:00:00Z"); // 09:00 CEST
expect(
slotsIso(med, "2026-06-02T00:00:00Z", "2026-06-10T00:00:00Z", last),
).toEqual(["2026-06-08T07:00:00.000Z"]);
// Not due yet → nothing.
expect(
slotsIso(med, "2026-06-02T00:00:00Z", "2026-06-08T06:59:00Z", last),
).toEqual([]);
});
it("start and end dates bound the slots", () => {
const med = medication({
startsOn: new Date("2026-06-09T00:00:00Z"),
endsOn: new Date("2026-06-10T00:00:00Z"),
});
expect(
slotsIso(med, "2026-06-01T00:00:00Z", "2026-06-20T00:00:00Z"),
).toEqual(["2026-06-09T17:00:00.000Z", "2026-06-10T17:00:00.000Z"]);
});
it("a PRN schedule has no slot to record", () => {
const med = medication({
schedules: [schedule({ scheduleType: "PRN", rrule: null })],
});
expect(
slotsIso(med, "2026-06-01T00:00:00Z", "2026-06-20T00:00:00Z"),
).toEqual([]);
});
it("a slot inside a pause era is dropped, an open era runs to now", () => {
const med = medication({
pauseEras: [
{
pausedAt: new Date("2026-06-09T00:00:00Z"),
resumedAt: new Date("2026-06-10T00:00:00Z"),
},
],
});
expect(
slotsIso(med, "2026-06-08T00:00:00Z", "2026-06-10T23:00:00Z"),
).toEqual(["2026-06-08T17:00:00.000Z", "2026-06-10T17:00:00.000Z"]);
const now = new Date("2026-06-12T00:00:00Z");
expect(
isInPauseEra(
new Date("2026-06-11T17:00:00Z"),
[{ pausedAt: new Date("2026-06-11T00:00:00Z"), resumedAt: null }],
now,
),
).toBe(true);
});
});
describe("dueAutoTakeSlots — DST in Europe/Berlin", () => {
it("autumn change: 19:00 stays 19:00 local, the UTC instant moves an hour", () => {
// Clocks go back on Sun 2026-10-25 at 03:00 CEST.
expect(
slotsIso(medication(), "2026-10-24T00:00:00Z", "2026-10-26T23:00:00Z"),
).toEqual([
"2026-10-24T17:00:00.000Z",
"2026-10-25T18:00:00.000Z",
"2026-10-26T18:00:00.000Z",
]);
});
it("spring change: 19:00 stays 19:00 local across the short night", () => {
// Clocks go forward on Sun 2026-03-29 at 02:00 CET.
expect(
slotsIso(medication(), "2026-03-28T00:00:00Z", "2026-03-30T23:00:00Z"),
).toEqual([
"2026-03-28T18:00:00.000Z",
"2026-03-29T17:00:00.000Z",
"2026-03-30T17:00:00.000Z",
]);
});
it("the changeover night yields exactly one slot for an early-morning dose", () => {
const med = medication({
schedules: [schedule({ timesOfDay: ["02:30"] })],
});
const autumn = slotsIso(
med,
"2026-10-25T00:00:00Z",
"2026-10-25T12:00:00Z",
);
expect(autumn).toHaveLength(1);
const spring = slotsIso(
med,
"2026-03-29T00:00:00Z",
"2026-03-29T12:00:00Z",
);
expect(spring).toHaveLength(1);
});
});
describe("autoTakeFloor — never before the option was switched on", () => {
it("takes the latest of switch-on, creation and the catch-up window", () => {
const now = new Date("2026-06-20T12:00:00Z");
const since = new Date("2026-06-20T17:30:00Z");
expect(
autoTakeFloor({
autoMarkTakenSince: since,
createdAt: new Date("2026-01-01T00:00:00Z"),
now,
}),
).toEqual(since);
expect(
autoTakeFloor({
autoMarkTakenSince: new Date("2026-01-01T00:00:00Z"),
createdAt: new Date("2026-02-01T00:00:00Z"),
now,
}).getTime(),
).toBe(now.getTime() - INTAKE_AUTO_TAKE_LOOKBACK_MS);
});
it("switched on at 19:30 leaves that evening's 19:00 slot alone", () => {
const med = medication({
autoMarkTakenSince: new Date("2026-06-10T17:30:00Z"),
});
const now = new Date("2026-06-11T18:00:00Z");
const floor = autoTakeFloor({
autoMarkTakenSince: med.autoMarkTakenSince!,
createdAt: med.createdAt,
now,
});
expect(
dueAutoTakeSlots({
medication: med,
userTz: TZ,
floor,
now,
lastIntakeAt: null,
}).map((o) => o.at.toISOString()),
).toEqual(["2026-06-11T17:00:00.000Z"]);
});
});
describe("decideSlot — the person's record always wins", () => {
const at = new Date("2026-06-10T17:00:00Z");
const base = {
scheduledFor: at,
takenAt: null as Date | null,
skipped: false,
autoMissed: false,
deletedAt: null as Date | null,
};
it("creates when nothing is on the slot", () => {
expect(decideSlot(at, [], null)).toEqual({ kind: "create" });
});
it("converts a pending placeholder in place", () => {
expect(decideSlot(at, [{ ...base, id: "p" }], null)).toEqual({
kind: "convert",
rowId: "p",
});
});
it("leaves a taken, skipped or auto-missed slot", () => {
for (const row of [
{ ...base, id: "t", takenAt: new Date("2026-06-10T17:20:00Z") },
{ ...base, id: "s", skipped: true },
{ ...base, id: "a", autoMissed: true },
]) {
expect(decideSlot(at, [row, { ...base, id: "p" }], null)).toEqual({
kind: "leave",
reason: "actioned",
});
}
});
it("leaves a slot whose only row the person removed", () => {
expect(
decideSlot(at, [{ ...base, id: "x", deletedAt: new Date() }], null),
).toEqual({ kind: "leave", reason: "removed" });
});
it("leaves a slot claimed by a take logged inside its band", () => {
const band = {
earlyStart: new Date("2026-06-10T15:00:00Z"),
overdueEnd: new Date("2026-06-10T21:00:00Z"),
};
const early = {
...base,
id: "e",
scheduledFor: new Date("2026-06-10T15:40:00Z"),
takenAt: new Date("2026-06-10T15:40:00Z"),
};
expect(decideSlot(at, [early], band)).toEqual({
kind: "leave",
reason: "taken_nearby",
});
// A take outside the band does not claim it.
expect(
decideSlot(
at,
[
{
...early,
scheduledFor: new Date("2026-06-10T07:00:00Z"),
takenAt: new Date("2026-06-10T07:00:00Z"),
},
],
band,
),
).toEqual({ kind: "create" });
});
});
describe("autoTakeOwnedFrom — the auto-miss pass yields the owned slots", () => {
const now = Date.parse("2026-06-20T12:00:00Z");
it("owns slots from switch-on, inside the catch-up window", () => {
expect(
autoTakeOwnedFrom(
{
active: true,
pausedAt: null,
autoMarkTaken: true,
autoMarkTakenSince: new Date("2026-06-19T00:00:00Z"),
},
now,
),
).toEqual(new Date("2026-06-19T00:00:00Z"));
expect(
autoTakeOwnedFrom(
{
active: true,
pausedAt: null,
autoMarkTaken: true,
autoMarkTakenSince: new Date("2026-01-01T00:00:00Z"),
},
now,
)?.getTime(),
).toBe(now - INTAKE_AUTO_TAKE_LOOKBACK_MS);
});
it("owns nothing while off or paused", () => {
const since = new Date("2026-06-19T00:00:00Z");
expect(
autoTakeOwnedFrom(
{
active: true,
pausedAt: null,
autoMarkTaken: false,
autoMarkTakenSince: since,
},
now,
),
).toBeNull();
expect(
autoTakeOwnedFrom(
{
active: true,
pausedAt: new Date(),
autoMarkTaken: true,
autoMarkTakenSince: since,
},
now,
),
).toBeNull();
});
});
// ── the pass over a fake client ───────────────────────────────────────
interface FakeRow {
id: string;
userId: string;
medicationId: string;
scheduledFor: Date;
takenAt: Date | null;
skipped: boolean;
autoMissed: boolean;
autoRecorded: boolean;
deletedAt: Date | null;
source: string;
syncVersion: number;
}
function fakeClient(
meds: ReturnType<typeof medication>[],
rows: FakeRow[],
) {
let seq = 0;
const inRange = (d: Date | null, r?: { gte?: Date; lte?: Date }) =>
!!d &&
(!r?.gte || d.getTime() >= r.gte.getTime()) &&
(!r?.lte || d.getTime() <= r.lte.getTime());
const client = {
medication: {
findMany: vi.fn(async () => meds),
},
medicationIntakeEvent: {
findMany: vi.fn(
async (args: {
where: {
medicationId: string;
OR: [
{ scheduledFor: { gte: Date; lte: Date } },
{ takenAt: { gte: Date; lte: Date } },
];
};
}) =>
rows.filter(
(r) =>
r.medicationId === args.where.medicationId &&
(inRange(r.scheduledFor, args.where.OR[0].scheduledFor) ||
inRange(r.takenAt, args.where.OR[1].takenAt)),
),
),
findFirst: vi.fn(async (args: { where: { medicationId: string } }) => {
const taken = rows
.filter(
(r) =>
r.medicationId === args.where.medicationId &&
r.deletedAt === null &&
r.takenAt !== null,
)
.sort((a, b) => b.takenAt!.getTime() - a.takenAt!.getTime());
return taken[0] ? { takenAt: taken[0].takenAt } : null;
}),
updateMany: vi.fn(
async (args: {
where: { id: string };
data: { takenAt: Date; autoRecorded: boolean };
}) => {
const row = rows.find(
(r) =>
r.id === args.where.id &&
r.deletedAt === null &&
r.takenAt === null &&
!r.skipped &&
!r.autoMissed,
);
if (!row) return { count: 0 };
row.takenAt = args.data.takenAt;
row.autoRecorded = args.data.autoRecorded;
row.syncVersion += 1;
return { count: 1 };
},
),
create: vi.fn(
async (args: {
data: Omit<FakeRow, "id" | "deletedAt" | "syncVersion" | "autoMissed">;
}) => {
const row: FakeRow = {
id: `new-${++seq}`,
autoMissed: false,
deletedAt: null,
syncVersion: 0,
...args.data,
};
rows.push(row);
return { id: row.id };
},
),
},
medicationInventoryItem: {},
$transaction: vi.fn(),
};
return client as unknown as Parameters<typeof runIntakeAutoTakePass>[0];
}
function row(partial: Partial<FakeRow> & { id: string; scheduledFor: Date }) {
return {
userId: "u1",
medicationId: "m1",
takenAt: null,
skipped: false,
autoMissed: false,
autoRecorded: false,
deletedAt: null,
source: "REMINDER",
syncVersion: 0,
...partial,
};
}
describe("runIntakeAutoTakePass — over a fake client", () => {
beforeEach(() => {
vi.mocked(consumeForIntake).mockClear();
vi.mocked(recomputeMedicationComplianceForEvent).mockClear();
vi.mocked(queueMedicationIntakeSync).mockClear();
});
it("records each due slot once, converts the placeholder, leaves the skip", async () => {
const med = medication({
autoMarkTakenSince: new Date("2026-06-08T10:00:00Z"),
});
const rows: FakeRow[] = [
// Mon 19:00: a pending placeholder the projector minted.
row({ id: "p1", scheduledFor: new Date("2026-06-08T17:00:00Z") }),
// Tue 19:00: skipped by hand.
row({
id: "s1",
scheduledFor: new Date("2026-06-09T17:00:00Z"),
skipped: true,
source: "WEB",
}),
];
const client = fakeClient([med], rows);
const now = Date.parse("2026-06-10T17:20:00Z"); // Wed, after 19:00
const first = await runIntakeAutoTakePass(client, { nowMs: now });
expect(first.recordedCount).toBe(2);
expect(first.convertedCount).toBe(1);
const live = rows.filter((r) => r.deletedAt === null);
const bySlot = new Map<string, FakeRow[]>();
for (const r of live) {
const key = r.scheduledFor.toISOString();
bySlot.set(key, [...(bySlot.get(key) ?? []), r]);
}
expect(bySlot.get("2026-06-08T17:00:00.000Z")).toHaveLength(1);
expect(bySlot.get("2026-06-08T17:00:00.000Z")![0]).toMatchObject({
id: "p1",
autoRecorded: true,
takenAt: new Date("2026-06-08T17:00:00Z"),
});
expect(bySlot.get("2026-06-09T17:00:00.000Z")![0]).toMatchObject({
id: "s1",
skipped: true,
takenAt: null,
autoRecorded: false,
});
expect(bySlot.get("2026-06-10T17:00:00.000Z")).toHaveLength(1);
expect(bySlot.get("2026-06-10T17:00:00.000Z")![0]).toMatchObject({
autoRecorded: true,
source: "REMINDER",
});
// Follow-ups once per recorded dose; one wake per user.
expect(consumeForIntake).toHaveBeenCalledTimes(2);
expect(queueMedicationIntakeSync).toHaveBeenCalledTimes(1);
// Idempotent: a second pass records nothing and writes nothing.
vi.mocked(consumeForIntake).mockClear();
const second = await runIntakeAutoTakePass(client, { nowMs: now + 60_000 });
expect(second.recordedCount).toBe(0);
expect(consumeForIntake).not.toHaveBeenCalled();
expect(rows).toHaveLength(3);
});
it("does not touch history before the option was switched on", async () => {
const med = medication({
autoMarkTakenSince: new Date("2026-06-10T17:30:00Z"),
});
const rows: FakeRow[] = [];
const client = fakeClient([med], rows);
const result = await runIntakeAutoTakePass(client, {
nowMs: Date.parse("2026-06-10T22:00:00Z"),
});
expect(result.recordedCount).toBe(0);
expect(rows).toEqual([]);
});
it("does not record a slot whose dose the person logged early", async () => {
const med = medication({
autoMarkTakenSince: new Date("2026-06-10T00:00:00Z"),
});
const rows: FakeRow[] = [
// Taken by hand at 18:30 local, bound to the 19:00 slot.
row({
id: "t1",
scheduledFor: new Date("2026-06-10T17:00:00Z"),
takenAt: new Date("2026-06-10T16:30:00Z"),
source: "WEB",
}),
];
const client = fakeClient([med], rows);
const result = await runIntakeAutoTakePass(client, {
nowMs: Date.parse("2026-06-10T18:00:00Z"),
});
expect(result.recordedCount).toBe(0);
expect(rows).toHaveLength(1);
});
it("a rolling cadence catches up one interval after another", async () => {
const med = medication({
autoMarkTakenSince: new Date("2026-06-01T00:00:00Z"),
schedules: [schedule({ rollingIntervalDays: 2, timesOfDay: ["09:00"] })],
});
const rows: FakeRow[] = [
row({
id: "t0",
scheduledFor: new Date("2026-06-01T07:00:00Z"),
takenAt: new Date("2026-06-01T07:00:00Z"),
source: "WEB",
}),
];
const client = fakeClient([med], rows);
const result = await runIntakeAutoTakePass(client, {
nowMs: Date.parse("2026-06-06T08:00:00Z"),
});
// 06-03 and 06-05 at 09:00 CEST.
expect(result.recordedCount).toBe(2);
expect(
rows
.filter((r) => r.autoRecorded)
.map((r) => r.scheduledFor.toISOString())
.sort(),
).toEqual(["2026-06-03T07:00:00.000Z", "2026-06-05T07:00:00.000Z"]);
});
});