From 8488092d486670b47a93ad670c85ac1c8e756187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 31 Jul 2026 07:36:17 +0200 Subject: [PATCH] test(medications): prove a dose taken before midnight really stops reminding The overnight reminder tests handed the check every intake row the fixture held, whatever date range it had actually asked for. That left the suppression assertion unable to fail. Narrow the read back to the day the check happens to run in and the 23:45 dose logged at 23:50 drops straight out of it, yet the test stayed green. The fake now honours the scheduledFor range it is given, so the assertion answers the question a person really asks: does it stop pestering me about a dose I already took. Two cases were missing at the other end of the same boundary. A dose genuinely missed at 23:45 still has to escalate in the small hours, and the missed-dose row it leaves behind has to name the evening it belongs to rather than the morning the check runs in. Both are pinned now, next to the case where the dose was taken and no missed row may be written at all. --- .../medication-reminder-check.test.ts | 133 +++++++++++++++--- 1 file changed, 115 insertions(+), 18 deletions(-) diff --git a/src/lib/jobs/__tests__/medication-reminder-check.test.ts b/src/lib/jobs/__tests__/medication-reminder-check.test.ts index d64e7e1cc..2db333d96 100644 --- a/src/lib/jobs/__tests__/medication-reminder-check.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-check.test.ts @@ -9,6 +9,37 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +/** + * The intake rows the tick is allowed to see, before its own range filter. + * + * The tick reads intakes with a real `scheduledFor` range and suppresses a + * slot from what comes back. A fake that hands every row over regardless of + * `where` cannot tell a correct range from a broken one — the suppression + * assertion stays green either way, which is exactly how a dose logged at + * 23:50 could keep being reminded after midnight while this file passed. So + * the fake honours the range the tick actually asks for. + */ +let intakeEvents: Record[] = []; + +function setIntakeEvents(events: Record[]): void { + intakeEvents = events; +} + +function readIntakeEvents(args?: { + where?: { scheduledFor?: { gte?: Date; lte?: Date } }; +}): Record[] { + const range = args?.where?.scheduledFor; + return intakeEvents.filter((event) => { + const scheduledFor = event.scheduledFor; + if (!(scheduledFor instanceof Date)) return true; + if (range?.gte && scheduledFor.getTime() < range.gte.getTime()) + return false; + if (range?.lte && scheduledFor.getTime() > range.lte.getTime()) + return false; + return true; + }); +} + const prismaMock = { telegramScheduledDeletion: { findMany: vi.fn().mockResolvedValue([]), @@ -22,7 +53,9 @@ const prismaMock = { findMany: vi.fn().mockResolvedValue([]), }, medicationIntakeEvent: { - findMany: vi.fn().mockResolvedValue([]), + findMany: vi.fn(async (args?: Parameters[0]) => + readIntakeEvents(args), + ), findFirst: vi.fn().mockResolvedValue(null), create: vi.fn(), count: vi.fn().mockResolvedValue(0), @@ -125,7 +158,7 @@ beforeEach(() => { prismaMock.telegramPromptContext.deleteMany.mockResolvedValue({ count: 0 }); prismaMock.medication.updateMany.mockResolvedValue({ count: 0 }); prismaMock.medication.findMany.mockResolvedValue([]); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + setIntakeEvents([]); prismaMock.medicationIntakeEvent.findFirst.mockResolvedValue(null); prismaMock.medicationIntakeEvent.create.mockResolvedValue(undefined); }); @@ -172,7 +205,7 @@ describe("handleReminderCheck per-slot reminder windows", () => { timesOfDay: ["08:00", "18:00"], }), ] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + setIntakeEvents([]); await handleReminderCheck([]); @@ -197,7 +230,7 @@ describe("handleReminderCheck per-slot reminder windows", () => { doseWindows: [{ timeOfDay: "08:00", start: "07:30", end: "09:30" }], }), ] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + setIntakeEvents([]); await handleReminderCheck([]); @@ -218,7 +251,7 @@ describe("handleReminderCheck per-slot reminder windows", () => { prismaMock.medication.findMany.mockResolvedValue([ medicationWithSchedule({}), ] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T08:00:00.000Z"), takenAt: new Date("2026-07-28T08:45:00.000Z"), @@ -250,7 +283,7 @@ describe("handleReminderCheck medication-wide slot attribution", () => { }, ]; prismaMock.medication.findMany.mockResolvedValue([medication] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T09:00:00.000Z"), takenAt: new Date("2026-07-28T08:50:00.000Z"), @@ -285,7 +318,7 @@ describe("handleReminderCheck medication-wide slot attribution", () => { }, ]; prismaMock.medication.findMany.mockResolvedValue([medication] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T08:00:00.000Z"), takenAt: new Date("2026-07-28T08:45:00.000Z"), @@ -316,7 +349,7 @@ describe("handleReminderCheck medication-wide slot attribution", () => { prismaMock.medicationIntakeEvent.findFirst.mockResolvedValue({ takenAt: new Date("2026-07-21T08:00:00.000Z"), }); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T08:00:00.000Z"), takenAt: null, @@ -339,7 +372,7 @@ describe("handleReminderCheck medication-wide slot attribution", () => { timesOfDay: [], }), ] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T08:00:00.000Z"), takenAt: new Date("2026-07-28T08:10:00.000Z"), @@ -372,7 +405,7 @@ describe("handleReminderCheck medication-wide slot attribution", () => { }, ]; prismaMock.medication.findMany.mockResolvedValue([medication] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T08:00:00.000Z"), takenAt: null, @@ -396,7 +429,7 @@ describe("handleReminderCheck medication-wide slot attribution", () => { prismaMock.medication.findMany.mockResolvedValue([ medicationWithSchedule({}), ] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T08:00:00.000Z"), takenAt: new Date("2026-07-28T13:00:00.000Z"), @@ -424,7 +457,7 @@ describe("handleReminderCheck medication-wide slot attribution", () => { }, ]; prismaMock.medication.findMany.mockResolvedValue([medication] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T08:00:30.000Z"), takenAt: null, @@ -451,7 +484,7 @@ describe("handleReminderCheck medication-wide slot attribution", () => { { validUntil: new Date("2026-07-28T08:30:00.000Z") }, ]; prismaMock.medication.findMany.mockResolvedValue([medication] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T08:00:00.000Z"), takenAt: new Date("2026-07-28T08:00:00.000Z"), @@ -479,7 +512,7 @@ describe("handleReminderCheck medication-wide slot attribution", () => { { validUntil: new Date("2026-07-28T08:30:00.000Z") }, ]; prismaMock.medication.findMany.mockResolvedValue([medication] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T08:00:00.000Z"), takenAt: new Date("2026-07-28T09:00:00.000Z"), @@ -525,7 +558,7 @@ describe("handleReminderCheck medication-wide slot attribution", () => { { validUntil: new Date("2026-07-28T08:30:00.000Z") }, ]; prismaMock.medication.findMany.mockResolvedValue([medication] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T10:00:00.000Z"), takenAt: null, @@ -553,7 +586,7 @@ describe("handleReminderCheck medication-wide slot attribution", () => { { validUntil: new Date("2026-07-28T08:30:00.000Z") }, ]; prismaMock.medication.findMany.mockResolvedValue([medication] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([ + setIntakeEvents([ { scheduledFor: new Date("2026-07-28T10:00:00.000Z"), takenAt: new Date("2026-07-28T08:00:00.000Z"), @@ -691,7 +724,7 @@ describe("issue #664 — exact occurrence identity survives local midnight", () prismaMock.medication.findMany.mockResolvedValue([ crossMidnightMedication(timezone), ] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + setIntakeEvents([]); await handleReminderCheck([]); @@ -720,7 +753,7 @@ describe("issue #664 — exact occurrence identity survives local midnight", () prismaMock.medication.findMany.mockResolvedValue([ crossMidnightMedication("Europe/Berlin"), ] as never); - prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([action]); + setIntakeEvents([action]); vi.setSystemTime(new Date("2026-07-28T22:15:00.000Z")); // 00:15 Jul 29 await handleReminderCheck([]); @@ -748,4 +781,68 @@ describe("issue #664 — exact occurrence identity survives local midnight", () }), ); }); + + it("still escalates a genuinely missed 23:45 dose hours after midnight", async () => { + // The other direction of the same boundary, and the one that costs a + // person a dose rather than a night's sleep: quietening the repeat must + // not quieten the escalation. 04:30 Berlin on Jul 29 is 240 minutes past + // the Jul 28 window's end, so the untaken dose reaches RED — and both the + // missed-intake row and the notification must name the Jul 28 slot, not + // the day the tick happens to run on. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-29T02:30:00.000Z")); // 04:30 Jul 29 + prismaMock.medication.findMany.mockResolvedValue([ + crossMidnightMedication("Europe/Berlin"), + ] as never); + setIntakeEvents([]); + + await handleReminderCheck([]); + + expect(prismaMock.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); + expect(prismaMock.medicationIntakeEvent.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + scheduledFor: new Date("2026-07-28T21:45:00.000Z"), + takenAt: null, + skipped: false, + source: "REMINDER", + }), + }), + ); + expect(dispatchNotification).toHaveBeenCalledTimes(1); + expect(dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + phase: "RED", + scheduledAt: "2026-07-28T21:45:00.000Z", + date: "2026-07-28", + }), + }), + ); + }); + + it("does not mint a second missed row for a dose taken just before midnight", async () => { + // The RED placeholder is the escalation's permanent trace: mint it for a + // dose the user already logged and the medication reads as missed forever + // after, in compliance and in every history surface. + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-29T02:30:00.000Z")); // 04:30 Jul 29 + prismaMock.medication.findMany.mockResolvedValue([ + crossMidnightMedication("Europe/Berlin"), + ] as never); + setIntakeEvents([ + { + scheduledFor: new Date("2026-07-28T21:45:00.000Z"), + takenAt: new Date("2026-07-28T21:50:00.000Z"), + skipped: false, + attributionSource: "AUTO", + updatedAt: new Date("2026-07-28T21:50:00.000Z"), + }, + ]); + + await handleReminderCheck([]); + + expect(prismaMock.medicationIntakeEvent.create).not.toHaveBeenCalled(); + expect(dispatchNotification).not.toHaveBeenCalled(); + }); });