mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
A course's end date is a calendar day and counts in full: the doses of that day stay due, reminded and expected until local midnight. That is right for an end date set in the editor and wrong for "End course", whose dialog promises that nothing more is due. A course now carries `endedAt` (migration 0482, additive, nullable), the instant it was ended on its `endsOn` day, projected onto the medication next to `startsOn` / `endsOn` by the one course writer. From that instant: - the schedule engine emits no slot, so a dose later that day is not due, not reminded, not projected onto the today list and never expected by compliance, while a dose earlier that day stays inside the course; - the course reads ENDED (`courseStatusAt` / `courseStatusOn`), so the dose actions, `remindersActive` and the reminder gate stop at once, follow-ups of an earlier dose included; - every engine context builder, compliance context, dose history, Coach snapshot, doctor report and the adherence storyline read it, through one select and one picker for the medication window. Any write that moves a course's `endsOn` clears the instant, so an end date chosen in the editor keeps meaning the whole day. Backups carry it in both purposes and a restore projects it again. `courseStatusAt` moves next to the other course rules. Refs #1280
710 lines
23 KiB
TypeScript
710 lines
23 KiB
TypeScript
/**
|
|
* v1.42.2 — "record doses automatically", against a real Postgres.
|
|
*
|
|
* Proves, through the real pass, the real inventory hook, the real auto-miss
|
|
* pass, the reminder worker and the medication route:
|
|
* - running the pass twice leaves exactly one taken intake per slot;
|
|
* - a slot the person skipped by hand is left as they left it;
|
|
* - a paused medication gets nothing;
|
|
* - stock goes down once per recorded dose, never again on a re-run;
|
|
* - nothing before the option was switched on is filled;
|
|
* - the auto-miss pass leaves the slots the auto-take pass owns alone;
|
|
* - no reminder goes out for the medication while the option is on;
|
|
* - the route stamps the switch-on instant on an off→on flip only.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { NextRequest } from "next/server";
|
|
|
|
import { cookieJar, headerJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
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),
|
|
}));
|
|
vi.mock("@/lib/notifications/dispatcher", () => ({
|
|
dispatchNotification: vi.fn().mockResolvedValue({
|
|
dispatched: true,
|
|
channelsAttempted: 1,
|
|
channelsSucceeded: 1,
|
|
}),
|
|
}));
|
|
vi.mock("@/lib/notifications/medication-intake-sync", () => ({
|
|
queueMedicationIntakeSync: vi.fn(),
|
|
}));
|
|
|
|
const { dispatchNotification } = await import("@/lib/notifications/dispatcher");
|
|
const { runIntakeAutoTakePass } = await import("@/lib/jobs/intake-auto-take");
|
|
const { runIntakeAutoSkipPass } = await import("@/lib/jobs/intake-auto-skip");
|
|
const { handleReminderCheck } =
|
|
await import("@/lib/jobs/reminder/medication-reminder-check");
|
|
|
|
const TZ = "Europe/Berlin";
|
|
let counter = 0;
|
|
|
|
type Handler = (
|
|
request: NextRequest,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
context: { params: Promise<any> },
|
|
) => Promise<Response>;
|
|
|
|
async function makeUser(): Promise<string> {
|
|
const suffix = `${Date.now()}-${counter++}`;
|
|
const user = await getPrismaClient().user.create({
|
|
data: {
|
|
username: `auto-take-${suffix}`,
|
|
email: `auto-take-${suffix}@example.test`,
|
|
timezone: TZ,
|
|
locale: "en",
|
|
},
|
|
});
|
|
const session = await getPrismaClient().session.create({
|
|
data: {
|
|
userId: user.id,
|
|
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
return user.id;
|
|
}
|
|
|
|
/** A supplement taken every evening at 19:00, with a 30-tablet bottle. */
|
|
async function seedEvening(
|
|
userId: string,
|
|
name: string,
|
|
options: {
|
|
autoMarkTakenSince: Date | null;
|
|
pausedAt?: Date | null;
|
|
active?: boolean;
|
|
},
|
|
): Promise<string> {
|
|
const prisma = getPrismaClient();
|
|
const medication = await prisma.medication.create({
|
|
data: {
|
|
userId,
|
|
name,
|
|
dose: "1 capsule",
|
|
createdAt: new Date("2026-06-01T00:00:00.000Z"),
|
|
autoMarkTaken: options.autoMarkTakenSince !== null,
|
|
autoMarkTakenSince: options.autoMarkTakenSince,
|
|
notificationsEnabled: true,
|
|
active: options.active ?? true,
|
|
pausedAt: options.pausedAt ?? null,
|
|
schedules: {
|
|
create: {
|
|
windowStart: "19:00",
|
|
windowEnd: "19:00",
|
|
timesOfDay: ["19:00"],
|
|
rrule: "FREQ=DAILY",
|
|
scheduleType: "SCHEDULED",
|
|
},
|
|
},
|
|
},
|
|
select: { id: true },
|
|
});
|
|
await prisma.medicationInventoryItem.create({
|
|
data: {
|
|
userId,
|
|
medicationId: medication.id,
|
|
state: "ACTIVE",
|
|
unitsTotal: 30,
|
|
unitsRemaining: 30,
|
|
},
|
|
});
|
|
return medication.id;
|
|
}
|
|
|
|
async function stock(medicationId: string): Promise<number> {
|
|
const items = await getPrismaClient().medicationInventoryItem.findMany({
|
|
where: { medicationId },
|
|
select: { unitsRemaining: true },
|
|
});
|
|
return items.reduce((sum, i) => sum + Number(i.unitsRemaining), 0);
|
|
}
|
|
|
|
async function liveRows(medicationId: string) {
|
|
return getPrismaClient().medicationIntakeEvent.findMany({
|
|
where: { medicationId, deletedAt: null },
|
|
orderBy: { scheduledFor: "asc" },
|
|
select: {
|
|
scheduledFor: true,
|
|
takenAt: true,
|
|
skipped: true,
|
|
autoMissed: true,
|
|
autoRecorded: true,
|
|
inventoryConsumption: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
function at(iso: string): void {
|
|
vi.setSystemTime(new Date(iso));
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
vi.useFakeTimers({ toFake: ["Date"] });
|
|
vi.mocked(dispatchNotification).mockClear();
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
await truncateAllTables(getPrismaClient());
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe("auto-mark-taken pass", () => {
|
|
it("records each slot once, leaves the hand-skipped slot and the paused medication, and moves stock once", async () => {
|
|
const prisma = getPrismaClient();
|
|
const userId = await makeUser();
|
|
// Switched on Mon 2026-06-08 at 12:00 local.
|
|
const since = new Date("2026-06-08T10:00:00.000Z");
|
|
const evening = await seedEvening(userId, "Magnesium", {
|
|
autoMarkTakenSince: since,
|
|
});
|
|
const paused = await seedEvening(userId, "Vitamin D", {
|
|
autoMarkTakenSince: since,
|
|
active: false,
|
|
pausedAt: new Date("2026-06-07T00:00:00.000Z"),
|
|
});
|
|
|
|
// A Sunday slot before the switch: a pending placeholder that must stay.
|
|
await prisma.medicationIntakeEvent.create({
|
|
data: {
|
|
userId,
|
|
medicationId: evening,
|
|
scheduledFor: new Date("2026-06-07T17:00:00.000Z"),
|
|
source: "REMINDER",
|
|
},
|
|
});
|
|
// Tuesday: skipped by hand.
|
|
await prisma.medicationIntakeEvent.create({
|
|
data: {
|
|
userId,
|
|
medicationId: evening,
|
|
scheduledFor: new Date("2026-06-09T17:00:00.000Z"),
|
|
skipped: true,
|
|
source: "WEB",
|
|
},
|
|
});
|
|
// Wednesday: the projector's pending placeholder.
|
|
await prisma.medicationIntakeEvent.create({
|
|
data: {
|
|
userId,
|
|
medicationId: evening,
|
|
scheduledFor: new Date("2026-06-10T17:00:00.000Z"),
|
|
source: "REMINDER",
|
|
},
|
|
});
|
|
|
|
const now = Date.parse("2026-06-10T17:25:00.000Z"); // Wed 19:25 local
|
|
const first = await runIntakeAutoTakePass(prisma, { nowMs: now });
|
|
expect(first.recordedCount).toBe(2);
|
|
const second = await runIntakeAutoTakePass(prisma, {
|
|
nowMs: now + 15 * 60_000,
|
|
});
|
|
expect(second.recordedCount).toBe(0);
|
|
|
|
const rows = await liveRows(evening);
|
|
expect(
|
|
rows.map((r) => ({
|
|
at: r.scheduledFor.toISOString(),
|
|
taken: r.takenAt?.toISOString() ?? null,
|
|
skipped: r.skipped,
|
|
auto: r.autoRecorded,
|
|
})),
|
|
).toEqual([
|
|
// Before the switch: untouched.
|
|
{
|
|
at: "2026-06-07T17:00:00.000Z",
|
|
taken: null,
|
|
skipped: false,
|
|
auto: false,
|
|
},
|
|
// Mon: created, taken at its time.
|
|
{
|
|
at: "2026-06-08T17:00:00.000Z",
|
|
taken: "2026-06-08T17:00:00.000Z",
|
|
skipped: false,
|
|
auto: true,
|
|
},
|
|
// Tue: the person's skip stands.
|
|
{
|
|
at: "2026-06-09T17:00:00.000Z",
|
|
taken: null,
|
|
skipped: true,
|
|
auto: false,
|
|
},
|
|
// Wed: the placeholder converted in place, no sibling.
|
|
{
|
|
at: "2026-06-10T17:00:00.000Z",
|
|
taken: "2026-06-10T17:00:00.000Z",
|
|
skipped: false,
|
|
auto: true,
|
|
},
|
|
]);
|
|
|
|
// Two doses recorded, two capsules gone, each stamped once.
|
|
expect(await stock(evening)).toBe(28);
|
|
expect(
|
|
rows.filter((r) => r.autoRecorded && r.inventoryConsumption !== null),
|
|
).toHaveLength(2);
|
|
|
|
// The paused medication got nothing and kept its stock.
|
|
expect(await liveRows(paused)).toEqual([]);
|
|
expect(await stock(paused)).toBe(30);
|
|
});
|
|
|
|
it("the auto-miss pass leaves the slots the auto-take pass owns", async () => {
|
|
const prisma = getPrismaClient();
|
|
const userId = await makeUser();
|
|
const evening = await seedEvening(userId, "Omega-3", {
|
|
autoMarkTakenSince: new Date("2026-06-08T10:00:00.000Z"),
|
|
});
|
|
// A pending placeholder on an owned slot, more than 24 h old.
|
|
await prisma.medicationIntakeEvent.create({
|
|
data: {
|
|
userId,
|
|
medicationId: evening,
|
|
scheduledFor: new Date("2026-06-08T17:00:00.000Z"),
|
|
source: "REMINDER",
|
|
},
|
|
});
|
|
const now = Date.parse("2026-06-10T08:00:00.000Z");
|
|
const skip = await runIntakeAutoSkipPass(prisma, { nowMs: now });
|
|
expect(skip.skippedCount).toBe(0);
|
|
|
|
await runIntakeAutoTakePass(prisma, { nowMs: now });
|
|
const monday = (await liveRows(evening)).find(
|
|
(r) => r.scheduledFor.toISOString() === "2026-06-08T17:00:00.000Z",
|
|
);
|
|
expect(monday).toMatchObject({ autoMissed: false, autoRecorded: true });
|
|
});
|
|
|
|
it("sends no reminder for the medication while the option is on", async () => {
|
|
const userId = await makeUser();
|
|
const auto = await seedEvening(userId, "Zinc", {
|
|
autoMarkTakenSince: new Date("2026-06-01T00:00:00.000Z"),
|
|
});
|
|
const manual = await seedEvening(userId, "Iron", {
|
|
autoMarkTakenSince: null,
|
|
});
|
|
for (const tick of [
|
|
"2026-06-10T17:30:00.000Z",
|
|
"2026-06-10T19:00:00.000Z",
|
|
"2026-06-10T22:00:00.000Z",
|
|
]) {
|
|
at(tick);
|
|
await handleReminderCheck([]);
|
|
}
|
|
const dispatchedFor = (medicationId: string) =>
|
|
vi
|
|
.mocked(dispatchNotification)
|
|
.mock.calls.filter(
|
|
([arg]) =>
|
|
(arg as { metadata?: { medicationId?: string } }).metadata
|
|
?.medicationId === medicationId,
|
|
).length;
|
|
expect(dispatchedFor(manual)).toBeGreaterThan(0);
|
|
expect(dispatchedFor(auto)).toBe(0);
|
|
});
|
|
|
|
it("the route stamps the switch-on instant on a flip, and only then", async () => {
|
|
const prisma = getPrismaClient();
|
|
const userId = await makeUser();
|
|
const medicationId = await seedEvening(userId, "B12", {
|
|
autoMarkTakenSince: null,
|
|
});
|
|
const route = await import("@/app/api/medications/[id]/route");
|
|
const put = (body: unknown) =>
|
|
(route.PUT as Handler)(
|
|
new NextRequest(`http://localhost/api/medications/${medicationId}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(body),
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
{ params: Promise.resolve({ id: medicationId }) },
|
|
);
|
|
|
|
at("2026-06-10T10:00:00.000Z");
|
|
expect((await put({ autoMarkTaken: true })).status).toBe(200);
|
|
const on = await prisma.medication.findUniqueOrThrow({
|
|
where: { id: medicationId },
|
|
select: { autoMarkTaken: true, autoMarkTakenSince: true },
|
|
});
|
|
expect(on.autoMarkTaken).toBe(true);
|
|
expect(on.autoMarkTakenSince?.toISOString()).toBe(
|
|
"2026-06-10T10:00:00.000Z",
|
|
);
|
|
|
|
// Re-saving with the option already on never moves the floor.
|
|
at("2026-06-11T10:00:00.000Z");
|
|
expect((await put({ autoMarkTaken: true, name: "B12 forte" })).status).toBe(
|
|
200,
|
|
);
|
|
const resaved = await prisma.medication.findUniqueOrThrow({
|
|
where: { id: medicationId },
|
|
select: { autoMarkTakenSince: true },
|
|
});
|
|
expect(resaved.autoMarkTakenSince?.toISOString()).toBe(
|
|
"2026-06-10T10:00:00.000Z",
|
|
);
|
|
|
|
// Off clears it.
|
|
expect((await put({ autoMarkTaken: false })).status).toBe(200);
|
|
const off = await prisma.medication.findUniqueOrThrow({
|
|
where: { id: medicationId },
|
|
select: { autoMarkTaken: true, autoMarkTakenSince: true },
|
|
});
|
|
expect(off).toEqual({ autoMarkTaken: false, autoMarkTakenSince: null });
|
|
|
|
// Each flip is named in the audit row with what it was before; the
|
|
// re-save that left it on names no change to it.
|
|
const audits = await prisma.auditLog.findMany({
|
|
where: { userId, action: "medication.update" },
|
|
orderBy: { createdAt: "asc" },
|
|
select: { details: true },
|
|
});
|
|
const details = audits.map(
|
|
(a) =>
|
|
JSON.parse(a.details ?? "{}") as {
|
|
fields: string[];
|
|
previous: Record<string, unknown>;
|
|
},
|
|
);
|
|
expect(details).toHaveLength(3);
|
|
// Two of the same timestamp: matched by content, not order.
|
|
const flips = details
|
|
.filter((d) => d.fields.includes("autoMarkTaken"))
|
|
.map((d) => d.previous.autoMarkTaken)
|
|
.sort();
|
|
expect(flips).toEqual([false, true]);
|
|
const resave = details.find((d) => d.fields.includes("name"));
|
|
expect(resave?.fields).not.toContain("autoMarkTaken");
|
|
});
|
|
|
|
it("the create audit row says whether doses are recorded automatically", async () => {
|
|
const prisma = getPrismaClient();
|
|
const userId = await makeUser();
|
|
const route = await import("@/app/api/medications/route");
|
|
at("2026-06-10T10:00:00.000Z");
|
|
const res = await (route.POST as Handler)(
|
|
new NextRequest("http://localhost/api/medications", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
name: "Vitamin D",
|
|
dose: "1 capsule",
|
|
autoMarkTaken: true,
|
|
schedules: [{ windowStart: "19:00", windowEnd: "19:00" }],
|
|
}),
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
{ params: Promise.resolve({}) },
|
|
);
|
|
expect(res.status).toBe(201);
|
|
const audit = await prisma.auditLog.findFirstOrThrow({
|
|
where: { userId, action: "medication.create" },
|
|
select: { details: true },
|
|
});
|
|
expect(JSON.parse(audit.details ?? "{}")).toMatchObject({
|
|
autoMarkTaken: true,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("reopening past days never backfills them", () => {
|
|
it("a later end date or a re-activation moves the floor to now", async () => {
|
|
const prisma = getPrismaClient();
|
|
const userId = await makeUser();
|
|
const medicationId = await seedEvening(userId, "Course", {
|
|
autoMarkTakenSince: new Date("2026-06-01T00:00:00.000Z"),
|
|
});
|
|
const route = await import("@/app/api/medications/[id]/route");
|
|
const put = (body: unknown) =>
|
|
(route.PUT as Handler)(
|
|
new NextRequest(`http://localhost/api/medications/${medicationId}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify(body),
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
{ params: Promise.resolve({ id: medicationId }) },
|
|
);
|
|
const since = async () =>
|
|
(
|
|
await prisma.medication.findUniqueOrThrow({
|
|
where: { id: medicationId },
|
|
select: { autoMarkTakenSince: true },
|
|
})
|
|
).autoMarkTakenSince?.toISOString();
|
|
|
|
// The course ended on 06-05; the pass filled up to then.
|
|
at("2026-06-03T10:00:00.000Z");
|
|
expect(
|
|
(await put({ startsOn: "2026-06-01", endsOn: "2026-06-05" })).status,
|
|
).toBe(200);
|
|
expect(await since()).toBe("2026-06-01T00:00:00.000Z");
|
|
at("2026-06-10T10:00:00.000Z");
|
|
await runIntakeAutoTakePass(prisma, { nowMs: Date.now() });
|
|
const filled = (await liveRows(medicationId)).length;
|
|
|
|
// Extended on 06-10 to the end of the month: 06-06..06-09 reopen, but
|
|
// the person never had them as due.
|
|
expect((await put({ endsOn: "2026-06-30" })).status).toBe(200);
|
|
expect(await since()).toBe("2026-06-10T10:00:00.000Z");
|
|
await runIntakeAutoTakePass(prisma, { nowMs: Date.now() });
|
|
expect((await liveRows(medicationId)).length).toBe(filled);
|
|
|
|
// Paused, then active again a week later: nothing in between is filled.
|
|
expect((await put({ active: false })).status).toBe(200);
|
|
at("2026-06-17T10:00:00.000Z");
|
|
expect((await put({ active: true })).status).toBe(200);
|
|
expect(await since()).toBe("2026-06-17T10:00:00.000Z");
|
|
await runIntakeAutoTakePass(prisma, { nowMs: Date.now() });
|
|
expect((await liveRows(medicationId)).length).toBe(filled);
|
|
});
|
|
});
|
|
|
|
describe("rolling cadence under auto-take", () => {
|
|
it("never stalls on a removed dose, and the card shows the slot the pass records", async () => {
|
|
const prisma = getPrismaClient();
|
|
const userId = await makeUser();
|
|
// Weekly at 09:00 local. Last taken Mon 06-01; the 06-08 dose the pass
|
|
// recorded was removed by the person (not taken that week).
|
|
const medication = await prisma.medication.create({
|
|
data: {
|
|
userId,
|
|
name: "Weekly injection",
|
|
dose: "1 pen",
|
|
createdAt: new Date("2026-05-01T00:00:00.000Z"),
|
|
autoMarkTaken: true,
|
|
autoMarkTakenSince: new Date("2026-05-01T00:00:00.000Z"),
|
|
schedules: {
|
|
create: {
|
|
windowStart: "09:00",
|
|
windowEnd: "09:00",
|
|
timesOfDay: ["09:00"],
|
|
rollingIntervalDays: 7,
|
|
scheduleType: "SCHEDULED",
|
|
},
|
|
},
|
|
},
|
|
select: { id: true },
|
|
});
|
|
await prisma.medicationIntakeEvent.createMany({
|
|
data: [
|
|
{
|
|
userId,
|
|
medicationId: medication.id,
|
|
scheduledFor: new Date("2026-06-01T07:00:00.000Z"),
|
|
takenAt: new Date("2026-06-01T07:00:00.000Z"),
|
|
source: "WEB",
|
|
},
|
|
{
|
|
userId,
|
|
medicationId: medication.id,
|
|
scheduledFor: new Date("2026-06-08T07:00:00.000Z"),
|
|
takenAt: new Date("2026-06-08T07:00:00.000Z"),
|
|
autoRecorded: true,
|
|
source: "REMINDER",
|
|
deletedAt: new Date("2026-06-08T12:00:00.000Z"),
|
|
},
|
|
],
|
|
});
|
|
|
|
const detail = await import("@/app/api/medications/[id]/route");
|
|
const list = await import("@/app/api/medications/route");
|
|
const nextDue = async () => {
|
|
const res = await (detail.GET as Handler)(
|
|
new NextRequest(`http://localhost/api/medications/${medication.id}`),
|
|
{ params: Promise.resolve({ id: medication.id }) },
|
|
);
|
|
const listRes = await (list.GET as Handler)(
|
|
new NextRequest("http://localhost/api/medications"),
|
|
{ params: Promise.resolve({}) },
|
|
);
|
|
const listed = (
|
|
(await listRes.json()) as {
|
|
data: Array<{ id: string; nextDueAt: string | null }>;
|
|
}
|
|
).data.find((m) => m.id === medication.id);
|
|
const own = ((await res.json()) as { data: { nextDueAt: string | null } })
|
|
.data.nextDueAt;
|
|
expect(listed?.nextDueAt).toBe(own);
|
|
return own;
|
|
};
|
|
|
|
at("2026-06-10T08:00:00.000Z");
|
|
expect(await nextDue()).toBe("2026-06-15T07:00:00.000Z");
|
|
|
|
at("2026-06-15T07:20:00.000Z");
|
|
const result = await runIntakeAutoTakePass(prisma, {
|
|
nowMs: Date.now(),
|
|
});
|
|
expect(result.recordedCount).toBe(1);
|
|
const recorded = (await liveRows(medication.id)).filter(
|
|
(r) => r.autoRecorded,
|
|
);
|
|
expect(recorded.map((r) => r.scheduledFor.toISOString())).toEqual([
|
|
"2026-06-15T07:00:00.000Z",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("remindersActive — the published value is the gate", () => {
|
|
it("equals sendsMedicationReminders() on the list and detail reads, for every combination", async () => {
|
|
const prisma = getPrismaClient();
|
|
const userId = await makeUser();
|
|
const { sendsMedicationReminders } =
|
|
await import("@/lib/medications/auto-mark-taken");
|
|
at("2026-06-10T10:00:00.000Z");
|
|
|
|
const combos: Array<{
|
|
notificationsEnabled: boolean;
|
|
autoMarkTaken: boolean;
|
|
active: boolean;
|
|
trackIntake: boolean;
|
|
asNeeded: boolean;
|
|
endsOn: Date | null;
|
|
endedAt: Date | null;
|
|
}> = [];
|
|
for (let bits = 0; bits < 64; bits++) {
|
|
combos.push({
|
|
notificationsEnabled: (bits & 1) !== 0,
|
|
autoMarkTaken: (bits & 2) !== 0,
|
|
active: (bits & 4) !== 0,
|
|
trackIntake: (bits & 8) !== 0,
|
|
asNeeded: (bits & 16) !== 0,
|
|
// A course that ended the day before.
|
|
endsOn: (bits & 32) !== 0 ? new Date("2026-06-09T00:00:00.000Z") : null,
|
|
endedAt: null,
|
|
});
|
|
}
|
|
const ids: string[] = [];
|
|
for (const [i, combo] of combos.entries()) {
|
|
const med = await prisma.medication.create({
|
|
data: {
|
|
userId,
|
|
name: `Combo ${i}`,
|
|
dose: "1",
|
|
...combo,
|
|
autoMarkTakenSince: combo.autoMarkTaken ? new Date() : null,
|
|
...(!combo.asNeeded && {
|
|
schedules: {
|
|
create: {
|
|
windowStart: "19:00",
|
|
windowEnd: "19:00",
|
|
timesOfDay: ["19:00"],
|
|
rrule: "FREQ=DAILY",
|
|
scheduleType: "SCHEDULED",
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
select: { id: true },
|
|
});
|
|
ids.push(med.id);
|
|
}
|
|
|
|
const list = await import("@/app/api/medications/route");
|
|
const listRes = await (list.GET as Handler)(
|
|
new NextRequest("http://localhost/api/medications"),
|
|
{ params: Promise.resolve({}) },
|
|
);
|
|
expect(listRes.status).toBe(200);
|
|
const listBody = (await listRes.json()) as {
|
|
data: Array<{ id: string; remindersActive: boolean }>;
|
|
};
|
|
const listed = new Map(
|
|
listBody.data.map((m) => [m.id, m.remindersActive] as const),
|
|
);
|
|
|
|
const detail = await import("@/app/api/medications/[id]/route");
|
|
let trueCount = 0;
|
|
for (const [i, id] of ids.entries()) {
|
|
const expected = sendsMedicationReminders(combos[i], {
|
|
now: new Date(),
|
|
timeZone: TZ,
|
|
});
|
|
if (expected) trueCount++;
|
|
expect(listed.get(id), `list combo ${i}`).toBe(expected);
|
|
const res = await (detail.GET as Handler)(
|
|
new NextRequest(`http://localhost/api/medications/${id}`),
|
|
{ params: Promise.resolve({ id }) },
|
|
);
|
|
const body = (await res.json()) as { data: { remindersActive: boolean } };
|
|
expect(body.data.remindersActive, `detail combo ${i}`).toBe(expected);
|
|
}
|
|
// Exactly one combination reminds: on, not auto, active, tracked,
|
|
// scheduled, course not ended.
|
|
expect(trueCount).toBe(1);
|
|
});
|
|
|
|
it("a course that has ended reads as not reminding, and the worker sends nothing for it", async () => {
|
|
const prisma = getPrismaClient();
|
|
const userId = await makeUser();
|
|
const ended = await seedEvening(userId, "Antibiotic", {
|
|
autoMarkTakenSince: null,
|
|
});
|
|
const running = await seedEvening(userId, "Iron", {
|
|
autoMarkTakenSince: null,
|
|
});
|
|
await prisma.medication.update({
|
|
where: { id: ended },
|
|
data: { endsOn: new Date("2026-06-09T00:00:00.000Z") },
|
|
});
|
|
for (const tick of [
|
|
"2026-06-10T17:30:00.000Z",
|
|
"2026-06-10T19:00:00.000Z",
|
|
"2026-06-10T22:00:00.000Z",
|
|
]) {
|
|
at(tick);
|
|
await handleReminderCheck([]);
|
|
}
|
|
const dispatchedFor = (medicationId: string) =>
|
|
vi
|
|
.mocked(dispatchNotification)
|
|
.mock.calls.filter(
|
|
([arg]) =>
|
|
(arg as { metadata?: { medicationId?: string } }).metadata
|
|
?.medicationId === medicationId,
|
|
).length;
|
|
expect(dispatchedFor(running)).toBeGreaterThan(0);
|
|
expect(dispatchedFor(ended)).toBe(0);
|
|
|
|
const list = await import("@/app/api/medications/route");
|
|
const res = await (list.GET as Handler)(
|
|
new NextRequest("http://localhost/api/medications"),
|
|
{ params: Promise.resolve({}) },
|
|
);
|
|
const published = new Map(
|
|
(
|
|
(await res.json()) as {
|
|
data: Array<{ id: string; remindersActive: boolean }>;
|
|
}
|
|
).data.map((m) => [m.id, m.remindersActive] as const),
|
|
);
|
|
expect(published.get(running)).toBe(true);
|
|
expect(published.get(ended)).toBe(false);
|
|
});
|
|
});
|