mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
test(01-11): reproduce cross-midnight reminder identity bug
- pin prior-day occurrence lookup across timezone and DST boundaries - prove real worker dedup and distinct next-dose behavior with PostgreSQL
This commit is contained in:
1 parent
8e944dbcd3
commit
81893ac972
3 files changed
+291
No files matched your search
@@ -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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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<void> {
|
||||
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"),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user