mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The matcher stored the registry entry's key, so LDL and HDL markers were recorded as total-cholesterol. The key now names what the marker is (the catalog slug, or a registry-only key for an analyte with no seed), and the registry resolves ldl and hdl to the cholesterol entry for the conversion as before. A key already written in the old form is rewritten by the same fill step that brings a stale key in line with its name, the first time a reading needs it. The OpenAPI descriptions say what the key is and that it follows the marker's name and unit.
376 lines
14 KiB
TypeScript
376 lines
14 KiB
TypeScript
/**
|
|
* A marker is linked to its analyte, and the link follows its name and unit.
|
|
*
|
|
* `Biomarker.analyteKey` says which analyte a marker is, so a reading in another
|
|
* unit can be converted (#1095). There is no manual choice: the key is derived
|
|
* from the name and unit. It is set when a marker is created, by all three
|
|
* creation paths, re-derived when a rename or unit change moves it, and, for a
|
|
* marker that already exists, brought in line 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;
|
|
* - a rename moves the key, or clears it, and the next reading in another
|
|
* unit converts with the new analyte's factor;
|
|
* - `ensureAnalyteKey` fills a null key, replaces one that disagrees with the
|
|
* name, 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("stores the catalog slug for LDL and HDL, and converts them as cholesterol", async () => {
|
|
for (const [name, key] of [
|
|
["LDL cholesterol", "ldl"],
|
|
["HDL-Cholesterin", "hdl"],
|
|
]) {
|
|
const res = await post({ name, unit: "mg/dL" });
|
|
const { data } = (await res.json()) as { data: { analyteKey: string } };
|
|
expect(data.analyteKey, name).toBe(key);
|
|
}
|
|
const ldl = await prisma.biomarker.findFirstOrThrow({
|
|
where: { userId: USER, name: "LDL cholesterol" },
|
|
});
|
|
const reading = await postReading({
|
|
biomarkerId: ldl.id,
|
|
value: 3,
|
|
unit: "mmol/L",
|
|
takenAt: "2026-05-04T08:00:00.000Z",
|
|
});
|
|
expect(reading.status).toBe(201);
|
|
const { data: dto } = (await reading.json()) as {
|
|
data: { value: number; converted: boolean };
|
|
};
|
|
expect(dto.converted).toBe(true);
|
|
// 3 mmol/L x 38.6664 (C27H46O, 386.664 g/mol).
|
|
expect(dto.value.toFixed(2)).toBe("116.00");
|
|
});
|
|
|
|
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();
|
|
}
|
|
});
|
|
});
|
|
|
|
async function patch(id: string, 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 { PUT } = await import("@/app/api/biomarkers/[id]/route");
|
|
return PUT(
|
|
new NextRequest(`http://localhost/api/biomarkers/${id}`, {
|
|
method: "PUT",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
}),
|
|
{ params: Promise.resolve({ id }) },
|
|
);
|
|
}
|
|
|
|
async function postReading(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/labs/route");
|
|
return POST(
|
|
new NextRequest("http://localhost/api/labs", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
}),
|
|
);
|
|
}
|
|
|
|
describe("PUT /api/biomarkers/[id]", () => {
|
|
it("moves the key with a rename, and the next reading converts with the new analyte", async () => {
|
|
const created = await post({ name: "Blood sugar", unit: "mg/dL" });
|
|
const { data: marker } = (await created.json()) as {
|
|
data: { id: string; analyteKey: string | null };
|
|
};
|
|
expect(marker.analyteKey).toBe("fasting-glucose");
|
|
|
|
const res = await patch(marker.id, { name: "Cholesterol" });
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as {
|
|
data: { analyteKey: string | null };
|
|
};
|
|
expect(body.data.analyteKey).toBe("total-cholesterol");
|
|
expect(
|
|
(await prisma.biomarker.findUnique({ where: { id: marker.id } }))
|
|
?.analyteKey,
|
|
).toBe("total-cholesterol");
|
|
|
|
// 5.2 mmol/L of cholesterol is 201.07 mg/dL (C27H46O, 386.664 g/mol). With
|
|
// the glucose key left over it would have been stored as 93.7.
|
|
const reading = await postReading({
|
|
biomarkerId: marker.id,
|
|
value: 5.2,
|
|
unit: "mmol/L",
|
|
takenAt: "2026-05-03T08:00:00.000Z",
|
|
});
|
|
expect(reading.status).toBe(201);
|
|
const { data: dto } = (await reading.json()) as {
|
|
data: { value: number; unit: string; converted: boolean };
|
|
};
|
|
expect(dto.converted).toBe(true);
|
|
expect(dto.unit).toBe("mg/dL");
|
|
expect(dto.value.toFixed(2)).toBe("201.07");
|
|
});
|
|
|
|
it("clears the key when the new name matches no analyte", async () => {
|
|
const created = await post({ name: "Harnstoff", unit: "mg/dL" });
|
|
const { data: marker } = (await created.json()) as {
|
|
data: { id: string; analyteKey: string | null };
|
|
};
|
|
expect(marker.analyteKey).toBe("urea");
|
|
const res = await patch(marker.id, { name: "Harnstoff (eigene Notiz)" });
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { data: { analyteKey: string | null } };
|
|
expect(body.data.analyteKey).toBeNull();
|
|
});
|
|
|
|
it("re-derives the key when the unit changes on a marker without readings", async () => {
|
|
const created = await post({ name: "Glukose", unit: "mEq/L" });
|
|
const { data: marker } = (await created.json()) as {
|
|
data: { id: string; analyteKey: string | null };
|
|
};
|
|
expect(marker.analyteKey).toBeNull();
|
|
const res = await patch(marker.id, { unit: "mg/dL" });
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { data: { analyteKey: string | null } };
|
|
expect(body.data.analyteKey).toBe("fasting-glucose");
|
|
});
|
|
|
|
it("leaves the key alone on an edit that touches neither name nor unit", async () => {
|
|
const created = await post({ name: "Kalzium gesamt", unit: "mmol/L" });
|
|
const { data: marker } = (await created.json()) as { data: { id: string } };
|
|
const res = await patch(marker.id, { lowerBound: 2.1 });
|
|
expect(res.status).toBe(200);
|
|
const body = (await res.json()) as { data: { analyteKey: string | null } };
|
|
expect(body.data.analyteKey).toBe("calcium-total");
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
// This used to pin the opposite: a set key was never replaced. With no
|
|
// manual choice of analyte, a key that disagrees with the name can only be
|
|
// left over from a rename (or written in an older form), and keeping it
|
|
// converted readings with the wrong analyte's factor. So the key follows the
|
|
// name.
|
|
it("replaces a key that disagrees with the marker's name", async () => {
|
|
const marker = await prisma.biomarker.create({
|
|
data: {
|
|
userId: USER,
|
|
name: "Blood glucose",
|
|
unit: "mg/dL",
|
|
analyteKey: "hba1c",
|
|
},
|
|
});
|
|
expect(await ensureAnalyteKey(prisma, marker)).toBe("fasting-glucose");
|
|
expect(
|
|
(await prisma.biomarker.findUnique({ where: { id: marker.id } }))
|
|
?.analyteKey,
|
|
).toBe("fasting-glucose");
|
|
});
|
|
|
|
it("clears a key when the name matches no analyte", async () => {
|
|
const marker = await prisma.biomarker.create({
|
|
data: {
|
|
userId: USER,
|
|
name: "Some other marker",
|
|
unit: "mg/dL",
|
|
analyteKey: "fasting-glucose",
|
|
},
|
|
});
|
|
expect(await ensureAnalyteKey(prisma, marker)).toBeNull();
|
|
expect(
|
|
(await prisma.biomarker.findUnique({ where: { id: marker.id } }))
|
|
?.analyteKey,
|
|
).toBeNull();
|
|
});
|
|
|
|
it("re-reads the row when the caller's copy is stale", async () => {
|
|
const marker = await prisma.biomarker.create({
|
|
data: {
|
|
userId: USER,
|
|
name: "Urea",
|
|
unit: "mg/dL",
|
|
analyteKey: "urea",
|
|
},
|
|
});
|
|
// A copy read before the key was set: the gated write misses, the row as it
|
|
// stands already agrees, and nothing is overwritten.
|
|
expect(
|
|
await ensureAnalyteKey(prisma, { ...marker, analyteKey: null }),
|
|
).toBe("urea");
|
|
expect(
|
|
(await prisma.biomarker.findUnique({ where: { id: marker.id } }))
|
|
?.analyteKey,
|
|
).toBe("urea");
|
|
});
|
|
|
|
it("corrects a key stored as the registry entry rather than the catalog slug", async () => {
|
|
// Before the slug was stored, an LDL marker got the cholesterol entry's
|
|
// key. Same conversion, wrong identity; the fill step rewrites it once.
|
|
const marker = await prisma.biomarker.create({
|
|
data: {
|
|
userId: USER,
|
|
name: "LDL-Cholesterin",
|
|
unit: "mg/dL",
|
|
analyteKey: "total-cholesterol",
|
|
},
|
|
});
|
|
expect(await ensureAnalyteKey(prisma, marker)).toBe("ldl");
|
|
const row = await prisma.biomarker.findUnique({ where: { id: marker.id } });
|
|
expect(row?.analyteKey).toBe("ldl");
|
|
// And a second run changes nothing.
|
|
expect(await ensureAnalyteKey(prisma, row!)).toBe("ldl");
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|