feat(medications): shared helpers — readError + route-guards + dose-string + research-mode-types

Hoist five duplicated helpers into single source modules so each
contract evolves in exactly one place:

- src/lib/api/read-error.ts — replaces the four hand-rolled copies
  in the onboarding flow (Welcome / Goals / Source / Baseline).
- src/lib/medications/research-mode-types.ts — shared ResearchModeStatus
  DTO + tri-state gate helper consumed by both the DrugLevelChart
  and the Settings advanced section.
- src/lib/medications/route-guards.ts — assertMedicationOwnership
  for the eleven medication sub-routes, preserving the 404 leak
  shape across the privacy boundary.
- src/lib/medications/dose-string.ts — unified parseDoseMg + null
  variant replacing the DrugLevelChart and titration-route parsers.
- src/lib/medications/glp1-knowledge.ts — adds findDrugIdByBrand
  reverse lookup so the brand-to-id pattern stops being open-coded
  at every chart render.

28 new unit tests across the five helpers.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 22:54:25 +02:00
1 parent 5f5b8dfb49
commit c8ee4cb9fc
10 files changed
+466

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { readError } from "../read-error";
function makeResponse(body: unknown, status: number, opts?: { invalidJson?: boolean }): Response {
if (opts?.invalidJson) {
return new Response("<<not json>>", {
status,
headers: { "content-type": "application/json" },
});
}
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
describe("readError", () => {
it("returns the server-supplied error string when present", async () => {
const res = makeResponse({ data: null, error: "Goal limit reached" }, 422);
expect(await readError(res)).toBe("Goal limit reached");
});
it("falls back to status-coded fallback when the envelope omits error", async () => {
const res = makeResponse({ data: null }, 500);
expect(await readError(res)).toBe("Request failed (500)");
});
it("falls back when the error field is empty", async () => {
const res = makeResponse({ data: null, error: "" }, 400);
expect(await readError(res)).toBe("Request failed (400)");
});
it("falls back when the JSON body fails to parse", async () => {
const res = makeResponse(null, 503, { invalidJson: true });
expect(await readError(res)).toBe("Request failed (503)");
});
it("falls back when the error field is the wrong type", async () => {
const res = makeResponse({ data: null, error: 42 }, 400);
expect(await readError(res)).toBe("Request failed (400)");
});
it("never throws even when the response body is empty", async () => {
const res = new Response(null, { status: 502 });
expect(await readError(res)).toBe("Request failed (502)");
});
});
+27
View File
@@ -0,0 +1,27 @@
/**
* v1.4.25 W21 Fix-N — shared client-side error reader.
*
* The four onboarding components (WelcomeCarousel, GoalsChipPicker,
* SourceCardGrid, BaselineForm) all hand-rolled the same helper to
* extract the `error` string from a non-OK `apiError` envelope, with an
* identical fall-through to `Request failed (<status>)`. Hoisting the
* helper keeps the four call sites honest — the envelope shape only
* needs to evolve once.
*
* Contract:
* - The server-side `apiError` helper writes `{ data: null, error: "<msg>" }`.
* - On parse failure (network error, non-JSON body, malformed shape)
* fall back to a stable string the toast can surface.
* - Never throw — the caller is already in a catch / setError path.
*/
export async function readError(res: Response): Promise<string> {
try {
const json = (await res.json()) as { error?: string };
if (typeof json.error === "string" && json.error.length > 0) {
return json.error;
}
} catch {
/* fall through */
}
return `Request failed (${res.status})`;
}
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { parseDoseMg, parseDoseMgOrNull } from "../dose-string";
describe("parseDoseMg", () => {
it("parses standard 'X mg' strings", () => {
expect(parseDoseMg("7.5 mg")).toBe(7.5);
expect(parseDoseMg("15 mg")).toBe(15);
expect(parseDoseMg("0.25 mg")).toBe(0.25);
});
it("accepts comma as decimal separator for German locale input", () => {
expect(parseDoseMg("12,5 mg")).toBe(12.5);
expect(parseDoseMg("0,25")).toBe(0.25);
});
it("returns NaN on unparseable input", () => {
expect(Number.isNaN(parseDoseMg(""))).toBe(true);
expect(Number.isNaN(parseDoseMg("as needed"))).toBe(true);
expect(Number.isNaN(parseDoseMg("mg"))).toBe(true);
});
it("extracts the first number when units precede / follow", () => {
expect(parseDoseMg("dose: 2.5 mg")).toBe(2.5);
expect(parseDoseMg("2.5mg")).toBe(2.5);
});
it("strips leading whitespace when the regex picks the number", () => {
expect(parseDoseMg(" 5 mg")).toBe(5);
});
});
describe("parseDoseMgOrNull", () => {
it("returns the numeric value when parseable", () => {
expect(parseDoseMgOrNull("7.5 mg")).toBe(7.5);
expect(parseDoseMgOrNull("0,5")).toBe(0.5);
});
it("returns null on unparseable input rather than NaN", () => {
expect(parseDoseMgOrNull("")).toBeNull();
expect(parseDoseMgOrNull("as needed")).toBeNull();
expect(parseDoseMgOrNull("mg")).toBeNull();
});
it("returns null when the numeric portion is non-finite", () => {
// Defence-in-depth — Number.parseFloat itself never returns
// Infinity from this regex, but the guard belongs to the helper
// contract.
expect(parseDoseMgOrNull("not a dose")).toBeNull();
});
it("preserves zero as a valid dose (not null) when the user logs 0 mg", () => {
expect(parseDoseMgOrNull("0 mg")).toBe(0);
});
});
@@ -4,6 +4,7 @@ import {
GLP1_DRUG_IDS,
GLP1_DRUGS,
findDrugByBrand,
findDrugIdByBrand,
routeForBrand,
type Glp1DrugId,
} from "@/lib/medications/glp1-knowledge";
@@ -244,6 +245,41 @@ describe("glp1-knowledge catalog", () => {
});
});
describe("findDrugIdByBrand", () => {
it("returns the drug id for a known brand", () => {
expect(findDrugIdByBrand("Mounjaro")).toBe("tirzepatide");
expect(findDrugIdByBrand("Ozempic")).toBe("semaglutide");
expect(findDrugIdByBrand("Saxenda")).toBe("liraglutide");
});
it("is case-insensitive on the brand argument", () => {
expect(findDrugIdByBrand("mounjaro")).toBe("tirzepatide");
expect(findDrugIdByBrand("OZEMPIC")).toBe("semaglutide");
});
it("returns null for an unknown brand", () => {
expect(findDrugIdByBrand("BogusName")).toBeNull();
});
it("returns null for empty input", () => {
expect(findDrugIdByBrand("")).toBeNull();
expect(findDrugIdByBrand(" ")).toBeNull();
});
it("trims surrounding whitespace before matching", () => {
expect(findDrugIdByBrand(" Wegovy ")).toBe("semaglutide");
});
it("preserves the (id ↔ record) round-trip with findDrugByBrand", () => {
for (const id of GLP1_DRUG_IDS) {
const record = GLP1_DRUGS[id];
const [firstBrand] = record.brands;
expect(findDrugIdByBrand(firstBrand)).toBe(id);
expect(findDrugByBrand(firstBrand)).toBe(record);
}
});
});
describe("citations", () => {
it("links every record to an EMA EPAR PDF", () => {
for (const id of GLP1_DRUG_IDS) {
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import {
researchModeGateState,
type ResearchModeStatus,
} from "../research-mode-types";
const ACK_OPEN: ResearchModeStatus = {
enabled: true,
acknowledgedAt: "2026-05-13T00:00:00.000Z",
acknowledgedVersion: "v3",
currentDisclaimerVersion: "v3",
};
const ACK_STALE: ResearchModeStatus = {
enabled: true,
acknowledgedAt: "2026-04-01T00:00:00.000Z",
acknowledgedVersion: "v2",
currentDisclaimerVersion: "v3",
};
const OFF: ResearchModeStatus = {
enabled: false,
acknowledgedAt: null,
acknowledgedVersion: null,
currentDisclaimerVersion: "v3",
};
describe("researchModeGateState", () => {
it("returns 'off' when status is null (unauthenticated / loading)", () => {
expect(researchModeGateState(null)).toBe("off");
});
it("returns 'off' when status is undefined", () => {
expect(researchModeGateState(undefined)).toBe("off");
});
it("returns 'off' when the user never enabled Research Mode", () => {
expect(researchModeGateState(OFF)).toBe("off");
});
it("returns 'open' when enabled and acknowledged versions align", () => {
expect(researchModeGateState(ACK_OPEN)).toBe("open");
});
it("returns 'stale' when enabled but server bumped the disclaimer version", () => {
expect(researchModeGateState(ACK_STALE)).toBe("stale");
});
it("treats acknowledgedVersion === null as stale when enabled", () => {
const status: ResearchModeStatus = {
enabled: true,
acknowledgedAt: null,
acknowledgedVersion: null,
currentDisclaimerVersion: "v3",
};
expect(researchModeGateState(status)).toBe("stale");
});
it("never returns 'open' when enabled is false even if versions happen to match", () => {
const status: ResearchModeStatus = {
enabled: false,
acknowledgedAt: "2026-05-13T00:00:00.000Z",
acknowledgedVersion: "v3",
currentDisclaimerVersion: "v3",
};
expect(researchModeGateState(status)).toBe("off");
});
});
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from "vitest";
import { assertMedicationOwnership } from "../route-guards";
function buildClient(med: { id: string; userId: string } | null) {
return {
medication: {
findUnique: vi.fn().mockResolvedValue(med),
},
};
}
describe("assertMedicationOwnership", () => {
it("returns null when the medication exists and matches the caller", async () => {
const client = buildClient({ id: "med-1", userId: "user-1" });
const result = await assertMedicationOwnership("med-1", "user-1", client);
expect(result).toBeNull();
expect(client.medication.findUnique).toHaveBeenCalledWith({
where: { id: "med-1" },
select: { id: true, userId: true },
});
});
it("returns 404 when the medication does not exist", async () => {
const client = buildClient(null);
const result = await assertMedicationOwnership("med-missing", "user-1", client);
expect(result).not.toBeNull();
expect(result?.status).toBe(404);
const body = await result!.json();
expect(body.error).toBe("Medication not found");
});
it("returns 404 when the medication belongs to a different user", async () => {
const client = buildClient({ id: "med-1", userId: "attacker" });
const result = await assertMedicationOwnership("med-1", "victim", client);
expect(result).not.toBeNull();
expect(result?.status).toBe(404);
const body = await result!.json();
// Privacy contract — we never leak "exists but wrong owner" via a
// 403; an attacker sees the same shape as a missing row.
expect(body.error).toBe("Medication not found");
});
it("never reveals user-id correlation in the 404 envelope", async () => {
const client = buildClient({ id: "med-1", userId: "someone-else" });
const result = await assertMedicationOwnership("med-1", "caller", client);
const body = (await result!.json()) as Record<string, unknown>;
const serialised = JSON.stringify(body);
expect(serialised).not.toContain("someone-else");
expect(serialised).not.toContain("caller");
});
it("propagates the underlying Prisma error rather than swallowing it", async () => {
const client = {
medication: {
findUnique: vi.fn().mockRejectedValue(new Error("DB offline")),
},
};
await expect(
assertMedicationOwnership("med-1", "user-1", client),
).rejects.toThrow("DB offline");
});
it("only selects the minimal columns required for the comparison", async () => {
const client = buildClient({ id: "med-1", userId: "user-1" });
await assertMedicationOwnership("med-1", "user-1", client);
const call = client.medication.findUnique.mock.calls[0][0];
expect(call.select).toEqual({ id: true, userId: true });
});
});
+38
View File
@@ -0,0 +1,38 @@
/**
* v1.4.25 W21 Fix-N — unified dose-string → mg parser.
*
* Replaces two near-identical helpers previously hand-rolled inside
* `<DrugLevelChart>` (`parseDoseMg`) and the titration route
* (`parseDoseString`). The DrugLevelChart variant returned `NaN` for
* unparseable strings; the titration variant returned `null`. Both
* code paths now go through this module — the legacy null-returning
* shape is preserved as `parseDoseMgOrNull` so the titration route
* keeps its "no current step" semantics.
*
* Accepts comma OR dot as decimal separator (Marc-memory: the German
* UI lets the user type "0,25 mg"). Trailing unit text after the
* number is ignored — the parser only owns the numeric extraction.
*/
/**
* Parse a dose string like "7.5 mg" → 7.5 or "0,25" → 0.25. Returns
* `NaN` when the string carries no recognisable number (e.g. "as
* needed") so callers can use `Number.isFinite` for the gate.
*/
export function parseDoseMg(input: string): number {
if (!input) return Number.NaN;
const match = input.match(/([0-9]+(?:[.,][0-9]+)?)/);
if (!match) return Number.NaN;
return Number.parseFloat(match[1].replace(",", "."));
}
/**
* Same as `parseDoseMg` but returns `null` instead of `NaN` for the
* unparseable case. The titration route reads this as "no current
* step" — null signals "we couldn't resolve a dose" rather than "the
* dose is zero".
*/
export function parseDoseMgOrNull(input: string): number | null {
const value = parseDoseMg(input);
return Number.isFinite(value) ? value : null;
}
+16
View File
@@ -580,6 +580,22 @@ export function findDrugByBrand(brand: string): Glp1DrugRecord | null {
return null;
}
/**
* Lookup helper — given an arbitrary brand string, find the drug ID
* (catalog key) that lists it. Combines `findDrugByBrand` with the
* `GLP1_DRUGS` reverse-lookup pattern previously hand-rolled in the
* DrugLevelChart and the titration route. Returns null when the brand
* is unknown.
*/
export function findDrugIdByBrand(brand: string): Glp1DrugId | null {
const record = findDrugByBrand(brand);
if (!record) return null;
for (const [id, candidate] of Object.entries(GLP1_DRUGS)) {
if (candidate === record) return id as Glp1DrugId;
}
return null;
}
/**
* The catalog's drug-ids in stable order. Useful for iteration in
* tests and UI surfaces that need a deterministic display order.
@@ -0,0 +1,45 @@
/**
* v1.4.25 W21 Fix-N — shared Research Mode status DTO.
*
* Both `<DrugLevelChart>` (medication detail page) and the Settings →
* Advanced surface (`<AdvancedSection>`) query the same
* `/api/auth/me/research-mode` endpoint to gate the drug-level chart on
* acknowledged disclaimer version. Until Fix-N both surfaces hand-rolled
* the same `ResearchModeStatus` interface; this module is now the single
* source of truth so the two readers can never disagree on shape.
*
* Contract mirrors the API response under the `data` key:
* { enabled, acknowledgedAt, acknowledgedVersion, currentDisclaimerVersion }
*
* `enabled === true && acknowledgedVersion === currentDisclaimerVersion`
* is the only gate-open shape; every other permutation (off, stale-on)
* renders the gated placeholder UI.
*/
export interface ResearchModeStatus {
/** User opt-in flag — false unless the user has acknowledged at least once. */
enabled: boolean;
/** ISO timestamp of the most recent acknowledgment, null when never acknowledged. */
acknowledgedAt: string | null;
/** Disclaimer version the user last acknowledged; null when never acknowledged. */
acknowledgedVersion: string | null;
/** Disclaimer version the server is currently serving. Drives the re-prompt branch. */
currentDisclaimerVersion: string;
}
/**
* Tri-state helper — narrow the raw status into one of the three UI
* branches: off, stale (acknowledged-but-server-bumped-version), or
* open. The DrugLevelChart and the Settings re-prompt banner both use
* this so the rules stay aligned.
*/
export type ResearchModeGateState = "off" | "stale" | "open";
export function researchModeGateState(
status: ResearchModeStatus | null | undefined,
): ResearchModeGateState {
if (!status || !status.enabled) return "off";
if (status.acknowledgedVersion !== status.currentDisclaimerVersion) {
return "stale";
}
return "open";
}
+62
View File
@@ -0,0 +1,62 @@
/**
* v1.4.25 W21 Fix-N — shared medication-route ownership guard.
*
* Eleven medication sub-routes (titration, side-effects, inventory,
* cadence, intake, compliance, phase-config, api-endpoint, glp1, plus
* the inventory `[itemId]` sub-route owned by Fix-M) all duplicated the
* same fetch-medication-then-compare-userId block, often with subtly
* different `select` clauses or response shapes. Hoisting the guard to
* one helper keeps the privacy contract honest — the 404 leak shape
* (we always return "Medication not found" so an attacker can't
* distinguish "row exists but isn't yours" from "row doesn't exist")
* lives in exactly one place.
*
* Callers compose it the same way the inline guards used to:
*
* const guard = await assertMedicationOwnership(id, user.id);
* if (guard) return guard;
*
* The helper accepts an optional Prisma client so test mocks can pass
* a stubbed `findUnique`. In production callers omit the third arg and
* pull `prisma` from the lazy `@/lib/db` import internally.
*/
import { apiError } from "@/lib/api-response";
interface MedicationOwnershipPrisma {
medication: {
findUnique: (args: {
where: { id: string };
select: { id: true; userId: true };
}) => Promise<{ id: string; userId: string } | null>;
};
}
/**
* Returns `null` when the medication exists AND belongs to the caller.
* Returns a 404 `Response` otherwise — the route handler should return
* it directly without modification.
*/
export async function assertMedicationOwnership(
medicationId: string,
userId: string,
prismaClient?: MedicationOwnershipPrisma,
): Promise<Response | null> {
const client = prismaClient ?? (await loadPrisma());
const med = await client.medication.findUnique({
where: { id: medicationId },
select: { id: true, userId: true },
});
if (!med || med.userId !== userId) {
return apiError("Medication not found", 404);
}
return null;
}
async function loadPrisma(): Promise<MedicationOwnershipPrisma> {
// Lazy import so test files that stub `@/lib/db` at the module level
// see their stub instead of the real Prisma client. Matches the
// pattern the route handlers themselves use.
const { prisma } = await import("@/lib/db");
return prisma as unknown as MedicationOwnershipPrisma;
}