mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
A lab runs its own method on its own device, so the window a physician evaluates a value against is the one printed next to it on that report, not a catalog band averaged over every lab. Reading a report pulled the value and dropped that range: the extraction asked for it, the commit handed it to the biomarker resolver, and the resolver only uses it when it mints a NEW marker. For every analyte the user already had — the normal case after a first panel — the printed range was read and then thrown away. Add `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` to `LabResult` and carry the range from the report to the reading. The text is stored whether or not numbers can be read out of it, because "bis 5,0" and "negativ" state a window too; a range that could not be parsed is kept verbatim, never replaced with invented bounds. `resolveEffectiveReferenceRange` is the one place the precedence lives: the source window wins for that single reading, and the catalog stays the net for readings without one. A printed string with no derivable bound does not displace the catalog. Every surface that judges a reading routes through it — the API verdict, the doctor report, the insights blocks and per-marker assessment, the coach snapshot, the MCP read and the FHIR `referenceRange` — so a value cannot read in range on one surface and out on another. The API keeps `referenceLow` / `referenceHigh` as the window in force, so a client that reads only those two renders the right range and the right verdict with no change of its own; the catalog band, the source window, its origin and a divergence flag ride alongside for clients that want to show both. Read side: the chart draws the source window as a second, visually distinct band with the reading it came from named beneath it, the reading rows name the printed range and the catalog band where the two disagree, and the doctor-report PDF prints the report's own string with a footnote naming the saved range. Entry side: one optional field on the lab form, folded away until asked for, and the printed range is editable on the scan review screen. The range parser covers the notations German and English reports actually print — two-sided, one-sided, comma or dot decimals, thousands separators, trailing units — reads units without ever converting them, and refuses to guess on anything ambiguous.
299 lines
10 KiB
TypeScript
299 lines
10 KiB
TypeScript
/**
|
|
* The whole path a printed reference range travels, against a real Postgres.
|
|
*
|
|
* The failure this pins is a two-ended one: until now the document reading
|
|
* pulled the value off a lab report and dropped the range printed beside it,
|
|
* and every end of that pipe looked healthy on its own. So the assertions here
|
|
* follow ONE range through the real assembly rather than checking each end in
|
|
* isolation:
|
|
*
|
|
* extract → the staged fact carries the printed string
|
|
* commit → the written row carries the derived bounds AND the string
|
|
* read → the API DTO serves both windows and says which is in force
|
|
* verdict → the reading is judged against the REPORT's window, not the
|
|
* catalog band — the case where the two disagree is the point
|
|
* report → the doctor-report payload and the FHIR bundle agree with it
|
|
*
|
|
* The catalog band is deliberately set so that the same number reads
|
|
* "in-range" against the report and "above" against the catalog. A test whose
|
|
* two windows agree cannot tell whether the precedence rule is wired at all.
|
|
*/
|
|
import { NextRequest } from "next/server";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { cookieJar } from "./mock-next-headers";
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
const USER_ID = "user-lab-source-range";
|
|
|
|
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),
|
|
}));
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
cookieJar.clear();
|
|
const prisma = getPrismaClient();
|
|
await prisma.user.create({
|
|
data: {
|
|
id: USER_ID,
|
|
username: "lab-source-range",
|
|
email: "lab-source-range@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);
|
|
});
|
|
|
|
/** The catalog band the marker carries. 5.2 sits ABOVE this window. */
|
|
const CATALOG = { lowerBound: 3.5, upperBound: 5.0 };
|
|
/** The window the report printed. 5.2 sits INSIDE this one. */
|
|
const PRINTED = "3,9 - 5,4";
|
|
const VALUE = 5.2;
|
|
|
|
async function seedMarker() {
|
|
return getPrismaClient().biomarker.create({
|
|
data: { userId: USER_ID, name: "Kalium", unit: "mmol/L", ...CATALOG },
|
|
});
|
|
}
|
|
|
|
function postReq(body: unknown): NextRequest {
|
|
return new NextRequest("http://localhost/api/labs", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
async function readDto(analyte = "Kalium") {
|
|
const { GET } = await import("@/app/api/labs/route");
|
|
const res = await GET(
|
|
new NextRequest(`http://localhost/api/labs?analyte=${analyte}`),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
return body.data.results[0];
|
|
}
|
|
|
|
describe("a printed reference range, end to end", () => {
|
|
it("survives the document path onto the row and governs its verdict", async () => {
|
|
const prisma = getPrismaClient();
|
|
const marker = await seedMarker();
|
|
|
|
// The document path, through the REAL commit helper the confirm route
|
|
// calls. The fact carries what a report prints: a value, a unit, and a
|
|
// window stated in German notation beside it.
|
|
const { commitApprovedFact } = await import("@/lib/documents/commit");
|
|
const { encryptFactData, encryptFactProvenance } =
|
|
await import("@/lib/documents/store");
|
|
const doc = await prisma.inboundDocument.create({
|
|
data: {
|
|
userId: USER_ID,
|
|
filename: "befund.pdf",
|
|
mimeType: "application/pdf",
|
|
byteSize: 1024,
|
|
contentEncrypted: Buffer.from("stand-in for the stored bytes"),
|
|
status: "EXTRACTED",
|
|
},
|
|
});
|
|
const fact = await prisma.extractedFact.create({
|
|
data: {
|
|
documentId: doc.id,
|
|
userId: USER_ID,
|
|
factType: "OBSERVATION",
|
|
status: "APPROVED",
|
|
confidence: 0.9,
|
|
needsReview: false,
|
|
dataEncrypted: encryptFactData({
|
|
label: "Kalium",
|
|
code: null,
|
|
codeSystem: null,
|
|
value: VALUE,
|
|
valueText: null,
|
|
unit: "mmol/L",
|
|
referenceLow: null,
|
|
referenceHigh: null,
|
|
referenceText: PRINTED,
|
|
effectiveDate: "2026-07-01",
|
|
}),
|
|
provenanceEncrypted: encryptFactProvenance({
|
|
sourceText: "Kalium 5,2 mmol/l 3,9 - 5,4",
|
|
anchored: true,
|
|
sourceOffset: 0,
|
|
page: 0,
|
|
confidence: 0.9,
|
|
}),
|
|
},
|
|
});
|
|
|
|
const ref = await commitApprovedFact(USER_ID, fact);
|
|
expect(ref.recordType).toBe("labResult");
|
|
|
|
// The row carries BOTH readings of the printed window: the bounds derived
|
|
// from it and the string itself.
|
|
const row = await prisma.labResult.findUniqueOrThrow({
|
|
where: { id: ref.recordId },
|
|
});
|
|
expect(row.sourceReferenceLow).toBe(3.9);
|
|
expect(row.sourceReferenceHigh).toBe(5.4);
|
|
expect(row.sourceReferenceText).toBe(PRINTED);
|
|
// The catalog stamp is untouched — the marker already existed, so the
|
|
// document's window did not rewrite the user's own band.
|
|
expect(row.referenceLow).toBe(CATALOG.lowerBound);
|
|
expect(row.referenceHigh).toBe(CATALOG.upperBound);
|
|
const markerAfter = await prisma.biomarker.findUniqueOrThrow({
|
|
where: { id: marker.id },
|
|
});
|
|
expect(markerAfter.lowerBound).toBe(CATALOG.lowerBound);
|
|
expect(markerAfter.upperBound).toBe(CATALOG.upperBound);
|
|
|
|
// The read side serves both windows, names the winner, and flags that the
|
|
// two disagree.
|
|
const dto = await readDto();
|
|
expect(dto).toMatchObject({
|
|
value: VALUE,
|
|
referenceLow: 3.9,
|
|
referenceHigh: 5.4,
|
|
catalogReferenceLow: CATALOG.lowerBound,
|
|
catalogReferenceHigh: CATALOG.upperBound,
|
|
sourceReferenceLow: 3.9,
|
|
sourceReferenceHigh: 5.4,
|
|
sourceReferenceText: PRINTED,
|
|
referenceOrigin: "source",
|
|
referenceDivergesFromCatalog: true,
|
|
});
|
|
|
|
// The verdict is the point. Against the catalog band 5.2 is above range;
|
|
// against the window the lab printed it is inside it, and the lab's window
|
|
// is the one the physician read the value against.
|
|
expect(dto.rangeStatus).toBe("in-range");
|
|
|
|
// The doctor report reads the same window, and prints the report's own
|
|
// string rather than a reformatted version of it.
|
|
const { loadLabResults } =
|
|
await import("@/lib/doctor-report/clinical-records");
|
|
const labs = await loadLabResults(
|
|
USER_ID,
|
|
new Date("2026-01-01T00:00:00.000Z"),
|
|
new Date("2026-12-31T23:59:59.000Z"),
|
|
);
|
|
expect(labs?.[0]).toMatchObject({
|
|
referenceLow: 3.9,
|
|
referenceHigh: 5.4,
|
|
catalogReferenceLow: CATALOG.lowerBound,
|
|
catalogReferenceHigh: CATALOG.upperBound,
|
|
sourceReferenceText: PRINTED,
|
|
referenceOrigin: "source",
|
|
referenceDivergesFromCatalog: true,
|
|
});
|
|
|
|
// FHIR exports the same window — a receiver that trusts referenceRange
|
|
// must not be handed the catalog band while the app shows another.
|
|
const { labObservations } = await import("@/lib/fhir/resources/labs");
|
|
let seq = 0;
|
|
const observations = labObservations(
|
|
{ labResults: labs } as Parameters<typeof labObservations>[0],
|
|
() => `obs-${++seq}`,
|
|
);
|
|
expect(observations[0].referenceRange?.[0]).toMatchObject({
|
|
low: { value: 3.9 },
|
|
high: { value: 5.4 },
|
|
text: PRINTED,
|
|
});
|
|
});
|
|
|
|
it("keeps a window it cannot read as text and leaves the catalog in force", async () => {
|
|
const prisma = getPrismaClient();
|
|
await seedMarker();
|
|
|
|
const { POST } = await import("@/app/api/labs/route");
|
|
const marker = await prisma.biomarker.findFirstOrThrow({
|
|
where: { userId: USER_ID },
|
|
});
|
|
const res = await POST(
|
|
postReq({
|
|
biomarkerId: marker.id,
|
|
value: VALUE,
|
|
sourceReferenceText: "siehe Befund",
|
|
takenAt: "2026-07-02T08:00:00.000Z",
|
|
}),
|
|
);
|
|
expect(res.status).toBe(201);
|
|
|
|
const row = await prisma.labResult.findFirstOrThrow({
|
|
where: { userId: USER_ID },
|
|
});
|
|
// The string is on the record — absence would be the old failure, and a
|
|
// fabricated bound would be worse than either.
|
|
expect(row.sourceReferenceText).toBe("siehe Befund");
|
|
expect(row.sourceReferenceLow).toBeNull();
|
|
expect(row.sourceReferenceHigh).toBeNull();
|
|
|
|
const dto = await readDto();
|
|
expect(dto).toMatchObject({
|
|
sourceReferenceText: "siehe Befund",
|
|
referenceOrigin: "catalog",
|
|
referenceDivergesFromCatalog: false,
|
|
referenceLow: CATALOG.lowerBound,
|
|
referenceHigh: CATALOG.upperBound,
|
|
});
|
|
// No derivable window means the catalog stays the net, so the catalog's
|
|
// verdict stands.
|
|
expect(dto.rangeStatus).toBe("above");
|
|
});
|
|
|
|
it("carries the window through a backup round trip", async () => {
|
|
const prisma = getPrismaClient();
|
|
const marker = await seedMarker();
|
|
await prisma.labResult.create({
|
|
data: {
|
|
userId: USER_ID,
|
|
biomarkerId: marker.id,
|
|
analyte: "Kalium",
|
|
unit: "mmol/L",
|
|
value: VALUE,
|
|
referenceLow: CATALOG.lowerBound,
|
|
referenceHigh: CATALOG.upperBound,
|
|
sourceReferenceLow: 3.9,
|
|
sourceReferenceHigh: 5.4,
|
|
sourceReferenceText: PRINTED,
|
|
takenAt: new Date("2026-07-01T08:00:00.000Z"),
|
|
},
|
|
});
|
|
|
|
const { buildRecordsBackupSection } =
|
|
await import("@/lib/export/records-backup");
|
|
const backup = await buildRecordsBackupSection(prisma, USER_ID, {
|
|
purpose: "disaster-recovery",
|
|
});
|
|
expect(backup.labResults[0]).toMatchObject({
|
|
sourceReferenceLow: 3.9,
|
|
sourceReferenceHigh: 5.4,
|
|
sourceReferenceText: PRINTED,
|
|
});
|
|
});
|
|
});
|