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

+55 -7
View File
@@ -14905,11 +14905,14 @@ paths:
"403": *a11
"409": *a7
"422":
description: Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue
description: "Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue
under `details.issues`), or `labs.unit.mismatch` when a numeric reading states a `unit` other than the one
its marker is tracked in (`meta.markerUnit`, `meta.readingUnit`). A unit is compared only when one is sent;
with a `biomarkerId` it may be omitted and the marker's unit is then the stated one. Spelling is forgiven
(`mmol/l`, `ug/L` for `µg/L`); a different unit is refused, never relabelled.
its marker is tracked in AND the pair cannot be converted (`meta.markerUnit`, `meta.readingUnit`,
`meta.reason` = `unknown_analyte` | `unknown_unit` | `not_finite`). A unit is compared only when one is
sent; with a `biomarkerId` it may be omitted and the marker's unit is then the stated one. Spelling is
forgiven (`mmol/l`, `ug/L` for `µg/L`); a different unit is converted when the marker's analyte is known and
the pair is in the unit-conversion registry (the 201 response then carries `converted: true`), and refused
otherwise, never relabelled."
content:
application/json:
schema:
@@ -67394,6 +67397,16 @@ components:
- type: "null"
unit:
type: string
converted:
type: boolean
sourceValue:
anyOf:
- type: number
- type: "null"
sourceUnit:
anyOf:
- type: string
- type: "null"
referenceLow:
anyOf:
- type: number
@@ -67446,6 +67459,9 @@ components:
- value
- valueText
- unit
- converted
- sourceValue
- sourceUnit
- referenceLow
- referenceHigh
- catalogReferenceLow
@@ -67476,8 +67492,11 @@ components:
numbers could be read out of it). `catalogReferenceLow` / `catalogReferenceHigh` are the Biomarker band.
`referenceOrigin` names the winner and `referenceDivergesFromCatalog` is true when the report's window and the
catalog band state different limits — a surface showing only one of the two is showing a partial answer. The
client renders these values and never recomputes the range. The encrypted note is never echoed in list rows;
`hasNote` flags its presence and the single-resource GET returns the decrypted `note`.
client renders these values and never recomputes the range. `converted` is true when the stored `value` was
converted into the marker's `unit` from another unit; `sourceValue` and `sourceUnit` are then what the report
printed (full precision, round for display), and the report's own window was converted with it. A reading stored
as stated has `converted: false` and two nulls. The encrypted note is never echoed in list rows; `hasNote` flags
its presence and the single-resource GET returns the decrypted `note`.
LabReferenceRangeOrigin:
type: string
enum:
@@ -67556,6 +67575,16 @@ components:
- type: "null"
unit:
type: string
converted:
type: boolean
sourceValue:
anyOf:
- type: number
- type: "null"
sourceUnit:
anyOf:
- type: string
- type: "null"
referenceLow:
anyOf:
- type: number
@@ -67610,6 +67639,9 @@ components:
- value
- valueText
- unit
- converted
- sourceValue
- sourceUnit
- referenceLow
- referenceHigh
- catalogReferenceLow
@@ -67820,7 +67852,8 @@ components:
- duplicate
- unit_mismatch
description: "`duplicate`: the row matched a live reading at commit time. `unit_mismatch`: the row states a unit other
than the one its marker is tracked in, so it was not written."
than the one its marker is tracked in and the pair cannot be converted, so it was not written. A pair
that can be converted is written in the marker's unit and reported with `converted: true`."
required:
- analyte
- reason
@@ -67867,6 +67900,16 @@ components:
- type: "null"
unit:
type: string
converted:
type: boolean
sourceValue:
anyOf:
- type: number
- type: "null"
sourceUnit:
anyOf:
- type: string
- type: "null"
referenceLow:
anyOf:
- type: number
@@ -67928,6 +67971,9 @@ components:
- value
- valueText
- unit
- converted
- sourceValue
- sourceUnit
- referenceLow
- referenceHigh
- catalogReferenceLow
@@ -67945,6 +67991,8 @@ components:
- updatedAt
additionalProperties: false
description: A written reading. Its reference window resolves exactly as it does on `/api/labs` — see `LabResult`.
`converted`, `sourceValue` and `sourceUnit` say when the row's unit was converted into its marker's and what the
report printed.
AiRunEnvelope:
type: object
properties:
@@ -722,6 +722,9 @@ const OCR_EXCEPTIONS: Readonly<Record<string, string>> = {
"inserted.value": OCR_ROW_EXCEPTION,
"inserted.valueText": OCR_ROW_EXCEPTION,
"inserted.unit": OCR_ROW_EXCEPTION,
"inserted.converted": OCR_ROW_EXCEPTION,
"inserted.sourceValue": OCR_ROW_EXCEPTION,
"inserted.sourceUnit": OCR_ROW_EXCEPTION,
"inserted.referenceLow": OCR_ROW_EXCEPTION,
"inserted.referenceHigh": OCR_ROW_EXCEPTION,
"inserted.catalogReferenceLow": OCR_ROW_EXCEPTION,
+9 -1
View File
@@ -192,7 +192,15 @@ export const PUT = apiHandler(
const data: Record<string, unknown> = {};
if (d.panel !== undefined) data.panel = d.panel;
if (d.analyte !== undefined) data.analyte = d.analyte;
if (d.value !== undefined) data.value = d.value;
if (d.value !== undefined) {
data.value = d.value;
// A number the person typed is no longer the conversion of what the
// report printed, so the "converted from" record would now be wrong.
if (existing.sourceValue !== null || existing.sourceUnit !== null) {
data.sourceValue = null;
data.sourceUnit = null;
}
}
// v1.18.9 — qualitative edit. A row stays single-typed: editing `value` on a
// qualitative row (or `valueText` on a numeric row) crosses the row's type
// and is rejected below. An edit of the matching field updates in place.
+60 -17
View File
@@ -33,6 +33,7 @@ import { withIdempotency } from "@/lib/idempotency";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
import { readingInMarkerUnit } from "@/lib/labs/reading-conversion";
import { sameLabUnit } from "@/lib/labs/unit-normalise";
import { parseReferenceRange } from "@/lib/labs/parse-reference-range";
import { serialiseLabResult } from "@/lib/labs/serialise";
@@ -75,7 +76,13 @@ async function isDuplicate(
analyte: { equals: row.analyte.trim(), mode: "insensitive" },
takenAt: labReadingDaySearchRange(day),
},
select: { value: true, valueText: true, takenAt: true },
select: {
value: true,
valueText: true,
sourceValue: true,
sourceUnit: true,
takenAt: true,
},
take: 100,
});
const candidates = rows.filter((c) => labReadingDay(c.takenAt, tz) === day);
@@ -83,6 +90,19 @@ async function isDuplicate(
if (row.value !== undefined && c.value !== null && c.value === row.value) {
return true;
}
// A reading stored converted holds the value in the marker's unit, so the
// same scan committed again no longer equals it. What the report printed is
// kept beside it: the same number in the same unit is the same reading.
if (
row.value !== undefined &&
row.unit !== undefined &&
c.sourceValue !== null &&
c.sourceValue === row.value &&
c.sourceUnit !== null &&
sameLabUnit(c.sourceUnit, row.unit)
) {
return true;
}
if (
row.valueText !== undefined &&
c.valueText !== null &&
@@ -179,19 +199,40 @@ async function commitOcrRows(request: NextRequest) {
panel: row.panel ?? null,
});
// The marker's unit is the one the reading is stored under, so a row that
// states another unit cannot be written: the number would be kept under a
// unit it is not in. The review screen marks such a row before Save; this
// is the backstop for a client that did not, and for a marker whose unit
// changed between the scan and the commit. A freshly minted marker adopted
// this row's unit, so it cannot disagree.
if (
!isQualitative &&
row.unit !== undefined &&
!sameLabUnit(row.unit, biomarker.unit)
) {
skipped.push({ analyte: row.analyte.trim(), reason: "unit_mismatch" });
continue;
// The marker's unit is the one the reading is stored under. A row that
// states another unit is converted when the marker's analyte is known and
// the registry has the pair, with what the report printed kept beside it;
// otherwise it cannot be written: the number would be kept under a unit it
// is not in. The review screen marks such a row before Save; this is the
// backstop for a client that did not, and for a marker whose unit changed
// between the scan and the commit. A freshly minted marker adopted this
// row's unit, so it cannot disagree.
let storedValue = row.value ?? null;
let storedSourceLow = isQualitative ? null : sourceLow;
let storedSourceHigh = isQualitative ? null : sourceHigh;
let convertedFrom: { sourceValue: number; sourceUnit: string } | null =
null;
if (!isQualitative && row.value !== undefined) {
const reading = await readingInMarkerUnit(prisma, {
marker: biomarker,
value: row.value,
unit: row.unit,
sourceReferenceLow: storedSourceLow,
sourceReferenceHigh: storedSourceHigh,
});
if (!reading.ok) {
skipped.push({ analyte: row.analyte.trim(), reason: "unit_mismatch" });
continue;
}
storedValue = reading.value;
storedSourceLow = reading.sourceReferenceLow;
storedSourceHigh = reading.sourceReferenceHigh;
if (reading.converted) {
convertedFrom = {
sourceValue: reading.sourceValue,
sourceUnit: reading.sourceUnit,
};
}
}
// Field-by-field — never spread the parsed row. The row stamps the resolved
@@ -202,13 +243,15 @@ async function commitOcrRows(request: NextRequest) {
biomarkerId: biomarker.id,
panel: biomarker.panel,
analyte: biomarker.name,
value: row.value ?? null,
value: storedValue,
valueText: row.valueText ?? null,
unit: biomarker.unit,
sourceValue: convertedFrom?.sourceValue ?? null,
sourceUnit: convertedFrom?.sourceUnit ?? null,
referenceLow: biomarker.lowerBound,
referenceHigh: biomarker.upperBound,
sourceReferenceLow: isQualitative ? null : sourceLow,
sourceReferenceHigh: isQualitative ? null : sourceHigh,
sourceReferenceLow: storedSourceLow,
sourceReferenceHigh: storedSourceHigh,
sourceReferenceText: isQualitative ? null : sourceText,
takenAt: row.takenAt,
source: "OCR",
+57 -27
View File
@@ -18,7 +18,7 @@ import { withIdempotency } from "@/lib/idempotency";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
import { sameLabUnit } from "@/lib/labs/unit-normalise";
import { readingInMarkerUnit } from "@/lib/labs/reading-conversion";
import {
type ResolvedBiomarker,
serialiseLabResult,
@@ -232,6 +232,7 @@ async function postLabResult(request: NextRequest) {
lowerBound: true,
upperBound: true,
panel: true,
analyteKey: true,
},
});
if (!found) {
@@ -253,30 +254,56 @@ async function postLabResult(request: NextRequest) {
});
}
// The marker's unit is the one every reading is stored under, so a reading
// that states another unit cannot be written: the number would be kept and
// the label swapped, and nothing downstream could tell. Compared only when a
// unit was actually sent: on the `biomarkerId` path it may be omitted, and
// then the marker's unit is the stated one. A freshly minted marker adopted
// the reading's own unit, so it cannot disagree with it.
if (
!isQualitative &&
unit !== undefined &&
!sameLabUnit(unit, biomarker.unit)
) {
annotate({
action: { name: "labs.create.unit-mismatch" },
meta: { biomarkerId: biomarker.id },
// The marker's unit is the one every reading is stored under. A reading that
// states another unit is converted when the marker's analyte is known and the
// registry has the pair (the value, and the report's own window with it, with
// what the report printed kept beside it); otherwise it cannot be written:
// the number would be kept and the label swapped, and nothing downstream
// could tell. Compared only when a unit was actually sent: on the
// `biomarkerId` path it may be omitted, and then the marker's unit is the
// stated one. A freshly minted marker adopted the reading's own unit, so it
// cannot disagree with it.
let storedValue = value ?? null;
let storedSourceLow = isQualitative ? null : (sourceReferenceLow ?? null);
let storedSourceHigh = isQualitative ? null : (sourceReferenceHigh ?? null);
let convertedFrom: { sourceValue: number; sourceUnit: string } | null = null;
if (!isQualitative && value !== undefined) {
const reading = await readingInMarkerUnit(prisma, {
marker: biomarker,
value,
unit,
sourceReferenceLow: storedSourceLow,
sourceReferenceHigh: storedSourceHigh,
});
return apiError(
`The reading is in ${unit}, but ${biomarker.name} is tracked in ${biomarker.unit}.`,
422,
{
errorCode: "labs.unit.mismatch",
markerUnit: biomarker.unit,
readingUnit: unit,
},
);
if (!reading.ok) {
annotate({
action: { name: "labs.create.unit-mismatch" },
meta: { biomarkerId: biomarker.id, reason: reading.reason },
});
return apiError(
`The reading is in ${unit}, but ${biomarker.name} is tracked in ${biomarker.unit}.`,
422,
{
errorCode: "labs.unit.mismatch",
markerUnit: biomarker.unit,
readingUnit: unit,
reason: reading.reason,
},
);
}
storedValue = reading.value;
storedSourceLow = reading.sourceReferenceLow;
storedSourceHigh = reading.sourceReferenceHigh;
if (reading.converted) {
convertedFrom = {
sourceValue: reading.sourceValue,
sourceUnit: reading.sourceUnit,
};
annotate({
action: { name: "labs.create.unit-converted" },
meta: { biomarkerId: biomarker.id },
});
}
}
// Field-by-field assignment — never spread `parsed.data`. The row stamps the
@@ -290,17 +317,20 @@ async function postLabResult(request: NextRequest) {
panel: biomarker.panel,
analyte: biomarker.name,
// Exactly one of value / valueText is set (the Zod XOR refine).
value: value ?? null,
value: storedValue,
valueText: valueText ?? null,
unit: biomarker.unit,
// What the report printed, when the value above is a conversion of it.
sourceValue: convertedFrom?.sourceValue ?? null,
sourceUnit: convertedFrom?.sourceUnit ?? null,
referenceLow: biomarker.lowerBound,
referenceHigh: biomarker.upperBound,
// The window this reading's own report printed, kept beside the catalog
// stamp rather than folded into it: it governs THIS reading's verdict,
// and the catalog band stays the net for every reading without one. A
// qualitative reading has no window to record.
sourceReferenceLow: isQualitative ? null : (sourceReferenceLow ?? null),
sourceReferenceHigh: isQualitative ? null : (sourceReferenceHigh ?? null),
sourceReferenceLow: storedSourceLow,
sourceReferenceHigh: storedSourceHigh,
sourceReferenceText: isQualitative ? null : (sourceReferenceText ?? null),
takenAt,
// v1.25 (iOS #36) — provenance: the on-device-OCR path posts
@@ -68,6 +68,9 @@ function reading(id: string, value: number, takenAt: string): LabResultDto {
value,
valueText: null,
unit: "mg/dL",
converted: false,
sourceValue: null,
sourceUnit: null,
referenceLow: 0,
referenceHigh: 100,
catalogReferenceLow: 0,
+8
View File
@@ -15,6 +15,14 @@ export interface LabResultDto {
/** v1.18.9 — qualitative result text ("negativ" / …); null for a numeric row. */
valueText: string | null;
unit: string;
/**
* True when the stored number was converted into the marker's unit from
* another one; `sourceValue` and `sourceUnit` are then what the report
* printed. A reading stored as stated has `converted: false` and two nulls.
*/
converted: boolean;
sourceValue: number | null;
sourceUnit: string | null;
/**
* The window this reading is judged against and the one `rangeStatus` was
* computed from: the range its own report printed when it carries one, the
+76 -8
View File
@@ -44,22 +44,25 @@ beforeEach(() => {
describe("commitApprovedFact — OBSERVATION unit integrity", () => {
it("rejects a numeric observation whose stated unit differs from an existing marker's unit", async () => {
// A pair the registry has no conversion for: ferritin in pmol/L against a
// marker stored in ng/mL (protein molecular weights vary by source).
vi.mocked(decryptFactData).mockReturnValue({
label: "Glucose",
value: 6.1,
unit: "mmol/L",
label: "Ferritin",
value: 450,
unit: "pmol/L",
referenceLow: null,
referenceHigh: null,
effectiveDate: "2025-01-01",
} as never);
// Existing marker stored in mg/dL — the resolver returns it as-is.
// Existing marker stored in ng/mL — the resolver returns it as-is.
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
id: "bm-1",
name: "Glucose",
unit: "mg/dL",
lowerBound: 70,
upperBound: 100,
name: "Ferritin",
unit: "ng/mL",
lowerBound: 30,
upperBound: 400,
panel: null,
analyteKey: null,
});
await expect(
@@ -71,6 +74,69 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => {
expect(prisma.labResult.create).not.toHaveBeenCalled();
});
it("converts a unit the marker's analyte can reach, keeping what the document stated", async () => {
vi.mocked(decryptFactData).mockReturnValue({
label: "Glucose",
value: 6.1,
unit: "mmol/L",
referenceLow: 3.9,
referenceHigh: 5.6,
effectiveDate: "2025-01-01",
} as never);
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
id: "bm-1",
name: "Glucose",
unit: "mg/dL",
lowerBound: 70,
upperBound: 100,
panel: null,
analyteKey: "fasting-glucose",
});
vi.mocked(prisma.labResult.create).mockResolvedValue({
id: "lr-conv",
} as never);
const ref = await commitApprovedFact("user-1", observationFact());
expect(ref).toEqual({ recordType: "labResult", recordId: "lr-conv" });
const data = vi.mocked(prisma.labResult.create).mock.calls[0]![0].data;
expect(data.value).toBeCloseTo(6.1 * 18.0182, 9);
expect(data.unit).toBe("mg/dL");
expect(data.sourceValue).toBe(6.1);
expect(data.sourceUnit).toBe("mmol/L");
// The report's own window moved with the value.
expect(data.sourceReferenceLow).toBeCloseTo(3.9 * 18.0182, 9);
expect(data.sourceReferenceHigh).toBeCloseTo(5.6 * 18.0182, 9);
});
it("stores a reading in the marker's own unit without a conversion record", async () => {
vi.mocked(decryptFactData).mockReturnValue({
label: "Glucose",
value: 95,
unit: "mg/dL",
referenceLow: null,
referenceHigh: null,
effectiveDate: "2025-01-01",
} as never);
vi.mocked(resolveOrMintBiomarker).mockResolvedValue({
id: "bm-1",
name: "Glucose",
unit: "mg/dL",
lowerBound: null,
upperBound: null,
panel: null,
analyteKey: "fasting-glucose",
});
vi.mocked(prisma.labResult.create).mockResolvedValue({
id: "lr-same",
} as never);
await commitApprovedFact("user-1", observationFact());
const data = vi.mocked(prisma.labResult.create).mock.calls[0]![0].data;
expect(data.value).toBe(95);
expect(data.sourceValue).toBeNull();
expect(data.sourceUnit).toBeNull();
});
it("commits when the stated unit is the marker's unit spelled another way", async () => {
vi.mocked(decryptFactData).mockReturnValue({
label: "Glucose",
@@ -87,6 +153,7 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => {
lowerBound: 70,
upperBound: 100,
panel: null,
analyteKey: null,
});
vi.mocked(prisma.labResult.create).mockResolvedValue({
id: "lr-1",
@@ -124,6 +191,7 @@ describe("commitApprovedFact — OBSERVATION unit integrity", () => {
lowerBound: null,
upperBound: null,
panel: null,
analyteKey: null,
});
vi.mocked(prisma.labResult.create).mockResolvedValue({
id: "lr-2",
+37 -12
View File
@@ -28,7 +28,7 @@ import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { emitDataArrival } from "@/lib/arrivals/emit-shared";
import { decryptFactData } from "@/lib/documents/store";
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
import { sameLabUnit } from "@/lib/labs/unit-normalise";
import { readingInMarkerUnit } from "@/lib/labs/reading-conversion";
import {
isUnreadableRange,
parseReferenceRange,
@@ -128,22 +128,45 @@ async function commitObservation(
referenceHigh: isQualitative ? null : sourceHigh,
panel: null,
});
// Fail closed on a unit mismatch against an EXISTING marker. A freshly
// minted marker adopts the document's unit, so this only bites when the
// stated unit disagrees with the catalog's authoritative one (e.g. a
// document states "6.1 mmol/L" against a marker stored in mg/dL). Writing
// the stated number under the catalog's unit would corrupt the reading AND
// break the "transcribe verbatim" contract — so reject the fact and let the
// user reconcile on the review screen, exactly like the `unitRequired` gate.
// A unit that differs from an EXISTING marker's is converted when the
// marker's analyte is known and the registry has the pair (a document states
// "6.1 mmol/L" against a marker stored in mg/dL), keeping what the document
// stated beside the stored number. Otherwise it fails closed: writing the
// stated number under the catalog's unit would corrupt the reading AND break
// the "transcribe verbatim" contract — so reject the fact and let the user
// reconcile on the review screen, exactly like the `unitRequired` gate. A
// freshly minted marker adopts the document's unit, so it cannot disagree.
// Spelling is compared through the shared normaliser, the same one the
// manual and scan paths use: `ug/L` is `µg/L`, but `MIU/L` is not `mIU/L`.
let storedValue: number | null = isQualitative
? null
: (data.value as number);
let storedSourceLow = isQualitative ? null : sourceLow;
let storedSourceHigh = isQualitative ? null : sourceHigh;
let convertedFrom: { sourceValue: number; sourceUnit: string } | null = null;
if (!isQualitative) {
if (!sameLabUnit(data.unit as string, biomarker.unit)) {
const reading = await readingInMarkerUnit(prisma, {
marker: biomarker,
value: data.value as number,
unit: data.unit as string,
sourceReferenceLow: storedSourceLow,
sourceReferenceHigh: storedSourceHigh,
});
if (!reading.ok) {
throw new FactCommitError(
"observation.unitMismatch",
`The stated unit (${(data.unit as string).trim()}) does not match the saved unit for ${biomarker.name} (${biomarker.unit || "none"}). Reconcile the unit before saving.`,
);
}
storedValue = reading.value;
storedSourceLow = reading.sourceReferenceLow;
storedSourceHigh = reading.sourceReferenceHigh;
if (reading.converted) {
convertedFrom = {
sourceValue: reading.sourceValue,
sourceUnit: reading.sourceUnit,
};
}
}
const created = await prisma.labResult.create({
data: {
@@ -151,15 +174,17 @@ async function commitObservation(
biomarkerId: biomarker.id,
panel: biomarker.panel,
analyte: biomarker.name,
value: isQualitative ? null : (data.value as number),
value: storedValue,
valueText: isQualitative ? data.valueText : null,
unit: biomarker.unit,
sourceValue: convertedFrom?.sourceValue ?? null,
sourceUnit: convertedFrom?.sourceUnit ?? null,
referenceLow: biomarker.lowerBound,
referenceHigh: biomarker.upperBound,
// The report's own window for THIS reading. A qualitative result has no
// window to record.
sourceReferenceLow: isQualitative ? null : sourceLow,
sourceReferenceHigh: isQualitative ? null : sourceHigh,
sourceReferenceLow: storedSourceLow,
sourceReferenceHigh: storedSourceHigh,
sourceReferenceText: isQualitative ? null : sourceText,
takenAt: statedDateOrNow(data.effectiveDate),
source: "DOCUMENT",
@@ -0,0 +1,187 @@
import { describe, expect, it, vi } from "vitest";
import { readingInMarkerUnit, type ReadingMarker } from "../reading-conversion";
/** A client whose `updateMany` records what the fill wrote. */
function client(current: string | null = null) {
return {
biomarker: {
updateMany: vi.fn(async () => ({ count: 1 })),
findUnique: vi.fn(async () => ({ analyteKey: current })),
},
} as never;
}
const glucose: ReadingMarker = {
id: "bm-1",
name: "Glucose",
unit: "mg/dL",
analyteKey: "fasting-glucose",
};
const read = (
marker: ReadingMarker,
value: number,
unit: string | undefined,
low: number | null = null,
high: number | null = null,
c = client(),
) =>
readingInMarkerUnit(c, {
marker,
value,
unit,
sourceReferenceLow: low,
sourceReferenceHigh: high,
});
describe("a reading in the marker's own unit", () => {
it("is stored as stated, with its window untouched", async () => {
expect(await read(glucose, 95, "mg/dL", 70, 100)).toEqual({
ok: true,
converted: false,
value: 95,
sourceReferenceLow: 70,
sourceReferenceHigh: 100,
});
});
it("is the same when the unit is spelled another way or not sent", async () => {
expect(await read(glucose, 95, " mg/DL ")).toMatchObject({
ok: true,
converted: false,
value: 95,
});
expect(await read(glucose, 95, undefined)).toMatchObject({
ok: true,
converted: false,
value: 95,
});
});
it("does not touch the database", async () => {
const c = client();
await read({ ...glucose, analyteKey: null }, 95, "mg/dL", null, null, c);
expect(
(c as never as { biomarker: { updateMany: unknown } }).biomarker
.updateMany,
).not.toHaveBeenCalled();
});
});
describe("a reading in another unit", () => {
it("is converted into the marker's unit, keeping what the report printed", async () => {
const result = await read(glucose, 5.6, "mmol/L");
expect(result).toMatchObject({
ok: true,
converted: true,
sourceValue: 5.6,
sourceUnit: "mmol/L",
});
if (!result.ok) return;
expect(result.value).toBeCloseTo(5.6 * 18.0182, 9);
});
it("stores full precision and trims the printed unit", async () => {
const result = await read(glucose, 5.6, " mmol/L ");
if (!result.ok || !result.converted) throw new Error("not converted");
expect(result.sourceUnit).toBe("mmol/L");
expect(result.value).not.toBe(Math.round(result.value));
});
it("moves the report's own window with the value", async () => {
const result = await read(glucose, 5.6, "mmol/L", 3.9, 5.6);
if (!result.ok) throw new Error("refused");
expect(result.sourceReferenceLow).toBeCloseTo(3.9 * 18.0182, 9);
expect(result.sourceReferenceHigh).toBeCloseTo(5.6 * 18.0182, 9);
expect(result.sourceReferenceLow!).toBeLessThan(
result.sourceReferenceHigh!,
);
});
it("leaves an open bound open", async () => {
const result = await read(glucose, 5, "mmol/L", null, 5.6);
if (!result.ok) throw new Error("refused");
expect(result.sourceReferenceLow).toBeNull();
expect(result.sourceReferenceHigh).toBeCloseTo(5.6 * 18.0182, 9);
});
it("converts an affine pair: 47.5 mmol/mol is 6.5 % HbA1c", async () => {
const hba1c: ReadingMarker = {
id: "bm-2",
name: "HbA1c",
unit: "%",
analyteKey: "hba1c",
};
const result = await read(hba1c, 47.5, "mmol/mol", 20, 42);
if (!result.ok) throw new Error("refused");
expect(result.value).toBeCloseTo(6.5, 1);
expect(result.sourceReferenceLow!).toBeLessThan(
result.sourceReferenceHigh!,
);
});
it("converts by scale alone, for a marker with no analyte", async () => {
const unknown: ReadingMarker = {
id: "bm-3",
name: "Some marker",
unit: "mg/dL",
analyteKey: null,
};
const result = await read(unknown, 1, "g/L");
expect(result).toMatchObject({ ok: true, converted: true, value: 100 });
});
});
describe("the analyte is filled on first use", () => {
it("links a marker that had no key yet, then converts", async () => {
const c = client();
const result = await read(
{ id: "bm-1", name: "Blutzucker", unit: "mg/dL", analyteKey: null },
5.6,
"mmol/L",
null,
null,
c,
);
expect(result).toMatchObject({ ok: true, converted: true });
const updateMany = (
c as never as {
biomarker: { updateMany: ReturnType<typeof vi.fn> };
}
).biomarker.updateMany;
expect(updateMany).toHaveBeenCalledWith({
where: { id: "bm-1", analyteKey: null },
data: { analyteKey: "fasting-glucose" },
});
});
});
describe("a refusal", () => {
it("is returned for a marker with no analyte and a different kind of unit", async () => {
const free: ReadingMarker = {
id: "bm-4",
name: "My own marker",
unit: "mg/dL",
analyteKey: null,
};
expect(await read(free, 5.6, "mmol/L")).toEqual({
ok: false,
reason: "unknown_analyte",
});
});
it("is returned for a pair the analyte's entry does not reach", async () => {
expect(await read(glucose, 5.6, "%")).toEqual({
ok: false,
reason: "unknown_unit",
});
});
it("is returned for a value that is not finite", async () => {
expect(await read(glucose, Number.NaN, "mmol/L")).toEqual({
ok: false,
reason: "not_finite",
});
});
});
@@ -38,6 +38,7 @@ describe("resolveOrMintBiomarker", () => {
lowerBound: null,
upperBound: 100,
panel: "lipids",
analyteKey: null,
});
const out = await resolveOrMintBiomarker("u1", {
@@ -65,6 +66,7 @@ describe("resolveOrMintBiomarker", () => {
lowerBound: null,
upperBound: 116,
panel: "lipids",
analyteKey: null,
});
const out = await resolveOrMintBiomarker("u1", baseInput);
@@ -90,6 +92,7 @@ describe("resolveOrMintBiomarker", () => {
lowerBound: 30,
upperBound: 400,
panel: null,
analyteKey: null,
});
await resolveOrMintBiomarker("u1", {
+2
View File
@@ -65,6 +65,7 @@ export interface MintedBiomarker {
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
analyteKey: string | null;
}
/**
@@ -102,6 +103,7 @@ export async function resolveOrMintBiomarker(
lowerBound: true,
upperBound: true,
panel: true,
analyteKey: true,
} as const;
// Case-insensitive match on the catalog identity. Postgres is the store;
+105
View File
@@ -0,0 +1,105 @@
/**
* A lab reading, written in its marker's unit.
*
* Every reading is stored under the unit of its marker. A reading that states
* the same unit (spelling aside) goes in as it is. One that states another is
* converted when the marker's analyte is known and the registry has the pair,
* keeping what the report printed beside it (`sourceValue`, `sourceUnit`) so a
* number nobody typed can always be traced back. Anything else is refused,
* exactly as before: the number is never kept under a unit it is not in.
*
* The report's own reference window is in the unit the report printed, so it is
* converted with the value; a verdict that compared a converted value with an
* unconverted window would be wrong. Full precision is stored, rounding is for
* display.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { ensureAnalyteKey } from "./analyte-key-store";
import {
convertLabRange,
convertLabValue,
type ConversionRefusal,
} from "./unit-conversion/convert";
import { sameLabUnit } from "./unit-normalise";
export interface ReadingMarker {
id: string;
name: string;
unit: string;
analyteKey?: string | null;
}
export type ReadingInMarkerUnit =
| {
ok: true;
converted: false;
value: number;
sourceReferenceLow: number | null;
sourceReferenceHigh: number | null;
}
| {
ok: true;
converted: true;
value: number;
sourceReferenceLow: number | null;
sourceReferenceHigh: number | null;
/** The value as the report printed it. */
sourceValue: number;
/** The unit as the report printed it, trimmed. */
sourceUnit: string;
}
| { ok: false; reason: ConversionRefusal };
export async function readingInMarkerUnit(
client: Pick<PrismaClient, "biomarker">,
input: {
marker: ReadingMarker;
value: number;
/** The unit the reading states; undefined means the marker's own. */
unit: string | undefined;
sourceReferenceLow: number | null;
sourceReferenceHigh: number | null;
},
): Promise<ReadingInMarkerUnit> {
const { marker, value, unit, sourceReferenceLow, sourceReferenceHigh } =
input;
if (unit === undefined || sameLabUnit(unit, marker.unit)) {
return {
ok: true,
converted: false,
value,
sourceReferenceLow,
sourceReferenceHigh,
};
}
const analyteKey = await ensureAnalyteKey(client, marker);
const converted = convertLabValue({
analyteKey,
value,
from: unit,
to: marker.unit,
});
if (!converted.ok) return { ok: false, reason: converted.reason };
// The printed window is in the printed unit; move it with the value.
const range = convertLabRange({
analyteKey,
low: sourceReferenceLow,
high: sourceReferenceHigh,
from: unit,
to: marker.unit,
});
if (!range.ok) return { ok: false, reason: range.reason };
return {
ok: true,
converted: true,
value: converted.value,
sourceReferenceLow: range.low,
sourceReferenceHigh: range.high,
sourceValue: value,
sourceUnit: unit.trim(),
};
}
+30
View File
@@ -53,6 +53,8 @@ export interface ResolvedBiomarker {
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
/** Which analyte the marker is (unit-conversion registry key), if known. */
analyteKey?: string | null;
}
/** The lab-result row shape the resolver reads (Prisma row subset). */
@@ -74,6 +76,12 @@ export interface LabRow {
takenAt: Date;
source: string;
biomarkerId: string | null;
/**
* When the reading was converted into the marker's unit: the value and unit
* as the report printed them. Both null for a reading stored as stated.
*/
sourceValue?: number | null;
sourceUnit?: string | null;
noteEncrypted: Uint8Array | null;
createdAt: Date;
updatedAt: Date;
@@ -179,6 +187,7 @@ export function serialiseLabResult(
biomarker?: ResolvedBiomarker | null,
) {
const resolved = resolveLabFields(row, biomarker);
const conversion = conversionFields(row);
return {
id: row.id,
biomarkerId: row.biomarkerId,
@@ -187,6 +196,9 @@ export function serialiseLabResult(
value: row.value,
valueText: row.valueText,
unit: resolved.unit,
converted: conversion.converted,
sourceValue: conversion.sourceValue,
sourceUnit: conversion.sourceUnit,
referenceLow: resolved.referenceLow,
referenceHigh: resolved.referenceHigh,
catalogReferenceLow: resolved.catalogReferenceLow,
@@ -210,6 +222,20 @@ export function serialiseLabResult(
};
}
/**
* Whether the stored number is a conversion, and what the report printed. A
* reading stored as stated reports `converted: false` and two nulls.
*/
function conversionFields(row: Pick<LabRow, "sourceValue" | "sourceUnit">) {
const sourceValue = row.sourceValue ?? null;
const sourceUnit = row.sourceUnit ?? null;
return {
converted: sourceValue !== null && sourceUnit !== null,
sourceValue,
sourceUnit,
};
}
/**
* Serialise a lab row to the detail DTO — same as the list DTO but carries the
* decrypted `note` instead of the `hasNote` flag.
@@ -220,6 +246,7 @@ export function serialiseLabResultDetail(
note: string | null,
) {
const resolved = resolveLabFields(row, biomarker);
const conversion = conversionFields(row);
return {
id: row.id,
biomarkerId: row.biomarkerId,
@@ -228,6 +255,9 @@ export function serialiseLabResultDetail(
value: row.value,
valueText: row.valueText,
unit: resolved.unit,
converted: conversion.converted,
sourceValue: conversion.sourceValue,
sourceUnit: conversion.sourceUnit,
referenceLow: resolved.referenceLow,
referenceHigh: resolved.referenceHigh,
catalogReferenceLow: resolved.catalogReferenceLow,
+5 -2
View File
@@ -70,6 +70,9 @@ const labResultRow = z
value: z.number().nullable(),
valueText: z.string().nullable(),
unit: z.string(),
converted: z.boolean(),
sourceValue: z.number().nullable(),
sourceUnit: z.string().nullable(),
referenceLow: z.number().nullable(),
referenceHigh: z.number().nullable(),
catalogReferenceLow: z.number().nullable(),
@@ -89,7 +92,7 @@ const labResultRow = z
.meta({
id: "LabResult",
description:
"A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. \"negativ\") is non-null; `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` are RESOLVED server-side from the Biomarker.\n\nThree reference windows ride on the row and the server says which one applies. `referenceLow` / `referenceHigh` are the window IN FORCE and the one `rangeStatus` was computed from — read these to render the range and you are correct without doing any resolution of your own. `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` are the range this reading's own report printed (the text verbatim, kept even when no numbers could be read out of it). `catalogReferenceLow` / `catalogReferenceHigh` are the Biomarker band. `referenceOrigin` names the winner and `referenceDivergesFromCatalog` is true when the report's window and the catalog band state different limits — a surface showing only one of the two is showing a partial answer. The client renders these values and never recomputes the range. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the single-resource GET returns the decrypted `note`.",
"A stored lab result. Exactly one of `value` (numeric) or `valueText` (qualitative, e.g. \"negativ\") is non-null; `rangeStatus` is always `unknown` for a qualitative row (no number to classify). `biomarkerId` links the user-scoped catalog marker (null for legacy free-text rows); when linked, `analyte` / `unit` are RESOLVED server-side from the Biomarker.\n\nThree reference windows ride on the row and the server says which one applies. `referenceLow` / `referenceHigh` are the window IN FORCE and the one `rangeStatus` was computed from — read these to render the range and you are correct without doing any resolution of your own. `sourceReferenceLow` / `sourceReferenceHigh` / `sourceReferenceText` are the range this reading's own report printed (the text verbatim, kept even when no numbers could be read out of it). `catalogReferenceLow` / `catalogReferenceHigh` are the Biomarker band. `referenceOrigin` names the winner and `referenceDivergesFromCatalog` is true when the report's window and the catalog band state different limits — a surface showing only one of the two is showing a partial answer. The client renders these values and never recomputes the range. `converted` is true when the stored `value` was converted into the marker's `unit` from another unit; `sourceValue` and `sourceUnit` are then what the report printed (full precision, round for display), and the report's own window was converted with it. A reading stored as stated has `converted: false` and two nulls. The encrypted note is never echoed in list rows; `hasNote` flags its presence and the single-resource GET returns the decrypted `note`.",
});
const labResultDetail = labResultRow
@@ -164,7 +167,7 @@ export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...stdResponses,
"422": {
description:
"Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue under `details.issues`), or `labs.unit.mismatch` when a numeric reading states a `unit` other than the one its marker is tracked in (`meta.markerUnit`, `meta.readingUnit`). A unit is compared only when one is sent; with a `biomarkerId` it may be omitted and the marker's unit is then the stated one. Spelling is forgiven (`mmol/l`, `ug/L` for `µg/L`); a different unit is refused, never relabelled.",
"Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue under `details.issues`), or `labs.unit.mismatch` when a numeric reading states a `unit` other than the one its marker is tracked in AND the pair cannot be converted (`meta.markerUnit`, `meta.readingUnit`, `meta.reason` = `unknown_analyte` | `unknown_unit` | `not_finite`). A unit is compared only when one is sent; with a `biomarkerId` it may be omitted and the marker's unit is then the stated one. Spelling is forgiven (`mmol/l`, `ug/L` for `µg/L`); a different unit is converted when the marker's analyte is known and the pair is in the unit-conversion registry (the 201 response then carries `converted: true`), and refused otherwise, never relabelled.",
content: { "application/json": { schema: errorEnvelope } },
},
...recordWriteRateLimitResponse,
+5 -2
View File
@@ -115,6 +115,9 @@ const committedRow = z
value: z.number().nullable(),
valueText: z.string().nullable(),
unit: z.string(),
converted: z.boolean(),
sourceValue: z.number().nullable(),
sourceUnit: z.string().nullable(),
referenceLow: z.number().nullable(),
referenceHigh: z.number().nullable(),
catalogReferenceLow: z.number().nullable(),
@@ -134,7 +137,7 @@ const committedRow = z
.meta({
id: "OcrCommittedLabResult",
description:
"A written reading. Its reference window resolves exactly as it does on `/api/labs` — see `LabResult`.",
"A written reading. Its reference window resolves exactly as it does on `/api/labs` — see `LabResult`. `converted`, `sourceValue` and `sourceUnit` say when the row's unit was converted into its marker's and what the report printed.",
});
const commitResponse = z
@@ -145,7 +148,7 @@ const commitResponse = z
analyte: z.string(),
reason: z.enum(["duplicate", "unit_mismatch"]).meta({
description:
"`duplicate`: the row matched a live reading at commit time. `unit_mismatch`: the row states a unit other than the one its marker is tracked in, so it was not written.",
"`duplicate`: the row matched a live reading at commit time. `unit_mismatch`: the row states a unit other than the one its marker is tracked in and the pair cannot be converted, so it was not written. A pair that can be converted is written in the marker's unit and reported with `converted: true`.",
}),
}),
),
+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);
});
});