mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge branch 'v143/labs' into release/v1.43.0
This commit is contained in:
commit
74fa4e10b6
96 files changed
+6992
-626
No files matched your search
+8
@@ -296,6 +296,14 @@ export const ADMITTED_MUTATING_HANDLERS = [
|
||||
"manage",
|
||||
"delete",
|
||||
),
|
||||
handler(
|
||||
"app/api/biomarkers/[id]/merge/route.ts",
|
||||
"/api/biomarkers/[id]/merge",
|
||||
"POST",
|
||||
"labs",
|
||||
"manage",
|
||||
"update",
|
||||
),
|
||||
handler(
|
||||
"app/api/allergies/route.ts",
|
||||
"/api/allergies",
|
||||
|
||||
@@ -244,6 +244,8 @@ const COUNT_BACK: Record<
|
||||
MoodTag: (p, userId) => p.moodTag.count({ where: { userId } }),
|
||||
LabResult: (p, userId) => p.labResult.count({ where: { userId } }),
|
||||
Biomarker: (p, userId) => p.biomarker.count({ where: { userId } }),
|
||||
BiomarkerAlias: (p, userId) => p.biomarkerAlias.count({ where: { userId } }),
|
||||
BiomarkerMerge: (p, userId) => p.biomarkerMerge.count({ where: { userId } }),
|
||||
Allergy: (p, userId) => p.allergy.count({ where: { userId } }),
|
||||
FamilyHistoryEntry: (p, userId) =>
|
||||
p.familyHistoryEntry.count({ where: { userId } }),
|
||||
@@ -738,6 +740,26 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
||||
const biomarker = await prisma.biomarker.create({
|
||||
data: { userId: OWNER_ID, name: "Ferritin", unit: "ng/mL" },
|
||||
});
|
||||
// Another spelling of the marker, and the ledger row of a marker that was
|
||||
// merged into it: both ride the marker (D1223).
|
||||
await prisma.biomarkerAlias.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
biomarkerId: biomarker.id,
|
||||
name: "Ferr.",
|
||||
nameFolded: "ferr.",
|
||||
source: "IMPORT_CHOICE",
|
||||
},
|
||||
});
|
||||
await prisma.biomarkerMerge.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
targetBiomarkerId: biomarker.id,
|
||||
sourceBiomarkerId: "merged-away-marker",
|
||||
sourceName: "Ferritin (Serum)",
|
||||
readingCount: 3,
|
||||
},
|
||||
});
|
||||
const labResult = await prisma.labResult.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* A marker's other spellings and the merge ledger survive a backup and a
|
||||
* restore, in both kinds of file (D1223).
|
||||
*
|
||||
* Without the aliases a restored account is asked again at the next scan
|
||||
* which marker each spelling belongs to; without the ledger a link to a
|
||||
* merged marker reads as a plain 404. Every column of both tables is compared
|
||||
* after a disaster-recovery round trip, and the portable file, which carries
|
||||
* no ids, is checked for what the resolver and the 404 actually use: the
|
||||
* spelling, its fold, where it came from and which marker it belongs to, and
|
||||
* which marker a merged id went into.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
|
||||
import { loadMarkerIndex, resolveInIndex } from "@/lib/labs/biomarker-resolve";
|
||||
import { mergedInto } from "@/lib/labs/biomarker-merge";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
import { legacyStreamedBlobFrom } from "@/__tests__/helpers/legacy-backup-blob";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { POST } from "./restore-job-driver";
|
||||
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/cache/invalidate", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/cache/invalidate")>()),
|
||||
invalidateUserData: vi.fn(),
|
||||
}));
|
||||
|
||||
const OWNER = "biomarker-backup-owner";
|
||||
|
||||
async function createOwner() {
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: OWNER,
|
||||
username: "biomarker-backup-owner",
|
||||
email: "biomarker-backup-owner@example.test",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function seedAdmin() {
|
||||
const prisma = getPrismaClient();
|
||||
const admin = await prisma.user.create({
|
||||
data: {
|
||||
username: "biomarker-backup-admin",
|
||||
email: "biomarker-backup-admin@example.test",
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: admin.id, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
/** Two markers, an alias from each source, and a two-step merge ledger. */
|
||||
async function seed() {
|
||||
const prisma = getPrismaClient();
|
||||
const alp = await prisma.biomarker.create({
|
||||
data: { userId: OWNER, name: "Alkaline Phosphatase", unit: "U/L" },
|
||||
});
|
||||
const bili = await prisma.biomarker.create({
|
||||
data: { userId: OWNER, name: "Bilirubin (Total)", unit: "mg/dL" },
|
||||
});
|
||||
await prisma.labResult.create({
|
||||
data: {
|
||||
userId: OWNER,
|
||||
biomarkerId: alp.id,
|
||||
analyte: "Alk.P",
|
||||
value: 80,
|
||||
unit: "U/L",
|
||||
takenAt: new Date("2026-05-01T08:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
await prisma.biomarkerAlias.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: OWNER,
|
||||
biomarkerId: alp.id,
|
||||
name: "Alk.P",
|
||||
nameFolded: "alk.p",
|
||||
source: "MERGE",
|
||||
createdAt: new Date("2026-05-02T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
userId: OWNER,
|
||||
biomarkerId: alp.id,
|
||||
name: "AP",
|
||||
nameFolded: "ap",
|
||||
source: "MANUAL",
|
||||
createdAt: new Date("2026-05-03T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
userId: OWNER,
|
||||
biomarkerId: bili.id,
|
||||
name: "Bilirubin ges.",
|
||||
nameFolded: "bilirubin ges.",
|
||||
source: "IMPORT_CHOICE",
|
||||
createdAt: new Date("2026-05-04T10:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
await prisma.biomarkerMerge.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: OWNER,
|
||||
targetBiomarkerId: alp.id,
|
||||
sourceBiomarkerId: "merged-alk-p",
|
||||
sourceName: "Alk.P",
|
||||
readingCount: 12,
|
||||
mergedAt: new Date("2026-05-02T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
userId: OWNER,
|
||||
targetBiomarkerId: bili.id,
|
||||
sourceBiomarkerId: "merged-t-bili",
|
||||
sourceName: "T.Bili",
|
||||
readingCount: 0,
|
||||
mergedAt: new Date("2026-05-05T10:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
return { alp, bili };
|
||||
}
|
||||
|
||||
async function snapshot() {
|
||||
const prisma = getPrismaClient();
|
||||
const markers = await prisma.biomarker.findMany({
|
||||
where: { userId: OWNER },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const nameOf = new Map(markers.map((m) => [m.id, m.name]));
|
||||
const aliases = (
|
||||
await prisma.biomarkerAlias.findMany({ where: { userId: OWNER } })
|
||||
)
|
||||
.map((a) => ({ ...a, marker: nameOf.get(a.biomarkerId) }))
|
||||
.sort((a, b) => a.nameFolded.localeCompare(b.nameFolded));
|
||||
const merges = (
|
||||
await prisma.biomarkerMerge.findMany({ where: { userId: OWNER } })
|
||||
)
|
||||
.map((m) => ({ ...m, target: nameOf.get(m.targetBiomarkerId) }))
|
||||
.sort((a, b) => a.sourceBiomarkerId.localeCompare(b.sourceBiomarkerId));
|
||||
return { aliases, merges };
|
||||
}
|
||||
|
||||
/** Empty the account, store `data` as a backup row, and restore it. */
|
||||
async function restore(
|
||||
data: Awaited<ReturnType<typeof legacyStreamedBlobFrom>> | string,
|
||||
type: string,
|
||||
) {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.delete({ where: { id: OWNER } });
|
||||
await createOwner();
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: { userId: OWNER, type, data },
|
||||
});
|
||||
const response = await POST(
|
||||
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
}) as never,
|
||||
{ params: Promise.resolve({ id: backup.id }) },
|
||||
);
|
||||
const body = await response.json();
|
||||
expect(response.status, JSON.stringify(body)).toBe(200);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
await seedAdmin();
|
||||
await createOwner();
|
||||
});
|
||||
|
||||
describe("biomarker aliases and merges in a backup", () => {
|
||||
it("come back column for column from a disaster-recovery file", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seed();
|
||||
const before = await snapshot();
|
||||
expect(before.aliases).toHaveLength(3);
|
||||
expect(before.merges).toHaveLength(2);
|
||||
|
||||
let json = "";
|
||||
await streamFullBackupJson(
|
||||
prisma,
|
||||
OWNER,
|
||||
(chunk) => {
|
||||
json += chunk;
|
||||
},
|
||||
{ purpose: "disaster-recovery", exportedAt: new Date() },
|
||||
);
|
||||
await restore(
|
||||
await legacyStreamedBlobFrom(async (write) => {
|
||||
await write(json);
|
||||
}),
|
||||
"BIOMARKER_DR_ROUND_TRIP",
|
||||
);
|
||||
|
||||
// Every column, ids and stamps included.
|
||||
expect(await snapshot()).toEqual(before);
|
||||
});
|
||||
|
||||
it("come back from a portable file with every value the resolver and the 404 read", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const { alp, bili } = await seed();
|
||||
const before = await snapshot();
|
||||
|
||||
const { payload } = await buildFullBackupPayload(prisma, OWNER);
|
||||
const parsed = parseBackupPayload(JSON.stringify(payload));
|
||||
expect(
|
||||
parsed.biomarkers.find((b) => b.name === "Alkaline Phosphatase")?.aliases,
|
||||
).toHaveLength(2);
|
||||
expect(parsed.biomarkerMerges).toHaveLength(2);
|
||||
// Stored the way an uploaded portable file is: encrypted JSON.
|
||||
await restore(
|
||||
encrypt(JSON.stringify(parsed)),
|
||||
"BIOMARKER_PORTABLE_ROUND_TRIP",
|
||||
);
|
||||
|
||||
const after = await snapshot();
|
||||
const portable = (s: typeof before) => ({
|
||||
aliases: s.aliases.map((a) => ({
|
||||
name: a.name,
|
||||
nameFolded: a.nameFolded,
|
||||
source: a.source,
|
||||
marker: a.marker,
|
||||
userId: a.userId,
|
||||
})),
|
||||
merges: s.merges.map((m) => ({
|
||||
sourceBiomarkerId: m.sourceBiomarkerId,
|
||||
sourceName: m.sourceName,
|
||||
readingCount: m.readingCount,
|
||||
mergedAt: m.mergedAt,
|
||||
target: m.target,
|
||||
userId: m.userId,
|
||||
})),
|
||||
});
|
||||
expect(portable(after)).toEqual(portable(before));
|
||||
|
||||
// What the values are for: the spellings resolve again, and the merged
|
||||
// ids still name the marker they went into.
|
||||
const index = await loadMarkerIndex(prisma, OWNER);
|
||||
const restoredAlp = index.markers.find((m) => m.name === alp.name)!;
|
||||
const restoredBili = index.markers.find((m) => m.name === bili.name)!;
|
||||
expect(resolveInIndex(index, "AP")).toEqual({
|
||||
marker: restoredAlp,
|
||||
matchKind: "alias",
|
||||
});
|
||||
expect(resolveInIndex(index, "bilirubin ges.")?.marker).toEqual(
|
||||
restoredBili,
|
||||
);
|
||||
expect(await mergedInto(OWNER, "merged-t-bili")).toBe(restoredBili.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,728 @@
|
||||
/**
|
||||
* Merging markers, aliases and catalog shorthand against real SQL (D1223).
|
||||
*
|
||||
* "Alk.P" and "Alkaline Phosphatase" written by two labs used to be two
|
||||
* markers with half a history each. These tests drive the shipped route
|
||||
* exports and readers: a merge moves every reading and every reader then
|
||||
* shows one series, a dry run answers the same and writes nothing, readings
|
||||
* in another unit are converted with what the report printed kept, a pair
|
||||
* that cannot be converted refuses the whole merge, and a spelling chosen once
|
||||
* on a scan or a document resolves on its own the next time.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { cookieJar } 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 USER_ID = "user-biomarker-merge";
|
||||
const OTHER_ID = "user-biomarker-merge-other";
|
||||
const DAY_MS = 86_400_000;
|
||||
const daysAgo = (days: number) => new Date(Date.now() - days * DAY_MS);
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
cookieJar.clear();
|
||||
await prisma.user.createMany({
|
||||
data: [
|
||||
{ id: USER_ID, username: "bm-merge", email: "bm-merge@example.test" },
|
||||
{ id: OTHER_ID, username: "bm-other", email: "bm-other@example.test" },
|
||||
],
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
});
|
||||
|
||||
async function seedMarker(
|
||||
name: string,
|
||||
unit: string,
|
||||
readings: number,
|
||||
options: { userId?: string; firstDay?: number; value?: number } = {},
|
||||
) {
|
||||
const prisma = getPrismaClient();
|
||||
const userId = options.userId ?? USER_ID;
|
||||
const marker = await prisma.biomarker.create({
|
||||
data: { userId, name, unit },
|
||||
});
|
||||
await prisma.labResult.createMany({
|
||||
data: Array.from({ length: readings }, (_, i) => ({
|
||||
userId,
|
||||
biomarkerId: marker.id,
|
||||
analyte: name,
|
||||
value: (options.value ?? 80) + (i % 7),
|
||||
unit,
|
||||
takenAt: daysAgo((options.firstDay ?? 10) + i * 3),
|
||||
})),
|
||||
});
|
||||
return marker;
|
||||
}
|
||||
|
||||
function json(body: unknown, url: string, method = "POST") {
|
||||
return new NextRequest(url, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function merge(
|
||||
targetId: string,
|
||||
body: { sourceIds: string[]; dryRun?: boolean },
|
||||
) {
|
||||
const { POST } = await import("@/app/api/biomarkers/[id]/merge/route");
|
||||
return POST(json(body, `http://localhost/api/biomarkers/${targetId}/merge`), {
|
||||
params: Promise.resolve({ id: targetId }),
|
||||
});
|
||||
}
|
||||
|
||||
async function tableCounts() {
|
||||
const prisma = getPrismaClient();
|
||||
return {
|
||||
markers: await prisma.biomarker.count(),
|
||||
readings: await prisma.labResult.count(),
|
||||
linked: await prisma.labResult.groupBy({
|
||||
by: ["biomarkerId"],
|
||||
_count: true,
|
||||
orderBy: { biomarkerId: "asc" },
|
||||
}),
|
||||
aliases: await prisma.biomarkerAlias.count(),
|
||||
merges: await prisma.biomarkerMerge.count(),
|
||||
values: await prisma.labResult.findMany({
|
||||
select: { id: true, value: true, unit: true, sourceValue: true },
|
||||
orderBy: { id: "asc" },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("POST /api/biomarkers/{id}/merge", () => {
|
||||
it("merges 30 and 12 readings into one series every reader shows as 42", async () => {
|
||||
const target = await seedMarker("Alkaline Phosphatase", "U/L", 30);
|
||||
const source = await seedMarker("Alk.P", "U/L", 12, { firstDay: 400 });
|
||||
|
||||
const res = await merge(target.id, { sourceIds: [source.id] });
|
||||
expect(res.status).toBe(200);
|
||||
const { data } = await res.json();
|
||||
expect(data).toMatchObject({
|
||||
dryRun: false,
|
||||
movedReadings: 12,
|
||||
convertedReadings: 0,
|
||||
aliasesAdded: ["Alk.P"],
|
||||
sources: [
|
||||
{
|
||||
id: source.id,
|
||||
name: "Alk.P",
|
||||
unit: "U/L",
|
||||
readingCount: 12,
|
||||
conversion: "none",
|
||||
refusal: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(data.mergeId).toEqual(expect.any(String));
|
||||
expect(data.target.aliases).toEqual(["Alk.P"]);
|
||||
|
||||
// The overview: one marker, 42 readings.
|
||||
const { GET: overview } = await import("@/app/api/labs/overview/route");
|
||||
const ov = await (
|
||||
await overview(new NextRequest("http://localhost/api/labs/overview"))
|
||||
).json();
|
||||
expect(ov.data.markers).toHaveLength(1);
|
||||
expect(ov.data.markers[0]).toMatchObject({
|
||||
key: `bm:${target.id}`,
|
||||
count: 42,
|
||||
});
|
||||
|
||||
// The marker detail's own list.
|
||||
const { GET: list } = await import("@/app/api/labs/route");
|
||||
const detail = await (
|
||||
await list(
|
||||
new NextRequest(
|
||||
`http://localhost/api/labs?biomarkerId=${target.id}&limit=100`,
|
||||
),
|
||||
)
|
||||
).json();
|
||||
expect(detail.data.meta.total).toBe(42);
|
||||
|
||||
// The doctor report: one line, counted 42, under the marker's name.
|
||||
const { loadLabResults } =
|
||||
await import("@/lib/doctor-report/clinical-records");
|
||||
const report = await loadLabResults(USER_ID, daysAgo(1000), new Date());
|
||||
expect(report).toHaveLength(1);
|
||||
expect(report![0]).toMatchObject({
|
||||
analyte: "Alkaline Phosphatase",
|
||||
count: 42,
|
||||
});
|
||||
|
||||
// FHIR: one Observation for the marker, named after it.
|
||||
const { labObservations } = await import("@/lib/fhir/resources/labs");
|
||||
let n = 0;
|
||||
const observations = labObservations(
|
||||
{ labResults: report } as Parameters<typeof labObservations>[0],
|
||||
() => `obs-${++n}`,
|
||||
);
|
||||
expect(observations).toHaveLength(1);
|
||||
expect(observations[0].code.text).toBe("Alkaline Phosphatase");
|
||||
|
||||
// MCP history: the printed name, the marker's name and the alias all read
|
||||
// the whole series.
|
||||
const { getLabHistory } = await import("@/lib/mcp/rich-reads");
|
||||
for (const analyte of ["Alk.P", "alkaline phosphatase", "ALP"]) {
|
||||
const total: number[] = [];
|
||||
let offset = 0;
|
||||
for (;;) {
|
||||
const page = await getLabHistory(USER_ID, { analyte, offset });
|
||||
expect(page.present, analyte).toBe(true);
|
||||
if (!page.present) break;
|
||||
total.push(page.readings!.length);
|
||||
if (!page.nextCursor) break;
|
||||
offset += page.readings!.length;
|
||||
}
|
||||
expect(
|
||||
total.reduce((a, b) => a + b, 0),
|
||||
analyte,
|
||||
).toBe(42);
|
||||
}
|
||||
|
||||
// The source is gone, its old id leads to the target, and the reading's
|
||||
// printed name survived.
|
||||
const prisma = getPrismaClient();
|
||||
expect(
|
||||
await prisma.biomarker.findUnique({ where: { id: source.id } }),
|
||||
).toBeNull();
|
||||
const { GET: getMarker } = await import("@/app/api/biomarkers/[id]/route");
|
||||
const stale = await getMarker(
|
||||
new NextRequest(`http://localhost/api/biomarkers/${source.id}`),
|
||||
{ params: Promise.resolve({ id: source.id }) },
|
||||
);
|
||||
expect(stale.status).toBe(404);
|
||||
const staleBody = await stale.json();
|
||||
expect(staleBody.meta).toMatchObject({
|
||||
errorCode: "labs.biomarker.notFound",
|
||||
mergedInto: target.id,
|
||||
});
|
||||
expect(
|
||||
await prisma.labResult.count({
|
||||
where: { biomarkerId: target.id, analyte: "Alk.P" },
|
||||
}),
|
||||
).toBe(12);
|
||||
const ledger = await prisma.biomarkerMerge.findMany({});
|
||||
expect(ledger).toEqual([
|
||||
expect.objectContaining({
|
||||
userId: USER_ID,
|
||||
targetBiomarkerId: target.id,
|
||||
sourceBiomarkerId: source.id,
|
||||
sourceName: "Alk.P",
|
||||
readingCount: 12,
|
||||
}),
|
||||
]);
|
||||
const audit = await prisma.auditLog.findFirstOrThrow({
|
||||
where: { action: "labs.biomarker.merge" },
|
||||
});
|
||||
expect(audit.details).not.toContain("Alk.P");
|
||||
expect(audit.details).toContain(source.id);
|
||||
});
|
||||
|
||||
it("answers a dry run exactly like the merge and writes nothing", async () => {
|
||||
const target = await seedMarker("Glucose", "mg/dL", 5, { value: 90 });
|
||||
const sameUnit = await seedMarker("Blutzucker", "mg/dL", 3);
|
||||
const molar = await seedMarker("Glukose", "mmol/L", 4, { value: 5 });
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.biomarkerAlias.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
biomarkerId: molar.id,
|
||||
name: "GLU",
|
||||
nameFolded: "glu",
|
||||
source: "MANUAL",
|
||||
},
|
||||
});
|
||||
|
||||
const before = await tableCounts();
|
||||
const preview = await merge(target.id, {
|
||||
sourceIds: [sameUnit.id, molar.id],
|
||||
dryRun: true,
|
||||
});
|
||||
expect(preview.status).toBe(200);
|
||||
const dry = (await preview.json()).data;
|
||||
expect(await tableCounts()).toEqual(before);
|
||||
expect(dry.dryRun).toBe(true);
|
||||
expect(dry.mergeId).toBeNull();
|
||||
expect(dry.target.aliases).toEqual([]);
|
||||
|
||||
const applied = (
|
||||
await (
|
||||
await merge(target.id, { sourceIds: [sameUnit.id, molar.id] })
|
||||
).json()
|
||||
).data;
|
||||
const comparable = (r: Record<string, unknown>) => {
|
||||
const rest = { ...r };
|
||||
delete rest.dryRun;
|
||||
delete rest.mergeId;
|
||||
delete rest.target;
|
||||
return rest;
|
||||
};
|
||||
expect(comparable(applied)).toEqual(comparable(dry));
|
||||
expect(applied).toMatchObject({
|
||||
movedReadings: 7,
|
||||
convertedReadings: 4,
|
||||
aliasesAdded: ["Blutzucker", "Glukose", "GLU"],
|
||||
});
|
||||
expect(applied.sources[1]).toMatchObject({
|
||||
id: molar.id,
|
||||
conversion: "converted",
|
||||
});
|
||||
expect([...applied.target.aliases].sort()).toEqual(
|
||||
["Blutzucker", "GLU", "Glukose"].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("converts readings in another unit and keeps what the report printed", async () => {
|
||||
const target = await seedMarker("Glucose", "mg/dL", 1, { value: 90 });
|
||||
const molar = await seedMarker("Glukose", "mmol/L", 0);
|
||||
const prisma = getPrismaClient();
|
||||
const fresh = await prisma.labResult.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
biomarkerId: molar.id,
|
||||
analyte: "Glukose",
|
||||
value: 5.5,
|
||||
unit: "mmol/L",
|
||||
sourceReferenceLow: 3.9,
|
||||
sourceReferenceHigh: 5.6,
|
||||
takenAt: daysAgo(40),
|
||||
},
|
||||
});
|
||||
// A reading converted on its way in already: its original pair stays.
|
||||
const twice = await prisma.labResult.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
biomarkerId: molar.id,
|
||||
analyte: "Glukose",
|
||||
value: 6,
|
||||
unit: "mmol/L",
|
||||
sourceValue: 6000,
|
||||
sourceUnit: "µmol/L",
|
||||
takenAt: daysAgo(50),
|
||||
},
|
||||
});
|
||||
|
||||
const res = await merge(target.id, { sourceIds: [molar.id] });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const moved = await prisma.labResult.findUniqueOrThrow({
|
||||
where: { id: fresh.id },
|
||||
});
|
||||
expect(moved.biomarkerId).toBe(target.id);
|
||||
expect(moved.unit).toBe("mg/dL");
|
||||
expect(moved.value).toBeCloseTo(99.09, 1);
|
||||
expect(moved.sourceValue).toBe(5.5);
|
||||
expect(moved.sourceUnit).toBe("mmol/L");
|
||||
expect(moved.sourceReferenceLow).toBeCloseTo(70.26, 1);
|
||||
expect(moved.sourceReferenceHigh).toBeCloseTo(100.89, 1);
|
||||
expect(moved.analyte).toBe("Glukose");
|
||||
|
||||
const kept = await prisma.labResult.findUniqueOrThrow({
|
||||
where: { id: twice.id },
|
||||
});
|
||||
expect(kept.value).toBeCloseTo(108.09, 1);
|
||||
expect(kept.sourceValue).toBe(6000);
|
||||
expect(kept.sourceUnit).toBe("µmol/L");
|
||||
});
|
||||
|
||||
it("refuses the whole merge when one source cannot be converted", async () => {
|
||||
const target = await seedMarker("ALT", "U/L", 3);
|
||||
const convertible = await seedMarker("GPT", "U/L", 2);
|
||||
const enzymeSi = await seedMarker("ALAT", "µkat/L", 2, { value: 1 });
|
||||
|
||||
const before = await tableCounts();
|
||||
const res = await merge(target.id, {
|
||||
sourceIds: [convertible.id, enzymeSi.id],
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.meta).toMatchObject({
|
||||
errorCode: "labs.biomarker.merge.unitMismatch",
|
||||
sources: [
|
||||
{
|
||||
id: enzymeSi.id,
|
||||
unit: "µkat/L",
|
||||
targetUnit: "U/L",
|
||||
reason: expect.any(String),
|
||||
},
|
||||
],
|
||||
});
|
||||
// Nothing moved, not even the source that would have converted.
|
||||
expect(await tableCounts()).toEqual(before);
|
||||
|
||||
// The preview names the refusal instead of failing.
|
||||
const preview = await merge(target.id, {
|
||||
sourceIds: [convertible.id, enzymeSi.id],
|
||||
dryRun: true,
|
||||
});
|
||||
expect(preview.status).toBe(200);
|
||||
const dry = (await preview.json()).data;
|
||||
expect(dry.sources[0].conversion).toBe("none");
|
||||
expect(dry.sources[1]).toMatchObject({
|
||||
conversion: "refused",
|
||||
refusal: expect.stringMatching(/unknown_(analyte|unit)/),
|
||||
});
|
||||
});
|
||||
|
||||
it("names every missing id, refuses a target among the sources, and never reaches another account", async () => {
|
||||
const target = await seedMarker("TSH", "mIU/L", 1);
|
||||
const foreign = await seedMarker("TSH basal", "mIU/L", 1, {
|
||||
userId: OTHER_ID,
|
||||
});
|
||||
|
||||
const missing = await merge(target.id, {
|
||||
sourceIds: [foreign.id, "nope"],
|
||||
});
|
||||
expect(missing.status).toBe(404);
|
||||
expect((await missing.json()).meta).toMatchObject({
|
||||
errorCode: "labs.biomarker.notFound",
|
||||
ids: [foreign.id, "nope"],
|
||||
});
|
||||
|
||||
const self = await merge(target.id, { sourceIds: [target.id] });
|
||||
expect(self.status).toBe(422);
|
||||
expect((await self.json()).meta.errorCode).toBe(
|
||||
"labs.biomarker.merge.invalid",
|
||||
);
|
||||
|
||||
expect(
|
||||
await getPrismaClient().biomarker.count({ where: { userId: OTHER_ID } }),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("follows a marker merged twice to where it ended up", async () => {
|
||||
const a = await seedMarker("Ferritin (Serum)", "ng/mL", 1);
|
||||
const b = await seedMarker("Ferr", "ng/mL", 1);
|
||||
const c = await seedMarker("Ferritin", "ng/mL", 1);
|
||||
expect((await merge(b.id, { sourceIds: [a.id] })).status).toBe(200);
|
||||
expect((await merge(c.id, { sourceIds: [b.id] })).status).toBe(200);
|
||||
|
||||
const { GET } = await import("@/app/api/biomarkers/[id]/route");
|
||||
for (const id of [a.id, b.id]) {
|
||||
const res = await GET(
|
||||
new NextRequest(`http://localhost/api/biomarkers/${id}`),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
expect((await res.json()).meta.mergedInto).toBe(c.id);
|
||||
}
|
||||
const aliases = await getPrismaClient().biomarkerAlias.findMany({
|
||||
where: { biomarkerId: c.id },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
expect(aliases.map((alias) => alias.name)).toEqual([
|
||||
"Ferr",
|
||||
"Ferritin (Serum)",
|
||||
]);
|
||||
expect(
|
||||
await getPrismaClient().labResult.count({
|
||||
where: { biomarkerId: c.id },
|
||||
}),
|
||||
).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aliases on the marker", () => {
|
||||
async function put(id: string, body: unknown) {
|
||||
const { PUT } = await import("@/app/api/biomarkers/[id]/route");
|
||||
return PUT(json(body, `http://localhost/api/biomarkers/${id}`, "PUT"), {
|
||||
params: Promise.resolve({ id }),
|
||||
});
|
||||
}
|
||||
|
||||
it("replaces the set, keeps where a kept spelling came from, and leaves it alone when omitted", async () => {
|
||||
const alp = await seedMarker("Alkaline Phosphatase", "U/L", 0);
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.biomarkerAlias.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
biomarkerId: alp.id,
|
||||
name: "Alk.P",
|
||||
nameFolded: "alk.p",
|
||||
source: "IMPORT_CHOICE",
|
||||
},
|
||||
});
|
||||
|
||||
const res = await put(alp.id, {
|
||||
aliases: ["Alk.P", "AP", "alkaline phosphatase"],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data.aliases).toEqual(["Alk.P", "AP"]);
|
||||
const rows = await prisma.biomarkerAlias.findMany({
|
||||
where: { biomarkerId: alp.id },
|
||||
orderBy: { nameFolded: "asc" },
|
||||
});
|
||||
expect(rows.map((r) => [r.name, r.source])).toEqual([
|
||||
["Alk.P", "IMPORT_CHOICE"],
|
||||
["AP", "MANUAL"],
|
||||
]);
|
||||
|
||||
// An older client's PUT without the field keeps them.
|
||||
const untouched = await put(alp.id, { lowerBound: 40 });
|
||||
expect((await untouched.json()).data.aliases).toEqual(["Alk.P", "AP"]);
|
||||
|
||||
const cleared = await put(alp.id, { aliases: [] });
|
||||
expect((await cleared.json()).data.aliases).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses a spelling another marker already answers to", async () => {
|
||||
const alt = await seedMarker("ALT", "U/L", 0);
|
||||
const ast = await seedMarker("AST", "U/L", 0);
|
||||
await getPrismaClient().biomarkerAlias.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
biomarkerId: ast.id,
|
||||
name: "GOT",
|
||||
nameFolded: "got",
|
||||
source: "MANUAL",
|
||||
},
|
||||
});
|
||||
|
||||
for (const [alias, owner] of [
|
||||
["ast", ast.id],
|
||||
["got", ast.id],
|
||||
] as const) {
|
||||
const res = await put(alt.id, { aliases: ["GPT", alias] });
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).meta).toMatchObject({
|
||||
errorCode: "labs.biomarker.alias.taken",
|
||||
alias,
|
||||
biomarkerId: owner,
|
||||
});
|
||||
}
|
||||
expect(
|
||||
await getPrismaClient().biomarkerAlias.count({
|
||||
where: { biomarkerId: alt.id },
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a spelling resolves on its own once chosen", () => {
|
||||
it("maps T.Bili to the chosen marker on the second scan and on manual entry", async () => {
|
||||
const bili = await seedMarker("Bilirubin (Total)", "mg/dL", 2, {
|
||||
value: 1,
|
||||
});
|
||||
const { annotateRow } = await import("@/lib/labs/ocr-extract");
|
||||
const scanned = {
|
||||
analyte: "Bilirubin ges.",
|
||||
value: 0.8,
|
||||
valueText: null,
|
||||
unit: "mg/dL",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
referenceText: null,
|
||||
takenAt: "2026-06-01",
|
||||
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
|
||||
};
|
||||
|
||||
// First scan: a name nothing resolves; the review offers the marker.
|
||||
const first = await annotateRow(USER_ID, scanned, null, "UTC");
|
||||
expect(first.biomarkerId).toBeNull();
|
||||
expect(first.biomarkerMatch).toBe("new");
|
||||
expect(first.suggestions[0]).toMatchObject({
|
||||
biomarkerId: bili.id,
|
||||
matchKind: "similar",
|
||||
});
|
||||
|
||||
// The catalog's shorthand resolves without a choice.
|
||||
const shorthand = await annotateRow(
|
||||
USER_ID,
|
||||
{ ...scanned, analyte: "T.Bili" },
|
||||
null,
|
||||
"UTC",
|
||||
);
|
||||
expect(shorthand).toMatchObject({
|
||||
biomarkerId: bili.id,
|
||||
matchKind: "catalog",
|
||||
biomarkerMatch: "existing",
|
||||
suggestions: [],
|
||||
});
|
||||
|
||||
// The person picks the marker for "Bilirubin ges." and commits.
|
||||
const { POST: commit } = await import("@/app/api/labs/ocr/commit/route");
|
||||
const committed = await commit(
|
||||
json(
|
||||
{
|
||||
rows: [
|
||||
{
|
||||
analyte: "Bilirubin ges.",
|
||||
value: 0.8,
|
||||
unit: "mg/dL",
|
||||
takenAt: "2026-06-01T08:00:00.000Z",
|
||||
biomarkerId: bili.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
"http://localhost/api/labs/ocr/commit",
|
||||
),
|
||||
);
|
||||
expect(committed.status).toBe(200);
|
||||
const prisma = getPrismaClient();
|
||||
expect(
|
||||
await prisma.labResult.count({ where: { biomarkerId: bili.id } }),
|
||||
).toBe(3);
|
||||
expect(
|
||||
await prisma.biomarkerAlias.findFirst({
|
||||
where: { biomarkerId: bili.id },
|
||||
select: { name: true, source: true },
|
||||
}),
|
||||
).toEqual({ name: "Bilirubin ges.", source: "IMPORT_CHOICE" });
|
||||
|
||||
// The second scan of the same lab matches without asking.
|
||||
const second = await annotateRow(
|
||||
USER_ID,
|
||||
{ ...scanned, takenAt: "2026-07-01" },
|
||||
null,
|
||||
"UTC",
|
||||
);
|
||||
expect(second).toMatchObject({
|
||||
biomarkerId: bili.id,
|
||||
matchKind: "alias",
|
||||
suggestions: [],
|
||||
});
|
||||
|
||||
// Manual entry with the alias lands on the marker, no new marker.
|
||||
const { POST: create } = await import("@/app/api/labs/route");
|
||||
const typed = await create(
|
||||
json(
|
||||
{
|
||||
analyte: "bilirubin ges.",
|
||||
value: 0.9,
|
||||
unit: "mg/dL",
|
||||
takenAt: new Date().toISOString(),
|
||||
},
|
||||
"http://localhost/api/labs",
|
||||
),
|
||||
);
|
||||
expect(typed.status).toBe(201);
|
||||
expect((await typed.json()).data.biomarkerId).toBe(bili.id);
|
||||
expect(await prisma.biomarker.count({ where: { userId: USER_ID } })).toBe(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a row naming a marker the record does not have", async () => {
|
||||
const foreign = await seedMarker("Ferritin", "ng/mL", 0, {
|
||||
userId: OTHER_ID,
|
||||
});
|
||||
const { POST: commit } = await import("@/app/api/labs/ocr/commit/route");
|
||||
const res = await commit(
|
||||
json(
|
||||
{
|
||||
rows: [
|
||||
{
|
||||
analyte: "Ferritin",
|
||||
value: 50,
|
||||
unit: "ng/mL",
|
||||
takenAt: "2026-06-01T08:00:00.000Z",
|
||||
biomarkerId: foreign.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
"http://localhost/api/labs/ocr/commit",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data.skipped).toEqual([
|
||||
{ analyte: "Ferritin", reason: "unknown_biomarker" },
|
||||
]);
|
||||
expect(await getPrismaClient().labResult.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("files a confirmed document value under the chosen marker and remembers the label", async () => {
|
||||
const alp = await seedMarker("Alkaline Phosphatase", "U/L", 0);
|
||||
const prisma = getPrismaClient();
|
||||
const { encryptFactData, encryptFactProvenance, serialiseFact } =
|
||||
await import("@/lib/documents/store");
|
||||
const document = await prisma.inboundDocument.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
kind: "LAB_RESULT",
|
||||
filename: "report.pdf",
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 10,
|
||||
contentEncrypted: new Uint8Array([1, 2, 3]),
|
||||
},
|
||||
});
|
||||
const fact = await prisma.extractedFact.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
documentId: document.id,
|
||||
factType: "OBSERVATION",
|
||||
status: "PENDING",
|
||||
confidence: 0.95,
|
||||
needsReview: false,
|
||||
dataEncrypted: encryptFactData({
|
||||
label: "Alk. Phosph.",
|
||||
code: null,
|
||||
codeSystem: null,
|
||||
value: 70,
|
||||
valueText: null,
|
||||
unit: "U/L",
|
||||
referenceLow: null,
|
||||
referenceHigh: null,
|
||||
referenceText: null,
|
||||
effectiveDate: "2026-05-02",
|
||||
}),
|
||||
provenanceEncrypted: encryptFactProvenance({
|
||||
sourceText: "Alk. Phosph. 70 U/L",
|
||||
anchored: true,
|
||||
sourceOffset: 0,
|
||||
page: 1,
|
||||
confidence: 0.95,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const { loadMarkerIndex } = await import("@/lib/labs/biomarker-resolve");
|
||||
const dto = serialiseFact(fact, await loadMarkerIndex(prisma, USER_ID));
|
||||
expect(dto.biomarkerId).toBeNull();
|
||||
expect(dto.suggestions?.[0]?.biomarkerId).toBe(alp.id);
|
||||
|
||||
const { commitApprovedFact } = await import("@/lib/documents/commit");
|
||||
const ref = await commitApprovedFact(USER_ID, fact, {
|
||||
biomarkerId: alp.id,
|
||||
});
|
||||
const reading = await prisma.labResult.findUniqueOrThrow({
|
||||
where: { id: ref.recordId },
|
||||
});
|
||||
expect(reading.biomarkerId).toBe(alp.id);
|
||||
expect((await prisma.biomarkerAlias.findFirstOrThrow({})).name).toBe(
|
||||
"Alk. Phosph.",
|
||||
);
|
||||
|
||||
const again = serialiseFact(fact, await loadMarkerIndex(prisma, USER_ID));
|
||||
expect(again).toMatchObject({ biomarkerId: alp.id, matchKind: "alias" });
|
||||
});
|
||||
});
|
||||
@@ -282,7 +282,7 @@ const ROUTE_IMPORTS = import.meta.glob<Record<string, unknown>>(
|
||||
|
||||
describe("the complete MANAGE handler matrix", () => {
|
||||
it("starts with a non-empty, exact handler inventory", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(88);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(89);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1124,6 +1124,51 @@ manageContract("DELETE /api/biomarkers/[id]", {
|
||||
})) === null,
|
||||
});
|
||||
|
||||
manageContract("POST /api/biomarkers/[id]/merge", {
|
||||
contract: ADMITTED_MUTATING_HANDLERS.find(
|
||||
(entry) => entry.route === "/api/biomarkers/[id]/merge",
|
||||
)!,
|
||||
prepare: async (userId) => {
|
||||
const prisma = getPrismaClient();
|
||||
const target = await prisma.biomarker.create({
|
||||
data: { userId, name: "Alkaline Phosphatase", unit: "U/L" },
|
||||
});
|
||||
const source = await prisma.biomarker.create({
|
||||
data: { userId, name: "Alk.P", unit: "U/L" },
|
||||
});
|
||||
await prisma.labResult.create({
|
||||
data: {
|
||||
userId,
|
||||
biomarkerId: source.id,
|
||||
analyte: "Alk.P",
|
||||
value: 81,
|
||||
unit: "U/L",
|
||||
takenAt: new Date("2026-05-01T08:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
return { id: target.id, sourceId: source.id };
|
||||
},
|
||||
act: async (row) => {
|
||||
const { POST } = await import("@/app/api/biomarkers/[id]/merge/route");
|
||||
return call(
|
||||
POST as Handler,
|
||||
"POST",
|
||||
`/api/biomarkers/${row.id}/merge`,
|
||||
{ sourceIds: [row.sourceId] },
|
||||
{ id: row.id },
|
||||
);
|
||||
},
|
||||
ok: 200,
|
||||
auditAction: "labs.biomarker.merge",
|
||||
applied: async (row) =>
|
||||
(await getPrismaClient().biomarker.findUnique({
|
||||
where: { id: row.sourceId },
|
||||
})) === null &&
|
||||
(await getPrismaClient().labResult.count({
|
||||
where: { biomarkerId: row.id },
|
||||
})) === 1,
|
||||
});
|
||||
|
||||
manageContract("POST /api/allergies", {
|
||||
contract: ADMITTED_MUTATING_HANDLERS.find(
|
||||
(entry) => entry.route === "/api/allergies" && entry.action === "POST",
|
||||
@@ -2878,13 +2923,13 @@ describe("the conditions the admissions were granted on", () => {
|
||||
describe("the complete MANAGE handler matrix", () => {
|
||||
it("registers one strict actor-and-effect driver for every admission", () => {
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
|
||||
expect(STRICT_DRIVER_KEYS.size).toBe(88);
|
||||
expect(STRICT_DRIVER_KEYS.size).toBe(89);
|
||||
expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected);
|
||||
});
|
||||
|
||||
it("executes every registered driver through its owned effect and actor audit", () => {
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort();
|
||||
expect(REAL_EFFECT_KEYS.size).toBe(88);
|
||||
expect(REAL_EFFECT_KEYS.size).toBe(89);
|
||||
expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -66,6 +66,8 @@ const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
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;
|
||||
/** What the hook adds to every request: the UI locale and the person's zone. */
|
||||
const CTX = { locale: "en", timeZone: "Europe/Berlin" } as const;
|
||||
|
||||
async function seedOwner() {
|
||||
const prisma = getPrismaClient();
|
||||
@@ -161,6 +163,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => {
|
||||
pivot,
|
||||
term: "",
|
||||
cursor: null,
|
||||
...CTX,
|
||||
});
|
||||
expect(first.results.some((row) => row.id === ferritinId)).toBe(true);
|
||||
// Nothing after the window's split is on it.
|
||||
@@ -182,6 +185,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => {
|
||||
pivot,
|
||||
term: "",
|
||||
cursor,
|
||||
...CTX,
|
||||
});
|
||||
for (const row of page.results) {
|
||||
expect(seen.has(row.id)).toBe(false);
|
||||
@@ -207,6 +211,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => {
|
||||
pivot: null,
|
||||
term,
|
||||
cursor: null,
|
||||
...CTX,
|
||||
});
|
||||
expect(page.results.map((row) => row.id)).toEqual([ferritinId]);
|
||||
}
|
||||
@@ -240,6 +245,7 @@ describe("the visit form's lab picker on a 1,200-result record", () => {
|
||||
pivot: pickerPivot(VISIT_AT),
|
||||
term: "",
|
||||
cursor: null,
|
||||
...CTX,
|
||||
}),
|
||||
);
|
||||
expect(list.past).toHaveLength(1);
|
||||
@@ -276,7 +282,13 @@ describe("the condition picker's window and search", () => {
|
||||
const pivot = pickerPivot(VISIT_AT);
|
||||
const around = await get<Array<{ id: string; onsetAt: string }>>(
|
||||
listEpisodes as never,
|
||||
episodePickerUrl({ direction: "older", pivot, term: "", cursor: null }),
|
||||
episodePickerUrl({
|
||||
direction: "older",
|
||||
pivot,
|
||||
term: "",
|
||||
cursor: null,
|
||||
...CTX,
|
||||
}),
|
||||
);
|
||||
expect(around[0]!.id).toBe(old.id);
|
||||
|
||||
@@ -287,6 +299,7 @@ describe("the condition picker's window and search", () => {
|
||||
pivot: null,
|
||||
term: "tendin",
|
||||
cursor: null,
|
||||
...CTX,
|
||||
}),
|
||||
);
|
||||
expect(found.map((row) => row.id)).toEqual([old.id]);
|
||||
@@ -297,7 +310,13 @@ describe("the condition picker's window and search", () => {
|
||||
for (let guard = 0; guard < 5; guard++) {
|
||||
const page: Array<{ id: string }> = await get(
|
||||
listEpisodes as never,
|
||||
episodePickerUrl({ direction: "newer", pivot, term: "", cursor }),
|
||||
episodePickerUrl({
|
||||
direction: "newer",
|
||||
pivot,
|
||||
term: "",
|
||||
cursor,
|
||||
...CTX,
|
||||
}),
|
||||
);
|
||||
for (const row of page) {
|
||||
expect(ids.has(row.id)).toBe(false);
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
/**
|
||||
* A picker search for a year or a month returns exactly that period.
|
||||
*
|
||||
* Typing "2019" into a visit's link picker used to send `q=2019`, which the
|
||||
* list routes match against names and titles, so filtering by a year found
|
||||
* nothing. A term that names a period is now sent as the route's own date
|
||||
* window. This drives every family's list route with the requests the
|
||||
* pickers build (`picker-requests.ts`), against three years of records, and
|
||||
* walks every page: the answer must be every record in the period and
|
||||
* nothing else, including the records far beyond the first page.
|
||||
*
|
||||
* The expected sets are derived independently of the builders: an instant
|
||||
* belongs to the period its day in the person's zone falls in
|
||||
* (`userDayKey`), a document to the period its stored calendar date is in.
|
||||
* The seeds put records just either side of each boundary in that zone.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { GET as listEncounters } from "@/app/api/encounters/route";
|
||||
import { GET as listDocuments } from "@/app/api/documents/inbound/route";
|
||||
import { GET as listEpisodes } from "@/app/api/illness/episodes/route";
|
||||
import { GET as listLabs } from "@/app/api/labs/route";
|
||||
import {
|
||||
documentPickerUrl,
|
||||
encounterPickerUrl,
|
||||
episodePickerUrl,
|
||||
labPickerUrl,
|
||||
} from "@/components/links/picker-requests";
|
||||
import {
|
||||
PICKER_PAGE_SIZE,
|
||||
nextOffset,
|
||||
type PickerPageRequest,
|
||||
} from "@/components/links/use-link-picker-pages";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
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 = "period-search-owner";
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
/** Every day from 2018-01-01 to 2020-12-31, as UTC-midnight instants. */
|
||||
const DAYS = Array.from(
|
||||
{ length: 1096 },
|
||||
(_, i) => new Date(Date.UTC(2018, 0, 1) + i * DAY_MS),
|
||||
);
|
||||
|
||||
/**
|
||||
* Instants just either side of the period boundaries in Berlin: 23:30 and
|
||||
* 00:30 local around New Year 2019 (UTC+1), the start of March 2019 (UTC+1)
|
||||
* and the end of March 2019 (UTC+2, after the DST change), and New Year 2020.
|
||||
*/
|
||||
const EDGES = [
|
||||
"2018-12-31T22:30:00.000Z",
|
||||
"2018-12-31T23:30:00.000Z",
|
||||
"2019-02-28T22:30:00.000Z",
|
||||
"2019-02-28T23:30:00.000Z",
|
||||
"2019-03-31T21:30:00.000Z",
|
||||
"2019-03-31T22:30:00.000Z",
|
||||
"2019-12-31T22:30:00.000Z",
|
||||
"2019-12-31T23:30:00.000Z",
|
||||
].map((iso) => new Date(iso));
|
||||
|
||||
const search = (term: string, locale: string): PickerPageRequest => ({
|
||||
direction: "search",
|
||||
pivot: null,
|
||||
term,
|
||||
cursor: null,
|
||||
locale,
|
||||
timeZone: TZ,
|
||||
});
|
||||
|
||||
async function seedOwner() {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: OWNER_ID,
|
||||
username: "period-search",
|
||||
email: "period-search@example.test",
|
||||
timezone: TZ,
|
||||
modulePreferencesJson: {
|
||||
labs: true,
|
||||
illness: true,
|
||||
inboundDocuments: 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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Ids of the instants whose day in the person's zone starts with `prefix`. */
|
||||
function expectedIds(
|
||||
rows: ReadonlyArray<{ id: string; at: Date }>,
|
||||
prefix: string,
|
||||
): Set<string> {
|
||||
return new Set(
|
||||
rows
|
||||
.filter((row) => userDayKey(row.at, TZ).startsWith(prefix))
|
||||
.map((r) => r.id),
|
||||
);
|
||||
}
|
||||
|
||||
/** Walk an offset-paged search to its end, newest first, without repeats. */
|
||||
async function walkOffsets<T extends { id: string }>(
|
||||
fetchPage: (cursor: string | null) => Promise<T[]>,
|
||||
at: (row: T) => string,
|
||||
): Promise<{ ids: Set<string>; pages: number }> {
|
||||
const ids = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
let pages = 0;
|
||||
let previous = Number.POSITIVE_INFINITY;
|
||||
for (let guard = 0; guard < 30; guard++) {
|
||||
const rows = await fetchPage(cursor);
|
||||
pages += 1;
|
||||
for (const row of rows) {
|
||||
expect(ids.has(row.id), row.id).toBe(false);
|
||||
ids.add(row.id);
|
||||
const when = new Date(at(row)).getTime();
|
||||
expect(when).toBeLessThanOrEqual(previous);
|
||||
previous = when;
|
||||
}
|
||||
cursor = nextOffset(cursor, rows.length);
|
||||
if (cursor === null) break;
|
||||
}
|
||||
return { ids, pages };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await seedOwner();
|
||||
});
|
||||
|
||||
describe("lab results: a period search walks exactly the period", () => {
|
||||
async function seed() {
|
||||
const prisma = getPrismaClient();
|
||||
// A date-only reading is stored at noon UTC; the edges carry a time.
|
||||
const instants = [
|
||||
...DAYS.map((day) => new Date(day.getTime() + 12 * 60 * 60 * 1000)),
|
||||
...EDGES,
|
||||
];
|
||||
await prisma.labResult.createMany({
|
||||
data: instants.map((takenAt, i) => ({
|
||||
userId: OWNER_ID,
|
||||
panel: "Metabolic",
|
||||
analyte: `Glucose ${i}`,
|
||||
value: 90,
|
||||
unit: "mg/dL",
|
||||
takenAt,
|
||||
})),
|
||||
});
|
||||
const rows = await prisma.labResult.findMany({
|
||||
where: { userId: OWNER_ID },
|
||||
select: { id: true, takenAt: true },
|
||||
});
|
||||
return rows.map((row) => ({ id: row.id, at: row.takenAt }));
|
||||
}
|
||||
|
||||
const walk = (request: PickerPageRequest) =>
|
||||
walkOffsets(
|
||||
async (cursor) => {
|
||||
const page = await get<{
|
||||
results: Array<{ id: string; takenAt: string }>;
|
||||
}>(listLabs as never, labPickerUrl({ ...request, cursor }));
|
||||
return page.results;
|
||||
},
|
||||
(row) => row.takenAt,
|
||||
);
|
||||
|
||||
it("a year", async () => {
|
||||
const rows = await seed();
|
||||
const expected = expectedIds(rows, "2019-");
|
||||
expect(expected.size).toBe(365 + 6);
|
||||
const { ids, pages } = await walk(search("2019", "en"));
|
||||
expect(pages).toBeGreaterThan(1);
|
||||
expect(ids.size).toBeGreaterThan(PICKER_PAGE_SIZE);
|
||||
expect(ids).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["de", "März 2019"],
|
||||
["de", "03.2019"],
|
||||
["en", "Mar 2019"],
|
||||
["pl", "marzec 2019"],
|
||||
["ko", "2019년 3월"],
|
||||
])("a month (%s %j)", async (locale, term) => {
|
||||
const rows = await seed();
|
||||
const expected = expectedIds(rows, "2019-03-");
|
||||
expect(expected.size).toBe(31 + 2);
|
||||
const { ids } = await walk(search(term, locale));
|
||||
expect(ids).toEqual(expected);
|
||||
});
|
||||
|
||||
it("a plain term is still a text search", async () => {
|
||||
await seed();
|
||||
const { ids } = await walk(search("Glucose 7", "en"));
|
||||
// "Glucose 7", "Glucose 70" … "Glucose 799", "Glucose 7xx": names only.
|
||||
expect(ids.size).toBeGreaterThan(0);
|
||||
expect(ids.size).toBeLessThan(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("conditions: a period search walks exactly the period", () => {
|
||||
it("a year and a month", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const onsets = [
|
||||
...DAYS.filter((_, i) => i % 2 === 0).map(
|
||||
(day) => new Date(day.getTime() + 5 * 60 * 60 * 1000),
|
||||
),
|
||||
...EDGES,
|
||||
];
|
||||
await prisma.illnessEpisode.createMany({
|
||||
data: onsets.map((onsetAt, i) => ({
|
||||
userId: OWNER_ID,
|
||||
label: `Cold ${i}`,
|
||||
type: "INFECTION" as const,
|
||||
onsetAt,
|
||||
resolvedAt: new Date(onsetAt.getTime() + 3 * DAY_MS),
|
||||
})),
|
||||
});
|
||||
const rows = (
|
||||
await prisma.illnessEpisode.findMany({
|
||||
where: { userId: OWNER_ID },
|
||||
select: { id: true, onsetAt: true },
|
||||
})
|
||||
).map((row) => ({ id: row.id, at: row.onsetAt }));
|
||||
|
||||
const walk = (request: PickerPageRequest) =>
|
||||
walkOffsets(
|
||||
async (cursor) => {
|
||||
const page = await get<Array<{ id: string; onsetAt: string }>>(
|
||||
listEpisodes as never,
|
||||
episodePickerUrl({ ...request, cursor }),
|
||||
);
|
||||
return page;
|
||||
},
|
||||
(row) => row.onsetAt,
|
||||
);
|
||||
|
||||
const year = await walk(search("2019", "fr"));
|
||||
expect(year.ids.size).toBeGreaterThan(PICKER_PAGE_SIZE);
|
||||
expect(year.ids).toEqual(expectedIds(rows, "2019-"));
|
||||
|
||||
const month = await walk(search("mars 2019", "fr"));
|
||||
expect(month.ids).toEqual(expectedIds(rows, "2019-03-"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("visits: the route without text search still searches a period", () => {
|
||||
it("a year and a month", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const instants = [
|
||||
...DAYS.map((day) => new Date(day.getTime() + 9 * 60 * 60 * 1000)),
|
||||
...EDGES,
|
||||
];
|
||||
await prisma.encounter.createMany({
|
||||
data: instants.map((occurredAt) => ({
|
||||
userId: OWNER_ID,
|
||||
occurredAt,
|
||||
status: "DONE" as const,
|
||||
kind: "ROUTINE" as const,
|
||||
})),
|
||||
});
|
||||
const rows = (
|
||||
await prisma.encounter.findMany({
|
||||
where: { userId: OWNER_ID },
|
||||
select: { id: true, occurredAt: true },
|
||||
})
|
||||
).map((row) => ({ id: row.id, at: row.occurredAt }));
|
||||
|
||||
const walk = (request: PickerPageRequest) =>
|
||||
walkOffsets(
|
||||
async (cursor) => {
|
||||
const list = await get<{
|
||||
upcoming: Array<{ id: string; occurredAt: string }>;
|
||||
past: Array<{ id: string; occurredAt: string }>;
|
||||
}>(
|
||||
listEncounters as never,
|
||||
encounterPickerUrl({ ...request, cursor }),
|
||||
);
|
||||
expect(list.upcoming).toEqual([]);
|
||||
return list.past;
|
||||
},
|
||||
(row) => row.occurredAt,
|
||||
);
|
||||
|
||||
const year = await walk(search("2019", "it"));
|
||||
expect(year.pages).toBeGreaterThan(1);
|
||||
expect(year.ids).toEqual(expectedIds(rows, "2019-"));
|
||||
|
||||
const month = await walk(search("marzo 2019", "it"));
|
||||
expect(month.ids).toEqual(expectedIds(rows, "2019-03-"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("documents: the day-granular window is the period's calendar days", () => {
|
||||
it("a year and a month, across keyset pages", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.inboundDocument.createMany({
|
||||
data: DAYS.map((day, i) => ({
|
||||
userId: OWNER_ID,
|
||||
kind: "OTHER" as const,
|
||||
filename: `doc-${i}.pdf`,
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 4,
|
||||
status: "STORED" as const,
|
||||
contentEncrypted: Buffer.from("test"),
|
||||
contentCodec: "v1",
|
||||
documentDate: day,
|
||||
})),
|
||||
});
|
||||
const rows = await prisma.inboundDocument.findMany({
|
||||
where: { userId: OWNER_ID },
|
||||
select: { id: true, documentDate: true },
|
||||
});
|
||||
const expected = (prefix: string) =>
|
||||
new Set(
|
||||
rows
|
||||
.filter((row) => row.documentDate!.toISOString().startsWith(prefix))
|
||||
.map((row) => row.id),
|
||||
);
|
||||
|
||||
async function walk(request: PickerPageRequest) {
|
||||
const ids = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
let pages = 0;
|
||||
for (let guard = 0; guard < 30; guard++) {
|
||||
const page: {
|
||||
documents: Array<{ id: string }>;
|
||||
nextCursor: string | null;
|
||||
} = await get(
|
||||
listDocuments as never,
|
||||
documentPickerUrl({ ...request, cursor }),
|
||||
);
|
||||
pages += 1;
|
||||
for (const doc of page.documents) {
|
||||
expect(ids.has(doc.id)).toBe(false);
|
||||
ids.add(doc.id);
|
||||
}
|
||||
cursor = page.nextCursor;
|
||||
if (cursor === null) break;
|
||||
}
|
||||
return { ids, pages };
|
||||
}
|
||||
|
||||
const year = await walk(search("2019", "es"));
|
||||
expect(year.pages).toBeGreaterThan(1);
|
||||
expect(year.ids).toEqual(expected("2019-"));
|
||||
expect(year.ids.size).toBe(365);
|
||||
|
||||
const month = await walk(search("marzo de 2019", "es"));
|
||||
expect(month.ids).toEqual(expected("2019-03-"));
|
||||
expect(month.ids.size).toBe(31);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user