mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user