mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge branch 'v1424/fixes' into release/v1.42.4
This commit is contained in:
commit
de40db7ad0
49 files changed
+2579
-269
No files matched your search
@@ -42,7 +42,9 @@ async function seedConnection(userId: string): Promise<void> {
|
||||
googleUserId: `google-${userId}`,
|
||||
accessToken: "encrypted-access",
|
||||
refreshToken: "encrypted-refresh",
|
||||
tokenExpiresAt: new Date("2027-01-01T00:00:00.000Z"),
|
||||
// Valid for a year from whenever the suite runs: a fixed date would
|
||||
// expire and send the sync down the refresh path.
|
||||
tokenExpiresAt: new Date(Date.now() + 365 * 86_400_000),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* An extra (off-schedule) dose recorded in Apple Health must arrive as its own
|
||||
* unscheduled dose.
|
||||
*
|
||||
* The phone posts every HealthKit dose event to the bulk route with the
|
||||
* event's UUID as `externalId`, `scheduledFor` set to the event's scheduled
|
||||
* date or, for an extra dose that has none, its own time. A taken write binds
|
||||
* to a slot by window band, and a weekly schedule's band reaches a day either
|
||||
* side of its slot. An extra dose on the Wednesday after a Tuesday dose
|
||||
* therefore landed on the Tuesday slot and overwrote it: the Tuesday time was
|
||||
* replaced by the Wednesday one and the extra dose existed nowhere.
|
||||
*
|
||||
* Three shapes, each through the shipped `POST` and `GET` exports: an extra
|
||||
* dose far from any slot, one half an hour after a slot already taken, and one
|
||||
* on a day the schedule has no slot. Each asserts the stored rows AND the dose
|
||||
* history the app reads back, where the extra dose must show as off-schedule
|
||||
* and the slot keep its own dose.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
import { localHmAsUtc } from "@/lib/tz/local-day";
|
||||
|
||||
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 TZ = "Europe/Berlin";
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const MIN = 60_000;
|
||||
|
||||
let counter = 0;
|
||||
|
||||
async function signedInUser(): Promise<string> {
|
||||
const prisma = getPrismaClient();
|
||||
const suffix = `extra-dose-${counter++}`;
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: suffix,
|
||||
email: `${suffix}@example.test`,
|
||||
role: "USER",
|
||||
timezone: TZ,
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: user.id, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return user.id;
|
||||
}
|
||||
|
||||
async function seedMedication(
|
||||
userId: string,
|
||||
schedule: { timesOfDay: string[]; daysOfWeek: string | null },
|
||||
): Promise<string> {
|
||||
const med = await getPrismaClient().medication.create({
|
||||
data: {
|
||||
userId,
|
||||
name: "Magnesium",
|
||||
dose: "400mg",
|
||||
active: true,
|
||||
// Dose events from Apple Health only target a mirrored medication.
|
||||
externalSource: "APPLE_HEALTH",
|
||||
externalId: `hk-medication-${userId}`,
|
||||
startsOn: new Date(Date.now() - 60 * DAY_MS),
|
||||
// The ledger expects slots only from the medication's creation on.
|
||||
createdAt: new Date(Date.now() - 60 * DAY_MS),
|
||||
schedules: {
|
||||
create: {
|
||||
windowStart: schedule.timesOfDay[0],
|
||||
windowEnd: schedule.timesOfDay[0],
|
||||
timesOfDay: schedule.timesOfDay,
|
||||
daysOfWeek: schedule.daysOfWeek,
|
||||
scheduleType: "SCHEDULED",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return med.id;
|
||||
}
|
||||
|
||||
/** A local day at least two weeks back with the given weekday (0 = Sunday). */
|
||||
function pastLocalDay(weekday: number): Date {
|
||||
const day = new Date(Date.now() - 21 * DAY_MS);
|
||||
day.setUTCHours(12, 0, 0, 0);
|
||||
while (day.getUTCDay() !== weekday) {
|
||||
day.setTime(day.getTime() - DAY_MS);
|
||||
}
|
||||
return day;
|
||||
}
|
||||
|
||||
interface BulkEntry {
|
||||
medicationId: string;
|
||||
scheduledFor: string;
|
||||
takenAt: string;
|
||||
externalId: string;
|
||||
}
|
||||
|
||||
/** The phone's shape: one HealthKit dose event per entry. */
|
||||
function appleDose(
|
||||
medicationId: string,
|
||||
takenAt: Date,
|
||||
externalId: string,
|
||||
scheduledFor: Date = takenAt,
|
||||
): BulkEntry {
|
||||
return {
|
||||
medicationId,
|
||||
scheduledFor: scheduledFor.toISOString(),
|
||||
takenAt: takenAt.toISOString(),
|
||||
externalId,
|
||||
};
|
||||
}
|
||||
|
||||
async function postBulk(entries: BulkEntry[]) {
|
||||
const { POST } = await import("@/app/api/medications/intake/bulk/route");
|
||||
const response = await POST(
|
||||
new NextRequest("http://localhost/api/medications/intake/bulk", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
entries: entries.map((entry) => ({
|
||||
...entry,
|
||||
source: "APPLE_HEALTH",
|
||||
})),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as {
|
||||
data: { entries: Array<{ status: string; id?: string }> };
|
||||
};
|
||||
return body.data.entries;
|
||||
}
|
||||
|
||||
type Handler = (
|
||||
request: NextRequest,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
context: { params: Promise<any> },
|
||||
) => Promise<Response>;
|
||||
|
||||
interface LedgerRow {
|
||||
kind: "slot" | "ad_hoc";
|
||||
at: string;
|
||||
status: string;
|
||||
intake: { id: string | null; takenAt: string | null } | null;
|
||||
}
|
||||
|
||||
async function readLedger(
|
||||
medicationId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Promise<LedgerRow[]> {
|
||||
const { GET } = await import("@/app/api/medications/[id]/dose-history/route");
|
||||
const search = new URLSearchParams({
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
});
|
||||
const response = await (GET as Handler)(
|
||||
new NextRequest(
|
||||
`http://localhost/api/medications/${medicationId}/dose-history?${search}`,
|
||||
{ method: "GET" },
|
||||
),
|
||||
{ params: Promise.resolve({ id: medicationId }) },
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as { data: { rows: LedgerRow[] } };
|
||||
return body.data.rows;
|
||||
}
|
||||
|
||||
async function rowsOf(medicationId: string) {
|
||||
return getPrismaClient().medicationIntakeEvent.findMany({
|
||||
where: { medicationId, deletedAt: null },
|
||||
orderBy: { takenAt: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
function takenAtOf(row: LedgerRow | undefined): number | null {
|
||||
const taken = row?.intake?.takenAt;
|
||||
return taken ? new Date(taken).getTime() : null;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("an extra dose from Apple Health is stored as an unscheduled dose", () => {
|
||||
it("(a) far from any slot", async () => {
|
||||
const userId = await signedInUser();
|
||||
const medicationId = await seedMedication(userId, {
|
||||
timesOfDay: ["08:00"],
|
||||
daysOfWeek: null,
|
||||
});
|
||||
const day = pastLocalDay(3);
|
||||
const slot = localHmAsUtc(day, TZ, 8, 0);
|
||||
const extraAt = localHmAsUtc(day, TZ, 15, 0);
|
||||
|
||||
const results = await postBulk([
|
||||
appleDose(medicationId, new Date(slot.getTime() + 5 * MIN), "hk-a-1"),
|
||||
appleDose(medicationId, extraAt, "hk-a-2"),
|
||||
]);
|
||||
expect(results.map((r) => r.status)).toEqual(["inserted", "inserted"]);
|
||||
|
||||
const rows = await rowsOf(medicationId);
|
||||
expect(rows).toHaveLength(2);
|
||||
const extra = rows.find((row) => row.externalId === "hk-a-2");
|
||||
expect(extra?.takenAt?.getTime()).toBe(extraAt.getTime());
|
||||
expect(extra?.scheduledFor.getTime()).toBe(extraAt.getTime());
|
||||
|
||||
const ledger = await readLedger(
|
||||
medicationId,
|
||||
new Date(slot.getTime() - 6 * 3_600_000),
|
||||
new Date(slot.getTime() + 18 * 3_600_000),
|
||||
);
|
||||
const adHoc = ledger.filter((row) => row.kind === "ad_hoc");
|
||||
expect(adHoc).toHaveLength(1);
|
||||
expect(takenAtOf(adHoc[0])).toBe(extraAt.getTime());
|
||||
const slotRow = ledger.find((row) => row.kind === "slot");
|
||||
expect(slotRow?.status).toBe("taken_on_time");
|
||||
});
|
||||
|
||||
it("(b) 30 minutes after a slot already taken", async () => {
|
||||
const userId = await signedInUser();
|
||||
const medicationId = await seedMedication(userId, {
|
||||
timesOfDay: ["22:00"],
|
||||
daysOfWeek: null,
|
||||
});
|
||||
const day = pastLocalDay(2);
|
||||
const slot = localHmAsUtc(day, TZ, 22, 0);
|
||||
const ownAt = new Date(slot.getTime() + 2 * MIN);
|
||||
const extraAt = new Date(slot.getTime() + 30 * MIN);
|
||||
|
||||
// The slot's own dose first, then the extra one in a later sync.
|
||||
expect(
|
||||
(await postBulk([appleDose(medicationId, ownAt, "hk-b-1", slot)])).map(
|
||||
(r) => r.status,
|
||||
),
|
||||
).toEqual(["inserted"]);
|
||||
expect(
|
||||
(await postBulk([appleDose(medicationId, extraAt, "hk-b-2")])).map(
|
||||
(r) => r.status,
|
||||
),
|
||||
).toEqual(["inserted"]);
|
||||
|
||||
const rows = await rowsOf(medicationId);
|
||||
expect(rows).toHaveLength(2);
|
||||
const own = rows.find((row) => row.externalId === "hk-b-1");
|
||||
const extra = rows.find((row) => row.externalId === "hk-b-2");
|
||||
// The slot keeps its own dose, time unchanged.
|
||||
expect(own?.scheduledFor.getTime()).toBe(slot.getTime());
|
||||
expect(own?.takenAt?.getTime()).toBe(ownAt.getTime());
|
||||
// The extra dose sits on its own instant.
|
||||
expect(extra?.scheduledFor.getTime()).toBe(extraAt.getTime());
|
||||
expect(extra?.takenAt?.getTime()).toBe(extraAt.getTime());
|
||||
|
||||
const ledger = await readLedger(
|
||||
medicationId,
|
||||
new Date(slot.getTime() - 6 * 3_600_000),
|
||||
new Date(slot.getTime() + 6 * 3_600_000),
|
||||
);
|
||||
const slotRow = ledger.find((row) => row.kind === "slot");
|
||||
expect(slotRow?.status).toBe("taken_on_time");
|
||||
expect(takenAtOf(slotRow)).toBe(ownAt.getTime());
|
||||
const adHoc = ledger.filter((row) => row.kind === "ad_hoc");
|
||||
expect(adHoc).toHaveLength(1);
|
||||
expect(takenAtOf(adHoc[0])).toBe(extraAt.getTime());
|
||||
});
|
||||
|
||||
it("(b2) a re-sync of the same dose event stays one dose", async () => {
|
||||
const userId = await signedInUser();
|
||||
const medicationId = await seedMedication(userId, {
|
||||
timesOfDay: ["22:00"],
|
||||
daysOfWeek: null,
|
||||
});
|
||||
const slot = localHmAsUtc(pastLocalDay(2), TZ, 22, 0);
|
||||
const ownAt = new Date(slot.getTime() + 2 * MIN);
|
||||
|
||||
await postBulk([appleDose(medicationId, ownAt, "hk-b2-1", slot)]);
|
||||
const again = await postBulk([
|
||||
appleDose(medicationId, ownAt, "hk-b2-1", slot),
|
||||
]);
|
||||
expect(again.map((r) => r.status)).toEqual(["duplicate"]);
|
||||
expect(await rowsOf(medicationId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("(c) on a day without a slot, next to the weekly slot the day before", async () => {
|
||||
const userId = await signedInUser();
|
||||
// Tuesdays and Fridays at 22:00.
|
||||
const medicationId = await seedMedication(userId, {
|
||||
timesOfDay: ["22:00"],
|
||||
daysOfWeek: "2,5",
|
||||
});
|
||||
const tuesday = pastLocalDay(2);
|
||||
const wednesday = new Date(tuesday.getTime() + DAY_MS);
|
||||
const tuesdaySlot = localHmAsUtc(tuesday, TZ, 22, 0);
|
||||
const tuesdayDose = localHmAsUtc(tuesday, TZ, 20, 23);
|
||||
const wednesdayDose = localHmAsUtc(wednesday, TZ, 20, 45);
|
||||
|
||||
await postBulk([appleDose(medicationId, tuesdayDose, "hk-c-tue")]);
|
||||
const results = await postBulk([
|
||||
appleDose(medicationId, wednesdayDose, "hk-c-wed"),
|
||||
]);
|
||||
expect(results.map((r) => r.status)).toEqual(["inserted"]);
|
||||
|
||||
const rows = await rowsOf(medicationId);
|
||||
expect(rows).toHaveLength(2);
|
||||
const tue = rows.find((row) => row.externalId === "hk-c-tue");
|
||||
const wed = rows.find((row) => row.externalId === "hk-c-wed");
|
||||
// Tuesday keeps its own time.
|
||||
expect(tue?.scheduledFor.getTime()).toBe(tuesdaySlot.getTime());
|
||||
expect(tue?.takenAt?.getTime()).toBe(tuesdayDose.getTime());
|
||||
// Wednesday is its own unscheduled dose.
|
||||
expect(wed?.scheduledFor.getTime()).toBe(wednesdayDose.getTime());
|
||||
expect(wed?.takenAt?.getTime()).toBe(wednesdayDose.getTime());
|
||||
|
||||
const ledger = await readLedger(
|
||||
medicationId,
|
||||
new Date(tuesday.getTime() - 2 * DAY_MS),
|
||||
new Date(tuesday.getTime() + 2 * DAY_MS),
|
||||
);
|
||||
const tuesdayRow = ledger.find(
|
||||
(row) =>
|
||||
row.kind === "slot" &&
|
||||
new Date(row.at).getTime() === tuesdaySlot.getTime(),
|
||||
);
|
||||
expect(takenAtOf(tuesdayRow)).toBe(tuesdayDose.getTime());
|
||||
const adHoc = ledger.filter((row) => row.kind === "ad_hoc");
|
||||
expect(adHoc.map(takenAtOf)).toEqual([wednesdayDose.getTime()]);
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@
|
||||
* tooltip.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup";
|
||||
@@ -46,7 +46,14 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
}));
|
||||
|
||||
/** Fixed clock: a ladder test that slides with the calendar is a date bomb. */
|
||||
const NOW = new Date("2026-08-08T09:00:00.000Z");
|
||||
const FIXED_NOW = new Date("2026-08-08T09:00:00.000Z");
|
||||
/**
|
||||
* The instant the seed and the job are dated against. The ladder cases keep
|
||||
* the fixed clock; the route cases move it to the real one, because the route
|
||||
* reads the forecast against the wall clock and a forecast seeded at a fixed
|
||||
* date goes stale under it as the calendar moves on.
|
||||
*/
|
||||
let NOW = FIXED_NOW;
|
||||
|
||||
function dayKey(daysAgo: number): string {
|
||||
return new Date(NOW.getTime() - daysAgo * DAY_MS).toISOString().slice(0, 10);
|
||||
@@ -271,6 +278,13 @@ describe("the minimum-n ladder, end to end", () => {
|
||||
});
|
||||
|
||||
describe("GET /api/mood/prognosis", () => {
|
||||
beforeEach(() => {
|
||||
NOW = new Date();
|
||||
});
|
||||
afterEach(() => {
|
||||
NOW = FIXED_NOW;
|
||||
});
|
||||
|
||||
/**
|
||||
* A signed-in browser session.
|
||||
*
|
||||
|
||||
@@ -795,7 +795,12 @@ manageContract("POST /api/measurement-reminders/[id]/snooze", {
|
||||
POST as Handler,
|
||||
"POST",
|
||||
`/api/measurement-reminders/${row.id}/snooze`,
|
||||
{ until: "2027-01-15" },
|
||||
// A snooze must end in the future; a fixed date stops being one.
|
||||
{
|
||||
until: new Date(Date.now() + 90 * 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10),
|
||||
},
|
||||
{ id: row.id },
|
||||
);
|
||||
},
|
||||
|
||||
@@ -34,6 +34,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
/**
|
||||
* The night the fixtures sleep, ten days back from whenever the suite runs.
|
||||
* The analytics route summarises a window that ends now; a fixed date slides
|
||||
* out of it as the calendar moves on.
|
||||
*/
|
||||
const SLEEP_DAY = new Date(Date.now() - 10 * 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
@@ -130,7 +139,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
|
||||
value: 420,
|
||||
unit: "minutes",
|
||||
source: "WITHINGS",
|
||||
measuredAt: new Date("2026-05-12T07:00:00.000Z"),
|
||||
measuredAt: new Date(`${SLEEP_DAY}T07:00:00.000Z`),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -162,7 +171,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
|
||||
value: 480,
|
||||
unit: "minutes",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-12T06:00:00.000Z"),
|
||||
measuredAt: new Date(`${SLEEP_DAY}T06:00:00.000Z`),
|
||||
externalId: "ah-sleep-1",
|
||||
},
|
||||
});
|
||||
@@ -174,7 +183,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
|
||||
value: 410,
|
||||
unit: "minutes",
|
||||
source: "WITHINGS",
|
||||
measuredAt: new Date("2026-05-12T07:00:00.000Z"),
|
||||
measuredAt: new Date(`${SLEEP_DAY}T07:00:00.000Z`),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -200,7 +209,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
|
||||
value: 380,
|
||||
unit: "minutes",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-12T06:00:00.000Z"),
|
||||
measuredAt: new Date(`${SLEEP_DAY}T06:00:00.000Z`),
|
||||
externalId: "ah-sleep-watch",
|
||||
deviceType: "watch",
|
||||
},
|
||||
@@ -212,7 +221,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
|
||||
value: 480,
|
||||
unit: "minutes",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-12T06:30:00.000Z"),
|
||||
measuredAt: new Date(`${SLEEP_DAY}T06:30:00.000Z`),
|
||||
externalId: "ah-sleep-phone",
|
||||
deviceType: "phone",
|
||||
},
|
||||
@@ -240,7 +249,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
|
||||
value: 480,
|
||||
unit: "minutes",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-12T06:00:00.000Z"),
|
||||
measuredAt: new Date(`${SLEEP_DAY}T06:00:00.000Z`),
|
||||
externalId: "ah-sleep-watch",
|
||||
deviceType: "watch",
|
||||
},
|
||||
@@ -252,7 +261,7 @@ describe("GET /api/analytics — two-axis canonical source resolution (W8c)", ()
|
||||
value: 380,
|
||||
unit: "minutes",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-12T06:30:00.000Z"),
|
||||
measuredAt: new Date(`${SLEEP_DAY}T06:30:00.000Z`),
|
||||
externalId: "ah-sleep-phone",
|
||||
deviceType: "phone",
|
||||
},
|
||||
|
||||
@@ -420,8 +420,15 @@ describe("per-user timezone — Pacific/Auckland end-to-end", () => {
|
||||
// doesn't trigger a tz divergence on its own. The point is to
|
||||
// confirm the route returns a non-zero streak and goes through
|
||||
// the user-tz path without crashing.
|
||||
// Three consecutive days ending a week back: the summary reads its
|
||||
// streaks from a window that ends now, so fixed dates would slide out.
|
||||
const firstDay = Date.UTC(
|
||||
new Date().getUTCFullYear(),
|
||||
new Date().getUTCMonth(),
|
||||
new Date().getUTCDate() - 9,
|
||||
);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const dt = new Date(`2026-05-${12 + i}T11:30:00.000Z`);
|
||||
const dt = new Date(firstDay + i * 86_400_000 + 11.5 * 3_600_000);
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: me.id,
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* A visit can link a record from anywhere in a long history.
|
||||
*
|
||||
* The visit form's lab picker loaded the newest 200 results and offered only
|
||||
* those, the condition picker the newest 100 conditions. On a record with a
|
||||
* long history everything older could not be linked: a 2019 visit could not
|
||||
* reach its own 2019 lab panel. The pickers now open on a window around the
|
||||
* visit's date and page and search through the list routes.
|
||||
*
|
||||
* Everything here goes through the shipped route exports with the requests
|
||||
* the pickers build (`picker-requests.ts`), against a record of 1,200 lab
|
||||
* results spread over seven years, so the window, the search and the paging
|
||||
* are proven against real SQL, and the link is saved and read back.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
GET as listEncounters,
|
||||
POST as createEncounter,
|
||||
} from "@/app/api/encounters/route";
|
||||
import { GET as listEpisodes } from "@/app/api/illness/episodes/route";
|
||||
import { GET as listLabs } from "@/app/api/labs/route";
|
||||
import {
|
||||
encounterPickerUrl,
|
||||
episodePickerUrl,
|
||||
labPickerUrl,
|
||||
} from "@/components/links/picker-requests";
|
||||
import {
|
||||
nextOffset,
|
||||
pickerPivot,
|
||||
type PickerPageRequest,
|
||||
} from "@/components/links/use-link-picker-pages";
|
||||
|
||||
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 = "long-history-owner";
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
/** Historical on purpose: the visit and its panel sit years back. */
|
||||
const VISIT_AT = "2019-04-03T09:30:00.000Z";
|
||||
const FERRITIN_AT = new Date("2019-04-03T07:00:00.000Z");
|
||||
const RESULT_COUNT = 1_200;
|
||||
|
||||
async function seedOwner() {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: OWNER_ID,
|
||||
username: "long-history",
|
||||
email: "long-history@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
modulePreferencesJson: { labs: true, illness: true },
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
expiresAt: new Date(Date.now() + 7 * DAY_MS),
|
||||
mfaVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 1,200 glucose results, one every two days back from today, plus the one
|
||||
* ferritin result drawn on the morning of the 2019 visit.
|
||||
*/
|
||||
async function seedLabs(): Promise<string> {
|
||||
const prisma = getPrismaClient();
|
||||
const now = Date.now();
|
||||
await prisma.labResult.createMany({
|
||||
data: Array.from({ length: RESULT_COUNT }, (_, i) => ({
|
||||
userId: OWNER_ID,
|
||||
panel: "Metabolic",
|
||||
analyte: "Glucose",
|
||||
value: 90 + (i % 10),
|
||||
unit: "mg/dL",
|
||||
takenAt: new Date(now - (i + 1) * 2 * DAY_MS),
|
||||
})),
|
||||
});
|
||||
const ferritin = await prisma.labResult.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
panel: "Iron studies",
|
||||
analyte: "Ferritin",
|
||||
value: 54,
|
||||
unit: "ng/mL",
|
||||
takenAt: FERRITIN_AT,
|
||||
},
|
||||
});
|
||||
return ferritin.id;
|
||||
}
|
||||
|
||||
async function get<T>(
|
||||
handler: (request: NextRequest) => Promise<Response>,
|
||||
url: string,
|
||||
): Promise<T> {
|
||||
const response = await handler(new NextRequest(`http://localhost${url}`));
|
||||
expect(response.status, url).toBe(200);
|
||||
return ((await response.json()) as { data: T }).data;
|
||||
}
|
||||
|
||||
interface LabPage {
|
||||
results: Array<{ id: string; analyte: string; takenAt: string }>;
|
||||
meta: { total: number };
|
||||
}
|
||||
|
||||
const labPage = (request: PickerPageRequest) =>
|
||||
get<LabPage>(listLabs as never, labPickerUrl(request));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await seedOwner();
|
||||
});
|
||||
|
||||
describe("the visit form's lab picker on a 1,200-result record", () => {
|
||||
it("the old newest-200 page never reached the 2019 result", async () => {
|
||||
const ferritinId = await seedLabs();
|
||||
const page = await get<LabPage>(
|
||||
listLabs as never,
|
||||
"/api/labs?limit=200&offset=0&sortDir=desc",
|
||||
);
|
||||
expect(page.meta.total).toBe(RESULT_COUNT + 1);
|
||||
expect(page.results.some((row) => row.id === ferritinId)).toBe(false);
|
||||
});
|
||||
|
||||
it("opens on the visit's own date, with the 2019 result on the first page", async () => {
|
||||
const ferritinId = await seedLabs();
|
||||
const pivot = pickerPivot(VISIT_AT);
|
||||
const first = await labPage({
|
||||
direction: "older",
|
||||
pivot,
|
||||
term: "",
|
||||
cursor: null,
|
||||
});
|
||||
expect(first.results.some((row) => row.id === ferritinId)).toBe(true);
|
||||
// Nothing after the window's split is on it.
|
||||
for (const row of first.results) {
|
||||
expect(new Date(row.takenAt).getTime()).toBeLessThanOrEqual(
|
||||
new Date(pivot!).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
// "Load newer" pages forward from the split, oldest first, without
|
||||
// repeating a row.
|
||||
const seen = new Set(first.results.map((row) => row.id));
|
||||
let cursor: string | null = null;
|
||||
let newerRows = 0;
|
||||
let previous = new Date(pivot!).getTime();
|
||||
for (let guard = 0; guard < 20; guard++) {
|
||||
const page = await labPage({
|
||||
direction: "newer",
|
||||
pivot,
|
||||
term: "",
|
||||
cursor,
|
||||
});
|
||||
for (const row of page.results) {
|
||||
expect(seen.has(row.id)).toBe(false);
|
||||
seen.add(row.id);
|
||||
const at = new Date(row.takenAt).getTime();
|
||||
expect(at).toBeGreaterThanOrEqual(previous);
|
||||
previous = at;
|
||||
}
|
||||
newerRows += page.results.length;
|
||||
cursor = nextOffset(cursor, page.results.length);
|
||||
if (cursor === null) break;
|
||||
}
|
||||
// Older window plus every newer page cover the whole record once.
|
||||
expect(seen.size).toBe(RESULT_COUNT + 1);
|
||||
expect(newerRows).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("a search finds the 2019 result among 1,200 others, by analyte and by panel", async () => {
|
||||
const ferritinId = await seedLabs();
|
||||
for (const term of ["ferritin", "iron stud"]) {
|
||||
const page = await labPage({
|
||||
direction: "search",
|
||||
pivot: null,
|
||||
term,
|
||||
cursor: null,
|
||||
});
|
||||
expect(page.results.map((row) => row.id)).toEqual([ferritinId]);
|
||||
}
|
||||
});
|
||||
|
||||
it("the found result links to the visit and reads back on it", async () => {
|
||||
const ferritinId = await seedLabs();
|
||||
const created = await createEncounter(
|
||||
new Request("http://localhost/api/encounters", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
occurredAt: VISIT_AT,
|
||||
status: "DONE",
|
||||
kind: "ROUTINE",
|
||||
labResultIds: [ferritinId],
|
||||
}),
|
||||
}) as never,
|
||||
);
|
||||
expect(created.status).toBe(201);
|
||||
|
||||
const list = await get<{
|
||||
past: Array<{
|
||||
id: string;
|
||||
links?: { labResults: Array<{ id: string; label: string | null }> };
|
||||
}>;
|
||||
}>(
|
||||
listEncounters as never,
|
||||
encounterPickerUrl({
|
||||
direction: "older",
|
||||
pivot: pickerPivot(VISIT_AT),
|
||||
term: "",
|
||||
cursor: null,
|
||||
}),
|
||||
);
|
||||
expect(list.past).toHaveLength(1);
|
||||
expect(list.past[0]!.links?.labResults).toEqual([
|
||||
expect.objectContaining({
|
||||
id: ferritinId,
|
||||
label: "Iron studies · Ferritin",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the condition picker's window and search", () => {
|
||||
it("pages the episodes around a date and finds one by name", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const now = Date.now();
|
||||
await prisma.illnessEpisode.createMany({
|
||||
data: Array.from({ length: 150 }, (_, i) => ({
|
||||
userId: OWNER_ID,
|
||||
label: `Cold ${i}`,
|
||||
type: "INFECTION" as const,
|
||||
onsetAt: new Date(now - (i + 1) * 10 * DAY_MS),
|
||||
})),
|
||||
});
|
||||
const old = await prisma.illnessEpisode.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
label: "Tendinitis",
|
||||
type: "INJURY",
|
||||
onsetAt: new Date("2019-03-20T00:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
|
||||
const pivot = pickerPivot(VISIT_AT);
|
||||
const around = await get<Array<{ id: string; onsetAt: string }>>(
|
||||
listEpisodes as never,
|
||||
episodePickerUrl({ direction: "older", pivot, term: "", cursor: null }),
|
||||
);
|
||||
expect(around[0]!.id).toBe(old.id);
|
||||
|
||||
const found = await get<Array<{ id: string }>>(
|
||||
listEpisodes as never,
|
||||
episodePickerUrl({
|
||||
direction: "search",
|
||||
pivot: null,
|
||||
term: "tendin",
|
||||
cursor: null,
|
||||
}),
|
||||
);
|
||||
expect(found.map((row) => row.id)).toEqual([old.id]);
|
||||
|
||||
// Offset paging walks the whole newer side without a repeat.
|
||||
const ids = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
for (let guard = 0; guard < 5; guard++) {
|
||||
const page: Array<{ id: string }> = await get(
|
||||
listEpisodes as never,
|
||||
episodePickerUrl({ direction: "newer", pivot, term: "", cursor }),
|
||||
);
|
||||
for (const row of page) {
|
||||
expect(ids.has(row.id)).toBe(false);
|
||||
ids.add(row.id);
|
||||
}
|
||||
cursor = nextOffset(cursor, page.length);
|
||||
if (cursor === null) break;
|
||||
}
|
||||
expect(ids.size).toBe(150);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user