Merge remote-tracking branch 'origin/work/parity-contracts' into release/v1.30.34

This commit is contained in:
Marc-André Bombeck committed 2026-07-19 12:46:10 +02:00
commit d68a7ca244
28 files changed
+1087 -86

No files matched your search

@@ -87,6 +87,8 @@ const ALL_KINDS = [
"totalBodyWater",
"boneMass",
"oxygenSaturation",
"mood",
"bmi",
] as const;
function card(kind: string) {
@@ -194,11 +196,28 @@ describe("GET /api/dashboard/summary — module gate", () => {
"totalBodyWater",
"boneMass",
"oxygenSaturation",
// BMI is derived from weight + the profile height and belongs to no
// module, so it rides through with the other core vitals.
"bmi",
]) {
expect(kinds).toContain(core);
}
});
it("drops the mood card when the mood module is off", async () => {
// Mood is not a MeasurementType, so it gates through the synthetic
// `MOOD_ENTRY` key rather than a measurement enum value. Without that
// join the card would be unmapped and silently ship past a disabled
// module.
setDisabledModules(["mood"]);
const kinds = await emittedKinds();
expect(kinds).not.toContain("mood");
expect(kinds).toContain("weight");
expect(kinds).toContain("bmi");
expect(kinds).toHaveLength(ALL_KINDS.length - 1);
});
it("gates the cached body, so a toggle takes effect on the next request", async () => {
// The analytics LRU is not evicted by a module toggle. The filter must
// therefore run on the way out, not inside the cached build.
@@ -68,6 +68,19 @@ vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(async () => null),
}));
// The mood card reads the canonical daily series; its own aggregation is
// covered by `src/lib/analytics/__tests__/mood-series.test.ts`. Pinned to an
// empty series by default so the legacy assertions keep their "no mood"
// expectation, and overridden per test below.
vi.mock("@/lib/analytics/mood-series", () => ({
buildMoodDailySeries: vi.fn(async () => ({
entries: [],
summary: null,
entryCount: 0,
source: "live" as const,
})),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
@@ -85,6 +98,7 @@ import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import { buildMoodDailySeries } from "@/lib/analytics/mood-series";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -1065,3 +1079,96 @@ describe("GET /api/dashboard/summary", () => {
expect(body.data.compliance.takenToday).toBe(1);
});
});
/**
* Mood + BMI cards.
*
* Both metrics existed in the record but the summary payload emitted
* neither, so the native home screen could not render those two tiles while
* every other tile worked. Both are resolved server-side from the canonical
* source — `buildMoodDailySeries` for mood, `computeBmi` for BMI — so the
* client consumes a finished figure instead of re-deriving one.
*/
describe("GET /api/dashboard/summary — mood + BMI cards", () => {
function cardsOf(body: unknown): Array<Record<string, unknown>> {
return (body as { data: { metrics: Array<Record<string, unknown>> } }).data
.metrics;
}
it("emits a mood card from the canonical daily series", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(buildMoodDailySeries).mockResolvedValue({
entries: [
{ date: "2026-07-01", score: 2, samples: 1 },
{ date: "2026-07-02", score: 3, samples: 2 },
{ date: "2026-07-03", score: 4.5, samples: 1 },
],
summary: null,
entryCount: 3,
source: "rollup",
} as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const mood = cardsOf(await res.json()).find((c) => c.kind === "mood");
expect(mood).toBeDefined();
// Headline is the newest day's score, verbatim from the series — the
// route must not re-average or re-round what the helper resolved.
expect(mood?.latestValue).toBe(4.5);
expect(mood?.sparkline).toEqual([2, 3, 4.5]);
expect(mood?.trend).toBe("up");
expect(mood?.allTimeCount).toBe(3);
expect(mood?.titleKey).toBe("dashboard.metric.title.mood");
expect(mood?.unitKey).toBe("dashboard.metric.unit.mood");
expect(mood?.lastSeenAt).toBe("2026-07-03T00:00:00.000Z");
});
it("omits the mood card when the account has never logged a mood", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
expect(cardsOf(await res.json()).find((c) => c.kind === "mood")).toBe(
undefined,
);
});
it("emits a BMI card computed from the profile height and latest weight", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// 180 cm profile.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: new Date("1986-01-01"),
gender: null,
heightCm: 180,
} as never);
// `computeBmi` reads recent weight rows through `measurement.findMany`.
// 81 kg at 1.80 m => 25.0.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 81, measuredAt: new Date("2026-07-03T08:00:00.000Z") },
] as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const bmi = cardsOf(await res.json()).find((c) => c.kind === "bmi");
expect(bmi).toBeDefined();
// The canonical helper's rounding (1 dp), not a second formula.
expect(bmi?.latestValue).toBe(25);
expect(bmi?.titleKey).toBe("dashboard.metric.title.bmi");
expect(bmi?.unitKey).toBe("dashboard.metric.unit.bmi");
});
it("omits the BMI card when the profile carries no height", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Default profile has `heightCm: null`; weight alone cannot yield a BMI.
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
{ value: 81, measuredAt: new Date("2026-07-03T08:00:00.000Z") },
] as never);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
expect(cardsOf(await res.json()).find((c) => c.kind === "bmi")).toBe(
undefined,
);
});
});
+87 -2
View File
@@ -74,6 +74,9 @@ import {
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import type { SleepStage } from "@/generated/prisma/client";
import { loadBaselineProfile } from "@/lib/insights/derived/baseline";
import { computeBmi } from "@/lib/insights/derived/bmi";
import { isDerivedOk } from "@/lib/insights/derived/types";
import { buildMoodDailySeries } from "@/lib/analytics/mood-series";
import {
reconstructNights,
sleepNeedMinutes,
@@ -96,7 +99,9 @@ type MetricKind =
| "steps"
| "totalBodyWater"
| "boneMass"
| "oxygenSaturation";
| "oxygenSaturation"
| "mood"
| "bmi";
interface MetricCard {
id: string;
@@ -196,6 +201,8 @@ const METRIC_TITLE_KEYS: Record<MetricKind, string> = {
totalBodyWater: "dashboard.metric.title.totalBodyWater",
boneMass: "dashboard.metric.title.boneMass",
oxygenSaturation: "dashboard.metric.title.oxygenSaturation",
mood: "dashboard.metric.title.mood",
bmi: "dashboard.metric.title.bmi",
};
const METRIC_UNIT_KEYS: Record<MetricKind, string> = {
@@ -209,6 +216,8 @@ const METRIC_UNIT_KEYS: Record<MetricKind, string> = {
totalBodyWater: "dashboard.metric.unit.totalBodyWater",
boneMass: "dashboard.metric.unit.boneMass",
oxygenSaturation: "dashboard.metric.unit.oxygenSaturation",
mood: "dashboard.metric.unit.mood",
bmi: "dashboard.metric.unit.bmi",
};
/**
@@ -615,11 +624,24 @@ async function buildDashboardSummary(
// before the per-night reconstruction sums it. v1.17.0 — the baseline
// profile rides along (one read) so the sleep-rhythm block resolves the
// age-based sleep need the cumulative-debt deficit uses.
const [sleepPriorityJson, sleepProfile] = await Promise.all([
const [sleepPriorityJson, sleepProfile, moodSeries] = await Promise.all([
loadUserSourcePriority(userId),
loadBaselineProfile(prisma, userId),
// The mood card reads the SAME canonical daily series the web mood
// analytics and the dashboard snapshot read (`buildMoodDailySeries`),
// rollup fast-path included — never a second aggregation.
time("moodSeries", () => buildMoodDailySeries(userId, prisma)),
]);
// BMI is derived, not measured: it needs the profile height loaded above,
// so it cannot join the round before it. `computeBmi` is the one canonical
// implementation (the same one the web vitals dashboard renders through
// the derived-metric dispatch) — the route resolves the figure and the
// client consumes it, rather than every client re-deriving weight / m².
const bmiDerived = await time("bmi", () =>
computeBmi(userId, sleepProfile, { now }),
);
// Per-type metadata lookup — typed Map so a metric with no readings
// at all falls through `metaForType` to the `{ allTimeCount: 0,
// lastSeenAt: null }` default. The aggregate row's `_count._all` is
@@ -954,6 +976,69 @@ async function buildDashboardSummary(
});
}
// Mood — the one metric on this payload that is not a `Measurement`. The
// daily series is the canonical `buildMoodDailySeries` output (rollup
// fast-path, 1..5 scale, one entry per day with an average score), the
// same series the web mood analytics and the dashboard snapshot render.
// Emitted only when the account has ever logged a mood, matching the
// `allTimeCount === 0` gate every optional card above uses.
if (moodSeries.entryCount > 0) {
const spark = moodSeries.entries.slice(-SPARK_DAYS).map((e) => e.score);
const latestEntry = moodSeries.entries[moodSeries.entries.length - 1];
// The series carries UTC day labels, not timestamps — the entry's day is
// the only instant the source actually pins, so it is emitted as the
// day's start rather than inventing a reading time.
const latestAt = latestEntry
? new Date(`${latestEntry.date}T00:00:00.000Z`).toISOString()
: null;
metrics.push({
id: "mood",
kind: "mood",
titleKey: METRIC_TITLE_KEYS.mood,
latestValue: latestEntry?.score ?? null,
secondaryValue: null,
unitKey: METRIC_UNIT_KEYS.mood,
unit: null,
sleepStages: null,
sleepSourceDiscrepancy: null,
trend: trendOf(spark),
sparkline: spark,
updatedAt: latestAt,
allTimeCount: moodSeries.entryCount,
lastSeenAt: latestAt,
});
}
// BMI — derived from the latest weight at the profile height by
// `computeBmi`, the single implementation the web vitals dashboard also
// renders. The card is omitted when the derivation is insufficient
// (no height on the profile, or no weight inside its window): the card
// shape has no "unknown" state to express honestly, and a card with a
// null value would read as a logging gap rather than a missing profile
// field. Clients that want to prompt for a height should do so from the
// profile surface, not from an empty dashboard tile.
if (isDerivedOk(bmiDerived)) {
const spark = bmiDerived.value.series.slice(-SPARK_DAYS);
metrics.push({
id: "bmi",
kind: "bmi",
titleKey: METRIC_TITLE_KEYS.bmi,
latestValue: bmiDerived.value.bmi,
secondaryValue: null,
unitKey: METRIC_UNIT_KEYS.bmi,
unit: null,
sleepStages: null,
sleepSourceDiscrepancy: null,
trend: trendOf(spark),
sparkline: spark,
// BMI moves exactly when weight does, so the weight metadata is the
// honest recency signal — there is no separate BMI reading.
updatedAt: metaForType("WEIGHT").lastSeenAt,
allTimeCount: metaForType("WEIGHT").allTimeCount,
lastSeenAt: metaForType("WEIGHT").lastSeenAt,
});
}
// Shared builder carries the exact tally semantics this tile always
// had (scheduled = every today-window row; taken = `takenAt` set and
// not skipped). The wire shape of `compliance` is locked for iOS.
@@ -517,3 +517,122 @@ describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => {
);
});
});
describe("dashboard widgets — preserve-when-absent on PUT", () => {
it("keeps the stored comparisonBaseline when the client omits it", async () => {
// The regression: a layout save from a client that doesn't know
// `comparisonBaseline` (the native client documents the field as
// web-only and never sends it) used to fall through to the serializer,
// which clamps a missing baseline to "none". The user's web-chosen
// comparison silently reset on every tile reorder from the phone.
const stored: DashboardLayout = serializeDashboardLayout({
version: 1,
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
comparisonBaseline: "lastYear",
});
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: stored,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: [
{ id: "weight", visible: false, tileVisible: true, order: 0 },
],
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { comparisonBaseline: string } };
};
expect(updateArg.data.dashboardWidgetsJson.comparisonBaseline).toBe(
"lastYear",
);
const body = (await res.json()) as {
data: { comparisonBaseline: string };
};
expect(body.data.comparisonBaseline).toBe("lastYear");
});
it("honours an explicitly sent comparisonBaseline over the stored one", async () => {
// Preserve-when-absent must not become preserve-always: the web
// CompareToggle sends the field, including an explicit "none" to clear.
const stored: DashboardLayout = serializeDashboardLayout({
version: 1,
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
comparisonBaseline: "lastYear",
});
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: stored,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await callPut(
makeReq({
version: 1,
widgets: [{ id: "weight", visible: true, tileVisible: true, order: 0 }],
comparisonBaseline: "none",
}),
);
expect(res.status).toBe(200);
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { comparisonBaseline: string } };
};
expect(updateArg.data.dashboardWidgetsJson.comparisonBaseline).toBe("none");
});
it("persists the four clinical tiles the native client pins", async () => {
// They used to hit the unknown-id filter ahead of Zod and vanish from
// the persisted layout, so the placement was lost on every save.
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dashboardWidgetsJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const clinicalIds = [
"gripStrength",
"painNRS",
"waistCircumference",
"waistToHeight",
];
const res = await callPut(
makeReq({
version: 1,
widgets: clinicalIds.map((id, i) => ({
id,
visible: true,
tileVisible: true,
order: i,
})),
}),
);
expect(res.status).toBe(200);
const dropAnnotate = vi
.mocked(annotate)
.mock.calls.find(
(c) =>
(c[0] as { action?: { name?: string } }).action?.name ===
"dashboard.widgets.unknown-id-dropped",
);
expect(dropAnnotate).toBeUndefined();
const updateArg = vi.mocked(prisma.user.update).mock
.calls[0]?.[0] as unknown as {
data: { dashboardWidgetsJson: { widgets: Array<{ id: string }> } };
};
const persistedIds = updateArg.data.dashboardWidgetsJson.widgets.map(
(w) => w.id,
);
for (const id of clinicalIds) {
expect(persistedIds).toContain(id);
}
});
});
+31 -37
View File
@@ -26,10 +26,9 @@ import {
MAX_SELECTED_SCORE_RINGS,
HERO_RING_IDS,
MAX_HERO_RING_ORDER,
type ChartOverlayPrefsMap,
layoutNeedsPreserveRead,
mergePreservedLayoutFields,
type DashboardLayout,
type ScoreRingId,
type HeroRingId,
} from "@/lib/dashboard-layout";
import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
@@ -80,7 +79,12 @@ const layoutSchema = z.object({
// 11 iOS-only = 35) fits with headroom after the v1.10 additive
// HealthKit signals became pinnable; the enum still bounds each id to
// one of the catalogue ids.
.max(40),
//
// Raised again to 50 when the four clinical signals joined the
// catalogue (42 ids). The cap only has to clear the catalogue size —
// the enum bounds each entry and the id set is closed — so it carries
// headroom rather than tracking the count exactly.
.max(50),
// v1.4.16 phase B8 — comparison baseline (Vormonat / Vorjahr) rides
// on the layout blob per research §7 Q3 (no Prisma migration). Optional
// so v1.4.15 clients that don't know the field can still PUT.
@@ -274,24 +278,27 @@ export const PUT = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
// v1.4.18 — preserve any per-chart overlay prefs that the client
// didn't send. The dashboard-layout PUT typically saves widget
// visibility / order; chart prefs are PUT through their own route
// (`/api/dashboard/chart-overlay-prefs`) and would otherwise be
// wiped here on a subsequent layout save. `selectedScoreRings` and
// `heroRingOrder` ride the same preserve-when-absent contract — an
// older client's layout save must not reset either choice. One
// stored-layout read covers all fallbacks.
let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data
.chartOverlayPrefs as ChartOverlayPrefsMap | undefined;
let mergedScoreRings: ScoreRingId[] | undefined =
parsed.data.selectedScoreRings;
let mergedHeroRingOrder: HeroRingId[] | undefined = parsed.data.heroRingOrder;
if (
mergedChartOverlayPrefs === undefined ||
mergedScoreRings === undefined ||
mergedHeroRingOrder === undefined
) {
// Preserve-when-absent: every field the client didn't send is carried
// forward from the stored layout rather than clamped to its default by
// the serializer. The dashboard-layout PUT typically saves widget
// visibility / order, while chart prefs are PUT through their own route
// (`/api/dashboard/chart-overlay-prefs`), the hero rings come from
// Settings, and `comparisonBaseline` is web-only — an omission from any
// one surface must not reset a choice made on another.
//
// The preserve set is not a list maintained here: it is derived from
// `LAYOUT_FIELD_MERGE_DISPOSITION`, which the type system forces to cover
// every field of `DashboardLayout`. Adding a layout field without a
// disposition fails typecheck, so the next field cannot silently miss this
// merge the way `comparisonBaseline` did. One stored-layout read covers
// every fallback, and the read is skipped entirely when the client sent
// the full set.
// The Zod shape leaves the per-chart `comparisonBaseline` optional while
// `ChartOverlayPrefs` requires it; `coerceChartOverlayPrefsMap` inside the
// serializer fills the gap, so the assertion is safe here exactly as it was
// on the previous per-field cast.
let toSerialize = parsed.data as Partial<DashboardLayout>;
if (layoutNeedsPreserveRead(toSerialize)) {
const existing = await prisma.user.findUnique({
where: { id: user.id },
select: { dashboardWidgetsJson: true },
@@ -299,22 +306,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const existingLayout = resolveDashboardLayout(
existing?.dashboardWidgetsJson,
);
if (mergedChartOverlayPrefs === undefined) {
mergedChartOverlayPrefs = existingLayout.chartOverlayPrefs ?? {};
}
if (mergedScoreRings === undefined) {
mergedScoreRings = existingLayout.selectedScoreRings;
}
if (mergedHeroRingOrder === undefined) {
mergedHeroRingOrder = existingLayout.heroRingOrder;
}
toSerialize = mergePreservedLayoutFields(toSerialize, existingLayout);
}
const normalized = serializeDashboardLayout({
...parsed.data,
chartOverlayPrefs: mergedChartOverlayPrefs,
selectedScoreRings: mergedScoreRings,
heroRingOrder: mergedHeroRingOrder,
} as DashboardLayout);
const normalized = serializeDashboardLayout(toSerialize as DashboardLayout);
await prisma.user.update({
where: { id: user.id },
@@ -183,3 +183,64 @@ describe("PATCH /api/medications/[id]/inventory/[itemId] — unitsRemaining stoc
expect(res.status).toBe(422);
});
});
describe("PATCH /api/medications/[id]/inventory/[itemId] — carton labelling", () => {
it("updates manufacturer + doseStrength when supplied", async () => {
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
id: "i1",
state: "ACTIVE",
} as never);
const res = await PATCH(
patchReq({
manufacturer: "Example Pharma",
doseStrength: "5 mg/0.5 ml",
}),
ROUTE_CTX,
);
expect(res.status).toBe(200);
const arg = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0]?.[0] as unknown as {
data: Record<string, unknown>;
};
expect(arg.data.manufacturer).toBe("Example Pharma");
expect(arg.data.doseStrength).toBe("5 mg/0.5 ml");
});
it("leaves both untouched when the request omits them", async () => {
// Absent must mean "I have nothing to say", never "blank it" — a client
// that does not know the fields must not wipe what another populated.
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
id: "i1",
state: "ACTIVE",
} as never);
const res = await PATCH(patchReq({ unitsRemaining: 2 }), ROUTE_CTX);
expect(res.status).toBe(200);
const arg = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0]?.[0] as unknown as {
data: Record<string, unknown>;
};
expect("manufacturer" in arg.data).toBe(false);
expect("doseStrength" in arg.data).toBe(false);
});
it("clears a value on an explicit null", async () => {
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
id: "i1",
state: "ACTIVE",
} as never);
const res = await PATCH(patchReq({ manufacturer: null }), ROUTE_CTX);
expect(res.status).toBe(200);
const arg = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0]?.[0] as unknown as {
data: Record<string, unknown>;
};
expect(arg.data.manufacturer).toBeNull();
expect("doseStrength" in arg.data).toBe(false);
});
});
@@ -79,6 +79,8 @@ export const PATCH = apiHandler(
markAsUsedUp,
printedExpiry,
unitsRemaining,
manufacturer,
doseStrength,
notes,
} = parsed.data;
@@ -151,6 +153,16 @@ export const PATCH = apiHandler(
? {}
: { notesEncrypted: encryptNote(notes), notes: null };
// Carton labelling follows the same absent-means-untouched rule as
// `notes`: an omitted field must not blank a value the other client
// populated. Built field by field, never spread from the parsed body.
const labellingData: {
manufacturer?: string | null;
doseStrength?: string | null;
} = {};
if (manufacturer !== undefined) labellingData.manufacturer = manufacturer;
if (doseStrength !== undefined) labellingData.doseStrength = doseStrength;
const updated = await prisma.medicationInventoryItem.update({
where: { id: itemId },
data: {
@@ -159,6 +171,7 @@ export const PATCH = apiHandler(
unitsRemaining: nextUnitsRemaining,
printedExpiry: nextPrintedExpiry,
expiresAt: nextExpiresAt,
...labellingData,
...notesData,
},
});
@@ -471,3 +471,62 @@ describe("POST /api/medications/[id]/inventory — 422 multi-issue (v1.4.43 W6)"
expect(body.details.issues.length).toBeGreaterThanOrEqual(3);
});
});
describe("POST /api/medications/[id]/inventory — carton labelling", () => {
it("persists manufacturer + doseStrength when the client sends them", async () => {
// The native pen list uses these two as its headline and subhead. With
// nowhere on the server to hold them, a container registered on the web
// could not be rendered there at all.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({
id: "inv-new",
state: "ACTIVE",
} as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
unitsTotal: 4,
containerType: "PEN",
manufacturer: "Example Pharma",
doseStrength: "5 mg/0.5 ml",
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({
data: expect.objectContaining({
manufacturer: "Example Pharma",
doseStrength: "5 mg/0.5 ml",
}),
});
});
it("stores null for both when the client omits them", async () => {
// A plain supply row (blister, bottle) carries no carton labelling, and
// every row that predates the columns is truthfully null.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({
id: "inv-new",
state: "ACTIVE",
} as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
unitsTotal: 30,
containerType: "BLISTER",
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({
data: expect.objectContaining({
manufacturer: null,
doseStrength: null,
}),
});
});
});
@@ -121,8 +121,15 @@ export const POST = apiHandler(
return returnAllZodIssues(parsed.error, 422);
}
const { unitsTotal, containerType, printedExpiry, purchasedAt, notes } =
parsed.data;
const {
unitsTotal,
containerType,
printedExpiry,
purchasedAt,
manufacturer,
doseStrength,
notes,
} = parsed.data;
const created = await prisma.medicationInventoryItem.create({
// The wire field `unitsTotal` counts UNITS (tablets / ampoules /
@@ -135,6 +142,8 @@ export const POST = apiHandler(
containerType: containerType ?? "OTHER",
printedExpiry: printedExpiry ?? null,
purchasedAt: purchasedAt ?? null,
manufacturer: manufacturer ?? null,
doseStrength: doseStrength ?? null,
notes: notes ?? null,
}),
});