Files
HealthLog/tests/integration/medication-cadence-worker.test.ts
T
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00

803 lines
28 KiB
TypeScript

/**
* 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 { afterEach, 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";
import { localHmAsUtc } from "@/lib/tz/local-day";
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/tz/local-day.ts` so the test
* window matches what the worker would build on that tick.
*/
// UTC midnight on the local calendar day that `now` falls within for `tz`.
// Mirrors the column convention for `startsOn`/`endsOn` (stored as
// `...T00:00:00.000Z` on the local calendar day). Deriving the day from
// the same timezone `todayBounds` uses keeps a one-shot's emit day inside
// the checked window regardless of wall-clock time — reading the UTC date
// instead drifts a day late in the UTC evening when the local zone has
// already rolled into the next calendar day.
function localCalendarMidnightUtc(now: Date, tz: string): 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);
return new Date(Date.UTC(y, m - 1, d, 0, 0, 0, 0));
}
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 = localCalendarMidnightUtc(today, "Europe/Berlin");
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 = localCalendarMidnightUtc(today, "Europe/Berlin");
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);
});
});
// ────────────────────────────────────────────────────────────────────
// 8. v1.8.2 — per-med intake POST collapses onto the canonical slot
// ────────────────────────────────────────────────────────────────────
describe("v1.8.2 per-med intake POST — source-agnostic slot collapse", () => {
// A taken-write must never snap onto a FUTURE slot, so the 07:00 slot
// these cases mint has to sit in the past relative to "now" — which it
// does not when the suite runs before 07:00 local. Pin the clock to
// local noon (kept on today via the real date) and fake only Date, so
// Prisma's real timers are untouched.
beforeEach(() => {
const noon = new Date();
noon.setHours(12, 0, 0, 0);
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(noon);
});
afterEach(() => {
vi.useRealTimers();
});
it("updates a pre-existing pending REMINDER slot row instead of inserting a second WEB row", async () => {
const prisma = getPrismaClient();
const med = await prisma.medication.create({
data: {
userId: TEST_USER_ID,
name: "Bisoprolol",
dose: "2.5mg",
active: true,
schedules: {
create: {
windowStart: "07:00",
windowEnd: "07:00",
timesOfDay: ["07:00"],
daysOfWeek: null,
scheduleType: "SCHEDULED",
},
},
},
});
const slot = localHmAsUtc(new Date(), "Europe/Berlin", 7, 0);
const pending = await prisma.medicationIntakeEvent.create({
data: {
userId: TEST_USER_ID,
medicationId: med.id,
scheduledFor: slot,
takenAt: null,
skipped: false,
source: "REMINDER",
},
});
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");
// +1 minute drift between the iOS write and the server's localHmAsUtc.
const drifted = new Date(slot.getTime() + 60_000);
const res = await POST(
new NextRequest(`http://localhost/api/medications/${med.id}/intake`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
scheduledFor: drifted.toISOString(),
takenAt: drifted.toISOString(),
skipped: false,
}),
}),
{ params: Promise.resolve({ id: med.id }) },
);
expect(res.status).toBe(201);
const json = (await res.json()) as { data: { id: string } };
expect(json.data.id).toBe(pending.id); // updated the SAME row
const rows = await prisma.medicationIntakeEvent.findMany({
where: { userId: TEST_USER_ID, medicationId: med.id },
});
expect(rows).toHaveLength(1);
expect(rows[0]?.id).toBe(pending.id);
expect(rows[0]?.takenAt).not.toBeNull();
expect(rows[0]?.scheduledFor.toISOString()).toBe(slot.toISOString());
});
});