fix(medications): derive side-effect category server-side + drift-guard test

simp-M1 — the side-effect category and entry tuples in the Zod
validators no longer restate the Prisma enum keys verbatim. Both
arrays now derive from MedicationSideEffectCategory and
MedicationSideEffectEntry via z.nativeEnum, so adding a new entry
only requires touching the Prisma schema + the taxonomy mapping.

code-M1 — drift-guard test asserts the three sources (Prisma enums,
taxonomy module, validators) cover the same keysets. Catching drift
at test-time prevents the silent 422-vs-DB-accept mismatch that bit
the previous shape.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 23:11:06 +02:00
1 parent 8399c47874
commit df719caa2f
2 files changed
+139 -31

No files matched your search

@@ -0,0 +1,115 @@
/**
* v1.4.25 W21 Fix-N (code-M1 + simp-M1) — drift guard.
*
* Three sources of truth historically described the side-effect enum:
*
* 1. The Prisma `MedicationSideEffectCategory` + `MedicationSideEffectEntry`
* enums (DB-level CHECK constraints).
* 2. The taxonomy module (`SIDE_EFFECT_CATEGORIES` map + the
* `SIDE_EFFECT_ENTRIES_BY_CATEGORY` grouping).
* 3. The Zod validators (`SIDE_EFFECT_CATEGORY_VALUES` /
* `SIDE_EFFECT_ENTRY_VALUES`).
*
* Adding a new entry historically required touching all three; any
* single omission silently broke production (e.g. a Prisma migration
* adds a new value, but the Zod validator's tuple still rejects it on
* the wire → 422 surface that maps to a value the DB will gladly
* accept). Now the validators DERIVE from the Prisma enum, and this
* test asserts the three keysets stay in lockstep.
*
* If any of these assertions fail: a new entry was added in Prisma
* but the taxonomy map / category grouping wasn't updated — fix those
* before merging.
*/
import { describe, expect, it } from "vitest";
import {
MedicationSideEffectCategory,
MedicationSideEffectEntry,
} from "@/generated/prisma/client";
import {
SIDE_EFFECT_CATEGORY_VALUES,
SIDE_EFFECT_ENTRY_VALUES,
} from "../validators";
import {
SIDE_EFFECT_CATEGORIES,
SIDE_EFFECT_CATEGORY_ORDER,
SIDE_EFFECT_ENTRIES_BY_CATEGORY,
SIDE_EFFECT_ENTRY_COUNT,
} from "../taxonomy";
function sortedKeys<T extends string>(keys: readonly T[]): T[] {
return [...keys].sort();
}
describe("side-effect taxonomy drift guard", () => {
describe("category keysets", () => {
it("validator categories equal Prisma enum categories", () => {
const prisma = Object.values(MedicationSideEffectCategory);
expect(sortedKeys(SIDE_EFFECT_CATEGORY_VALUES)).toEqual(
sortedKeys(prisma),
);
});
it("taxonomy category order equals Prisma enum categories", () => {
const prisma = Object.values(MedicationSideEffectCategory);
expect(sortedKeys(SIDE_EFFECT_CATEGORY_ORDER)).toEqual(
sortedKeys(prisma),
);
});
it("entries-by-category covers every Prisma category", () => {
const prisma = Object.values(MedicationSideEffectCategory);
const grouping = Object.keys(SIDE_EFFECT_ENTRIES_BY_CATEGORY);
expect(sortedKeys(grouping)).toEqual(sortedKeys(prisma));
});
});
describe("entry keysets", () => {
it("validator entries equal Prisma enum entries", () => {
const prisma = Object.values(MedicationSideEffectEntry);
expect(sortedKeys(SIDE_EFFECT_ENTRY_VALUES)).toEqual(
sortedKeys(prisma),
);
});
it("taxonomy SIDE_EFFECT_CATEGORIES covers every Prisma entry", () => {
const prisma = Object.values(MedicationSideEffectEntry);
const taxonomy = Object.keys(SIDE_EFFECT_CATEGORIES);
expect(sortedKeys(taxonomy)).toEqual(sortedKeys(prisma));
});
it("SIDE_EFFECT_ENTRIES_BY_CATEGORY covers every Prisma entry exactly once", () => {
const fromGrouping = Object.values(SIDE_EFFECT_ENTRIES_BY_CATEGORY)
.flat()
.sort();
const prisma = sortedKeys(Object.values(MedicationSideEffectEntry));
expect(fromGrouping).toEqual(prisma);
});
it("SIDE_EFFECT_ENTRY_COUNT matches the live Prisma enum size", () => {
expect(SIDE_EFFECT_ENTRY_COUNT).toBe(
Object.values(MedicationSideEffectEntry).length,
);
});
});
describe("category-for-entry agreement", () => {
it("every entry in SIDE_EFFECT_CATEGORIES maps to a known category", () => {
for (const category of Object.values(SIDE_EFFECT_CATEGORIES)) {
expect(Object.values(MedicationSideEffectCategory)).toContain(category);
}
});
it("grouping-side and map-side agree on every (entry, category) pair", () => {
for (const [category, entries] of Object.entries(
SIDE_EFFECT_ENTRIES_BY_CATEGORY,
)) {
for (const entry of entries) {
expect(SIDE_EFFECT_CATEGORIES[entry]).toBe(category);
}
}
});
});
});
+24 -31
View File
@@ -15,37 +15,30 @@
import { z } from "zod/v4";
export const SIDE_EFFECT_CATEGORY_VALUES = [
"GI",
"METABOLIC",
"INJECTION_SITE",
"COGNITIVE",
"GLP1_SPECIFIC",
] as const;
import {
MedicationSideEffectCategory,
MedicationSideEffectEntry,
} from "@/generated/prisma/client";
export const SIDE_EFFECT_ENTRY_VALUES = [
"NAUSEA",
"VOMITING",
"DIARRHEA",
"CONSTIPATION",
"ABDOMINAL_PAIN",
"HYPOGLYCEMIA_SYMPTOMS",
"DEHYDRATION",
"ANOREXIA",
"ELECTROLYTE_FATIGUE",
"INJECTION_REDNESS",
"INJECTION_SWELLING",
"INJECTION_BRUISING",
"INJECTION_INDURATION",
"BRAIN_FOG",
"DIZZINESS",
"LOW_MOOD",
"LOW_ENERGY",
"EARLY_SATIETY",
"GASTROPARESIS_LIKE",
"DYSGEUSIA",
"GALLBLADDER_DISCOMFORT",
] as const;
/**
* v1.4.25 W21 Fix-N (simp-M1) — these arrays used to restate the
* Prisma enum keys verbatim, which meant adding a new entry required
* three separate edits (schema, taxonomy map, validator array) that
* could silently drift. The arrays are now derived from the Prisma
* enum constant at module load. The drift-guard test
* (`__tests__/drift-guard.test.ts`) asserts the three sources (Prisma
* enum, taxonomy map, validator arrays) cover the same keys.
*
* Zod accepts the `nativeEnum` shape from the Prisma client const so
* the schema stays expressed in the same TypeScript-narrowable form.
*/
export const SIDE_EFFECT_CATEGORY_VALUES = Object.values(
MedicationSideEffectCategory,
) as readonly MedicationSideEffectCategory[];
export const SIDE_EFFECT_ENTRY_VALUES = Object.values(
MedicationSideEffectEntry,
) as readonly MedicationSideEffectEntry[];
export const SIDE_EFFECT_NOTES_MAX = 280;
@@ -59,7 +52,7 @@ export const SIDE_EFFECT_NOTES_MAX = 280;
* envelope.
*/
export const createSideEffectSchema = z.object({
entry: z.enum(SIDE_EFFECT_ENTRY_VALUES),
entry: z.nativeEnum(MedicationSideEffectEntry),
severity: z.number().int().min(1).max(5),
occurredAt: z.iso
.datetime({ offset: true })