feat(labs): link a marker to its analyte for the unit conversion

Slice 1 of phase 3 of #1095. A Biomarker row keeps only its name, so nothing
said which analyte it was. This adds the explicit alias list in the catalog the
issue settled on, including the registry-only keys (urea, blood urea nitrogen,
uric acid, total calcium, total bilirubin), and sets biomarkers.analyte_key
(from migration 0385) from it.

matchAnalyteKey equals a marker's folded name against the localised names of the
catalog seeds the registry knows and against the alias list. It matches the
whole name only, so "Calcium (ionisiert)" is not "Calcium"; a name two analytes
claim matches neither; and the marker's own unit must be one the analyte reaches,
so a marker called "Glucose" in a unit the registry cannot convert stays
unlinked.

The key is set when a marker is created (the biomarker route, the mint used by
lab writes, the legacy backfill) and, for an existing marker, by
ensureAnalyteKey the first time a reading needs it. It never overwrites a key
that is set. There is no boot job for the existing markers: the conversion that
uses the key fills it on first use, and a sweep can be added later if wanted.

The key is returned on the biomarker API and in the OpenAPI schema.

Refs #1095
This commit is contained in:
BernhardBuckel committed 2026-10-11 20:04:43 +02:00
1 parent c2be166f72
commit 52ea3b9c2f
13 files changed
+660 -3

No files matched your search

