mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
medication_compliance_rollups lost its last reader when compliance moved to the schedule-anchored computation, yet every dose write still upserted it, a boot backfill refolded it, and the wipe and backup plans carried it. The per-dose hook keeps the one derived row that still hangs off a dose day, the supplement nutrients, under dose-day-refresh. The pending-row mints (today projection, reminder) no longer call it, since an untaken dose adds no nutrients. The import job finishes without its per-day recompute phase; its progress keeps touchedDays and rollupProcessed on the wire, inert and documented as deprecated. The backfill queue is retired on boot and migration 0414 drops the table under a bounded lock wait.
141 lines
4.4 KiB
TypeScript
141 lines
4.4 KiB
TypeScript
/**
|
|
* 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,
|
|
}),
|
|
}));
|
|
|
|
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(),
|
|
}),
|
|
}),
|
|
);
|
|
|
|
// The anchor is a record-scoped scheduler event, not a delivery attempt.
|
|
// It used to be a `push_attempts` row on a synthetic `DEDUP` channel; the
|
|
// separation moved it to `notification_events` and nothing writes that
|
|
// channel any more, so a query for it matched no rows and passed on an
|
|
// empty set whatever the tick did. Read the ledger the claim actually
|
|
// appends to, and pin the count, so a second dispatch shows up as a
|
|
// second row rather than as silence.
|
|
const anchors = await prisma.notificationEvent.findMany({
|
|
where: {
|
|
recordUserId: USER_ID,
|
|
eventType: "MEDICATION_REMINDER",
|
|
},
|
|
select: { dedupKey: true },
|
|
});
|
|
expect(anchors).toEqual([
|
|
{
|
|
dedupKey: expect.stringContaining("23:45:YELLOW:2026-07-28"),
|
|
},
|
|
]);
|
|
});
|
|
});
|