Files
HealthLog/tests/integration/labs-unit-conversion.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

317 lines
11 KiB
TypeScript

/**
* 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);
});
});