+9 -2
View File
@@ -68333,6 +68333,10 @@ components:
anyOf:
- type: string
- type: "null"
analyteKey:
anyOf:
- type: string
- type: "null"
hasContext:
type: boolean
context:
@@ -68352,6 +68356,7 @@ components:
- lowerBound
- upperBound
- panel
- analyteKey
- hasContext
- context
- hidden
@@ -68359,8 +68364,10 @@ components:
- updatedAt
additionalProperties: false
description: A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its
presence. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it
(its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds
presence. `analyteKey` is the analyte the marker was recognised as (the unit-conversion registry's key, e.g.
`fasting-glucose`), or null when its name matches none; a reading in another unit is converted only for a marker
that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting
it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds
from here.
CreateBiomarkerResponse:
type: object
+2
View File
@@ -41,6 +41,7 @@ function serialiseBiomarker(row: {
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
analyteKey: string | null;
hidden: boolean;
contextEncrypted: Uint8Array | null;
createdAt: Date;
@@ -53,6 +54,7 @@ function serialiseBiomarker(row: {
lowerBound: row.lowerBound,
upperBound: row.upperBound,
panel: row.panel,
analyteKey: row.analyteKey,
hasContext: row.contextEncrypted !== null,
context: row.contextEncrypted
? decryptContextFromBytes(row.contextEncrypted)
+6
View File
@@ -17,6 +17,7 @@ import {
decryptContextSoft,
encryptContextToBytes,
} from "@/lib/labs/biomarker-store";
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
import { annotate } from "@/lib/logging/context";
import { createBiomarkerSchema } from "@/lib/validations/biomarkers";
@@ -51,6 +52,7 @@ function serialiseBiomarker(row: {
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
analyteKey: string | null;
hidden: boolean;
contextEncrypted: Uint8Array | null;
createdAt: Date;
@@ -63,6 +65,7 @@ function serialiseBiomarker(row: {
lowerBound: row.lowerBound,
upperBound: row.upperBound,
panel: row.panel,
analyteKey: row.analyteKey,
hasContext: row.contextEncrypted !== null,
context: decryptContextSoft(row.contextEncrypted),
hidden: row.hidden,
@@ -158,6 +161,9 @@ async function postBiomarker(request: NextRequest) {
lowerBound: lowerBound ?? null,
upperBound: upperBound ?? null,
panel: panel ?? null,
// Which analyte it is, for the unit conversion; null when the name is no
// analyte the registry knows.
analyteKey: matchAnalyteKey({ name, unit }),
contextEncrypted: context ? encryptContextToBytes(context) : null,
},
});
+5
View File
@@ -60,6 +60,11 @@ export interface BiomarkerDto {
lowerBound: number | null;
upperBound: number | null;
panel: string | null;
/**
* Which analyte the marker is, for converting a reading in another unit
* (the unit-conversion registry's key), or null when it is none that is known.
*/
analyteKey: string | null;
hasContext: boolean;
context: string | null;
/** v1.22 — a hidden marker drops from the active list + lab-entry pickers. */
+2
View File
@@ -28,6 +28,7 @@ import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { integrationBackfillSourceOptions } from "@/lib/jobs/integration-backfill-admission";
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
import { annotate } from "@/lib/logging/context";
export const LAB_BIOMARKER_BACKFILL_QUEUE = "lab-biomarker-backfill";
@@ -161,6 +162,7 @@ export async function runLabBiomarkerBackfillForUser(
lowerBound: group.referenceLow,
upperBound: group.referenceHigh,
panel: group.panel,
analyteKey: matchAnalyteKey({ name: group.name, unit: group.unit }),
},
});
markers += 1;
@@ -0,0 +1,164 @@
import { describe, expect, it } from "vitest";
import { foldForMatch } from "@/lib/i18n/fold-for-match";
import { matchAnalyteKey, analyteNameIndex } from "../analyte-match";
import { ANALYTE_ALIASES, BIOMARKER_CATALOG } from "../biomarker-catalog";
import { analyteAcceptsUnit } from "../unit-conversion/convert";
import {
allAnalyteConversions,
analyteConversionFor,
} from "../unit-conversion/registry";
const match = (name: string, unit: string) => matchAnalyteKey({ name, unit });
describe("the catalog's own names, in every language", () => {
it.each([
["Fasting glucose", "mg/dL", "fasting-glucose"],
["Nüchternglukose", "mg/dL", "fasting-glucose"],
["Glucosa en ayunas", "mg/dL", "fasting-glucose"],
["Glycémie à jeun", "mmol/L", "fasting-glucose"],
["Glicemia a digiuno", "mg/dL", "fasting-glucose"],
["공복혈당", "mg/dL", "fasting-glucose"],
["Glukoza na czczo", "mg/dL", "fasting-glucose"],
["HbA1c", "%", "hba1c"],
["Total cholesterol", "mg/dL", "total-cholesterol"],
["Gesamtcholesterin", "mg/dL", "total-cholesterol"],
["Cholestérol total", "mmol/L", "total-cholesterol"],
["LDL cholesterol", "mg/dL", "ldl"],
["LDL-Cholesterin", "mg/dL", "total-cholesterol"],
["Colesterolo HDL", "mg/dL", "total-cholesterol"],
["Vitamin D (25-OH)", "ng/mL", "vitamin-d"],
["Créatinine", "mg/dL", "creatinine"],
["Kreatynina", "µmol/L", "creatinine"],
])("%s (%s) is %s", (name, unit, key) => {
// LDL and HDL are aliases of cholesterol: the registry reports one key.
const expected = analyteConversionFor(key)!.key;
expect(match(name, unit)).toBe(expected);
});
it("recognises a seed typed without the app's gloss", () => {
expect(match("Vitamin D", "ng/mL")).toBe("vitamin-d");
});
});
describe("typed names", () => {
it.each([
["Glucose", "mg/dL", "fasting-glucose"],
["Blutzucker", "mg/dL", "fasting-glucose"],
["blood sugar", "mmol/L", "fasting-glucose"],
["Cholesterin", "mg/dL", "total-cholesterol"],
["Serum Creatinine", "mg/dL", "creatinine"],
["Urea", "mmol/L", "urea"],
["Harnstoff", "mg/dL", "urea"],
["BUN", "mg/dL", "blood-urea-nitrogen"],
["Blood Urea Nitrogen", "mg/dL", "blood-urea-nitrogen"],
["Harnsäure", "µmol/L", "uric-acid"],
["Uric acid", "mg/dL", "uric-acid"],
["Kalzium", "mmol/L", "calcium-total"],
["Calcium", "mg/dL", "calcium-total"],
["Gesamtbilirubin", "µmol/L", "bilirubin-total"],
["Bilirubin total", "mg/dL", "bilirubin-total"],
])("%s (%s) is %s", (name, unit, key) => {
expect(match(name, unit)).toBe(key);
});
it("folds case, accents, hyphens and spacing the same on both sides", () => {
expect(match(" HARNSÄURE ", "mg/dL")).toBe("uric-acid");
expect(match("ldl-c", "mg/dL")).toBe(analyteConversionFor("ldl")!.key);
expect(match("blood urea-nitrogen", "mg/dL")).toBe("blood-urea-nitrogen");
});
});
describe("a name that is not the whole analyte name stays unlinked", () => {
it.each([
["Calcium (ionisiert)", "mmol/L"],
["Ionised calcium", "mmol/L"],
["Calcium ionisiert", "mmol/L"],
["Urea nitrogen ratio", "mg/dL"],
["Glucose (urine)", "mg/dL"],
["Glucose tolerance", "mg/dL"],
["Non-HDL cholesterol", "mg/dL"],
["Cholesterol ratio", "mg/dL"],
["Direct bilirubin", "mg/dL"],
["Free thyroxine", "pmol/L"],
["", "mg/dL"],
])("%s", (name, unit) => {
expect(match(name, unit)).toBeNull();
});
it("keeps urea and blood urea nitrogen apart", () => {
expect(match("Urea", "mmol/L")).toBe("urea");
expect(match("Urea nitrogen", "mg/dL")).toBe("blood-urea-nitrogen");
});
});
describe("the marker's unit must be one the analyte reaches", () => {
it("links a unit the entry lists and one that differs by a power of ten", () => {
expect(match("Glucose", "mg/dL")).toBe("fasting-glucose");
expect(match("Glucose", "mmol/L")).toBe("fasting-glucose");
expect(match("Glucose", "g/L")).toBe("fasting-glucose");
expect(match("Creatinine", "mmol/L")).toBe("creatinine");
});
it("leaves a unit the registry cannot reach unlinked", () => {
expect(match("Glucose", "mEq/L")).toBeNull();
expect(match("Glucose", "%")).toBeNull();
expect(match("HbA1c", "mg/dL")).toBeNull();
expect(match("Calcium", "furlongs")).toBeNull();
});
it("reads a spelling of the unit through the normaliser", () => {
expect(match("Glucose", "MG/DL")).toBe("fasting-glucose");
expect(match("Creatinine", "umol/l")).toBe("creatinine");
});
});
describe("the alias list, as a guard", () => {
it("names only analytes the registry knows", () => {
for (const { key } of ANALYTE_ALIASES) {
expect(analyteConversionFor(key), key).not.toBeNull();
}
});
it("writes every name already folded, so what is read is what is matched", () => {
for (const { key, names } of ANALYTE_ALIASES) {
for (const name of names) {
expect(foldForMatch(name), `${key}: ${name}`).toBe(name);
}
}
});
it("has no name that two analytes claim", () => {
expect(analyteNameIndex().ambiguous).toEqual([]);
});
it("gives every registry entry at least one name to be found by", () => {
const { index } = analyteNameIndex();
const reached = new Set(index.values());
for (const entry of allAnalyteConversions()) {
expect(reached.has(entry.key), entry.key).toBe(true);
}
});
it("covers every catalog seed the registry knows, in all seven languages", () => {
const { index } = analyteNameIndex();
const seeds = BIOMARKER_CATALOG.filter((seed) =>
analyteConversionFor(seed.slug),
);
expect(seeds.length).toBeGreaterThanOrEqual(6);
for (const seed of seeds) {
const key = analyteConversionFor(seed.slug)!.key;
const names = [...index.entries()].filter(([, k]) => k === key);
expect(names.length, seed.slug).toBeGreaterThan(0);
}
});
it("links each seed at its own catalog unit", () => {
for (const seed of BIOMARKER_CATALOG) {
const entry = analyteConversionFor(seed.slug);
if (!entry) continue;
expect(analyteAcceptsUnit(entry.key, seed.unit), seed.slug).toBe(true);
}
});
});
+37
View File
@@ -0,0 +1,37 @@
/**
* Link a marker to its analyte for the unit conversion.
*
* The key is set when a marker is created and, for a marker that already
* existed, the first time a reading needs it. It is never overwritten: a key a
* person (or an earlier pass) set stands, and `updateMany` is gated on the
* column still being null, so two concurrent writers converge. A marker whose
* name matches no analyte keeps a null key and gets no conversion.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { matchAnalyteKey } from "./analyte-match";
export async function ensureAnalyteKey(
client: Pick<PrismaClient, "biomarker">,
marker: {
id: string;
name: string;
unit: string;
analyteKey?: string | null;
},
): Promise<string | null> {
if (marker.analyteKey) return marker.analyteKey;
const key = matchAnalyteKey({ name: marker.name, unit: marker.unit });
if (!key) return null;
const { count } = await client.biomarker.updateMany({
where: { id: marker.id, analyteKey: null },
data: { analyteKey: key },
});
if (count > 0) return key;
// Another writer set it first; read what stands.
const current = await client.biomarker.findUnique({
where: { id: marker.id },
select: { analyteKey: true },
});
return current?.analyteKey ?? null;
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Which analyte a marker is, for the unit conversion.
*
* `matchAnalyteKey` answers from two places: the localised names of the
* catalog seeds the registry knows (so a marker created from the "pick a common
* marker" list is recognised in every language the app ships) and the alias
* list in the catalog. The name must equal one of them as a WHOLE, folded the
* same way on both sides; a part of a name never matches.
*
* Two safeguards keep a link from being wrong:
* - a folded name that two analytes claim matches neither;
* - the marker's own unit must be one the analyte reaches, so a marker called
* "Glucose" in a unit the registry cannot convert is left unlinked.
*
* Returns the registry key, or null. Pure apart from reading the message
* bundles once.
*/
import { foldForMatch } from "@/lib/i18n/fold-for-match";
import { localisedValues } from "@/lib/i18n/shared-resolve";
import { ANALYTE_ALIASES, BIOMARKER_CATALOG } from "./biomarker-catalog";
import { analyteAcceptsUnit } from "./unit-conversion/convert";
import { analyteConversionFor } from "./unit-conversion/registry";
/** The folded name without a trailing parenthetical gloss: "Vitamin D (25-OH)" -> "vitamin d". */
function withoutGloss(folded: string): string {
return folded.replace(/\s*\([^)]*\)/gu, "").trim();
}
let cache: ReadonlyMap<string, string> | null = null;
/**
* folded name -> registry key, with every name two analytes claim removed.
* Exported for the guard test, which asserts nothing was dropped silently.
*/
export function analyteNameIndex(): {
index: ReadonlyMap<string, string>;
ambiguous: readonly string[];
} {
const claims = new Map<string, Set<string>>();
const claim = (name: string, key: string) => {
const folded = foldForMatch(name);
if (!folded) return;
const keys = claims.get(folded) ?? new Set<string>();
keys.add(key);
claims.set(folded, keys);
};
for (const seed of BIOMARKER_CATALOG) {
const entry = analyteConversionFor(seed.slug);
if (!entry) continue;
for (const name of localisedValues(`labs.catalog.${seed.slug}`)) {
claim(name, entry.key);
// The bare name too, for a marker typed without the app's gloss.
claim(withoutGloss(foldForMatch(name)), entry.key);
}
}
for (const { key, names } of ANALYTE_ALIASES) {
const entry = analyteConversionFor(key);
if (!entry) continue;
for (const name of names) claim(name, entry.key);
}
const index = new Map<string, string>();
const ambiguous: string[] = [];
for (const [folded, keys] of claims) {
if (keys.size === 1) index.set(folded, [...keys][0]);
else ambiguous.push(folded);
}
return { index, ambiguous };
}
export function matchAnalyteKey(input: {
name: string;
unit: string;
}): string | null {
cache ??= analyteNameIndex().index;
const key = cache.get(foldForMatch(input.name));
if (!key) return null;
return analyteAcceptsUnit(key, input.unit) ? key : null;
}
+147
View File
@@ -276,3 +276,150 @@ export const BIOMARKER_PANELS = [
"electrolytes",
"bloodCount",
] as const;
/**
* Names a marker may carry for an analyte the unit-conversion registry knows
* (`src/lib/labs/unit-conversion/registry.ts`), keyed by the registry's key.
*
* A `Biomarker` row keeps only its name, not the seed it came from, so this is
* how a marker is told which analyte it is: its folded name equals one of these,
* or one of the localised names of a seed the registry knows (`ldl`, `hba1c`,
* `fasting-glucose`, ...). The match is the WHOLE name, never a part of it, so
* "Calcium (ionisiert)" is not "Calcium" and "Urea nitrogen" is not "Urea". A
* name that two analytes claim is left out of the match, and a marker named in
* some other way stays unlinked, which is the safe default.
*
* Names are compared folded (lower case, no accents, hyphens and slashes as
* spaces), so "Harnsäure" is written "harnsaure" here and "Nüchtern-Glukose"
* needs no entry of its own. The registry-only keys (urea, blood urea
* nitrogen, uric acid, total calcium, total bilirubin) have no seed, so their
* names live only here.
*/
export interface AnalyteAliases {
/** The registry key of the analyte. */
key: string;
names: readonly string[];
}
export const ANALYTE_ALIASES: readonly AnalyteAliases[] = [
{
key: "fasting-glucose",
names: [
"glucose",
"blood glucose",
"blood sugar",
"plasma glucose",
"fasting blood glucose",
"fasting blood sugar",
"fasting plasma glucose",
"glukose",
"blutglukose",
"blutzucker",
"nuchternblutzucker",
"glucosa",
"glicemia",
"glycemie",
"glucosio",
"glukoza",
"glikemia",
],
},
{
key: "hba1c",
names: [
"hb a1c",
"a1c",
"hemoglobin a1c",
"glycated hemoglobin",
"glycosylated hemoglobin",
"glykohamoglobin",
"hamoglobin a1c",
],
},
{
key: "total-cholesterol",
names: [
"cholesterol",
"cholesterin",
"cholesterol total",
"cholesterin gesamt",
"cholesterol gesamt",
"colesterol",
"colesterolo",
"cholesterol calkowity",
],
},
{ key: "ldl", names: ["ldl", "ldl c", "ldl chol", "ldl cholesterin"] },
{ key: "hdl", names: ["hdl", "hdl c", "hdl chol", "hdl cholesterin"] },
{
key: "creatinine",
names: [
"serum creatinine",
"serumkreatinin",
"kreatinin im serum",
"creatinine serique",
],
},
{
key: "vitamin-d",
names: [
"25 oh vitamin d",
"25 hydroxy vitamin d",
"25 hydroxyvitamin d",
"25 oh d",
"calcidiol",
"vitamin d 25 oh",
],
},
{ key: "urea", names: ["urea", "harnstoff", "uree", "urea serum"] },
{
key: "blood-urea-nitrogen",
names: [
"bun",
"blood urea nitrogen",
"urea nitrogen",
"harnstoff stickstoff",
"stickstoff harnstoff",
"azote ureique",
],
},
{
key: "uric-acid",
names: [
"uric acid",
"urate",
"harnsaure",
"acide urique",
"acido urico",
"kwas moczowy",
],
},
{
key: "calcium-total",
names: [
"calcium",
"total calcium",
"calcium total",
"calcium gesamt",
"gesamtcalcium",
"kalzium",
"kalzium gesamt",
"gesamtkalzium",
"calcio",
"wapn",
],
},
{
key: "bilirubin-total",
names: [
"bilirubin",
"total bilirubin",
"bilirubin total",
"bilirubin gesamt",
"gesamtbilirubin",
"bilirrubina",
"bilirubine",
"bilirubina",
],
},
];
+2
View File
@@ -13,6 +13,7 @@ import { Buffer } from "node:buffer";
import { decrypt, encrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { matchAnalyteKey } from "@/lib/labs/analyte-match";
import { getEvent } from "@/lib/logging/context";
/** Encrypt a UTF-8 context note into the `Bytes` payload the schema stores. */
@@ -120,6 +121,7 @@ export async function resolveOrMintBiomarker(
lowerBound: input.referenceLow,
upperBound: input.referenceHigh,
panel: input.panel,
analyteKey: matchAnalyteKey({ name, unit: input.unit }),
},
select,
});
+16
View File
@@ -165,3 +165,19 @@ export function conversionsFor(input: {
found.delete(to);
return [...found].filter((unit) => normaliseLabUnit(unit) !== to);
}
/**
* Whether the analyte's entry reaches `unit`: the unit is one it lists, or
* differs from one only by a power of ten. A marker is linked to an analyte only
* when its own unit passes this, so a marker called "Glucose" tracked in some
* unit the registry cannot reach is not given a conversion it cannot make.
*/
export function analyteAcceptsUnit(
analyteKey: string | null | undefined,
unit: string,
): boolean {
const entry = analyteConversionFor(analyteKey);
return (
entry !== null && intoEntryUnit(entry, normaliseLabUnit(unit)) !== null
);
}
+2 -1
View File
@@ -53,6 +53,7 @@ const biomarkerRow = z
lowerBound: z.number().nullable(),
upperBound: z.number().nullable(),
panel: z.string().nullable(),
analyteKey: z.string().nullable(),
hasContext: z.boolean(),
context: z.string().nullable(),
hidden: z.boolean(),
@@ -62,7 +63,7 @@ const biomarkerRow = z
.meta({
id: "Biomarker",
description:
"A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.",
"A user-scoped catalog marker. `context` is the decrypted per-marker note (or null); `hasContext` flags its presence. `analyteKey` is the analyte the marker was recognised as (the unit-conversion registry's key, e.g. `fasting-glucose`), or null when its name matches none; a reading in another unit is converted only for a marker that has one. `hidden` drops the marker from the active catalog list and the lab-entry pickers without deleting it (its readings and unit/range survive). A `LabResult` linking this marker resolves its unit + reference bounds from here.",
});
const listResponse = z
+187
View File
@@ -0,0 +1,187 @@
/**
* A marker is linked to its analyte, once, and the link is never overwritten.
*
* `Biomarker.analyteKey` says which analyte a marker is, so a reading in another
* unit can be converted (#1095). It is set when a marker is created, by all
* three creation paths, and, for a marker that already exists, the first time
* it is needed. Against a real Postgres:
* - a marker created by name is linked when the name is a known analyte and
* its unit is one the analyte reaches, and left null otherwise;
* - the key reaches the API response;
* - `ensureAnalyteKey` fills a null key once and never replaces a key that is
* set, even by a different match, and two concurrent calls converge;
* - the mint path used by the lab writes and the legacy backfill set it too.
*/
import { NextRequest } from "next/server";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { cookieJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
import { runLabBiomarkerBackfillForUser } from "@/lib/jobs/lab-biomarker-backfill";
import { ensureAnalyteKey } from "@/lib/labs/analyte-key-store";
import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store";
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 = "analyte-key-user";
beforeAll(async () => {
await truncateAllTables(prisma);
await prisma.user.create({ data: { id: USER, username: USER } });
});
async function post(body: 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 { POST } = await import("@/app/api/biomarkers/route");
return POST(
new NextRequest("http://localhost/api/biomarkers", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}),
);
}
describe("POST /api/biomarkers", () => {
it("links a marker whose name is a known analyte, and returns the key", async () => {
const res = await post({ name: "Blutzucker", unit: "mg/dL" });
expect(res.status).toBe(201);
const { data } = (await res.json()) as { data: { analyteKey: string } };
expect(data.analyteKey).toBe("fasting-glucose");
const row = await prisma.biomarker.findFirst({
where: { userId: USER, name: "Blutzucker" },
});
expect(row?.analyteKey).toBe("fasting-glucose");
});
it("links a catalog seed by its localised name", async () => {
const res = await post({ name: "Gesamtcholesterin", unit: "mg/dL" });
const { data } = (await res.json()) as { data: { analyteKey: string } };
expect(data.analyteKey).toBe("total-cholesterol");
});
it("leaves a free name, and a known name in an unreachable unit, unlinked", async () => {
for (const [name, unit] of [
["My own marker", "mg/dL"],
["Glucose", "mEq/L"],
["Calcium (ionisiert)", "mmol/L"],
]) {
const res = await post({ name, unit });
expect(res.status).toBe(201);
const { data } = (await res.json()) as {
data: { analyteKey: string | null };
};
expect(data.analyteKey, name).toBeNull();
}
});
});
describe("ensureAnalyteKey", () => {
it("fills a null key once", async () => {
const marker = await prisma.biomarker.create({
data: { userId: USER, name: "Harnsäure", unit: "µmol/L" },
});
expect(marker.analyteKey).toBeNull();
expect(await ensureAnalyteKey(prisma, marker)).toBe("uric-acid");
expect(
(await prisma.biomarker.findUnique({ where: { id: marker.id } }))
?.analyteKey,
).toBe("uric-acid");
});
it("never replaces a key that is set, even when the name would match another", async () => {
const marker = await prisma.biomarker.create({
data: {
userId: USER,
name: "Blood glucose",
unit: "mg/dL",
analyteKey: "hba1c",
},
});
// The caller read the row with the key set.
expect(await ensureAnalyteKey(prisma, marker)).toBe("hba1c");
// A caller holding a stale copy with a null key does not overwrite either.
expect(
await ensureAnalyteKey(prisma, { ...marker, analyteKey: null }),
).toBe("hba1c");
expect(
(await prisma.biomarker.findUnique({ where: { id: marker.id } }))
?.analyteKey,
).toBe("hba1c");
});
it("converges when two callers fill the same marker", async () => {
const marker = await prisma.biomarker.create({
data: { userId: USER, name: "Kreatinin", unit: "mg/dL" },
});
const [a, b] = await Promise.all([
ensureAnalyteKey(prisma, marker),
ensureAnalyteKey(prisma, marker),
]);
expect(a).toBe("creatinine");
expect(b).toBe("creatinine");
});
it("leaves a marker with no analyte null", async () => {
const marker = await prisma.biomarker.create({
data: { userId: USER, name: "Ferritin", unit: "ng/mL" },
});
expect(await ensureAnalyteKey(prisma, marker)).toBeNull();
});
});
describe("the other creation paths", () => {
it("links a marker minted by a lab write", async () => {
const minted = await resolveOrMintBiomarker(USER, {
analyte: "Gesamtbilirubin",
unit: "µmol/L",
referenceLow: null,
referenceHigh: null,
panel: null,
});
const row = await prisma.biomarker.findUnique({ where: { id: minted.id } });
expect(row?.analyteKey).toBe("bilirubin-total");
});
it("links a marker the legacy backfill mints", async () => {
await prisma.labResult.create({
data: {
userId: USER,
analyte: "Kalzium",
value: 2.4,
unit: "mmol/L",
takenAt: new Date("2026-05-01T08:00:00Z"),
source: "MANUAL",
},
});
await runLabBiomarkerBackfillForUser(USER);
const row = await prisma.biomarker.findFirst({
where: { userId: USER, name: "Kalzium" },
});
expect(row?.analyteKey).toBe("calcium-total");
});
});