diff --git a/src/lib/jobs/__tests__/medication-reminder-check.test.ts b/src/lib/jobs/__tests__/medication-reminder-check.test.ts index a57f60528..d64e7e1cc 100644 --- a/src/lib/jobs/__tests__/medication-reminder-check.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-check.test.ts @@ -643,3 +643,109 @@ describe("handleReminderCheck configured wall-clock phase bounds", () => { ); }); }); + +describe("issue #664 — exact occurrence identity survives local midnight", () => { + function crossMidnightMedication(timezone: string) { + const medication = medicationWithSchedule({ + windowStart: "23:45", + windowEnd: "00:30", + timesOfDay: ["23:45"], + rrule: "FREQ=DAILY", + }) as { + user: { id: string; timezone: string; locale: string }; + }; + medication.user.timezone = timezone; + return medication; + } + + it.each([ + { + label: "UTC", + timezone: "UTC", + occurrence: "2026-07-28T23:45:00.000Z", + tick: "2026-07-29T00:15:00.000Z", + }, + { + label: "Berlin summer time", + timezone: "Europe/Berlin", + occurrence: "2026-07-28T21:45:00.000Z", + tick: "2026-07-28T22:15:00.000Z", + }, + { + label: "Berlin DST fall-back eve", + timezone: "Europe/Berlin", + occurrence: "2026-10-24T21:45:00.000Z", + tick: "2026-10-24T22:15:00.000Z", + }, + { + label: "positive-offset Auckland", + timezone: "Pacific/Auckland", + occurrence: "2026-07-28T11:45:00.000Z", + tick: "2026-07-28T12:15:00.000Z", + }, + ])( + "keeps an untaken $label 23:45 occurrence actionable after midnight", + async ({ timezone, occurrence, tick }) => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(tick)); + prismaMock.medication.findMany.mockResolvedValue([ + crossMidnightMedication(timezone), + ] as never); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]); + + await handleReminderCheck([]); + + expect(dispatchNotification).toHaveBeenCalledTimes(1); + expect(dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + scheduledAt: occurrence, + timeOfDay: "23:45", + }), + }), + ); + }, + ); + + it("reads and suppresses the prior-day occurrence, but not tomorrow's distinct dose", async () => { + vi.useFakeTimers(); + const priorOccurrence = new Date("2026-07-28T21:45:00.000Z"); + const action = { + scheduledFor: priorOccurrence, + takenAt: new Date("2026-07-28T21:50:00.000Z"), + skipped: false, + attributionSource: "AUTO", + updatedAt: new Date("2026-07-28T21:50:00.000Z"), + }; + prismaMock.medication.findMany.mockResolvedValue([ + crossMidnightMedication("Europe/Berlin"), + ] as never); + prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([action]); + + vi.setSystemTime(new Date("2026-07-28T22:15:00.000Z")); // 00:15 Jul 29 + await handleReminderCheck([]); + expect(dispatchNotification).not.toHaveBeenCalled(); + + const postMidnightRead = prismaMock.medicationIntakeEvent.findMany.mock + .calls[0][0] as { + where: { scheduledFor: { gte: Date; lte: Date } }; + }; + expect( + postMidnightRead.where.scheduledFor.gte.getTime(), + ).toBeLessThanOrEqual(priorOccurrence.getTime()); + expect( + postMidnightRead.where.scheduledFor.lte.getTime(), + ).toBeGreaterThanOrEqual(priorOccurrence.getTime()); + + vi.setSystemTime(new Date("2026-07-29T21:50:00.000Z")); // 23:50 Jul 29 + await handleReminderCheck([]); + expect(dispatchNotification).toHaveBeenCalledTimes(1); + expect(dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + scheduledAt: "2026-07-29T21:45:00.000Z", + }), + }), + ); + }); +}); diff --git a/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts b/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts index b212c4a2c..0b841639a 100644 --- a/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts @@ -312,3 +312,50 @@ describe("medication reminder — client-managed suppression is APNs-only", () = expect(dispatchNotification).toHaveBeenCalledTimes(1); }); }); + +describe("issue #664 — dedup follows the scheduled occurrence across midnight", () => { + it("dispatches an untreated overnight occurrence once and anchors its own local date", async () => { + const med = medicationFixture(); + med.schedules[0] = { + ...med.schedules[0], + windowStart: "23:45", + windowEnd: "00:30", + timesOfDay: ["23:45"], + rrule: "FREQ=DAILY", + }; + med.user.timezone = "Europe/Berlin"; + prismaMock.medication.findMany.mockResolvedValue([med]); + + // 00:15 and 00:30 on Jul 29 in Berlin are still the Jul 28 23:45 + // occurrence's open window. + await tickAt("2026-07-28T22:15:00.000Z"); + await tickAt("2026-07-28T22:30:00.000Z"); + + expect(dispatchNotification).toHaveBeenCalledTimes(1); + expect(pushAttempts).toHaveLength(1); + expect(pushAttempts[0]?.reason).toBe( + "med:med-1:sched-1:23:45:YELLOW:2026-07-28", + ); + }); + + it("keeps consecutive 23:45 occurrences distinct", async () => { + const { medicationReminderDedupKey } = + await import("@/lib/notifications/reminder-dedup"); + const first = medicationReminderDedupKey({ + medicationId: "med-1", + scheduleId: "sched-1", + slotTime: "23:45", + phase: "YELLOW", + localDate: "2026-07-28", + }); + const second = medicationReminderDedupKey({ + medicationId: "med-1", + scheduleId: "sched-1", + slotTime: "23:45", + phase: "YELLOW", + localDate: "2026-07-29", + }); + + expect(first).not.toBe(second); + }); +}); diff --git a/tests/integration/medication-reminder-cross-midnight.test.ts b/tests/integration/medication-reminder-cross-midnight.test.ts new file mode 100644 index 000000000..a2b2e8593 --- /dev/null +++ b/tests/integration/medication-reminder-cross-midnight.test.ts @@ -0,0 +1,138 @@ +/** + * Issue #664 — the reminder worker must carry one exact scheduled occurrence + * through recurrence, persisted intake lookup, dispatch metadata, and the + * durable push-attempt dedup ledger when its window crosses local midnight. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/notifications/dispatcher", () => ({ + dispatchNotification: vi.fn().mockResolvedValue({ + dispatched: true, + channelsAttempted: 1, + channelsSucceeded: 1, + }), +})); +vi.mock("@/lib/rollups/medication-compliance-rollups", () => ({ + recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined), +})); + +const { dispatchNotification } = + await import("@/lib/notifications/dispatcher"); +const { handleReminderCheck } = + await import("@/lib/jobs/reminder/medication-reminder-check"); + +const USER_ID = "cross-midnight-reminder-user"; +const TZ = "Europe/Berlin"; +const PRIOR_OCCURRENCE = new Date("2026-07-28T21:45:00.000Z"); // 23:45 CEST + +async function seedMedication(): Promise { + const prisma = getPrismaClient(); + const medication = await prisma.medication.create({ + data: { + userId: USER_ID, + name: "Cross-midnight tablet", + dose: "1 tablet", + active: true, + asNeeded: false, + notificationsEnabled: true, + startsOn: new Date("2026-07-01T00:00:00.000Z"), + schedules: { + create: { + windowStart: "23:45", + windowEnd: "00:30", + timesOfDay: ["23:45"], + rrule: "FREQ=DAILY", + scheduleType: "SCHEDULED", + }, + }, + }, + select: { id: true }, + }); + return medication.id; +} + +async function tickAt(instant: string): Promise { + vi.setSystemTime(new Date(instant)); + await handleReminderCheck([]); +} + +beforeEach(async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.mocked(dispatchNotification).mockClear(); + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + await prisma.user.create({ + data: { + id: USER_ID, + username: "cross-midnight-reminder", + email: "cross-midnight-reminder@example.test", + timezone: TZ, + locale: "en", + }, + }); +}); + +describe("medication reminder cross-midnight persistence", () => { + it("suppresses an exact taken occurrence after midnight without suppressing the next dose", async () => { + const prisma = getPrismaClient(); + const medicationId = await seedMedication(); + await prisma.medicationIntakeEvent.create({ + data: { + userId: USER_ID, + medicationId, + scheduledFor: PRIOR_OCCURRENCE, + takenAt: new Date("2026-07-28T21:50:00.000Z"), + skipped: false, + source: "WEB", + }, + }); + + await tickAt("2026-07-28T22:15:00.000Z"); // Jul 29 00:15 CEST + expect(dispatchNotification).not.toHaveBeenCalled(); + + await tickAt("2026-07-29T21:50:00.000Z"); // next distinct 23:45 dose + expect(dispatchNotification).toHaveBeenCalledTimes(1); + expect(dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + medicationId, + scheduledAt: "2026-07-29T21:45:00.000Z", + }), + }), + ); + }); + + it("dispatches an untaken prior-day occurrence once across worker retries", async () => { + const prisma = getPrismaClient(); + const medicationId = await seedMedication(); + + await tickAt("2026-07-28T22:15:00.000Z"); + await tickAt("2026-07-28T22:30:00.000Z"); + + expect(dispatchNotification).toHaveBeenCalledTimes(1); + expect(dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + medicationId, + scheduledAt: PRIOR_OCCURRENCE.toISOString(), + }), + }), + ); + + const anchors = await prisma.pushAttempt.findMany({ + where: { + userId: USER_ID, + channel: "DEDUP", + eventType: "MEDICATION_REMINDER", + }, + select: { reason: true }, + }); + expect(anchors).toEqual([ + { + reason: expect.stringContaining("23:45:YELLOW:2026-07-28"), + }, + ]); + }); +});