mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
The sweep that retires an appointment reminder no visit claims had no test of its own. Two now, because the rule has two halves and only one of them is obvious. It reaps a live row nothing points at, and it must leave a row whose only claimant is a soft-deleted visit. That second half is the subtle one: the sweep matches on the relation and does not read the visit's tombstone, so a deleted visit still owns its nudge and restoring the visit finds it intact. A future change that "tidied" the predicate by also excluding tombstoned visits would destroy that on the next unrelated write, and nothing else would notice. The visit is tombstoned directly rather than through the delete route, which retires the reminder deliberately and would have proven nothing.
845 lines
28 KiB
TypeScript
845 lines
28 KiB
TypeScript
/**
|
|
* The appointment arm against real Postgres, through the real routes.
|
|
*
|
|
* Every property here is about ROWS — how many exist, which instant they carry,
|
|
* whether a second call wrote anything — and none of them can be answered by a
|
|
* mocked client. The one that matters most is a count: a reschedule must leave
|
|
* the reminder count at one, and a service that minted a second on every edit
|
|
* would pass any assertion phrased as "the reminder moved".
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import { POST as createEncounter } from "@/app/api/encounters/route";
|
|
import {
|
|
PATCH as patchEncounter,
|
|
DELETE as deleteEncounter,
|
|
} from "@/app/api/encounters/[id]/route";
|
|
import { GET as listReminders } from "@/app/api/measurement-reminders/route";
|
|
|
|
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),
|
|
}));
|
|
|
|
const OWNER_ID = "appointment-owner";
|
|
|
|
/** Far enough out that the appointment is still in the future mid-run. */
|
|
function daysFromNow(days: number): Date {
|
|
return new Date(Date.now() + days * 24 * 60 * 60 * 1000);
|
|
}
|
|
|
|
async function seedSession() {
|
|
const prisma = getPrismaClient();
|
|
await prisma.user.create({
|
|
data: {
|
|
id: OWNER_ID,
|
|
username: "appointment-owner",
|
|
email: "appointment-owner@example.test",
|
|
timezone: "Europe/Berlin",
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
expiresAt: daysFromNow(7),
|
|
mfaVerifiedAt: new Date(),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
}
|
|
|
|
function post(body: unknown): Promise<Response> {
|
|
return createEncounter(
|
|
new Request("http://localhost/api/encounters", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
}) as never,
|
|
);
|
|
}
|
|
|
|
function patch(id: string, body: unknown): Promise<Response> {
|
|
return patchEncounter(
|
|
new Request(`http://localhost/api/encounters/${id}`, {
|
|
method: "PATCH",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
}) as never,
|
|
{ params: Promise.resolve({ id }) } as never,
|
|
);
|
|
}
|
|
|
|
async function body(res: Response) {
|
|
return (await res.json()) as { data: Record<string, unknown> };
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
await seedSession();
|
|
});
|
|
|
|
describe("booking a visit", () => {
|
|
it("mints exactly one reminder, anchored on the appointment", async () => {
|
|
const prisma = getPrismaClient();
|
|
const occurredAt = daysFromNow(14);
|
|
|
|
const res = await post({
|
|
occurredAt: occurredAt.toISOString(),
|
|
status: "PLANNED",
|
|
kind: "SPECIALIST",
|
|
});
|
|
expect(res.status).toBe(201);
|
|
|
|
const reminders = await prisma.measurementReminder.findMany({
|
|
where: { userId: OWNER_ID },
|
|
});
|
|
expect(reminders).toHaveLength(1);
|
|
expect(reminders[0]!.origin).toBe("ENCOUNTER");
|
|
// Both cadence columns NULL is the schema's one-shot: it fires once and
|
|
// the engine can never compute a second occurrence for it.
|
|
expect(reminders[0]!.intervalDays).toBeNull();
|
|
expect(reminders[0]!.rrule).toBeNull();
|
|
expect(reminders[0]!.nextDueAt?.toISOString()).toBe(
|
|
occurredAt.toISOString(),
|
|
);
|
|
|
|
const created = (await body(res)).data;
|
|
expect(created.reminderNextDueAt).toBe(occurredAt.toISOString());
|
|
});
|
|
|
|
it("does not mint one for a visit that already happened", async () => {
|
|
const prisma = getPrismaClient();
|
|
const res = await post({
|
|
occurredAt: daysFromNow(-3).toISOString(),
|
|
status: "DONE",
|
|
});
|
|
expect(res.status).toBe(201);
|
|
expect(await prisma.measurementReminder.count()).toBe(0);
|
|
});
|
|
|
|
it("saves with a date and nothing else", async () => {
|
|
// The product constraint, asserted rather than described: no practitioner,
|
|
// no kind, no reason, no link, and the visit still files.
|
|
const res = await post({ occurredAt: daysFromNow(-1).toISOString() });
|
|
expect(res.status).toBe(201);
|
|
const created = (await body(res)).data;
|
|
expect(created.practitioner).toBeNull();
|
|
expect(created.reason).toBeNull();
|
|
expect(created.status).toBe("DONE");
|
|
});
|
|
});
|
|
|
|
describe("what the notification will say", () => {
|
|
it("labels a practice-less appointment in the person's own language", async () => {
|
|
const prisma = getPrismaClient();
|
|
await prisma.user.update({
|
|
where: { id: OWNER_ID },
|
|
data: { locale: "de" },
|
|
});
|
|
|
|
await post({
|
|
occurredAt: daysFromNow(10).toISOString(),
|
|
status: "PLANNED",
|
|
kind: "SPECIALIST",
|
|
});
|
|
|
|
const reminder = await prisma.measurementReminder.findFirstOrThrow({
|
|
where: { userId: OWNER_ID, origin: "ENCOUNTER" },
|
|
});
|
|
// The label IS the push body. A raw enum constant reaching a lock screen
|
|
// is not a fallback, it is a leak of the schema into the product.
|
|
expect(reminder.label).not.toBe("SPECIALIST");
|
|
expect(reminder.label).not.toMatch(/^[A-Z_]+$/);
|
|
expect(reminder.label).toBe("Facharzttermin");
|
|
});
|
|
|
|
it("prefers the practice name when there is one", async () => {
|
|
const prisma = getPrismaClient();
|
|
const practitioner = await prisma.practitioner.create({
|
|
data: { userId: OWNER_ID, name: "Praxis Nord" },
|
|
});
|
|
|
|
await post({
|
|
occurredAt: daysFromNow(10).toISOString(),
|
|
status: "PLANNED",
|
|
kind: "SPECIALIST",
|
|
practitionerId: practitioner.id,
|
|
});
|
|
|
|
const reminder = await prisma.measurementReminder.findFirstOrThrow({
|
|
where: { userId: OWNER_ID, origin: "ENCOUNTER" },
|
|
});
|
|
expect(reminder.label).toBe("Praxis Nord");
|
|
});
|
|
});
|
|
|
|
describe("rescheduling", () => {
|
|
it("moves the same row and never mints a second", async () => {
|
|
const prisma = getPrismaClient();
|
|
const created = (
|
|
await body(
|
|
await post({
|
|
occurredAt: daysFromNow(14).toISOString(),
|
|
status: "PLANNED",
|
|
}),
|
|
)
|
|
).data;
|
|
|
|
const moved = daysFromNow(21);
|
|
const res = await patch(created.id as string, {
|
|
occurredAt: moved.toISOString(),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
|
|
const reminders = await prisma.measurementReminder.findMany({
|
|
where: { userId: OWNER_ID },
|
|
});
|
|
// The count is the assertion. "The reminder moved" would also be true of a
|
|
// service that left the old row behind and minted a fresh one.
|
|
expect(reminders).toHaveLength(1);
|
|
expect(reminders[0]!.nextDueAt?.toISOString()).toBe(moved.toISOString());
|
|
});
|
|
|
|
it("stops nudging once the visit is cancelled, without deleting the row", async () => {
|
|
const prisma = getPrismaClient();
|
|
const created = (
|
|
await body(
|
|
await post({
|
|
occurredAt: daysFromNow(14).toISOString(),
|
|
status: "PLANNED",
|
|
}),
|
|
)
|
|
).data;
|
|
|
|
await patch(created.id as string, { status: "CANCELLED" });
|
|
|
|
const reminders = await prisma.measurementReminder.findMany({
|
|
where: { userId: OWNER_ID },
|
|
});
|
|
expect(reminders).toHaveLength(1);
|
|
expect(reminders[0]!.enabled).toBe(false);
|
|
expect(reminders[0]!.nextDueAt).toBeNull();
|
|
});
|
|
|
|
it("retires the reminder when the visit is deleted", async () => {
|
|
const prisma = getPrismaClient();
|
|
const created = (
|
|
await body(
|
|
await post({
|
|
occurredAt: daysFromNow(14).toISOString(),
|
|
status: "PLANNED",
|
|
}),
|
|
)
|
|
).data;
|
|
|
|
const res = await deleteEncounter(
|
|
new Request(`http://localhost/api/encounters/${created.id}`, {
|
|
method: "DELETE",
|
|
}) as never,
|
|
{ params: Promise.resolve({ id: created.id as string }) } as never,
|
|
);
|
|
expect(res.status).toBe(200);
|
|
|
|
const reminder = await prisma.measurementReminder.findFirstOrThrow({
|
|
where: { userId: OWNER_ID },
|
|
});
|
|
expect(reminder.deletedAt).not.toBeNull();
|
|
expect(reminder.nextDueAt).toBeNull();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The orphan class, replayed as the sequence that produced it.
|
|
*
|
|
* A reminder nobody points at still fires: the cron reads the engine table, not
|
|
* the visit list. So an ENCOUNTER-origin row that no encounter owns pushes
|
|
* notifications for an appointment the person can no longer see, edit or
|
|
* cancel, and deleting the visit does not reach it. Every assertion below
|
|
* counts LIVE ENCOUNTER rows rather than checking that some particular row
|
|
* moved, because the defect was an extra row rather than a wrong one.
|
|
*/
|
|
async function liveEncounterReminders() {
|
|
return getPrismaClient().measurementReminder.findMany({
|
|
where: { userId: OWNER_ID, origin: "ENCOUNTER", deletedAt: null },
|
|
});
|
|
}
|
|
|
|
describe("an appointment reminder can never be orphaned", () => {
|
|
async function seedCheckup() {
|
|
return getPrismaClient().measurementReminder.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
label: "Annual blood panel",
|
|
intervalDays: 365,
|
|
anchorDate: daysFromNow(-370),
|
|
notifyHour: 9,
|
|
nextDueAt: daysFromNow(-5),
|
|
},
|
|
});
|
|
}
|
|
|
|
it("refuses to point a still-booked visit at a checkup", async () => {
|
|
const created = (
|
|
await body(
|
|
await post({
|
|
occurredAt: daysFromNow(14).toISOString(),
|
|
status: "PLANNED",
|
|
}),
|
|
)
|
|
).data;
|
|
const checkup = await seedCheckup();
|
|
|
|
const res = await patch(created.id as string, { reminderId: checkup.id });
|
|
|
|
// The same contradiction the create arm already refuses: a visit that has
|
|
// not happened cannot have closed anything.
|
|
expect(res.status).toBe(422);
|
|
expect(await liveEncounterReminders()).toHaveLength(1);
|
|
});
|
|
|
|
it("keeps exactly one live reminder across the sequence that produced the orphan", async () => {
|
|
const created = (
|
|
await body(
|
|
await post({
|
|
occurredAt: daysFromNow(14).toISOString(),
|
|
status: "PLANNED",
|
|
}),
|
|
)
|
|
).data;
|
|
const checkup = await seedCheckup();
|
|
const first = await liveEncounterReminders();
|
|
expect(first).toHaveLength(1);
|
|
|
|
// 1. Re-point at a checkup — refused while the visit is still booked.
|
|
await patch(created.id as string, { reminderId: checkup.id });
|
|
expect(await liveEncounterReminders()).toHaveLength(1);
|
|
|
|
// 2. Move the date. Before the fix this minted a SECOND reminder, because
|
|
// the re-anchor path looked only at the visit's foreign key and that key
|
|
// had been walked off its own row by step 1.
|
|
const moved = daysFromNow(21);
|
|
expect(
|
|
(await patch(created.id as string, { occurredAt: moved.toISOString() }))
|
|
.status,
|
|
).toBe(200);
|
|
|
|
const after = await liveEncounterReminders();
|
|
expect(after).toHaveLength(1);
|
|
expect(after[0]!.nextDueAt?.toISOString()).toBe(moved.toISOString());
|
|
});
|
|
|
|
it("leaves nothing live behind when the visit is deleted", async () => {
|
|
const created = (
|
|
await body(
|
|
await post({
|
|
occurredAt: daysFromNow(14).toISOString(),
|
|
status: "PLANNED",
|
|
}),
|
|
)
|
|
).data;
|
|
const checkup = await seedCheckup();
|
|
await patch(created.id as string, { reminderId: checkup.id });
|
|
await patch(created.id as string, {
|
|
occurredAt: daysFromNow(21).toISOString(),
|
|
});
|
|
|
|
await deleteEncounter(
|
|
new Request(`http://localhost/api/encounters/${created.id}`, {
|
|
method: "DELETE",
|
|
}) as never,
|
|
{ params: Promise.resolve({ id: created.id as string }) } as never,
|
|
);
|
|
|
|
// Nothing may still be armed for a visit that no longer exists.
|
|
expect(await liveEncounterReminders()).toEqual([]);
|
|
// The person's own checkup is untouched — it was never this visit's to retire.
|
|
expect(
|
|
(
|
|
await getPrismaClient().measurementReminder.findUniqueOrThrow({
|
|
where: { id: checkup.id },
|
|
})
|
|
).deletedAt,
|
|
).toBeNull();
|
|
});
|
|
|
|
it("switching a booked visit to done leaves no live appointment reminder", async () => {
|
|
const created = (
|
|
await body(
|
|
await post({
|
|
occurredAt: daysFromNow(14).toISOString(),
|
|
status: "PLANNED",
|
|
}),
|
|
)
|
|
).data;
|
|
|
|
await patch(created.id as string, {
|
|
status: "DONE",
|
|
occurredAt: daysFromNow(-1).toISOString(),
|
|
});
|
|
|
|
// The row may survive as history, but it must not still be armed.
|
|
const live = await liveEncounterReminders();
|
|
for (const row of live) {
|
|
expect(row.enabled).toBe(false);
|
|
expect(row.nextDueAt).toBeNull();
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The orphan sweep, in both directions.
|
|
*
|
|
* The tests above prove the sequence that produced an orphan no longer does.
|
|
* These two pin the sweep itself, because it is the part that makes the state
|
|
* unreachable rather than merely unproduced — and because its retention rule
|
|
* is subtler than it looks.
|
|
*
|
|
* It reaps by the RELATION (`encounters: { none: {} }`), which counts visits
|
|
* pointing at a reminder without caring whether those visits are tombstoned.
|
|
* That asymmetry is deliberate and is the second test: a soft-deleted visit
|
|
* still owns its reminder, so restoring the visit finds its nudge intact. A
|
|
* sweep that also read the visit's tombstone would quietly destroy that on the
|
|
* next unrelated write, and nothing else in the system would notice.
|
|
*/
|
|
describe("the orphan sweep", () => {
|
|
/** A live appointment row with no visit pointing at it — the state itself. */
|
|
async function strandedReminder() {
|
|
return getPrismaClient().measurementReminder.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
label: "Stranded appointment",
|
|
origin: "ENCOUNTER",
|
|
anchorDate: daysFromNow(5),
|
|
notifyHour: 9,
|
|
nextDueAt: daysFromNow(5),
|
|
enabled: true,
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Any visit write; the sweep rides the transaction rather than a route. */
|
|
async function anyVisitWrite() {
|
|
const created = (
|
|
await body(
|
|
await post({
|
|
occurredAt: daysFromNow(-4).toISOString(),
|
|
status: "DONE",
|
|
}),
|
|
)
|
|
).data;
|
|
expect(
|
|
(await patch(created.id as string, { kind: "ROUTINE" })).status,
|
|
).toBe(200);
|
|
}
|
|
|
|
it("tombstones a row no visit claims", async () => {
|
|
const prisma = getPrismaClient();
|
|
const stranded = await strandedReminder();
|
|
|
|
await anyVisitWrite();
|
|
|
|
const after = await prisma.measurementReminder.findUniqueOrThrow({
|
|
where: { id: stranded.id },
|
|
});
|
|
// Disarmed AND tombstoned. Disabling alone would leave a row the engine
|
|
// ignores but every future reader still has to reason about.
|
|
expect(after.deletedAt).not.toBeNull();
|
|
expect(after.enabled).toBe(false);
|
|
expect(after.nextDueAt).toBeNull();
|
|
});
|
|
|
|
it("leaves a row that a SOFT-DELETED visit still claims", async () => {
|
|
const prisma = getPrismaClient();
|
|
await post({
|
|
occurredAt: daysFromNow(9).toISOString(),
|
|
status: "PLANNED",
|
|
});
|
|
const owned = await prisma.measurementReminder.findFirstOrThrow({
|
|
where: { userId: OWNER_ID, origin: "ENCOUNTER" },
|
|
});
|
|
const visit = await prisma.encounter.findFirstOrThrow({
|
|
where: { userId: OWNER_ID, reminderId: owned.id },
|
|
});
|
|
|
|
// Tombstoned directly rather than through the delete route: that route
|
|
// retires the reminder on purpose, which would prove nothing about the
|
|
// sweep. This is the state a soft-deleted visit is actually in.
|
|
await prisma.encounter.update({
|
|
where: { id: visit.id },
|
|
data: { deletedAt: new Date() },
|
|
});
|
|
|
|
await anyVisitWrite();
|
|
|
|
const after = await prisma.measurementReminder.findUniqueOrThrow({
|
|
where: { id: owned.id },
|
|
});
|
|
// Still claimed, so still there. Restoring the visit has to find its nudge.
|
|
expect(after.deletedAt).toBeNull();
|
|
expect(after.enabled).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("closing a checkup by filing a visit", () => {
|
|
async function seedCheckup() {
|
|
return getPrismaClient().measurementReminder.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
label: "Annual blood panel",
|
|
intervalDays: 365,
|
|
anchorDate: daysFromNow(-370),
|
|
notifyHour: 9,
|
|
nextDueAt: daysFromNow(-5),
|
|
},
|
|
});
|
|
}
|
|
|
|
it("advances the checkup when the visit is filed as done", async () => {
|
|
const prisma = getPrismaClient();
|
|
const checkup = await seedCheckup();
|
|
|
|
const res = await post({
|
|
occurredAt: daysFromNow(-1).toISOString(),
|
|
status: "DONE",
|
|
reminderId: checkup.id,
|
|
});
|
|
expect(res.status).toBe(201);
|
|
|
|
const after = await prisma.measurementReminder.findUniqueOrThrow({
|
|
where: { id: checkup.id },
|
|
});
|
|
expect(after.lastSatisfiedAt).not.toBeNull();
|
|
expect(after.nextDueAt!.getTime()).toBeGreaterThan(Date.now());
|
|
});
|
|
|
|
it("is idempotent — filing it done twice changes nothing and still succeeds", async () => {
|
|
const prisma = getPrismaClient();
|
|
const checkup = await seedCheckup();
|
|
const created = (
|
|
await body(
|
|
await post({
|
|
occurredAt: daysFromNow(-1).toISOString(),
|
|
status: "DONE",
|
|
reminderId: checkup.id,
|
|
}),
|
|
)
|
|
).data;
|
|
|
|
const first = await prisma.measurementReminder.findUniqueOrThrow({
|
|
where: { id: checkup.id },
|
|
});
|
|
|
|
const res = await patch(created.id as string, { status: "DONE" });
|
|
expect(res.status).toBe(200);
|
|
|
|
const second = await prisma.measurementReminder.findUniqueOrThrow({
|
|
where: { id: checkup.id },
|
|
});
|
|
expect(second.lastSatisfiedAt?.toISOString()).toBe(
|
|
first.lastSatisfiedAt?.toISOString(),
|
|
);
|
|
expect(second.nextDueAt?.toISOString()).toBe(
|
|
first.nextDueAt?.toISOString(),
|
|
);
|
|
});
|
|
|
|
it("anchors the checkup on the visit date, not on when it was typed in", async () => {
|
|
const prisma = getPrismaClient();
|
|
const checkup = await seedCheckup();
|
|
const visitAt = daysFromNow(-30);
|
|
|
|
await post({
|
|
occurredAt: visitAt.toISOString(),
|
|
status: "DONE",
|
|
reminderId: checkup.id,
|
|
});
|
|
|
|
const after = await prisma.measurementReminder.findUniqueOrThrow({
|
|
where: { id: checkup.id },
|
|
});
|
|
// A yearly panel drawn thirty days ago is next due 335 days from now, not
|
|
// 365. Satisfying at "now" would quietly move every backdated entry's next
|
|
// due date forward by however long the person took to record it.
|
|
expect(after.lastSatisfiedAt?.toISOString()).toBe(visitAt.toISOString());
|
|
const daysOut =
|
|
(after.nextDueAt!.getTime() - Date.now()) / (24 * 60 * 60 * 1000);
|
|
expect(daysOut).toBeGreaterThan(330);
|
|
expect(daysOut).toBeLessThan(340);
|
|
});
|
|
|
|
it("does not advance the checkup again when a visit re-enters DONE", async () => {
|
|
const prisma = getPrismaClient();
|
|
const checkup = await seedCheckup();
|
|
const created = (
|
|
await body(
|
|
await post({
|
|
occurredAt: daysFromNow(-1).toISOString(),
|
|
status: "DONE",
|
|
reminderId: checkup.id,
|
|
}),
|
|
)
|
|
).data;
|
|
const first = await prisma.measurementReminder.findUniqueOrThrow({
|
|
where: { id: checkup.id },
|
|
});
|
|
|
|
// Correcting a mis-filed visit is an ordinary thing to do, and the round
|
|
// trip is a transition each way — so the status gate alone does not stop
|
|
// the second satisfy. Satisfying at the VISIT instant does: it is not
|
|
// strictly after the last one, and the primitive is forward-only.
|
|
await patch(created.id as string, { status: "NO_SHOW" });
|
|
expect((await patch(created.id as string, { status: "DONE" })).status).toBe(
|
|
200,
|
|
);
|
|
|
|
const second = await prisma.measurementReminder.findUniqueOrThrow({
|
|
where: { id: checkup.id },
|
|
});
|
|
expect(second.lastSatisfiedAt?.toISOString()).toBe(
|
|
first.lastSatisfiedAt?.toISOString(),
|
|
);
|
|
expect(second.nextDueAt?.toISOString()).toBe(
|
|
first.nextDueAt?.toISOString(),
|
|
);
|
|
});
|
|
|
|
it("refuses a planned visit that also claims to close a checkup", async () => {
|
|
const checkup = await seedCheckup();
|
|
const res = await post({
|
|
occurredAt: daysFromNow(14).toISOString(),
|
|
status: "PLANNED",
|
|
reminderId: checkup.id,
|
|
});
|
|
// A visit that has not happened cannot have closed anything.
|
|
expect(res.status).toBe(422);
|
|
});
|
|
|
|
it("refuses an appointment reminder as the checkup to close", async () => {
|
|
const prisma = getPrismaClient();
|
|
await post({
|
|
occurredAt: daysFromNow(14).toISOString(),
|
|
status: "PLANNED",
|
|
});
|
|
const appointment = await prisma.measurementReminder.findFirstOrThrow({
|
|
where: { userId: OWNER_ID, origin: "ENCOUNTER" },
|
|
});
|
|
|
|
const res = await post({
|
|
occurredAt: daysFromNow(-1).toISOString(),
|
|
status: "DONE",
|
|
reminderId: appointment.id,
|
|
});
|
|
// Accepting it would let one visit re-point another visit's reminder.
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe("the list window", () => {
|
|
it("honours an upper bound that is earlier than now", async () => {
|
|
// The past arm bounds itself at `now`, and the user's own `to` used to be
|
|
// overwritten by that bound rather than combined with it, so every
|
|
// narrower window silently widened to "everything up to today".
|
|
await post({ occurredAt: daysFromNow(-2).toISOString(), status: "DONE" });
|
|
|
|
const { GET: listVisits } = await import("@/app/api/encounters/route");
|
|
const res = await listVisits(
|
|
new Request(
|
|
`http://localhost/api/encounters?to=${daysFromNow(-30).toISOString()}`,
|
|
) as never,
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const listed = (await res.json()) as {
|
|
data: { upcoming: unknown[]; past: unknown[] };
|
|
};
|
|
expect(listed.data.past).toEqual([]);
|
|
expect(listed.data.upcoming).toEqual([]);
|
|
});
|
|
|
|
it("still returns a visit inside the window", async () => {
|
|
// The counterpart, so the fix cannot be "return nothing".
|
|
await post({ occurredAt: daysFromNow(-2).toISOString(), status: "DONE" });
|
|
|
|
const { GET: listVisits } = await import("@/app/api/encounters/route");
|
|
const res = await listVisits(
|
|
new Request(
|
|
`http://localhost/api/encounters?from=${daysFromNow(-5).toISOString()}&to=${daysFromNow(-1).toISOString()}`,
|
|
) as never,
|
|
);
|
|
const listed = (await res.json()) as { data: { past: unknown[] } };
|
|
expect(listed.data.past).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("an appointment is not addressable through the reminder API", () => {
|
|
async function bookedReminder() {
|
|
await post({
|
|
occurredAt: daysFromNow(14).toISOString(),
|
|
status: "PLANNED",
|
|
});
|
|
return getPrismaClient().measurementReminder.findFirstOrThrow({
|
|
where: { userId: OWNER_ID, origin: "ENCOUNTER" },
|
|
});
|
|
}
|
|
|
|
it("404s the read, and does not leak the appointment", async () => {
|
|
const reminder = await bookedReminder();
|
|
const { GET } = await import("@/app/api/measurement-reminders/[id]/route");
|
|
const res = await GET(
|
|
new Request(
|
|
`http://localhost/api/measurement-reminders/${reminder.id}`,
|
|
) as never,
|
|
{ params: Promise.resolve({ id: reminder.id }) } as never,
|
|
);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("404s complete WITHOUT committing the satisfy", async () => {
|
|
// The shape that mattered most: this is the endpoint behind the Done
|
|
// button on an appointment push. It used to write, then 500 on the mapper.
|
|
const reminder = await bookedReminder();
|
|
const { POST: complete } =
|
|
await import("@/app/api/measurement-reminders/[id]/complete/route");
|
|
const res = await complete(
|
|
new Request(
|
|
`http://localhost/api/measurement-reminders/${reminder.id}/complete`,
|
|
{ method: "POST" },
|
|
) as never,
|
|
{ params: Promise.resolve({ id: reminder.id }) } as never,
|
|
);
|
|
|
|
expect(res.status).toBe(404);
|
|
const after = await getPrismaClient().measurementReminder.findUniqueOrThrow(
|
|
{ where: { id: reminder.id } },
|
|
);
|
|
expect(after.lastSatisfiedAt).toBeNull();
|
|
expect(after.nextDueAt?.toISOString()).toBe(
|
|
reminder.nextDueAt?.toISOString(),
|
|
);
|
|
});
|
|
|
|
it("404s satisfy WITHOUT committing it", async () => {
|
|
const reminder = await bookedReminder();
|
|
const { POST: satisfy } =
|
|
await import("@/app/api/measurement-reminders/[id]/satisfy/route");
|
|
const res = await satisfy(
|
|
new Request(
|
|
`http://localhost/api/measurement-reminders/${reminder.id}/satisfy`,
|
|
{ method: "POST" },
|
|
) as never,
|
|
{ params: Promise.resolve({ id: reminder.id }) } as never,
|
|
);
|
|
|
|
expect(res.status).toBe(404);
|
|
expect(
|
|
(
|
|
await getPrismaClient().measurementReminder.findUniqueOrThrow({
|
|
where: { id: reminder.id },
|
|
})
|
|
).lastSatisfiedAt,
|
|
).toBeNull();
|
|
});
|
|
|
|
it("404s the edit, so a one-shot cannot be given a cadence", async () => {
|
|
const reminder = await bookedReminder();
|
|
const { PATCH: editReminder } =
|
|
await import("@/app/api/measurement-reminders/[id]/route");
|
|
const res = await editReminder(
|
|
new Request(`http://localhost/api/measurement-reminders/${reminder.id}`, {
|
|
method: "PATCH",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ intervalDays: 7 }),
|
|
}) as never,
|
|
{ params: Promise.resolve({ id: reminder.id }) } as never,
|
|
);
|
|
|
|
expect(res.status).toBe(404);
|
|
// A cadence here would turn a spent one-shot into a permanent re-nag that
|
|
// shows up in no list the person can reach.
|
|
expect(
|
|
(
|
|
await getPrismaClient().measurementReminder.findUniqueOrThrow({
|
|
where: { id: reminder.id },
|
|
})
|
|
).intervalDays,
|
|
).toBeNull();
|
|
});
|
|
|
|
it("404s the delete, so the row survives for the visit that owns it", async () => {
|
|
const reminder = await bookedReminder();
|
|
const { DELETE: destroy } =
|
|
await import("@/app/api/measurement-reminders/[id]/route");
|
|
const res = await destroy(
|
|
new Request(`http://localhost/api/measurement-reminders/${reminder.id}`, {
|
|
method: "DELETE",
|
|
}) as never,
|
|
{ params: Promise.resolve({ id: reminder.id }) } as never,
|
|
);
|
|
|
|
expect(res.status).toBe(404);
|
|
// This verb hard-deletes. A delegate holding only the measurements domain
|
|
// reaching it would destroy a row belonging to the profile domain.
|
|
expect(
|
|
await getPrismaClient().measurementReminder.count({
|
|
where: { id: reminder.id },
|
|
}),
|
|
).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("the Vorsorge list", () => {
|
|
it("does not show a booked appointment as a checkup", async () => {
|
|
const prisma = getPrismaClient();
|
|
await prisma.measurementReminder.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
label: "Annual blood panel",
|
|
intervalDays: 365,
|
|
notifyHour: 9,
|
|
nextDueAt: daysFromNow(30),
|
|
},
|
|
});
|
|
await post({
|
|
occurredAt: daysFromNow(14).toISOString(),
|
|
status: "PLANNED",
|
|
});
|
|
|
|
// Two rows exist on the engine; one of them is an appointment.
|
|
expect(await prisma.measurementReminder.count()).toBe(2);
|
|
|
|
const res = await listReminders();
|
|
expect(res.status).toBe(200);
|
|
const listed = (await res.json()) as {
|
|
data: Array<{ label: string; origin: string }>;
|
|
};
|
|
// Exactly the checkup. Had the exclusion been missing, the mapper would
|
|
// have thrown and this would be a 500 rather than a wrong list — which is
|
|
// the point of the refusal.
|
|
expect(listed.data).toHaveLength(1);
|
|
expect(listed.data[0]!.label).toBe("Annual blood panel");
|
|
});
|
|
});
|