Files
HealthLog/tests/integration/lab-unit-mismatch.test.ts
T
BernhardBuckel caa7a937fd feat(labs): convert a reading into its marker's unit when the pair is known
Slice 2 of phase 3 of #1095. A reading that stated a unit other than its
marker's was refused. When the marker's analyte is known and the registry has the
pair, the three write paths now convert it instead: POST /api/labs, the OCR
commit and the document commit.

The converted value is stored at full precision in the marker's unit, with what
the report printed in the existing source_value and source_unit columns. The
report's own reference window is in the printed unit, so it is converted with the
value; otherwise the verdict would compare a converted value with an unconverted
window. A pair the registry does not have is still refused as before (the 422
now says why), as is a marker whose analyte is unknown.

The marker's key is filled the first time a reading needs it. The same scan
committed again is still a duplicate: a converted row holds the number in the
marker's unit, so the duplicate check also matches on what the report printed.
Editing the value of a converted reading clears the conversion record, because
the number is then the person's own.

The lab result DTO and the OpenAPI schemas (labs and the OCR commit) carry
converted, sourceValue and sourceUnit.

Refs #1095
2026-10-11 20:28:00 +02:00

305 lines
9.4 KiB
TypeScript

/**
* A reading may only be stored under the unit it is actually in.
*
* Before this, a reading that stated another unit than its marker's (5.6
* mmol/L against a marker kept in mg/dL) was written with its value unchanged
* and the marker's unit stamped on it, and the range check then judged 5.6
* against a mg/dL band. These tests follow that reading through the real
* routes and the real database, and assert both what is refused and what is
* not: a different spelling of the same unit, a qualitative reading, a
* reading with no unit on the structured path and a brand-new marker must all
* still write.
*
* "Another unit" is refused only where the pair cannot be converted. Ferritin
* in pmol/L against a marker kept in ng/mL is the standing example: protein
* molecular weights vary by source, so the registry has no conversion and the
* reading stays refused. Where the marker's analyte is known and the pair is in
* the registry (glucose in mmol/L against mg/dL) the reading is converted
* instead; that is `labs-unit-conversion.test.ts`.
*/
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-unit-mismatch";
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-unit-mismatch",
email: "lab-unit-mismatch@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);
});
const TAKEN_AT = "2026-07-10T12:00:00.000Z";
async function seedGlucose() {
return getPrismaClient().biomarker.create({
data: {
userId: USER_ID,
name: "Glucose",
unit: "mg/dL",
lowerBound: 70,
upperBound: 99,
},
});
}
/** A marker whose analyte the registry has no conversion for. */
async function seedFerritin() {
return getPrismaClient().biomarker.create({
data: {
userId: USER_ID,
name: "Ferritin",
unit: "ng/mL",
lowerBound: 30,
upperBound: 400,
},
});
}
function jsonReq(path: string, body: unknown): NextRequest {
return new NextRequest(`http://localhost${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
async function postLab(body: Record<string, unknown>) {
const { POST } = await import("@/app/api/labs/route");
const res = await POST(jsonReq("/api/labs", { takenAt: TAKEN_AT, ...body }));
return { status: res.status, body: await res.json() };
}
async function commitRows(rows: Record<string, unknown>[]) {
const { POST } = await import("@/app/api/labs/ocr/commit/route");
const res = await POST(
jsonReq("/api/labs/ocr/commit", {
rows: rows.map((row) => ({ takenAt: TAKEN_AT, ...row })),
}),
);
return { status: res.status, body: await res.json() };
}
const labCount = () =>
getPrismaClient().labResult.count({ where: { userId: USER_ID } });
describe("POST /api/labs", () => {
it("refuses a reading in another unit than its marker's and writes nothing", async () => {
await seedFerritin();
const { status, body } = await postLab({
analyte: "Ferritin",
value: 450,
unit: "pmol/L",
});
expect(status).toBe(422);
expect(body.meta).toMatchObject({
errorCode: "labs.unit.mismatch",
markerUnit: "ng/mL",
readingUnit: "pmol/L",
reason: "unknown_analyte",
});
expect(await labCount()).toBe(0);
});
it("refuses it on the structured path too, when a unit is sent", async () => {
const marker = await seedFerritin();
const { status, body } = await postLab({
biomarkerId: marker.id,
value: 450,
unit: "pmol/L",
});
expect(status).toBe(422);
expect(body.meta.errorCode).toBe("labs.unit.mismatch");
expect(await labCount()).toBe(0);
});
it("writes a reading on the structured path that sends no unit, under the marker's", async () => {
const marker = await seedGlucose();
const { status, body } = await postLab({
biomarkerId: marker.id,
value: 95,
});
expect(status).toBe(201);
expect(body.data).toMatchObject({ value: 95, unit: "mg/dL" });
});
it.each(["mg/dl", "mg/DL", "MG/DL", " mg / dL "])(
"writes the same unit spelled %j",
async (spelling) => {
await seedGlucose();
const { status, body } = await postLab({
analyte: "Glucose",
value: 95,
unit: spelling,
});
expect(status).toBe(201);
expect(body.data).toMatchObject({ value: 95, unit: "mg/dL" });
},
);
it("does not compare a qualitative reading, which has no unit", async () => {
await seedGlucose();
const { status } = await postLab({
analyte: "Glucose",
valueText: "negative",
unit: "mmol/L",
});
expect(status).toBe(201);
});
it("lets a new marker adopt the unit of its first reading", async () => {
const { status, body } = await postLab({
analyte: "Ferritin",
value: 80,
unit: "ng/mL",
});
expect(status).toBe(201);
expect(body.data.unit).toBe("ng/mL");
// …and then holds the next reading in another unit to it.
const next = await postLab({
analyte: "Ferritin",
value: 180,
unit: "pmol/L",
});
expect(next.status).toBe(422);
expect(await labCount()).toBe(1);
});
it("never judges the refused value against the marker's range", async () => {
// The case from the report, for a pair that is refused: the number must
// never be kept under a unit it is not in and judged against that band.
await seedFerritin();
await postLab({ analyte: "Ferritin", value: 450, unit: "pmol/L" });
const { GET } = await import("@/app/api/labs/route");
const res = await GET(
new NextRequest("http://localhost/api/labs?analyte=Ferritin"),
);
expect((await res.json()).data.results).toEqual([]);
});
});
describe("POST /api/labs/ocr/commit", () => {
it("writes the matching row, skips the one in another unit, and reports a partial outcome", async () => {
await seedFerritin();
const { status, body } = await commitRows([
{ analyte: "Ferritin", value: 95, unit: "ng/mL" },
{
analyte: "Ferritin",
value: 450,
unit: "pmol/L",
takenAt: "2026-08-10T12:00:00.000Z",
},
]);
expect(status).toBe(200);
expect(body.data.inserted).toHaveLength(1);
expect(body.data.inserted[0]).toMatchObject({ value: 95, unit: "ng/mL" });
expect(body.data.skipped).toEqual([
{ analyte: "Ferritin", reason: "unit_mismatch" },
]);
expect(body.data.outcome).toBe("partial");
expect(await labCount()).toBe(1);
});
it("reports a failed outcome when every row is in another unit", async () => {
await seedFerritin();
const { body } = await commitRows([
{ analyte: "Ferritin", value: 450, unit: "pmol/L" },
]);
expect(body.data.inserted).toHaveLength(0);
expect(body.data.outcome).toBe("failed");
expect(await labCount()).toBe(0);
});
it("accepts a different spelling of the marker's unit", async () => {
await seedGlucose();
const { body } = await commitRows([
{ analyte: "Glucose", value: 95, unit: "MG/DL" },
]);
expect(body.data.outcome).toBe("success");
expect(body.data.inserted[0].unit).toBe("mg/dL");
});
it("does not compare a qualitative row", async () => {
await seedGlucose();
const { body } = await commitRows([
{ analyte: "Glucose", valueText: "negative", unit: "mmol/L" },
]);
expect(body.data.outcome).toBe("success");
});
});
describe("the scan review's view of a row", () => {
const scanned = (analyte: string, unit: string) => ({
analyte,
value: 5.6,
valueText: null,
unit,
referenceLow: null,
referenceHigh: null,
referenceText: null,
takenAt: null,
confidence: { analyte: 1, value: 1, unit: 1, range: 1 },
});
it("carries the matched marker's unit, so the screen can say so before Save", async () => {
await seedGlucose();
const { annotateRow } = await import("@/lib/labs/ocr-extract");
const known = await annotateRow(
USER_ID,
scanned("Glucose", "mmol/L"),
"2026-07-10",
"UTC",
);
expect(known).toMatchObject({
biomarkerMatch: "existing",
markerUnit: "mg/dL",
});
const fresh = await annotateRow(
USER_ID,
scanned("Creatinine", "µmol/L"),
"2026-07-10",
"UTC",
);
expect(fresh).toMatchObject({ biomarkerMatch: "new", markerUnit: null });
});
});