mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
test(medications): cover per-slot units consumption and wizard round-trip
Integration: a medication with a morning slot at 1 unit and a noon slot at 0.5 draws the right count for each taken dose against a real Postgres. Unit: a per-slot dose + units set in the wizard survives create and round-trips on edit-hydrate.
This commit is contained in:
1 parent
3d6770ddb6
commit
33769de372
2 files changed
+211
No files matched your search
@@ -426,6 +426,61 @@ describe("buildCreateBody", () => {
|
||||
expect(hydratedDefault.unitsPerDose).toBe("1");
|
||||
});
|
||||
|
||||
it("carries a per-slot dose + units onto the body and round-trips them on edit-hydrate (#219)", () => {
|
||||
// A split plan: this schedule takes half a tablet, overriding the
|
||||
// medication-level dose + units. The flat mirror is what Step 7 writes.
|
||||
const p: WizardPayload = {
|
||||
...withCadence("daily"),
|
||||
treatmentRow: "bloodPressure",
|
||||
timesOfDay: ["12:00"],
|
||||
startsOn: new Date(Date.UTC(2026, 4, 28)),
|
||||
endsOn: null,
|
||||
scheduleDose: "0.5 tablet",
|
||||
scheduleUnitsPerDose: "0.5",
|
||||
};
|
||||
const body = buildCreateBody(p);
|
||||
// This is the assertion that goes RED when the encode path stops
|
||||
// emitting the per-slot fields on save.
|
||||
expect(body.schedules[0].dose).toBe("0.5 tablet");
|
||||
expect(body.schedules[0].unitsPerDose).toBe(0.5);
|
||||
|
||||
// A blank override omits both, so the schedule inherits the med level.
|
||||
const inherit = buildCreateBody({
|
||||
...p,
|
||||
scheduleDose: "",
|
||||
scheduleUnitsPerDose: "",
|
||||
});
|
||||
expect(inherit.schedules[0].dose).toBeUndefined();
|
||||
expect(inherit.schedules[0].unitsPerDose).toBeUndefined();
|
||||
|
||||
// Edit-hydrate: a persisted schedule carrying the split round-trips back
|
||||
// onto the draft (and the flat mirror) so an edit keeps it.
|
||||
const hydrated = hydrateWizardPayload({
|
||||
id: "m1",
|
||||
name: "Foo",
|
||||
dose: "1 tablet",
|
||||
category: "BLOOD_PRESSURE",
|
||||
notificationsEnabled: true,
|
||||
startsOn: null,
|
||||
endsOn: null,
|
||||
oneShot: false,
|
||||
schedules: [
|
||||
{
|
||||
windowStart: "12:00",
|
||||
windowEnd: "13:00",
|
||||
timesOfDay: ["12:00"],
|
||||
rrule: "FREQ=DAILY",
|
||||
dose: "0.5 tablet",
|
||||
unitsPerDose: 0.5,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(hydrated.scheduleDose).toBe("0.5 tablet");
|
||||
expect(hydrated.scheduleUnitsPerDose).toBe("0.5");
|
||||
expect(hydrated.schedules[0].dose).toBe("0.5 tablet");
|
||||
expect(hydrated.schedules[0].unitsPerDose).toBe("0.5");
|
||||
});
|
||||
|
||||
it("emits DIABETES category for the diabetes row", () => {
|
||||
const p: WizardPayload = {
|
||||
...withCadence("daily"),
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* #219 — per-schedule units-per-dose consumption.
|
||||
*
|
||||
* A medication owns two schedules at different times of day, each with its own
|
||||
* `unitsPerDose`: a whole tablet in the morning, a half at noon. The intake
|
||||
* consume hook must decrement the SLOT's unit count, not the medication-level
|
||||
* one, so a noon half-dose draws 0.5 tablets while a morning dose draws 1.
|
||||
*
|
||||
* A unit test with a mocked client cannot prove this: the hook resolves the
|
||||
* slot by binding the intake's `scheduledFor` wall-clock time (in the user's
|
||||
* zone) to a schedule, then reads that schedule's Decimal column. This drives
|
||||
* the real consume hook against a testcontainers Postgres so the Decimal, the
|
||||
* timezone match, and the inventory decrement are all real.
|
||||
*
|
||||
* Requires Docker / OrbStack; runs under `pnpm test:integration`.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { consumeForIntake } from "@/lib/medications/inventory/consumption";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
const USER_ID = "per-slot-units-user";
|
||||
|
||||
/** Sum of the units the intake event's consumption stamp recorded. */
|
||||
async function consumedUnits(
|
||||
prisma: PrismaClient,
|
||||
eventId: string,
|
||||
): Promise<number> {
|
||||
const row = await prisma.medicationIntakeEvent.findUniqueOrThrow({
|
||||
where: { id: eventId },
|
||||
select: { inventoryConsumption: true },
|
||||
});
|
||||
const stamp = row.inventoryConsumption;
|
||||
if (!Array.isArray(stamp)) return 0;
|
||||
return stamp.reduce(
|
||||
(sum, entry) =>
|
||||
sum +
|
||||
(entry && typeof (entry as { units?: unknown }).units === "number"
|
||||
? (entry as { units: number }).units
|
||||
: 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
async function takeDose(
|
||||
prisma: PrismaClient,
|
||||
medicationId: string,
|
||||
scheduledFor: Date,
|
||||
): Promise<string> {
|
||||
const event = await prisma.medicationIntakeEvent.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
medicationId,
|
||||
scheduledFor,
|
||||
takenAt: scheduledFor,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
await consumeForIntake({
|
||||
client: prisma,
|
||||
userId: USER_ID,
|
||||
medicationId,
|
||||
eventId: event.id,
|
||||
intakeAt: scheduledFor,
|
||||
});
|
||||
return event.id;
|
||||
}
|
||||
|
||||
describe("#219 per-schedule units-per-dose consumption — integration", () => {
|
||||
let medicationId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
// Zone fixed to UTC so a `scheduledFor` at 08:00Z / 12:00Z reads back as
|
||||
// the "08:00" / "12:00" wall clock the schedules name.
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: USER_ID,
|
||||
username: "per-slot-units",
|
||||
email: "per-slot-units@example.test",
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
const med = await prisma.medication.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
name: "Split tablet",
|
||||
dose: "10mg",
|
||||
deliveryForm: "ORAL",
|
||||
// Medication-level default is a WHOLE unit; the noon slot overrides it.
|
||||
unitsPerDose: 1,
|
||||
schedules: {
|
||||
create: [
|
||||
{
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
timesOfDay: ["08:00"],
|
||||
rrule: "FREQ=DAILY",
|
||||
unitsPerDose: 1,
|
||||
},
|
||||
{
|
||||
windowStart: "12:00",
|
||||
windowEnd: "13:00",
|
||||
timesOfDay: ["12:00"],
|
||||
rrule: "FREQ=DAILY",
|
||||
unitsPerDose: 0.5,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
medicationId = med.id;
|
||||
// One open container with ample stock so neither dose floors at zero.
|
||||
await prisma.medicationInventoryItem.create({
|
||||
data: {
|
||||
userId: USER_ID,
|
||||
medicationId,
|
||||
state: "IN_USE",
|
||||
containerType: "BOTTLE",
|
||||
unitsTotal: 100,
|
||||
unitsRemaining: 100,
|
||||
firstUseAt: new Date("2026-08-01T00:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("draws 1 unit for the morning slot and 0.5 for the noon slot", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
const morning = await takeDose(
|
||||
prisma,
|
||||
medicationId,
|
||||
new Date("2026-08-10T08:00:00.000Z"),
|
||||
);
|
||||
const noon = await takeDose(
|
||||
prisma,
|
||||
medicationId,
|
||||
new Date("2026-08-10T12:00:00.000Z"),
|
||||
);
|
||||
|
||||
// The morning slot (unitsPerDose 1) draws a whole unit; the noon slot
|
||||
// (unitsPerDose 0.5) draws a half. This is the assertion that goes RED
|
||||
// when the hook reads only the medication-level column.
|
||||
expect(await consumedUnits(prisma, morning)).toBe(1);
|
||||
expect(await consumedUnits(prisma, noon)).toBe(0.5);
|
||||
|
||||
const item = await prisma.medicationInventoryItem.findFirstOrThrow({
|
||||
where: { medicationId },
|
||||
select: { unitsRemaining: true },
|
||||
});
|
||||
expect(Number(item.unitsRemaining)).toBe(98.5);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user