Files
HealthLog/src/lib/validations/__tests__/labs.test.ts
T
Marc-André Bombeck a5b3715a46 feat(labs): record qualitative lab results
Add first-class qualitative lab findings (positive / negative / borderline
serology) alongside the existing numeric path. A LabResult is now EITHER a
numeric reading (value + unit, optional reference bounds) OR a qualitative one
(valueText, e.g. "negativ"), never both, never neither.

- schema: make `value` nullable, add `value_text`; migration 0181 is additive
  (DROP NOT NULL + ADD COLUMN), every existing numeric row is untouched.
- validation: numeric-XOR-qualitative refine on create + update; unit/bounds
  apply to the numeric path only; valueText capped at 120 chars. The single
  resource PUT rejects a cross-type edit.
- serialise: carry valueText through both DTOs; a qualitative row reports the
  neutral `unknown` range verdict (no number to classify).
- FHIR: a qualitative result emits valueCodeableConcept — SNOMED qualifier
  value for confidently-known terms (Negative 260385009, Positive 10828004,
  Not detected 260415000, Detected 260373001), text-only for borderline /
  grenzwertig and any unrecognised term. The raw text always rides .text.
- UI: a numeric/qualitative toggle in the lab form; list, history, detail,
  chart, sparkline, and the doctor-report PDF all render qualitative readings
  as their text and never plot or divide on a null value.
- i18n: new labs.form keys across all six locales.
- openapi: response carries a nullable value + valueText; regenerated.
2026-06-20 11:17:43 +02:00

