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
This commit is contained in:
BernhardBuckel committed 2026-10-11 20:28:00 +02:00
1 parent 08c69d4a5a
commit caa7a937fd
18 files changed
+1011 -105

No files matched your search

+50 -29
View File
@@ -9,6 +9,13 @@
* 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";
@@ -74,6 +81,19 @@ async function seedGlucose() {
});
}
/** 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",
@@ -103,27 +123,28 @@ const labCount = () =>
describe("POST /api/labs", () => {
it("refuses a reading in another unit than its marker's and writes nothing", async () => {
await seedGlucose();
await seedFerritin();
const { status, body } = await postLab({
analyte: "Glucose",
value: 5.6,
unit: "mmol/L",
analyte: "Ferritin",
value: 450,
unit: "pmol/L",
});
expect(status).toBe(422);
expect(body.meta).toMatchObject({
errorCode: "labs.unit.mismatch",
markerUnit: "mg/dL",
readingUnit: "mmol/L",
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 seedGlucose();
const marker = await seedFerritin();
const { status, body } = await postLab({
biomarkerId: marker.id,
value: 5.6,
unit: "mmol/L",
value: 450,
unit: "pmol/L",
});
expect(status).toBe(422);
expect(body.meta.errorCode).toBe("labs.unit.mismatch");
@@ -166,30 +187,30 @@ describe("POST /api/labs", () => {
it("lets a new marker adopt the unit of its first reading", async () => {
const { status, body } = await postLab({
analyte: "Creatinine",
analyte: "Ferritin",
value: 80,
unit: "µmol/L",
unit: "ng/mL",
});
expect(status).toBe(201);
expect(body.data.unit).toBe("µmol/L");
expect(body.data.unit).toBe("ng/mL");
// …and then holds the next reading in another unit to it.
const next = await postLab({
analyte: "Creatinine",
value: 0.9,
unit: "mg/dL",
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: 5.6 mmol/L is an ordinary glucose, and 5.6
// mg/dL would read as far below range.
await seedGlucose();
await postLab({ analyte: "Glucose", value: 5.6, unit: "mmol/L" });
// 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=Glucose"),
new NextRequest("http://localhost/api/labs?analyte=Ferritin"),
);
expect((await res.json()).data.results).toEqual([]);
});
@@ -197,30 +218,30 @@ describe("POST /api/labs", () => {
describe("POST /api/labs/ocr/commit", () => {
it("writes the matching row, skips the one in another unit, and reports a partial outcome", async () => {
await seedGlucose();
await seedFerritin();
const { status, body } = await commitRows([
{ analyte: "Glucose", value: 95, unit: "mg/dL" },
{ analyte: "Ferritin", value: 95, unit: "ng/mL" },
{
analyte: "Glucose",
value: 5.6,
unit: "mmol/L",
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: "mg/dL" });
expect(body.data.inserted[0]).toMatchObject({ value: 95, unit: "ng/mL" });
expect(body.data.skipped).toEqual([
{ analyte: "Glucose", reason: "unit_mismatch" },
{ 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 seedGlucose();
await seedFerritin();
const { body } = await commitRows([
{ analyte: "Glucose", value: 5.6, unit: "mmol/L" },
{ analyte: "Ferritin", value: 450, unit: "pmol/L" },
]);
expect(body.data.inserted).toHaveLength(0);
expect(body.data.outcome).toBe("failed");
@@ -0,0 +1,316 @@
/**
* A lab reading in another unit is converted into its marker's unit (#1095).
*
* Until now 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, keep what the report printed beside it, and move
* the report's own reference window with the value. Against a real Postgres:
*
* - POST /api/labs converts, stores full precision and the printed value and
* unit, converts the printed window, and says so in the response;
* - a pair that cannot be converted is still the 422 it always was, now with
* the reason;
* - the verdict is judged against the converted window, so a value in range in
* the printed unit is in range after conversion;
* - the OCR commit converts, and the same scan committed again is a duplicate
* (the stored number is converted, so the printed one is what matches);
* - editing the value of a converted reading clears the conversion record;
* - the list returns the fields.
*/
import { NextRequest } from "next/server";
import { beforeAll, describe, expect, it, vi } from "vitest";
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 prisma = getPrismaClient();
const USER = "lab-conversion-user";
const MGDL_PER_MMOL = 18.0182;
beforeAll(async () => {
await truncateAllTables(prisma);
await prisma.user.create({ data: { id: USER, username: USER } });
// A catalog marker the user tracks in mg/dL, with a catalog band.
await prisma.biomarker.create({
data: {
id: "bm-glucose",
userId: USER,
name: "Glucose",
unit: "mg/dL",
lowerBound: 70,
upperBound: 100,
},
});
// A marker the registry knows nothing about.
await prisma.biomarker.create({
data: {
id: "bm-free",
userId: USER,
name: "My own marker",
unit: "mg/dL",
},
});
});
async function call<T = unknown>(
route: "labs" | "ocr" | "one",
method: "GET" | "POST" | "PUT",
body?: unknown,
id?: string,
): Promise<{
status: number;
json: { data?: T; meta?: Record<string, unknown> };
}> {
const s = await prisma.session.create({
data: { userId: USER, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.clear();
cookieJar.set("healthlog_session", s.id);
const url =
route === "labs"
? "http://localhost/api/labs"
: route === "ocr"
? "http://localhost/api/labs/ocr/commit"
: `http://localhost/api/labs/${id}`;
const init = {
method,
headers: { "content-type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
};
const req = new NextRequest(url, init);
let res: Response;
if (route === "labs") {
const mod = await import("@/app/api/labs/route");
res = await (method === "GET" ? mod.GET(req) : mod.POST(req));
} else if (route === "ocr") {
const mod = await import("@/app/api/labs/ocr/commit/route");
res = await mod.POST(req);
} else {
const mod = await import("@/app/api/labs/[id]/route");
res = await (method === "PUT"
? mod.PUT(req, { params: Promise.resolve({ id: id! }) })
: mod.GET(req, { params: Promise.resolve({ id: id! }) }));
}
return { status: res.status, json: (await res.json()) as never };
}
type Dto = {
id: string;
value: number;
unit: string;
converted: boolean;
sourceValue: number | null;
sourceUnit: string | null;
referenceLow: number | null;
referenceHigh: number | null;
sourceReferenceLow: number | null;
sourceReferenceHigh: number | null;
rangeStatus: string;
};
describe("POST /api/labs", () => {
it("converts a reading in another unit and keeps what the report printed", async () => {
const { status, json } = await call<Dto>("labs", "POST", {
biomarkerId: "bm-glucose",
value: 5.6,
unit: "mmol/L",
sourceReferenceLow: 3.9,
sourceReferenceHigh: 6.0,
takenAt: "2026-05-01T08:00:00.000Z",
});
expect(status).toBe(201);
const dto = json.data!;
expect(dto.converted).toBe(true);
expect(dto.unit).toBe("mg/dL");
expect(dto.value).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9);
expect(dto.sourceValue).toBe(5.6);
expect(dto.sourceUnit).toBe("mmol/L");
// The report's own window moved with the value.
expect(dto.sourceReferenceLow).toBeCloseTo(3.9 * MGDL_PER_MMOL, 9);
expect(dto.sourceReferenceHigh).toBeCloseTo(6.0 * MGDL_PER_MMOL, 9);
// And the verdict is judged against it: 5.6 of 3.9-6.0 is in range.
expect(dto.rangeStatus).toBe("in-range");
const row = await prisma.labResult.findUniqueOrThrow({
where: { id: dto.id },
});
expect(row.value).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9);
expect(row.sourceValue).toBe(5.6);
expect(row.sourceUnit).toBe("mmol/L");
});
it("flags a converted reading outside the printed window as such", async () => {
const { json } = await call<Dto>("labs", "POST", {
biomarkerId: "bm-glucose",
value: 7.2,
unit: "mmol/L",
sourceReferenceLow: 3.9,
sourceReferenceHigh: 6.0,
takenAt: "2026-05-02T08:00:00.000Z",
});
expect(json.data!.rangeStatus).toBe("above");
});
it("stores a reading in the marker's unit as stated, with no conversion record", async () => {
const { json } = await call<Dto>("labs", "POST", {
biomarkerId: "bm-glucose",
value: 95,
unit: "mg/dL",
takenAt: "2026-05-03T08:00:00.000Z",
});
const dto = json.data!;
expect(dto.converted).toBe(false);
expect(dto.value).toBe(95);
expect(dto.sourceValue).toBeNull();
expect(dto.sourceUnit).toBeNull();
});
it("links a marker that had no key yet, the first time it is needed", async () => {
const before = await prisma.biomarker.findUniqueOrThrow({
where: { id: "bm-glucose" },
});
// The marker was created without a key in this test; the first conversion
// above has already filled it.
expect(before.analyteKey).toBe("fasting-glucose");
});
it("still refuses a pair that cannot be converted, now with the reason", async () => {
const { status, json } = await call("labs", "POST", {
biomarkerId: "bm-free",
value: 5.6,
unit: "mmol/L",
takenAt: "2026-05-04T08:00:00.000Z",
});
expect(status).toBe(422);
expect(json.meta).toMatchObject({
errorCode: "labs.unit.mismatch",
markerUnit: "mg/dL",
readingUnit: "mmol/L",
reason: "unknown_analyte",
});
expect(
await prisma.labResult.count({ where: { biomarkerId: "bm-free" } }),
).toBe(0);
});
});
describe("the OCR commit", () => {
const row = {
analyte: "Glucose",
value: 5.0,
unit: "mmol/L",
referenceText: "3.9 - 5.6",
takenAt: "2026-06-01T08:00:00.000Z",
};
it("converts the row and reports it", async () => {
const { status, json } = await call<{
inserted: Dto[];
skipped: unknown[];
}>("ocr", "POST", { rows: [row] });
expect(status).toBe(200);
expect(json.data!.skipped).toEqual([]);
const [inserted] = json.data!.inserted;
expect(inserted.converted).toBe(true);
expect(inserted.unit).toBe("mg/dL");
expect(inserted.value).toBeCloseTo(5.0 * MGDL_PER_MMOL, 9);
expect(inserted.sourceValue).toBe(5.0);
expect(inserted.sourceUnit).toBe("mmol/L");
expect(inserted.sourceReferenceLow).toBeCloseTo(3.9 * MGDL_PER_MMOL, 9);
expect(inserted.sourceReferenceHigh).toBeCloseTo(5.6 * MGDL_PER_MMOL, 9);
});
it("treats the same scan committed again as a duplicate", async () => {
const { json } = await call<{
inserted: unknown[];
skipped: Array<{ reason: string }>;
}>("ocr", "POST", { rows: [row] });
expect(json.data!.inserted).toEqual([]);
expect(json.data!.skipped.map((s) => s.reason)).toEqual(["duplicate"]);
});
it("skips a row whose unit cannot be converted, as before", async () => {
const { json } = await call<{
inserted: unknown[];
skipped: Array<{ reason: string }>;
}>("ocr", "POST", {
rows: [
{
analyte: "My own marker",
value: 3,
unit: "mmol/L",
takenAt: "2026-06-02T08:00:00.000Z",
},
],
});
expect(json.data!.inserted).toEqual([]);
expect(json.data!.skipped.map((s) => s.reason)).toEqual(["unit_mismatch"]);
});
});
describe("editing and reading back", () => {
it("clears the conversion record when the value is edited", async () => {
const created = await call<Dto>("labs", "POST", {
biomarkerId: "bm-glucose",
value: 6.0,
unit: "mmol/L",
takenAt: "2026-07-01T08:00:00.000Z",
});
const id = created.json.data!.id;
expect(created.json.data!.converted).toBe(true);
const edited = await call<Dto>("one", "PUT", { value: 110 }, id);
expect(edited.status).toBe(200);
expect(edited.json.data!.value).toBe(110);
expect(edited.json.data!.converted).toBe(false);
expect(edited.json.data!.sourceValue).toBeNull();
expect(edited.json.data!.sourceUnit).toBeNull();
});
it("keeps the conversion record when only the note is edited", async () => {
const created = await call<Dto>("labs", "POST", {
biomarkerId: "bm-glucose",
value: 6.1,
unit: "mmol/L",
takenAt: "2026-07-02T08:00:00.000Z",
});
const id = created.json.data!.id;
const edited = await call<Dto>("one", "PUT", { note: "fasting" }, id);
expect(edited.status).toBe(200);
expect(edited.json.data!.converted).toBe(true);
expect(edited.json.data!.sourceValue).toBe(6.1);
});
it("returns the fields on the list and on the single reading", async () => {
const list = await call<{ results: Dto[] }>("labs", "GET");
const converted = list.json.data!.results.filter((r) => r.converted);
expect(converted.length).toBeGreaterThan(0);
for (const r of converted) {
expect(r.sourceValue).not.toBeNull();
expect(r.sourceUnit).not.toBeNull();
}
const one = await call<Dto>("one", "GET", undefined, converted[0].id);
expect(one.json.data!.converted).toBe(true);
});
});