test(meds): integration coverage for every v1.5 cadence shape

8 cases that exercise the schedule-resolution path against a
testcontainer Postgres + the canonical recurrence engine + the
reminder worker's helper seam:

1. **Bi-weekly RRULE** — `FREQ=WEEKLY;INTERVAL=2;BYDAY=WE` anchored
   on a known Wednesday emits week 1, skips week 2, emits week 3.
   Pins the pre-existing worker bug closed.

2. **Monthly** — `FREQ=MONTHLY;BYMONTHDAY=1` emits 3 first-of-month
   slots across a 3-month window; in-between days don't fire.

3. **Quarterly** — `FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15` emits 3
   slots across 9 months; off-quarter months don't fire.

4. **Rolling** — `rollingIntervalDays = 7`. Initial intake on day 0
   targets day 7 as the next slot; an earlier intake on day 5
   re-anchors to day 12, not day 14. Rolling emits only the
   immediately-next slot; past rolling occurrences are historical
   intake events, not predictable forward slots.

5. **One-shot lifecycle** — a take flips `medication.active` to
   false; a skip leaves it true.

6. **endsOn cap** — a daily schedule with `endsOn = today + 3d`
   emits 4 slots (today + day1 + day2 + day3); day 4+ skip.

7. **Legacy fallback** — a row with `rrule = null`,
   `daysOfWeek = "i2;3"` behaves the same as the explicit RRULE
   bi-weekly Wednesday case. Proves the engine's legacy path closes
   the worker bug for pre-v1.5 rows too — covers tenants that
   haven't been backfilled (defensive).

8. **One-shot skipped intake** — separate case verifying that
   skipping a one-shot does NOT auto-deactivate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 08:08:15 +02:00