280 lines
7.6 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
classifyReferenceRange,
createLabResultSchema,
listLabResultsSchema,
updateLabResultSchema,
} from "../labs";
const FUTURE = new Date(Date.now() + 60 * 60 * 1000).toISOString();
const RECENT = new Date(Date.now() - 60 * 60 * 1000).toISOString();
describe("createLabResultSchema", () => {
it("accepts a minimal valid reading", () => {
const r = createLabResultSchema.safeParse({
analyte: "HbA1c",
value: 5.4,
unit: "%",
takenAt: RECENT,
});
expect(r.success).toBe(true);
});
it("normalises a backdatable takenAt to a Date", () => {
const r = createLabResultSchema.safeParse({
analyte: "LDL",
value: 110,
unit: "mg/dL",
takenAt: RECENT,
});
expect(r.success).toBe(true);
if (r.success) expect(r.data.takenAt).toBeInstanceOf(Date);
});
it("rejects a future takenAt", () => {
const r = createLabResultSchema.safeParse({
analyte: "LDL",
value: 110,
unit: "mg/dL",
takenAt: FUTURE,
});
expect(r.success).toBe(false);
});
it("rejects an empty analyte", () => {
const r = createLabResultSchema.safeParse({
analyte: " ",
value: 1,
unit: "%",
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
it("rejects a transposed reference range (low > high)", () => {
const r = createLabResultSchema.safeParse({
analyte: "Ferritin",
value: 50,
unit: "ng/mL",
referenceLow: 300,
referenceHigh: 30,
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
it("accepts a single (upper-only) reference bound", () => {
const r = createLabResultSchema.safeParse({
analyte: "LDL",
value: 110,
unit: "mg/dL",
referenceHigh: 116,
takenAt: RECENT,
});
expect(r.success).toBe(true);
});
it("accepts a structured entry (biomarkerId, no analyte/unit)", () => {
const r = createLabResultSchema.safeParse({
biomarkerId: "bm_123",
value: 110,
takenAt: RECENT,
});
expect(r.success).toBe(true);
});
it("rejects an entry with neither biomarkerId nor analyte", () => {
const r = createLabResultSchema.safeParse({
value: 110,
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
it("rejects a free-text entry missing the unit", () => {
const r = createLabResultSchema.safeParse({
analyte: "LDL",
value: 110,
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
it("normalises an empty-string panel to undefined", () => {
const r = createLabResultSchema.safeParse({
analyte: "TSH",
value: 2,
unit: "mIU/L",
panel: "",
takenAt: RECENT,
});
expect(r.success).toBe(true);
if (r.success) expect(r.data.panel).toBeUndefined();
});
it("rejects a non-finite value", () => {
const r = createLabResultSchema.safeParse({
analyte: "LDL",
value: Number.POSITIVE_INFINITY,
unit: "mg/dL",
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
// ── qualitative readings (v1.18.9) ────────────────────────────────
it("accepts a qualitative reading (valueText, no numeric value/unit)", () => {
const r = createLabResultSchema.safeParse({
analyte: "Hepatitis Bs-Antigen",
valueText: "negativ",
takenAt: RECENT,
});
expect(r.success).toBe(true);
});
it("accepts a qualitative reading against a catalog biomarker", () => {
const r = createLabResultSchema.safeParse({
biomarkerId: "bm_123",
valueText: "positiv",
takenAt: RECENT,
});
expect(r.success).toBe(true);
});
it("rejects a reading carrying BOTH value and valueText", () => {
const r = createLabResultSchema.safeParse({
analyte: "LDL",
value: 110,
unit: "mg/dL",
valueText: "negativ",
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
it("rejects a reading carrying NEITHER value nor valueText", () => {
const r = createLabResultSchema.safeParse({
analyte: "LDL",
unit: "mg/dL",
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
it("rejects an empty qualitative valueText", () => {
const r = createLabResultSchema.safeParse({
analyte: "Serology",
valueText: " ",
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
it("rejects an over-long valueText (> 120 chars)", () => {
const r = createLabResultSchema.safeParse({
analyte: "Serology",
valueText: "x".repeat(121),
takenAt: RECENT,
});
expect(r.success).toBe(false);
});
it("does not require a unit for a qualitative reading", () => {
const r = createLabResultSchema.safeParse({
analyte: "Serology",
valueText: "negativ",
takenAt: RECENT,
});
expect(r.success).toBe(true);
});
});
describe("updateLabResultSchema", () => {
it("accepts an explicit null to clear panel + note", () => {
const r = updateLabResultSchema.safeParse({ panel: null, note: null });
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.panel).toBeNull();
expect(r.data.note).toBeNull();
}
});
it("accepts a partial value-only edit", () => {
const r = updateLabResultSchema.safeParse({ value: 5.6 });
expect(r.success).toBe(true);
});
it("accepts a partial valueText-only edit (v1.18.9)", () => {
const r = updateLabResultSchema.safeParse({ valueText: "positiv" });
expect(r.success).toBe(true);
});
it("rejects an edit carrying both value and valueText", () => {
const r = updateLabResultSchema.safeParse({
value: 5.6,
valueText: "negativ",
});
expect(r.success).toBe(false);
});
it("still enforces the range order when both bounds are supplied", () => {
const r = updateLabResultSchema.safeParse({
referenceLow: 10,
referenceHigh: 1,
});
expect(r.success).toBe(false);
});
});
describe("listLabResultsSchema", () => {
it("defaults limit/offset/sortDir", () => {
const r = listLabResultsSchema.safeParse({});
expect(r.success).toBe(true);
if (r.success) {
expect(r.data.limit).toBe(100);
expect(r.data.offset).toBe(0);
expect(r.data.sortDir).toBe("desc");
}
});
it("caps limit at 500", () => {
const r = listLabResultsSchema.safeParse({ limit: "9999" });
expect(r.success).toBe(false);
});
});
describe("classifyReferenceRange", () => {
it("returns unknown when no bounds are given", () => {
expect(classifyReferenceRange(5, null, null)).toBe("unknown");
expect(classifyReferenceRange(5, undefined, undefined)).toBe("unknown");
});
it("returns in-range inside both bounds", () => {
expect(classifyReferenceRange(5, 4, 6)).toBe("in-range");
});
it("treats the bounds as inclusive", () => {
expect(classifyReferenceRange(4, 4, 6)).toBe("in-range");
expect(classifyReferenceRange(6, 4, 6)).toBe("in-range");
});
it("returns below when under the low bound", () => {
expect(classifyReferenceRange(3.9, 4, 6)).toBe("below");
});
it("returns above when over the high bound", () => {
expect(classifyReferenceRange(6.1, 4, 6)).toBe("above");
});
it("classifies against a single upper bound", () => {
expect(classifyReferenceRange(120, null, 116)).toBe("above");
expect(classifyReferenceRange(100, null, 116)).toBe("in-range");
});
it("classifies against a single lower bound", () => {
expect(classifyReferenceRange(8, 10, null)).toBe("below");
expect(classifyReferenceRange(12, 10, null)).toBe("in-range");
});
});