mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
test(labs): prove merges, aliases and their backup against real SQL
The merge moves every reading and every reader shows one series, a dry run answers the same and writes nothing, conversions keep the printed value, an unconvertible source refuses the whole merge, a chosen spelling resolves on the next scan and on manual entry, and aliases and the merge ledger survive both kinds of backup. The merge route joins the record-scoped manage inventory with its driver.
This commit is contained in:
1 parent
81be73fca8
commit
e5544913f6
6 files changed
+1075
-6
No files matched your search
@@ -859,6 +859,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
|
||||
domain: "labs",
|
||||
why: "One biomarker of the record, fetch-then-guard against the resolved user.",
|
||||
},
|
||||
"app/api/biomarkers/[id]/merge/route.ts": {
|
||||
domain: "labs",
|
||||
why: "Merging markers of the record into one (v1.43): every read and write is narrowed to the resolved user, and the sources must be that record's markers.",
|
||||
},
|
||||
"app/api/allergies/route.ts": {
|
||||
domain: "profile",
|
||||
why: "The record's allergy list — the single most useful thing a caregiver can read, and a plain list of the owner's own rows. The POST beside it is NOT delegable and is deliberately absent from the write literal below: the only surface that posts to it lives under `/settings`, which a switch closes, so admitting the write would freeze a permission ahead of any caller for it. The route comment carries the argument.",
|
||||
@@ -1612,6 +1616,11 @@ const DELEGABLE_MANAGE_ROUTES: Record<string, ManageEntry> = {
|
||||
conditions: ["C3", "C4"],
|
||||
why: "Editing an analyte's reference range, the one admitted edit whose error is silent, which is why C4 puts it in the feed. The DELETE beside it resolves through the same labs authority since v1.43 instead of a bare requireAuth; it hard-deletes the marker with its readings, so C3 files the name, unit and reading count it took.",
|
||||
},
|
||||
"app/api/biomarkers/[id]/merge/route.ts": {
|
||||
domain: "labs",
|
||||
conditions: ["C3"],
|
||||
why: "Merging markers moves their readings into one and deletes the sources, with no undo, so it sits at the same level as the delete beside it. C3: each deleted source files its id and how many readings went with it; the name stays in the merge ledger. The shared record-write ceiling is charged to the actor, like every record write.",
|
||||
},
|
||||
"app/api/allergies/route.ts": {
|
||||
domain: "profile",
|
||||
conditions: [],
|
||||
@@ -2117,8 +2126,11 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* facts (`profile`) and the nutrient catalogue beside the nutrient reads
|
||||
* (`measurements`). The biomarker DELETE joins the manage literal on a module
|
||||
* that was already there. 256 -> 258.
|
||||
*
|
||||
* v1.43 -- merging biomarkers joins the record list and the manage literal
|
||||
* (D1223). 258 -> 260.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 258;
|
||||
const FROZEN_ENTRY_COUNT = 260;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
@@ -2879,7 +2891,7 @@ describe("(g) the MANAGE route set is frozen", () => {
|
||||
|
||||
it("keeps the admitted mutation inventory complete and discoverable", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(88);
|
||||
expect(ADMITTED_MUTATING_HANDLERS.length).toBe(89);
|
||||
|
||||
const expected = ADMITTED_MUTATING_HANDLERS.map(
|
||||
({ handlerModule, action, level }) =>
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => {
|
||||
});
|
||||
|
||||
it("keeps every admitted mutation uniquely addressable and fully controlled", () => {
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(88);
|
||||
expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(89);
|
||||
expect(
|
||||
new Set(
|
||||
ADMITTED_MUTATING_HANDLERS.map(
|
||||
|
||||
+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",
|
||||
|
||||
@@ -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,722 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user