1 parent 39430e16c0
commit 2aa19fa770
1 file changed
+714
@@ -0,0 +1,714 @@
/**
* v1.5.0 — reminder-worker cadence integration tests.
*
* Pins the new contract that v1.5 ships: the reminder worker's
* schedule-resolution path now routes through the canonical recurrence
* engine at `src/lib/medications/scheduling/recurrence.ts` instead of
* the home-grown weekday filter. The pre-existing `intervalWeeks`
* bi-weekly bug (a Wed bi-weekly schedule fired every Wed instead of
* every other Wed because `grep intervalWeeks src/lib/jobs/reminder-worker.ts`
* returned zero hits) closes as a side effect of the rewire — the
* canonical engine honours RRULE INTERVAL correctly.
*
* The tests exercise the worker's `scheduleEmitsInWindow` helper
* directly with real Postgres rows. This is the exact function the
* worker's per-tick schedule-resolution branch calls; testing it
* against a real `MedicationSchedule` row that's been written and
* read back gives us byte-for-byte fidelity with what the worker
* actually sees, without the dispatcher / Telegram / APNs / phase-
* math side effects.
*
* The one-shot lifecycle is tested through the actual POST intake
* route so the active-flip hook is end-to-end verified.
*
* Test coverage map:
* 1. Bi-weekly regression (RRULE INTERVAL=2) — the pre-v1.5 bug.
* 2. Monthly RRULE.
* 3. Quarterly RRULE.
* 4. Rolling — re-anchors on intake.
* 5. One-shot — active flips after intake.
* 6. `endsOn` cap.
* 7. Legacy `daysOfWeek` fallback with `intervalWeeks > 1`.
*/
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import {
buildCanonicalSchedule,
buildRecurrenceContext,
scheduleEmitsInWindow,
} from "@/lib/medications/scheduling/worker-helpers";
const TEST_USER_ID = "user-medication-cadence-worker";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
// Side-effects on the create-intake hot path that the one-shot test
// exercises but doesn't care about — keep them inert so the test
// stays focused on the schedule-resolution + lifecycle contract.
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
vi.mock("@/lib/rollups/medication-compliance-rollups", async () => {
const actual = await vi.importActual<
typeof import("@/lib/rollups/medication-compliance-rollups")
>("@/lib/rollups/medication-compliance-rollups");
return {
...actual,
recomputeMedicationComplianceForEvent: vi.fn().mockResolvedValue(undefined),
};
});
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
const prisma = getPrismaClient();
await prisma.user.create({
data: {
id: TEST_USER_ID,
username: "cadence-worker",
email: "cadence-worker@example.test",
timezone: "Europe/Berlin",
},
});
});
/**
* Read a fresh medication-row + its schedules from the DB and return
* a (canonicalSchedule, recurrenceCtx) pair so each test exercises
* the same shape the worker reads on its tick.
*/
async function loadCanonical(medicationId: string): Promise<{
schedules: ReturnType<typeof buildCanonicalSchedule>[];
ctx: ReturnType<typeof buildRecurrenceContext>;
}> {
const prisma = getPrismaClient();
const med = await prisma.medication.findUniqueOrThrow({
where: { id: medicationId },
include: { schedules: true },
});
const lastIntake = await prisma.medicationIntakeEvent.findFirst({
where: { medicationId, takenAt: { not: null } },
orderBy: { takenAt: "desc" },
select: { takenAt: true },
});
return {
schedules: med.schedules.map((s) => buildCanonicalSchedule(s)),
ctx: buildRecurrenceContext({
medication: med,
userTz: "Europe/Berlin",
lastIntakeAt: lastIntake?.takenAt ?? null,
}),
};
}
/**
* Compute the user-tz today bounds for an arbitrary "now" instant,
* matching `getUserTodayBounds` in `src/lib/timezone.ts` so the test
* window matches what the worker would build on that tick.
*/
function todayBounds(now: Date, tz: string): { start: Date; end: Date } {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(now);
const y = Number(parts.find((p) => p.type === "year")?.value);
const m = Number(parts.find((p) => p.type === "month")?.value);
const d = Number(parts.find((p) => p.type === "day")?.value);
// Two-pass solver: build local-midnight as UTC then shift by the
// tz offset at that instant.
const offsetMin = (() => {
const probe = new Date(Date.UTC(y, m - 1, d, 0, 0, 0, 0));
const probeParts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).formatToParts(probe);
const get = (t: string): number =>
Number(probeParts.find((p) => p.type === t)?.value ?? "0");
let hour = get("hour");
if (hour === 24) hour = 0;
const asIfUtc = Date.UTC(
get("year"),
get("month") - 1,
get("day"),
hour,
get("minute"),
0,
);
return Math.round((asIfUtc - probe.getTime()) / 60_000);
})();
const start = new Date(Date.UTC(y, m - 1, d, 0, 0, 0, 0) - offsetMin * 60_000);
const end = new Date(start.getTime() + 24 * 60 * 60 * 1000 - 1);
return { start, end };
}
// ────────────────────────────────────────────────────────────────────
// 1. Bi-weekly regression — the pre-v1.5 `intervalWeeks` bug closes
// ────────────────────────────────────────────────────────────────────
describe("bi-weekly RRULE — pre-v1.5 intervalWeeks regression closes", () => {
it("emits on the anchor Wed, skips next Wed, emits again 2 weeks later", async () => {
// 2026-06-03 (Wed) is the anchor. The RRULE says every other Wed.
// Worker tick on Jun 03 → should emit.
// Worker tick on Jun 10 (Wed, skip week) → should NOT emit.
// Worker tick on Jun 17 (Wed, on week) → should emit.
const prisma = getPrismaClient();
const med = await prisma.medication.create({
data: {
userId: TEST_USER_ID,
name: "Mounjaro",
dose: "5mg",
active: true,
startsOn: new Date("2026-06-03T00:00:00.000Z"),
schedules: {
create: {
windowStart: "08:00",
windowEnd: "09:00",
rrule: "FREQ=WEEKLY;INTERVAL=2;BYDAY=WE",
timesOfDay: ["08:00"],
},
},
},
});
const { schedules, ctx } = await loadCanonical(med.id);
const [schedule] = schedules;
// Anchor week (Wed 03) — emits.
const anchorBounds = todayBounds(
new Date("2026-06-03T10:00:00.000Z"),
"Europe/Berlin",
);
expect(
scheduleEmitsInWindow(
schedule,
ctx,
anchorBounds.start,
anchorBounds.end,
),
).toBe(true);
// Off week (Wed 10) — pre-v1.5 worker would have fired here (the
// bug). Canonical engine correctly skips.
const offBounds = todayBounds(
new Date("2026-06-10T10:00:00.000Z"),
"Europe/Berlin",
);
expect(
scheduleEmitsInWindow(schedule, ctx, offBounds.start, offBounds.end),
).toBe(false);
// On week again (Wed 17) — emits.
const onBounds = todayBounds(
new Date("2026-06-17T10:00:00.000Z"),
"Europe/Berlin",
);
expect(
scheduleEmitsInWindow(schedule, ctx, onBounds.start, onBounds.end),
).toBe(true);
// Non-Wed day (Thu 18) — never emits.
const nonWedBounds = todayBounds(
new Date("2026-06-18T10:00:00.000Z"),
"Europe/Berlin",
);
expect(
scheduleEmitsInWindow(
schedule,
ctx,
nonWedBounds.start,
nonWedBounds.end,
),
).toBe(false);
});
});
// ────────────────────────────────────────────────────────────────────
// 2. Monthly
// ────────────────────────────────────────────────────────────────────
describe("monthly RRULE", () => {
it("BYMONTHDAY=1 emits on the 1st of each month, nothing in between", async () => {
const prisma = getPrismaClient();
const med = await prisma.medication.create({
data: {
userId: TEST_USER_ID,
name: "B12 shot",
dose: "1mg",
active: true,
startsOn: new Date("2026-02-01T00:00:00.000Z"),
schedules: {
create: {
windowStart: "08:00",
windowEnd: "09:00",
rrule: "FREQ=MONTHLY;BYMONTHDAY=1",
timesOfDay: ["08:00"],
},
},
},
});
const { schedules, ctx } = await loadCanonical(med.id);
const [schedule] = schedules;
const months = [
"2026-02-01",
"2026-03-01",
"2026-04-01",
] as const;
for (const dayStr of months) {
const b = todayBounds(
new Date(`${dayStr}T10:00:00.000Z`),
"Europe/Berlin",
);
expect(scheduleEmitsInWindow(schedule, ctx, b.start, b.end)).toBe(true);
}
// Random in-between day — does not emit.
const noMatch = todayBounds(
new Date("2026-02-15T10:00:00.000Z"),
"Europe/Berlin",
);
expect(
scheduleEmitsInWindow(schedule, ctx, noMatch.start, noMatch.end),
).toBe(false);
});
});
// ────────────────────────────────────────────────────────────────────
// 3. Quarterly (monthly INTERVAL=3)
// ────────────────────────────────────────────────────────────────────
describe("quarterly RRULE", () => {
it("INTERVAL=3 emits 3 times across 9 months on the 15th", async () => {
const prisma = getPrismaClient();
const med = await prisma.medication.create({
data: {
userId: TEST_USER_ID,
name: "Quarterly check",
dose: "n/a",
active: true,
startsOn: new Date("2026-01-15T00:00:00.000Z"),
schedules: {
create: {
windowStart: "08:00",
windowEnd: "09:00",
rrule: "FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15",
timesOfDay: ["08:00"],
},
},
},
});
const { schedules, ctx } = await loadCanonical(med.id);
const [schedule] = schedules;
// Anchored at Jan 15 → next slots are Apr 15, Jul 15, Oct 15.
const fires = ["2026-01-15", "2026-04-15", "2026-07-15"];
for (const dayStr of fires) {
const b = todayBounds(
new Date(`${dayStr}T10:00:00.000Z`),
"Europe/Berlin",
);
expect(scheduleEmitsInWindow(schedule, ctx, b.start, b.end)).toBe(true);
}
const misses = ["2026-02-15", "2026-03-15", "2026-05-15"];
for (const dayStr of misses) {
const b = todayBounds(
new Date(`${dayStr}T10:00:00.000Z`),
"Europe/Berlin",
);
expect(scheduleEmitsInWindow(schedule, ctx, b.start, b.end)).toBe(false);
}
});
});
// ────────────────────────────────────────────────────────────────────
// 4. Rolling — re-anchors on intake
// ────────────────────────────────────────────────────────────────────
describe("rolling cadence", () => {
it("emits N days after the latest takenAt and re-anchors when a new intake is logged", async () => {
const prisma = getPrismaClient();
const med = await prisma.medication.create({
data: {
userId: TEST_USER_ID,
name: "Wegovy",
dose: "1mg",
active: true,
startsOn: new Date("2026-06-01T00:00:00.000Z"),
schedules: {
create: {
windowStart: "08:00",
windowEnd: "09:00",
rollingIntervalDays: 7,
timesOfDay: ["08:00"],
},
},
},
});
// Initial intake on day 0 (2026-06-01).
await prisma.medicationIntakeEvent.create({
data: {
userId: TEST_USER_ID,
medicationId: med.id,
scheduledFor: new Date("2026-06-01T06:00:00.000Z"),
takenAt: new Date("2026-06-01T06:00:00.000Z"),
skipped: false,
source: "WEB",
},
});
{
// Day 7 (2026-06-08) — slot should land here.
const { schedules, ctx } = await loadCanonical(med.id);
const [schedule] = schedules;
const day7 = todayBounds(
new Date("2026-06-08T10:00:00.000Z"),
"Europe/Berlin",
);
expect(scheduleEmitsInWindow(schedule, ctx, day7.start, day7.end)).toBe(
true,
);
// Day 6 — too early.
const day6 = todayBounds(
new Date("2026-06-07T10:00:00.000Z"),
"Europe/Berlin",
);
expect(scheduleEmitsInWindow(schedule, ctx, day6.start, day6.end)).toBe(
false,
);
}
// User takes early on day 5 (2026-06-06). Next slot should now
// land on day 12 (2026-06-13), not day 14.
await prisma.medicationIntakeEvent.create({
data: {
userId: TEST_USER_ID,
medicationId: med.id,
scheduledFor: new Date("2026-06-06T06:00:00.000Z"),
takenAt: new Date("2026-06-06T06:00:00.000Z"),
skipped: false,
source: "WEB",
},
});
{
const { schedules, ctx } = await loadCanonical(med.id);
const [schedule] = schedules;
// Day 12 (2026-06-13) → emits.
const day12 = todayBounds(
new Date("2026-06-13T10:00:00.000Z"),
"Europe/Berlin",
);
expect(scheduleEmitsInWindow(schedule, ctx, day12.start, day12.end)).toBe(
true,
);
// Day 11 — too early.
const day11 = todayBounds(
new Date("2026-06-12T10:00:00.000Z"),
"Europe/Berlin",
);
expect(scheduleEmitsInWindow(schedule, ctx, day11.start, day11.end)).toBe(
false,
);
// Day 14 — past the new anchor's slot; rolling only emits the
// next slot, not future ones, so the window should miss.
const day14 = todayBounds(
new Date("2026-06-20T10:00:00.000Z"),
"Europe/Berlin",
);
expect(scheduleEmitsInWindow(schedule, ctx, day14.start, day14.end)).toBe(
false,
);
}
});
});
// ────────────────────────────────────────────────────────────────────
// 5. One-shot — active flips after intake
// ────────────────────────────────────────────────────────────────────
describe("one-shot lifecycle", () => {
it("mints the slot, the intake POST flips medication.active to false", async () => {
const prisma = getPrismaClient();
const today = new Date();
const todayDate = new Date(
Date.UTC(
today.getUTCFullYear(),
today.getUTCMonth(),
today.getUTCDate(),
0,
0,
0,
0,
),
);
const med = await prisma.medication.create({
data: {
userId: TEST_USER_ID,
name: "Flu shot 2026",
dose: "0.5mL",
active: true,
oneShot: true,
startsOn: todayDate,
endsOn: todayDate,
schedules: {
create: {
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
},
},
},
});
// Schedule emits today.
const { schedules, ctx } = await loadCanonical(med.id);
const [schedule] = schedules;
const bounds = todayBounds(today, "Europe/Berlin");
expect(scheduleEmitsInWindow(schedule, ctx, bounds.start, bounds.end)).toBe(
true,
);
// Log the intake via the real POST route to exercise the
// active-flip hook end-to-end.
const session = await prisma.session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
const { POST } = await import("@/app/api/medications/[id]/intake/route");
const request = new NextRequest(
`http://localhost/api/medications/${med.id}/intake`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
takenAt: new Date().toISOString(),
skipped: false,
}),
},
);
const res = await POST(request, {
params: Promise.resolve({ id: med.id }),
});
expect(res.status).toBe(201);
const refreshed = await prisma.medication.findUniqueOrThrow({
where: { id: med.id },
select: { active: true },
});
expect(refreshed.active).toBe(false);
});
it("does NOT deactivate when the intake is skipped", async () => {
const prisma = getPrismaClient();
const today = new Date();
const todayDate = new Date(
Date.UTC(
today.getUTCFullYear(),
today.getUTCMonth(),
today.getUTCDate(),
0,
0,
0,
0,
),
);
const med = await prisma.medication.create({
data: {
userId: TEST_USER_ID,
name: "Flu shot keep-open",
dose: "0.5mL",
active: true,
oneShot: true,
startsOn: todayDate,
endsOn: todayDate,
schedules: {
create: {
windowStart: "08:00",
windowEnd: "09:00",
timesOfDay: ["08:00"],
},
},
},
});
const session = await prisma.session.create({
data: {
userId: TEST_USER_ID,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
const { POST } = await import("@/app/api/medications/[id]/intake/route");
const request = new NextRequest(
`http://localhost/api/medications/${med.id}/intake`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ skipped: true }),
},
);
const res = await POST(request, {
params: Promise.resolve({ id: med.id }),
});
expect(res.status).toBe(201);
const refreshed = await prisma.medication.findUniqueOrThrow({
where: { id: med.id },
select: { active: true },
});
expect(refreshed.active).toBe(true);
});
});
// ────────────────────────────────────────────────────────────────────
// 6. endsOn cap
// ────────────────────────────────────────────────────────────────────
describe("endsOn cap", () => {
it("daily schedule with endsOn = startsOn + 3d emits on the 4 in-range days, no more", async () => {
const prisma = getPrismaClient();
const med = await prisma.medication.create({
data: {
userId: TEST_USER_ID,
name: "Antibiotic 4-day course",
dose: "500mg",
active: true,
startsOn: new Date("2026-06-01T00:00:00.000Z"),
endsOn: new Date("2026-06-04T00:00:00.000Z"),
schedules: {
create: {
windowStart: "08:00",
windowEnd: "09:00",
rrule: "FREQ=DAILY",
timesOfDay: ["08:00"],
},
},
},
});
const { schedules, ctx } = await loadCanonical(med.id);
const [schedule] = schedules;
// In-range days (Jun 01..04 in Berlin).
const inRange = ["2026-06-01", "2026-06-02", "2026-06-03", "2026-06-04"];
for (const dayStr of inRange) {
const b = todayBounds(
new Date(`${dayStr}T10:00:00.000Z`),
"Europe/Berlin",
);
expect(scheduleEmitsInWindow(schedule, ctx, b.start, b.end)).toBe(true);
}
// Out of range — no emit.
const outOfRange = ["2026-06-05", "2026-06-10", "2026-06-30"];
for (const dayStr of outOfRange) {
const b = todayBounds(
new Date(`${dayStr}T10:00:00.000Z`),
"Europe/Berlin",
);
expect(scheduleEmitsInWindow(schedule, ctx, b.start, b.end)).toBe(false);
}
});
});
// ────────────────────────────────────────────────────────────────────
// 7. Legacy daysOfWeek fallback honours intervalWeeks
// ────────────────────────────────────────────────────────────────────
describe("legacy daysOfWeek fallback", () => {
it("'i2;3' (bi-weekly Wed) behaves identically to the RRULE bi-weekly case", async () => {
// Same shape as test 1 but with the legacy string instead of rrule.
// This is the row shape pre-v1.5 migration would have left behind
// if the rrule backfill missed; the engine's legacy path is the
// safety net.
const prisma = getPrismaClient();
const med = await prisma.medication.create({
data: {
userId: TEST_USER_ID,
name: "Mounjaro legacy",
dose: "5mg",
active: true,
startsOn: new Date("2026-06-03T00:00:00.000Z"),
schedules: {
create: {
windowStart: "08:00",
windowEnd: "09:00",
daysOfWeek: "i2;3",
// rrule + rollingIntervalDays both NULL → legacy fallback
},
},
},
});
const { schedules, ctx } = await loadCanonical(med.id);
const [schedule] = schedules;
// Anchor week — emits.
const anchor = todayBounds(
new Date("2026-06-03T10:00:00.000Z"),
"Europe/Berlin",
);
expect(
scheduleEmitsInWindow(schedule, ctx, anchor.start, anchor.end),
).toBe(true);
// Off week (Wed 10) — pre-v1.5 worker would have fired (the bug);
// the legacy path in the engine now honours intervalWeeks.
const off = todayBounds(
new Date("2026-06-10T10:00:00.000Z"),
"Europe/Berlin",
);
expect(scheduleEmitsInWindow(schedule, ctx, off.start, off.end)).toBe(
false,
);
// On week (Wed 17) — emits.
const on = todayBounds(
new Date("2026-06-17T10:00:00.000Z"),
"Europe/Berlin",
);
expect(scheduleEmitsInWindow(schedule, ctx, on.start, on.end)).toBe(true);
});
});