mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
The window before a booked visit started at the last matching visit however far back it lay, while the health-report export covers 730 days and fell back to its 90-day default for anything longer. The card then offered a report the export did not build. The window now stops at the export's limit, read from one shared constant, and preparation.capped says when it did; the web says "The last two years" for it. The export clamps an over-long explicit range to the last 730 days before its end instead of falling back to 90 days, so a custom range or a linked window cannot shrink silently either, and its day count forgives the hour a clock change adds, so two years of local days count as 730.
313 lines
10 KiB
TypeScript
313 lines
10 KiB
TypeScript
/**
|
|
* The window before a booked visit, through the real list route and real
|
|
* Postgres.
|
|
*
|
|
* The unit test pins the rule over hand-built rows. What only the database can
|
|
* answer is the read: that it looks at visits that took place (DONE, not
|
|
* cancelled, not booked), that it ignores the list's own filters and paging,
|
|
* that a soft-deleted visit is not an anchor, and that the practitioner's
|
|
* specialty reaches the rule through the join.
|
|
*/
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
import { GET as listEncounters } from "@/app/api/encounters/route";
|
|
import { normaliseDateRange } from "@/lib/doctor-report-helpers";
|
|
import { REPORT_MAX_RANGE_DAYS } from "@/lib/doctor-report-range";
|
|
import type { EncounterListDTO } from "@/lib/encounters/dto";
|
|
import { daysBetweenDateKeys, shiftDateKey } from "@/lib/tz/format";
|
|
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
|
|
|
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),
|
|
})),
|
|
};
|
|
});
|
|
|
|
const OWNER_ID = "prep-owner";
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
function daysFromNow(days: number): Date {
|
|
return new Date(Date.now() + days * DAY_MS);
|
|
}
|
|
|
|
/** The local day of an instant on the owner's calendar. */
|
|
function berlinDay(at: Date): string {
|
|
return at.toLocaleDateString("sv-SE", { timeZone: "Europe/Berlin" });
|
|
}
|
|
|
|
async function seed() {
|
|
const prisma = getPrismaClient();
|
|
await prisma.user.create({
|
|
data: {
|
|
id: OWNER_ID,
|
|
username: "prep-owner",
|
|
email: "prep-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);
|
|
}
|
|
|
|
async function practitioner(name: string, specialty: string | null) {
|
|
return getPrismaClient().practitioner.create({
|
|
data: { userId: OWNER_ID, name, specialty },
|
|
});
|
|
}
|
|
|
|
async function encounter(data: {
|
|
occurredAt: Date;
|
|
status?: "PLANNED" | "DONE" | "CANCELLED" | "NO_SHOW";
|
|
kind?: "ROUTINE" | "SPECIALIST" | "THERAPY" | "OTHER";
|
|
practitionerId?: string | null;
|
|
deletedAt?: Date | null;
|
|
}) {
|
|
return getPrismaClient().encounter.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
occurredAt: data.occurredAt,
|
|
status: data.status ?? "DONE",
|
|
kind: data.kind ?? "OTHER",
|
|
practitionerId: data.practitionerId ?? null,
|
|
deletedAt: data.deletedAt ?? null,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function list(query = ""): Promise<EncounterListDTO> {
|
|
const res = await listEncounters(
|
|
new Request(`http://localhost/api/encounters${query}`) as never,
|
|
);
|
|
expect(res.status).toBe(200);
|
|
return ((await res.json()) as { data: EncounterListDTO }).data;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
await seed();
|
|
});
|
|
|
|
describe("GET /api/encounters — the window before a booked visit", () => {
|
|
it("starts at the same practitioner's last visit, not the last visit of any kind", async () => {
|
|
const derm = await practitioner("Skin practice", "Dermatology");
|
|
const internist = await practitioner(
|
|
"Internal practice",
|
|
"Internal medicine",
|
|
);
|
|
const dermVisit = await encounter({
|
|
occurredAt: daysFromNow(-200),
|
|
kind: "SPECIALIST",
|
|
practitionerId: derm.id,
|
|
});
|
|
// The newer visit elsewhere the old rule anchored on.
|
|
await encounter({
|
|
occurredAt: daysFromNow(-5),
|
|
kind: "SPECIALIST",
|
|
practitionerId: internist.id,
|
|
});
|
|
const next = await encounter({
|
|
occurredAt: daysFromNow(2),
|
|
status: "PLANNED",
|
|
kind: "SPECIALIST",
|
|
practitionerId: derm.id,
|
|
});
|
|
|
|
const body = await list();
|
|
const booked = body.upcoming.find((v) => v.id === next.id);
|
|
expect(booked?.preparation).toEqual({
|
|
from: berlinDay(dermVisit.occurredAt),
|
|
to: berlinDay(next.occurredAt),
|
|
basis: "practitioner",
|
|
since: {
|
|
id: dermVisit.id,
|
|
occurredAt: dermVisit.occurredAt.toISOString(),
|
|
kind: "SPECIALIST",
|
|
practitionerName: "Skin practice",
|
|
specialty: "Dermatology",
|
|
},
|
|
capped: false,
|
|
});
|
|
// Only a booked visit carries one.
|
|
expect(body.past.every((v) => v.preparation === null)).toBe(true);
|
|
});
|
|
|
|
it("matches a specialty across practitioners, through the join", async () => {
|
|
const oldDerm = await practitioner("Former skin practice", "dermatology ");
|
|
const newDerm = await practitioner("Skin practice", "Dermatology");
|
|
const anchor = await encounter({
|
|
occurredAt: daysFromNow(-300),
|
|
kind: "SPECIALIST",
|
|
practitionerId: oldDerm.id,
|
|
});
|
|
const next = await encounter({
|
|
occurredAt: daysFromNow(3),
|
|
status: "PLANNED",
|
|
kind: "SPECIALIST",
|
|
practitionerId: newDerm.id,
|
|
});
|
|
|
|
const booked = (await list()).upcoming.find((v) => v.id === next.id);
|
|
expect(booked?.preparation?.basis).toBe("specialty");
|
|
expect(booked?.preparation?.since?.id).toBe(anchor.id);
|
|
});
|
|
|
|
it("anchors only on a visit that took place and still exists", async () => {
|
|
const derm = await practitioner("Skin practice", "Dermatology");
|
|
await encounter({
|
|
occurredAt: daysFromNow(-20),
|
|
status: "CANCELLED",
|
|
practitionerId: derm.id,
|
|
});
|
|
await encounter({
|
|
occurredAt: daysFromNow(-30),
|
|
status: "NO_SHOW",
|
|
practitionerId: derm.id,
|
|
});
|
|
await encounter({
|
|
occurredAt: daysFromNow(-40),
|
|
practitionerId: derm.id,
|
|
deletedAt: new Date(),
|
|
});
|
|
// Another booked visit with the same practice is not one that happened.
|
|
await encounter({
|
|
occurredAt: daysFromNow(1),
|
|
status: "PLANNED",
|
|
practitionerId: derm.id,
|
|
});
|
|
const next = await encounter({
|
|
occurredAt: daysFromNow(9),
|
|
status: "PLANNED",
|
|
practitionerId: derm.id,
|
|
});
|
|
|
|
const booked = (await list()).upcoming.find((v) => v.id === next.id);
|
|
expect(booked?.preparation).toEqual({
|
|
from: shiftDateKey(berlinDay(new Date()), -90),
|
|
to: berlinDay(next.occurredAt),
|
|
basis: "default",
|
|
since: null,
|
|
capped: false,
|
|
});
|
|
});
|
|
|
|
it("reads the whole history, whatever the list was filtered to", async () => {
|
|
const derm = await practitioner("Skin practice", "Dermatology");
|
|
const anchor = await encounter({
|
|
occurredAt: daysFromNow(-400),
|
|
practitionerId: derm.id,
|
|
});
|
|
const next = await encounter({
|
|
occurredAt: daysFromNow(4),
|
|
status: "PLANNED",
|
|
practitionerId: derm.id,
|
|
});
|
|
|
|
const from = daysFromNow(-1).toISOString();
|
|
const body = await list(`?from=${encodeURIComponent(from)}`);
|
|
expect(body.past).toEqual([]);
|
|
expect(
|
|
body.upcoming.find((v) => v.id === next.id)?.preparation?.since?.id,
|
|
).toBe(anchor.id);
|
|
});
|
|
|
|
it("caps a window older than a report covers, and the export builds that report", async () => {
|
|
const derm = await practitioner("Skin practice", "Dermatology");
|
|
// Three years back: further than the 730 days a report covers.
|
|
const anchor = await encounter({
|
|
occurredAt: daysFromNow(-1100),
|
|
practitionerId: derm.id,
|
|
});
|
|
const next = await encounter({
|
|
occurredAt: daysFromNow(6),
|
|
status: "PLANNED",
|
|
practitionerId: derm.id,
|
|
});
|
|
|
|
const prep = (await list()).upcoming.find(
|
|
(v) => v.id === next.id,
|
|
)?.preparation;
|
|
const to = berlinDay(next.occurredAt);
|
|
expect(prep).toMatchObject({
|
|
from: shiftDateKey(to, -(REPORT_MAX_RANGE_DAYS - 1)),
|
|
to,
|
|
basis: "practitioner",
|
|
capped: true,
|
|
});
|
|
// The anchor is still named: the window is measured from it.
|
|
expect(prep?.since?.id).toBe(anchor.id);
|
|
expect(daysBetweenDateKeys(prep!.from, to) + 1).toBe(REPORT_MAX_RANGE_DAYS);
|
|
|
|
// The report the panel requests for that window, cut the way it cuts
|
|
// it: from the first instant of `from` to the last of `to`. The export
|
|
// takes it whole instead of falling back to its 90-day default.
|
|
const startDate = startOfLocalDayKey(prep!.from, "Europe/Berlin");
|
|
const endDate = new Date(
|
|
startOfLocalDayKey(shiftDateKey(to, 1), "Europe/Berlin").getTime() - 1,
|
|
);
|
|
const range = normaliseDateRange({
|
|
startDate: startDate.toISOString(),
|
|
endDate: endDate.toISOString(),
|
|
});
|
|
expect(range.start.toISOString()).toBe(startDate.toISOString());
|
|
expect(range.end.toISOString()).toBe(endDate.toISOString());
|
|
expect(range.days).toBe(REPORT_MAX_RANGE_DAYS);
|
|
});
|
|
|
|
it("leaves a window that fits a report as it is", async () => {
|
|
const derm = await practitioner("Skin practice", "Dermatology");
|
|
const next = await encounter({
|
|
occurredAt: daysFromNow(6),
|
|
status: "PLANNED",
|
|
practitionerId: derm.id,
|
|
});
|
|
const to = berlinDay(next.occurredAt);
|
|
// Exactly the longest report, both end days counted.
|
|
const anchorDay = shiftDateKey(to, -(REPORT_MAX_RANGE_DAYS - 1));
|
|
await encounter({
|
|
occurredAt: new Date(
|
|
startOfLocalDayKey(anchorDay, "Europe/Berlin").getTime() +
|
|
10 * 60 * 60 * 1000,
|
|
),
|
|
practitionerId: derm.id,
|
|
});
|
|
const prep = (await list()).upcoming.find(
|
|
(v) => v.id === next.id,
|
|
)?.preparation;
|
|
expect(prep).toMatchObject({ from: anchorDay, capped: false });
|
|
});
|
|
|
|
it("leaves a cancelled upcoming visit without a window", async () => {
|
|
const cancelled = await encounter({
|
|
occurredAt: daysFromNow(5),
|
|
status: "CANCELLED",
|
|
});
|
|
const booked = (await list()).upcoming.find((v) => v.id === cancelled.id);
|
|
expect(booked?.preparation).toBeNull();
|
|
});
|
|
});
|