fix: align stock and refill semantics

Squash merge PR #474
This commit is contained in:
Daniel Volz
2026-03-25 06:49:34 +01:00
committed by GitHub
parent 37fc2b8e66
commit 7059c25f1c
18 changed files with 1063 additions and 463 deletions
+8 -4
View File
@@ -464,7 +464,7 @@ const stockAdjustmentBodySchema = {
looseTablets: { type: "integer", minimum: 0 },
totalPills: { type: "integer", minimum: 0 },
packageAmountValue: { type: "integer", minimum: 0 },
packCount: { type: "integer", minimum: 1 },
packCount: { type: "integer", minimum: 0 },
},
example: {
stockAdjustment: -2,
@@ -1238,8 +1238,8 @@ export async function medicationRoutes(app: FastifyInstance) {
) {
return reply.badRequest("packageAmountValue must be a non-negative integer");
}
if (packCount !== undefined && (typeof packCount !== "number" || !Number.isInteger(packCount) || packCount < 1)) {
return reply.badRequest("packCount must be an integer >= 1");
if (packCount !== undefined && (typeof packCount !== "number" || !Number.isInteger(packCount) || packCount < 0)) {
return reply.badRequest("packCount must be a non-negative integer");
}
const updateFields: {
@@ -1258,12 +1258,16 @@ export async function medicationRoutes(app: FastifyInstance) {
const packageType = normalizePackageType(existing.packageType);
const allowsAmountBaseUpdate = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType);
const allowsBottleCapacityUpdate = packageType === "bottle";
if (allowsAmountBaseUpdate) {
if (totalPills !== undefined) updateFields.totalPills = totalPills;
if (looseTablets !== undefined) updateFields.looseTablets = looseTablets;
if (packageAmountValue !== undefined) updateFields.packageAmountValue = packageAmountValue;
if (packCount !== undefined) updateFields.packCount = packCount;
}
if (allowsBottleCapacityUpdate && totalPills !== undefined) {
updateFields.totalPills = totalPills;
}
if (packCount !== undefined) updateFields.packCount = packCount;
if (looseTablets !== undefined) {
updateFields.looseTablets = looseTablets;
}
+4 -1
View File
@@ -197,18 +197,21 @@ export async function refillRoutes(app: FastifyInstance) {
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
: (med.prescriptionRemainingRefills ?? null);
const refillBaselineAt = new Date();
const updatePayload: {
packCount: number;
looseTablets: number;
totalPills?: number;
packageAmountValue?: number;
prescriptionRemainingRefills: number | null;
lastStockCorrectionAt: Date;
updatedAt: Date;
} = {
packCount: newPackCount,
looseTablets: newLooseTablets,
prescriptionRemainingRefills: newRemainingRefills,
updatedAt: new Date(),
lastStockCorrectionAt: refillBaselineAt,
updatedAt: refillBaselineAt,
};
if (isCountBasedAmountPackage) {
+461 -7
View File
@@ -1867,6 +1867,133 @@ describe("E2E Tests with Real Routes", () => {
expect(data.newStock.looseTablets).toBe(15); // 5 + 10
});
it("should reset automatic stock baseline on refill so pre-refill dose history no longer reduces current stock", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Automatic Refill Baseline",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 14,
looseTablets: 0,
blisters: [{ usage: 1, every: 1, start: "2024-01-01T08:00:00.000Z" }],
},
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
const preRefillDoseDateOnlyMs = new Date("2025-01-05T00:00:00.000Z").getTime();
const preRefillTakenAtMs = new Date("2025-01-05T10:00:00.000Z").getTime();
await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
VALUES (?, ?, ?, 0)`,
args: [userId, `${medId}-0-${preRefillDoseDateOnlyMs}`, preRefillTakenAtMs],
});
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0 },
});
expect(refillResponse.statusCode).toBe(200);
expect(refillResponse.json().newStock.packCount).toBe(2);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const nextWeek = new Date();
nextWeek.setDate(nextWeek.getDate() + 7);
const usageResponse = await app.inject({
method: "POST",
url: "/medications/usage",
payload: {
startDate: tomorrow.toISOString(),
endDate: nextWeek.toISOString(),
},
});
expect(usageResponse.statusCode).toBe(200);
const med = usageResponse.json().find((item: Record<string, unknown>) => item.medicationId === medId);
expect(med).toBeDefined();
expect(med.totalPills).toBe(28);
expect(med.currentPills).toBe(28);
});
it("should reset manual stock baseline on refill for liquid_container packages before later dose tracking", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
args: [userId],
});
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Manual Liquid Refill Baseline",
medicationForm: "liquid",
packageType: "liquid_container",
doseUnit: "ml",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 1,
packageAmountValue: 5,
packageAmountUnit: "ml",
totalPills: 5,
looseTablets: 5,
blisters: [{ usage: 5, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
const preRefillDoseDateOnlyMs = new Date("2025-01-05T00:00:00.000Z").getTime();
const preRefillTakenAtMs = new Date("2025-01-05T10:00:00.000Z").getTime();
await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
VALUES (?, ?, ?, 0)`,
args: [userId, `${medId}-0-${preRefillDoseDateOnlyMs}`, preRefillTakenAtMs],
});
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0 },
});
expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json();
expect(refillData.refill.loosePillsAdded).toBe(5);
expect(refillData.newStock.totalPills).toBe(10);
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
expect(medsResponse.statusCode).toBe(200);
const med = medsResponse.json().find((item: Record<string, unknown>) => item.id === medId);
expect(med).toBeTruthy();
expect(med.lastStockCorrectionAt).toBeTruthy();
expect(med.totalPills).toBe(10);
expect(med.looseTablets).toBe(10);
const firstPostRefillDoseId = `${medId}-0-${new Date("2026-01-06T00:00:00.000Z").getTime()}`;
const firstDoseResponse = await app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId: firstPostRefillDoseId },
});
expect(firstDoseResponse.statusCode).toBe(200);
expect(firstDoseResponse.json()).toEqual({ success: true });
const secondPostRefillDoseId = `${medId}-0-${new Date("2026-01-07T00:00:00.000Z").getTime()}`;
const secondDoseResponse = await app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId: secondPostRefillDoseId },
});
expect(secondDoseResponse.statusCode).toBe(200);
expect(secondDoseResponse.json()).toEqual({ success: true });
});
it("should decrement remaining refills and mark history when using prescription refill", async () => {
const createResponse = await app.inject({
method: "POST",
@@ -2134,6 +2261,187 @@ describe("E2E Tests with Real Routes", () => {
expect(data.updatedAt).toBeTruthy();
});
it("should accept packCount set to 0 in stock adjustment patch", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Pack Count Zero Patch Med",
packageType: "blister",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 4,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
const response = await app.inject({
method: "PATCH",
url: `/medications/${medId}/stock-adjustment`,
payload: { stockAdjustment: 0, packCount: 0, looseTablets: 0 },
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.stockAdjustment).toBe(0);
const getResponse = await app.inject({ method: "GET", url: "/medications" });
expect(getResponse.statusCode).toBe(200);
const med = getResponse.json().find((item: Record<string, unknown>) => item.id === medId);
expect(med).toBeTruthy();
expect(med.packCount).toBe(0);
expect(med.looseTablets).toBe(0);
expect(med.stockAdjustment).toBe(0);
});
it("should persist blister zero reset with packCount 0", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Blister Zero Reset Med",
packageType: "blister",
packCount: 2,
blistersPerPack: 3,
pillsPerBlister: 10,
looseTablets: 5,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
const response = await app.inject({
method: "PATCH",
url: `/medications/${medId}/stock-adjustment`,
payload: { stockAdjustment: 0, packCount: 0, looseTablets: 0 },
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.stockAdjustment).toBe(0);
const getResponse = await app.inject({ method: "GET", url: "/medications" });
expect(getResponse.statusCode).toBe(200);
const med = getResponse.json().find((item: Record<string, unknown>) => item.id === medId);
expect(med).toBeTruthy();
expect(med.packCount).toBe(0);
expect(med.looseTablets).toBe(0);
expect(med.stockAdjustment).toBe(0);
});
it("should persist bottle zero reset with packCount 0 and zero totals", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Bottle Zero Reset Med",
packageType: "bottle",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 100,
looseTablets: 20,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
const response = await app.inject({
method: "PATCH",
url: `/medications/${medId}/stock-adjustment`,
payload: { stockAdjustment: 0, packCount: 0, looseTablets: 0, totalPills: 0 },
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.stockAdjustment).toBe(0);
const getResponse = await app.inject({ method: "GET", url: "/medications" });
expect(getResponse.statusCode).toBe(200);
const med = getResponse.json().find((item: Record<string, unknown>) => item.id === medId);
expect(med).toBeTruthy();
expect(med.packCount).toBe(0);
expect(med.looseTablets).toBe(0);
expect(med.totalPills).toBe(0);
expect(med.stockAdjustment).toBe(0);
});
it.each([
{
label: "liquid container",
payload: {
name: "Liquid Zero Reset Med",
medicationForm: "liquid",
packageType: "liquid_container",
doseUnit: "ml",
packCount: 1,
packageAmountValue: 180,
packageAmountUnit: "ml",
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 180,
looseTablets: 180,
blisters: [{ usage: 5, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
},
{
label: "tube",
payload: {
name: "Tube Zero Reset Med",
medicationForm: "topical",
packageType: "tube",
doseUnit: "units",
packCount: 2,
packageAmountValue: 40,
packageAmountUnit: "g",
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 80,
looseTablets: 80,
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
},
])("should persist $label zero reset with zeroed amount-base fields", async ({ payload }) => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload,
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
const response = await app.inject({
method: "PATCH",
url: `/medications/${medId}/stock-adjustment`,
payload: {
stockAdjustment: 0,
packCount: 0,
looseTablets: 0,
totalPills: 0,
packageAmountValue: 0,
},
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.stockAdjustment).toBe(0);
const getResponse = await app.inject({ method: "GET", url: "/medications" });
expect(getResponse.statusCode).toBe(200);
const med = getResponse.json().find((item: Record<string, unknown>) => item.id === medId);
expect(med).toBeTruthy();
expect(med.packCount).toBe(0);
expect(med.looseTablets).toBe(0);
expect(med.totalPills).toBe(0);
expect(med.packageAmountValue).toBe(0);
expect(med.stockAdjustment).toBe(0);
});
it("should persist stockAdjustment in GET /medications", async () => {
const createResponse = await app.inject({
method: "POST",
@@ -2853,26 +3161,83 @@ describe("E2E Tests with Real Routes", () => {
expect(data.medications[0].totalPills).toBe(65);
});
it("should calculate correct refill totalPillsAdded for bottle type", async () => {
it("should refill bottle stock from loose tablets without mutating explicit capacity", async () => {
const bottleWithExplicitCapacity = {
...bottleMedication,
totalPills: 100,
looseTablets: 20,
};
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: bottleMedication,
payload: bottleWithExplicitCapacity,
});
const medId = createResponse.json().id;
// Refill bottle: only loosePillsAdded matters, packs should add 0 pills
// Refill bottle: only loosePillsAdded should affect current stock.
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 0, loosePillsAdded: 30 },
payload: { packsAdded: 0, loosePillsAdded: 50 },
});
expect(refillResponse.statusCode).toBe(200);
const data = refillResponse.json();
expect(data.refill.totalPillsAdded).toBe(30);
// newStock.totalPills should be looseTablets only (no blister math)
expect(data.newStock.totalPills).toBe(150); // 120 + 30
expect(data.refill.totalPillsAdded).toBe(50);
// Bottle current stock must be based on looseTablets, not configured capacity.
expect(data.newStock.totalPills).toBe(70);
expect(data.newStock.looseTablets).toBe(70);
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
expect(medsResponse.statusCode).toBe(200);
const med = medsResponse.json().find((item: Record<string, unknown>) => item.id === medId);
expect(med).toBeTruthy();
expect(med.packCount).toBe(0);
expect(med.looseTablets).toBe(70);
// Persisted bottle capacity must remain unchanged on later GET /medications.
expect(med.totalPills).toBe(100);
});
it("should use one prescription refill for bottle package refills and ignore pack count", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
...bottleMedication,
prescriptionEnabled: true,
prescriptionAuthorizedRefills: 3,
prescriptionRemainingRefills: 2,
prescriptionLowRefillThreshold: 1,
},
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 3, loosePillsAdded: 30, usePrescription: true },
});
expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json();
expect(refillData.refill.packsAdded).toBe(0);
expect(refillData.refill.loosePillsAdded).toBe(30);
expect(refillData.prescription.used).toBe(true);
expect(refillData.prescription.remainingRefills).toBe(1);
expect(refillData.newStock.packCount).toBe(0);
expect(refillData.newStock.looseTablets).toBe(150);
const historyResponse = await app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(historyResponse.statusCode).toBe(200);
expect(historyResponse.json()[0]).toMatchObject({
packsAdded: 0,
loosePillsAdded: 30,
usedPrescription: true,
});
});
it("should calculate correct refill totalPillsAdded for blister type", async () => {
@@ -2893,6 +3258,16 @@ describe("E2E Tests with Real Routes", () => {
expect(refillResponse.statusCode).toBe(200);
const data = refillResponse.json();
expect(data.refill.totalPillsAdded).toBe(35); // 1*30 + 5
expect(data.newStock.packCount).toBe(3);
expect(data.newStock.looseTablets).toBe(10);
expect(data.newStock.totalPills).toBe(100);
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
expect(medsResponse.statusCode).toBe(200);
const med = medsResponse.json().find((item: Record<string, unknown>) => item.id === medId);
expect(med).toBeTruthy();
expect(med.packCount).toBe(3);
expect(med.looseTablets).toBe(10);
});
it("should keep liquid_container refill additive and preserve amount baseline", async () => {
@@ -2931,6 +3306,85 @@ describe("E2E Tests with Real Routes", () => {
expect(med.looseTablets).toBe(360);
});
it.each([
{
name: "liquid_container",
payload: {
...liquidContainerMedication,
packCount: 1,
packageAmountValue: 180,
packageAmountUnit: "ml",
totalPills: 180,
looseTablets: 180,
prescriptionEnabled: true,
prescriptionAuthorizedRefills: 3,
prescriptionRemainingRefills: 2,
prescriptionLowRefillThreshold: 1,
},
refillPayload: { packsAdded: 0, loosePillsAdded: 180, usePrescription: true },
expectedPacksAdded: 1,
expectedLooseAdded: 180,
expectedRemainingRefills: 1,
expectedTotalPills: 360,
},
{
name: "tube",
payload: {
...tubeMedication,
prescriptionEnabled: true,
prescriptionAuthorizedRefills: 4,
prescriptionRemainingRefills: 3,
prescriptionLowRefillThreshold: 1,
},
refillPayload: { packsAdded: 0, loosePillsAdded: 80, usePrescription: true },
expectedPacksAdded: 2,
expectedLooseAdded: 80,
expectedRemainingRefills: 1,
expectedTotalPills: 160,
},
])("should derive amount-based refill counts and decrement prescription remaining refills for $name", async ({
payload,
refillPayload,
expectedPacksAdded,
expectedLooseAdded,
expectedRemainingRefills,
expectedTotalPills,
}) => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload,
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: refillPayload,
});
expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json();
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
expect(refillData.refill.loosePillsAdded).toBe(expectedLooseAdded);
expect(refillData.refill.totalPillsAdded).toBe(expectedLooseAdded);
expect(refillData.prescription.used).toBe(true);
expect(refillData.prescription.remainingRefills).toBe(expectedRemainingRefills);
expect(refillData.newStock.totalPills).toBe(expectedTotalPills);
const historyResponse = await app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(historyResponse.statusCode).toBe(200);
expect(historyResponse.json()[0]).toMatchObject({
packsAdded: expectedPacksAdded,
loosePillsAdded: expectedLooseAdded,
usedPrescription: true,
});
});
it("should keep tube refill additive and preserve amount baseline", async () => {
const createResponse = await app.inject({
method: "POST",
-396
View File
@@ -1,396 +0,0 @@
/**
* Tests for /medications/:id/refill and /medications/:id/refills API endpoints.
* Tests adding refills to medication stock and retrieving refill history.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import {
buildTestApp,
clearTestData,
closeTestApp,
createTestMedication,
createTestUser,
type TestContext,
} from "./setup.js";
// Store userId at module level so routes can access it
let currentUserId = 1;
// =============================================================================
// Route Registration
// =============================================================================
async function registerRefillRoutes(ctx: TestContext) {
const { app, client } = ctx;
// POST /medications/:id/refill - Add stock and record history
app.post<{ Params: { id: string }; Body: { packsAdded?: number; loosePillsAdded?: number } }>(
"/medications/:id/refill",
async (request, reply) => {
const userId = currentUserId;
const medId = parseInt(request.params.id, 10);
const { packsAdded = 0, loosePillsAdded = 0 } = request.body || {};
// Validate input
if (packsAdded < 0 || loosePillsAdded < 0) {
return reply.status(400).send({ error: "packsAdded and loosePillsAdded must be non-negative" });
}
if (packsAdded === 0 && loosePillsAdded === 0) {
return reply
.status(400)
.send({ error: "At least one of packsAdded or loosePillsAdded must be greater than 0" });
}
// Check medication exists and belongs to user
const medResult = await client.execute({
sql: `SELECT id, pack_count, loose_tablets, blisters_per_pack, pills_per_blister
FROM medications WHERE id = ? AND user_id = ?`,
args: [medId, userId],
});
if (medResult.rows.length === 0) {
return reply.status(404).send({ error: "Medication not found" });
}
const med = medResult.rows[0];
const newPackCount = (med.pack_count as number) + packsAdded;
const newLooseTablets = (med.loose_tablets as number) + loosePillsAdded;
const pillsPerPack = (med.blisters_per_pack as number) * (med.pills_per_blister as number);
const totalPillsAdded = packsAdded * pillsPerPack + loosePillsAdded;
// Update medication stock
await client.execute({
sql: `UPDATE medications SET pack_count = ?, loose_tablets = ? WHERE id = ?`,
args: [newPackCount, newLooseTablets, medId],
});
// Record refill history
await client.execute({
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added)
VALUES (?, ?, ?, ?)`,
args: [medId, userId, packsAdded, loosePillsAdded],
});
return {
success: true,
pillsAdded: totalPillsAdded,
newPackCount,
newLooseTablets,
};
}
);
// GET /medications/:id/refills - Get refill history
app.get<{ Params: { id: string } }>("/medications/:id/refills", async (request, reply) => {
const userId = currentUserId;
const medId = parseInt(request.params.id, 10);
// Check medication exists and belongs to user
const medResult = await client.execute({
sql: `SELECT id FROM medications WHERE id = ? AND user_id = ?`,
args: [medId, userId],
});
if (medResult.rows.length === 0) {
return reply.status(404).send({ error: "Medication not found" });
}
// Get refill history, newest first
const refillResult = await client.execute({
sql: `SELECT id, packs_added, loose_pills_added, refill_date
FROM refill_history
WHERE medication_id = ? AND user_id = ?
ORDER BY refill_date DESC`,
args: [medId, userId],
});
return {
refills: refillResult.rows.map((r) => ({
id: r.id,
packsAdded: r.packs_added,
loosePillsAdded: r.loose_pills_added,
refillDate: r.refill_date,
})),
};
});
}
// =============================================================================
// Tests
// =============================================================================
describe("Refill API", () => {
let ctx: TestContext;
let userId: number;
let medId: number;
beforeAll(async () => {
ctx = await buildTestApp();
await registerRefillRoutes(ctx);
await ctx.app.ready();
});
afterAll(async () => {
await closeTestApp(ctx);
});
beforeEach(async () => {
await clearTestData(ctx.client);
// Create test user
userId = await createTestUser(ctx.client, { username: "testuser" });
// Update the module-level userId so routes use the correct one
currentUserId = userId;
// Create a test medication with 1 pack (10 blisters × 10 pills = 100 pills/pack)
medId = await createTestMedication(ctx.client, {
userId,
name: "Test Med",
packCount: 1,
blistersPerPack: 10,
pillsPerBlister: 10,
looseTablets: 5,
});
});
// ---------------------------------------------------------------------------
// POST /medications/:id/refill
// ---------------------------------------------------------------------------
describe("POST /medications/:id/refill", () => {
it("should add packs to medication stock", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 2 },
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.success).toBe(true);
expect(data.pillsAdded).toBe(200); // 2 packs × 100 pills
expect(data.newPackCount).toBe(3); // 1 + 2
// Verify in database
const result = await ctx.client.execute({
sql: `SELECT pack_count FROM medications WHERE id = ?`,
args: [medId],
});
expect(result.rows[0].pack_count).toBe(3);
});
it("should add loose pills to medication stock", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { loosePillsAdded: 15 },
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.success).toBe(true);
expect(data.pillsAdded).toBe(15);
expect(data.newLooseTablets).toBe(20); // 5 + 15
// Verify in database
const result = await ctx.client.execute({
sql: `SELECT loose_tablets FROM medications WHERE id = ?`,
args: [medId],
});
expect(result.rows[0].loose_tablets).toBe(20);
});
it("should add both packs and loose pills", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 10 },
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.success).toBe(true);
expect(data.pillsAdded).toBe(110); // 1 pack (100) + 10 loose
expect(data.newPackCount).toBe(2);
expect(data.newLooseTablets).toBe(15);
});
it("should record refill in history", async () => {
await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 2, loosePillsAdded: 5 },
});
// Check history
const result = await ctx.client.execute({
sql: `SELECT packs_added, loose_pills_added FROM refill_history WHERE medication_id = ?`,
args: [medId],
});
expect(result.rows.length).toBe(1);
expect(result.rows[0].packs_added).toBe(2);
expect(result.rows[0].loose_pills_added).toBe(5);
});
it("should reject refill with zero amounts", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 0, loosePillsAdded: 0 },
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toContain("At least one");
});
it("should reject refill with negative amounts", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: -1 },
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toContain("non-negative");
});
it("should return 404 for non-existent medication", async () => {
const response = await ctx.app.inject({
method: "POST",
url: `/medications/99999/refill`,
payload: { packsAdded: 1 },
});
expect(response.statusCode).toBe(404);
expect(response.json().error).toBe("Medication not found");
});
});
// ---------------------------------------------------------------------------
// GET /medications/:id/refills
// ---------------------------------------------------------------------------
describe("GET /medications/:id/refills", () => {
it("should return empty array when no refills", async () => {
const response = await ctx.app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ refills: [] });
});
it("should return refill history newest first", async () => {
// Add two refills with different values so we can identify them
await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0 },
});
// Increase delay to ensure different timestamps (SQLite datetime has second precision)
await new Promise((r) => setTimeout(r, 1100));
await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 0, loosePillsAdded: 20 },
});
const response = await ctx.app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.refills).toHaveLength(2);
// Newest first (loose pills - added second)
expect(data.refills[0].packsAdded).toBe(0);
expect(data.refills[0].loosePillsAdded).toBe(20);
// Older (packs - added first)
expect(data.refills[1].packsAdded).toBe(1);
expect(data.refills[1].loosePillsAdded).toBe(0);
// Each entry should have an id and refillDate
for (const refill of data.refills) {
expect(refill.id).toBeTypeOf("number");
expect(refill.refillDate).toBeTruthy();
}
});
it("should return 404 for non-existent medication", async () => {
const response = await ctx.app.inject({
method: "GET",
url: `/medications/99999/refills`,
});
expect(response.statusCode).toBe(404);
expect(response.json().error).toBe("Medication not found");
});
});
// ---------------------------------------------------------------------------
// Cascade Delete Tests
// ---------------------------------------------------------------------------
describe("Cascade Delete", () => {
it("should delete refill history when medication is deleted", async () => {
// Add a refill
await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1 },
});
// Verify refill exists
let result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM refill_history WHERE medication_id = ?`,
args: [medId],
});
expect(result.rows[0].count).toBe(1);
// Delete medication
await ctx.client.execute({
sql: `DELETE FROM medications WHERE id = ?`,
args: [medId],
});
// Verify refill history was cascade deleted
result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM refill_history WHERE medication_id = ?`,
args: [medId],
});
expect(result.rows[0].count).toBe(0);
});
it("should delete refill history when user is deleted", async () => {
// Add a refill
await ctx.app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1 },
});
// Verify refill exists
let result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM refill_history WHERE user_id = ?`,
args: [userId],
});
expect(result.rows[0].count).toBe(1);
// Delete user
await ctx.client.execute({
sql: `DELETE FROM users WHERE id = ?`,
args: [userId],
});
// Verify refill history was cascade deleted
result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM refill_history WHERE user_id = ?`,
args: [userId],
});
expect(result.rows[0].count).toBe(0);
});
});
});
+12
View File
@@ -0,0 +1,12 @@
# Agent Memory Notes
Purpose: persistent agent work memory to survive context loss.
## Entries
### 2026-03-25
- Task: Split the stock/refill semantics changes into a standalone release branch and repair split-induced frontend test corruption until focused local validation passed.
- Decisions: Kept this branch limited to stock/refill semantics, repaired the shared MedicationsPage/UI tests against clean main structure, and kept root main clean by moving releasable scope into this worktree.
- Files touched: backend/src/routes/medications.ts, backend/src/routes/refills.ts, backend/src/test/e2e-routes.test.ts, frontend/src/components/MedDetailModal.tsx, frontend/src/components/ReportModal.tsx, frontend/src/components/UserFilterModal.tsx, frontend/src/hooks/useRefill.ts, frontend/src/pages/MedicationsPage.tsx, frontend/src/test/components/MedDetailModal.test.tsx, frontend/src/test/components/ReportModal.test.tsx, frontend/src/test/components/UserFilterModal.test.tsx, frontend/src/test/hooks/useRefill.test.ts, frontend/src/test/pages/MedicationsPage.test.tsx, frontend/src/test/types.test.ts, frontend/src/types/index.ts.
- Follow-up: Create a dedicated bug issue, push the branch, open a PR, and wait for GitHub CI before merge.
+16
View File
@@ -0,0 +1,16 @@
# Work Report
## Entries
### 2026-03-25
- Scope: Isolate and validate the stock/refill semantics fix as its own PR-ready branch.
- What changed:
- Consolidated the stock/refill behavior changes into a dedicated branch scope covering backend refill routes, stock display typing, and the affected medication detail/report/filter UI paths.
- Repaired split-induced corruption in the shared MedicationsPage page and its focused test coverage so the branch is parse-clean and locally testable again.
- Removed the obsolete backend refill-specific test file and kept the surviving backend coverage in the targeted e2e route suite.
- Validation:
- Backend changed-file Biome: passed.
- Frontend changed-file Biome: passed.
- Backend Vitest `backend/src/test/e2e-routes.test.ts`: passed (`124` tests, `0` failures).
- Frontend Vitest targeted stock/refill files: passed (`159` tests, `0` failures).
- Result: This branch is locally green and ready for upstream PR creation.
+4 -2
View File
@@ -20,6 +20,7 @@ import {
getMedDisplayName,
getMedTotal,
getPackageSize,
getStockDisplayCapacity,
type IntakeUnit,
isAmountBasedPackageType,
isLiquidContainerPackageType,
@@ -213,9 +214,10 @@ export function MedDetailModal({
const medCoverage = coverage.all.find((c) => c.name === getMedDisplayName(selectedMed));
const packageSize = getPackageSize(selectedMed);
const stockDisplayCapacity = getStockDisplayCapacity(selectedMed);
// Structural max = sealed package capacity only (excludes pre-existing looseTablets).
const structuralMax = isAmountBasedPackageType(selectedMed.packageType)
? (selectedMed.totalPills ?? packageSize)
? stockDisplayCapacity
: selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : getMedTotal(selectedMed);
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
@@ -226,7 +228,7 @@ export function MedDetailModal({
const currentPartialPills = Math.max(0, stock.openBlisterPills);
const currentLoosePills = Math.max(0, stock.loosePills);
const stockDisplayTotal = isAmountBasedPackageType(selectedMed.packageType)
? (selectedMed.totalPills ?? packageSize)
? stockDisplayCapacity
: Math.max(0, structuralMax);
const packageCount = Math.max(1, Number(selectedMed.packCount) || 1);
const amountPerPackage = (() => {
+3 -3
View File
@@ -5,7 +5,7 @@ import { useScrollLock } from "../hooks/useScrollLock";
import type { Medication } from "../types";
import {
getMedDisplayName,
getPackageSize,
getMedTotal,
isAmountBasedPackageType,
isLiquidContainerPackageType,
isTubePackageType,
@@ -313,9 +313,9 @@ function getTotalCapacityLabel(med: Medication, t: TFn): string {
function getCurrentStockText(med: Medication, t: TFn): string {
if (isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)) {
return `${getPackageSize(med)} ${t(getTubeUnitKey(med))}`;
return `${getMedTotal(med)} ${t(getTubeUnitKey(med))}`;
}
return `${getPackageSize(med)} ${t("common.pills")}`;
return `${getMedTotal(med)} ${t("common.pills")}`;
}
function getReportPackageTypeLabel(med: Medication, t: TFn): string {
+2 -2
View File
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { MedicationAvatar } from "../components";
import { useEscapeKey } from "../hooks/useEscapeKey";
import type { Coverage, IntakeUnit, Medication, StockThresholds } from "../types";
import { getMedDisplayName, getMedTotal, getPackageSize } from "../types";
import { getMedDisplayName, getMedTotal, getStockDisplayCapacity } from "../types";
import { allowsPillFormSelection, isLiquidContainerPackageType, isTubePackageType } from "../types/package-profiles";
import { formatNumber } from "../utils";
import { getSystemLocale } from "../utils/formatters";
@@ -99,7 +99,7 @@ export function UserFilterModal({
const status = medCoverage
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings, med.packageType)
: getStockStatus(null, getMedTotal(med), settings, med.packageType);
const packageSize = getPackageSize(med);
const packageSize = getStockDisplayCapacity(med);
const currentStock = medCoverage ? medCoverage.medsLeft : getMedTotal(med);
// Get intakes relevant to this person
+28 -9
View File
@@ -70,12 +70,16 @@ export function useRefill(): UseRefillReturn {
const [editStockSaving, setEditStockSaving] = useState(false);
const [editStockMedication, setEditStockMedication] = useState<Medication | null>(null);
const clearRefillState = useCallback(() => {
setShowRefillModal(false);
const resetRefillForm = useCallback(() => {
setRefillPacks(1);
setRefillLoose(0);
setUsePrescriptionRefill(false);
setRefillSaving(false);
}, []);
const clearRefillState = useCallback(() => {
setShowRefillModal(false);
resetRefillForm();
setRefillHistory([]);
setRefillHistoryExpanded(false);
setShowEditStockModal(false);
@@ -84,7 +88,7 @@ export function useRefill(): UseRefillReturn {
setEditStockLoosePills(0);
setEditStockSaving(false);
setEditStockMedication(null);
}, []);
}, [resetRefillForm]);
// Load refill history for a medication
const loadRefillHistory = useCallback(async (medId: number) => {
@@ -190,9 +194,11 @@ export function useRefill(): UseRefillReturn {
const structuralMax = isAmountPackage
? (selectedMed.totalPills ?? getPackageSize(selectedMed))
: selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister;
const correctedLiquidBottleCount = isLiquidPackage
? Math.max(1, finalFullBlisters)
: Math.max(1, selectedMed.packCount);
const isZeroReset = finalFullBlisters === 0 && finalPartialPills === 0 && finalLoosePills === 0;
let correctedLiquidBottleCount = Math.max(0, selectedMed.packCount);
if (isLiquidPackage) {
correctedLiquidBottleCount = isZeroReset ? 0 : Math.max(1, finalFullBlisters);
}
const liquidStructuralMax = isLiquidPackage
? correctedLiquidBottleCount * liquidAmountPerBottle
: structuralMax;
@@ -217,8 +223,10 @@ export function useRefill(): UseRefillReturn {
let baseTotal: number;
if (isLiquidPackage) {
baseTotal = liquidStructuralMax;
} else if (selectedMed.packageType === "bottle") {
baseTotal = selectedMed.looseTablets;
} else if (isAmountPackage) {
baseTotal = getPackageSize(selectedMed); // bottle: stockAdjustment relative to fixed looseTablets base
baseTotal = getPackageSize(selectedMed);
} else {
baseTotal = structuralMax + finalLoosePills; // blister: base = sealed capacity + NEW loose pills
}
@@ -236,7 +244,17 @@ export function useRefill(): UseRefillReturn {
} = {
stockAdjustment: newStockAdjustment,
};
if (isTubePackage) {
if (isZeroReset) {
patchBody.stockAdjustment = 0;
patchBody.packCount = 0;
patchBody.looseTablets = 0;
if (selectedMed.packageType === "bottle" || isAmountPackage) {
patchBody.totalPills = 0;
}
if (isTubePackage || isLiquidPackage) {
patchBody.packageAmountValue = 0;
}
} else if (isTubePackage) {
// Tube has fixed count=1 and no automatic depletion.
// Correction must update the base amount fields directly.
patchBody.stockAdjustment = 0;
@@ -277,9 +295,10 @@ export function useRefill(): UseRefillReturn {
);
const openRefillModal = useCallback(() => {
resetRefillForm();
setShowRefillModal(true);
window.history.pushState({ modal: "refill" }, "");
}, []);
}, [resetRefillForm]);
const closeRefillModal = useCallback(() => {
if (showRefillModal) {
+30 -16
View File
@@ -33,8 +33,10 @@ import {
DOSE_UNITS,
FIELD_LIMITS,
getMedDisplayName,
getMedTotal,
getPackageProfile,
getPackageSize,
getStockDisplayCapacity,
isAmountBasedPackageType,
isLiquidContainerPackageType,
isTubePackageType,
@@ -1392,23 +1394,35 @@ export function MedicationsPage() {
</div>
)}
<div className="med-total">
{t("medications.details.stock")}:{" "}
{coverageByMed[getMedDisplayName(med)]
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
: getPackageSize(med)}{" "}
/ {getPackageSize(med)}
{(() => {
const stockDisplayCapacity = getStockDisplayCapacity(med);
const currentStock = coverageByMed[getMedDisplayName(med)]
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
: getMedTotal(med);
return (
<>
{t("medications.details.stock")}: {currentStock} / {stockDisplayCapacity}
</>
);
})()}
{getMedicationStockSuffix(med)}
{(coverageByMed[getMedDisplayName(med)]
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
: getPackageSize(med)) > getPackageSize(med) && (
<span
className="info-tooltip tooltip-align-left warning-text"
data-tooltip={t("tooltips.stockExceedsCapacity")}
>
{" "}
</span>
)}
{(() => {
const stockDisplayCapacity = getStockDisplayCapacity(med);
const currentStock = coverageByMed[getMedDisplayName(med)]
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
: getMedTotal(med);
return currentStock > stockDisplayCapacity ? (
<span
className="info-tooltip tooltip-align-left warning-text"
data-tooltip={t("tooltips.stockExceedsCapacity")}
>
{" "}
</span>
) : null;
})()}
</div>
</div>
</div>
@@ -921,6 +921,39 @@ describe("MedDetailModal stock overflow warning", () => {
});
});
describe("MedDetailModal amount-based stock display", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows current liquid stock against configured structural capacity", () => {
const liquidMed: Medication = {
...mockMedication,
id: 20,
name: "Liquid Multi",
packageType: "liquid_container",
packCount: 4,
packageAmountValue: 150,
packageAmountUnit: "ml",
totalPills: 450,
looseTablets: 450,
};
const liquidCoverage: Coverage = {
name: "Liquid Multi",
medsLeft: 450,
daysLeft: 45,
depletionDate: "2024-04-01",
depletionTime: Date.now() + 45 * 86400000,
nextDose: null,
};
render(<MedDetailModal {...defaultProps} selectedMed={liquidMed} coverage={{ all: [liquidCoverage] }} />);
expect(screen.getByText("450 / 600 form.packageAmountUnitMl")).toBeInTheDocument();
expect(screen.queryByText("450 / 450 form.packageAmountUnitMl")).not.toBeInTheDocument();
});
});
describe("MedDetailModal bottle package type", () => {
const bottleMed: Medication = {
id: 2,
@@ -113,6 +113,56 @@ describe("ReportModal", () => {
expect(onClose).toHaveBeenCalledTimes(1);
});
it("exports bottle current stock separately from configured capacity", async () => {
const onClose = vi.fn();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => ({
1: {
dosesTaken: 0,
automaticDosesTaken: 0,
dosesDismissed: 0,
firstDoseAt: null,
lastDoseAt: null,
refills: [],
},
}),
});
render(
<ReportModal
isOpen={true}
onClose={onClose}
medications={[
createMedication({
packageType: "bottle",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 100,
looseTablets: 20,
stockAdjustment: 50,
}),
]}
/>
);
fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i }));
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
await waitFor(() => {
expect(URL.createObjectURL).toHaveBeenCalled();
});
const [blob] = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls.at(-1) ?? [];
const content = await (blob as Blob).text();
expect(content).toContain("report.docTotalCapacity: 100");
expect(content).toContain("report.docCurrentStock: 70 common.pills");
expect(content).not.toContain("report.docCurrentStock: 100 common.pills");
expect(onClose).toHaveBeenCalledTimes(1);
});
it("generates printable report when PDF format is selected", async () => {
const onClose = vi.fn();
const mockWrite = vi.fn();
@@ -344,6 +344,58 @@ describe("UserFilterModal", () => {
expect(screen.queryByText(/600\/600 .*common\.pills/)).not.toBeInTheDocument();
});
it("shows liquid stock against configured multi-container capacity", () => {
const onClose = vi.fn();
const onOpenMedDetail = vi.fn();
const liquidMedication: Medication = {
...mockMedication,
id: 13,
name: "Liquid Multi",
genericName: "Liquid Generic",
packageType: "liquid_container",
packCount: 4,
packageAmountValue: 150,
packageAmountUnit: "ml",
totalPills: 450,
looseTablets: 450,
intakes: [
{
usage: 2,
every: 1,
start: "2024-01-01T09:32:00",
intakeUnit: "ml",
takenBy: "John",
intakeRemindersEnabled: true,
},
],
};
const liquidCoverage: Coverage = {
name: "Liquid Multi",
medsLeft: 450,
daysLeft: 30,
depletionDate: null,
depletionTime: null,
nextDose: null,
};
render(
<UserFilterModal
selectedUser="John"
meds={[liquidMedication]}
coverage={{ all: [liquidCoverage] }}
settings={defaultSettings}
onClose={onClose}
onClearUser={vi.fn()}
onOpenMedDetail={onOpenMedDetail}
/>
);
expect(screen.getByText("450/600 form.packageAmountUnitMl")).toBeInTheDocument();
expect(screen.queryByText("450/450 form.packageAmountUnitMl")).not.toBeInTheDocument();
});
it("renders liquid container intakes and stock in ml", () => {
const onClose = vi.fn();
const onOpenMedDetail = vi.fn();
+195 -20
View File
@@ -89,6 +89,25 @@ describe("useRefill", () => {
expect(window.history.pushState).toHaveBeenCalledWith({ modal: "refill" }, "");
});
it("resets stale refill form state when opening modal", () => {
const { result } = renderHook(() => useRefill());
act(() => {
result.current.setRefillPacks(4);
result.current.setRefillLoose(9);
result.current.setUsePrescriptionRefill(true);
});
act(() => {
result.current.openRefillModal();
});
expect(result.current.showRefillModal).toBe(true);
expect(result.current.refillPacks).toBe(1);
expect(result.current.refillLoose).toBe(0);
expect(result.current.usePrescriptionRefill).toBe(false);
});
it("closes refill modal using history back", () => {
const { result } = renderHook(() => useRefill());
@@ -325,42 +344,197 @@ describe("useRefill", () => {
expect(mockLoadMeds).toHaveBeenCalled();
});
it("stock correction uses correct base for bottle type medications", async () => {
// BUG FIX: submitStockCorrection used blister formula (packCount * blistersPerPack * pillsPerBlister + looseTablets)
// for ALL medications, but getMedTotal() uses only looseTablets + stockAdjustment for bottles.
// This mismatch caused the correction to compute the wrong stockAdjustment.
it("resets blister stock correction payload to zero base fields", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
const bottleMed: Medication = {
id: 4,
name: "Pills in a Box",
packageType: "bottle",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 1,
looseTablets: 150,
stockAdjustment: -2,
const blisterMed: Medication = {
id: 8,
name: "Zero Reset Blister",
packageType: "blister",
packCount: 2,
blistersPerPack: 3,
pillsPerBlister: 10,
looseTablets: 5,
stockAdjustment: -4,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2026-01-31T20:27:00" }],
updatedAt: null,
};
// getMedTotal for bottle = looseTablets + stockAdjustment = 150 + (-2) = 148
// getPackageSize for bottle = looseTablets = 150
const mockLoadMeds = vi.fn();
const { result } = renderHook(() => useRefill());
act(() => {
result.current.openEditStockModal(blisterMed, {
all: [{ name: "Zero Reset Blister", medsLeft: 31, daysLeft: 31 }] as Coverage[],
});
result.current.setEditStockFullBlisters(0);
result.current.setEditStockPartialBlisterPills(0);
result.current.setEditStockLoosePills(0);
});
await act(async () => {
await result.current.submitStockCorrection(8, blisterMed, mockLoadMeds);
});
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(requestInit.body as string);
expect(body).toEqual({
stockAdjustment: 0,
packCount: 0,
looseTablets: 0,
});
});
it("resets bottle stock correction payload to zero base fields", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
const bottleMed: Medication = {
id: 9,
name: "Zero Reset Bottle",
packageType: "bottle",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 100,
looseTablets: 20,
stockAdjustment: 5,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2026-01-31T20:27:00" }],
updatedAt: null,
};
const mockLoadMeds = vi.fn();
const { result } = renderHook(() => useRefill());
// Pre-fill for bottle: full=0, partial=current total
act(() => {
result.current.openEditStockModal(bottleMed, {
all: [{ name: "Pills in a Box", medsLeft: 148, daysLeft: 148 }] as Coverage[],
all: [{ name: "Zero Reset Bottle", medsLeft: 25, daysLeft: 25 }] as Coverage[],
});
result.current.setEditStockFullBlisters(0);
result.current.setEditStockPartialBlisterPills(0);
});
await act(async () => {
await result.current.submitStockCorrection(9, bottleMed, mockLoadMeds);
});
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(requestInit.body as string);
expect(body).toEqual({
stockAdjustment: 0,
packCount: 0,
looseTablets: 0,
totalPills: 0,
});
});
it.each([
{
label: "liquid container",
id: 10,
med: {
id: 10,
name: "Zero Reset Liquid",
medicationForm: "liquid",
packageType: "liquid_container",
doseUnit: "ml",
packCount: 1,
packageAmountValue: 180,
packageAmountUnit: "ml",
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 180,
looseTablets: 180,
stockAdjustment: 0,
takenBy: [],
blisters: [{ usage: 5, every: 1, start: "2026-01-31T20:27:00" }],
updatedAt: null,
} satisfies Medication,
coverage: 180,
},
{
label: "tube",
id: 11,
med: {
id: 11,
name: "Zero Reset Tube",
medicationForm: "topical",
packageType: "tube",
doseUnit: "units",
packCount: 2,
packageAmountValue: 40,
packageAmountUnit: "g",
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 80,
looseTablets: 80,
stockAdjustment: 0,
takenBy: [],
blisters: [{ usage: 2, every: 1, start: "2026-01-31T20:27:00" }],
updatedAt: null,
} satisfies Medication,
coverage: 80,
},
])("resets $label stock correction payload to zero amount-base fields", async ({ id, med, coverage }) => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
const mockLoadMeds = vi.fn();
const { result } = renderHook(() => useRefill());
act(() => {
result.current.openEditStockModal(med, {
all: [{ name: med.name, medsLeft: coverage, daysLeft: coverage }] as Coverage[],
});
result.current.setEditStockFullBlisters(0);
result.current.setEditStockPartialBlisterPills(0);
});
await act(async () => {
await result.current.submitStockCorrection(id, med, mockLoadMeds);
});
const [, requestInit] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(requestInit.body as string);
expect(body).toEqual({
stockAdjustment: 0,
packCount: 0,
looseTablets: 0,
totalPills: 0,
packageAmountValue: 0,
});
});
it("stock correction uses loose tablets rather than bottle capacity as the base", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
const bottleMed: Medication = {
id: 4,
name: "Capacity Bottle",
packageType: "bottle",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 100,
looseTablets: 20,
stockAdjustment: 5,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2026-01-31T20:27:00" }],
updatedAt: null,
};
const mockLoadMeds = vi.fn();
const { result } = renderHook(() => useRefill());
act(() => {
result.current.openEditStockModal(bottleMed, {
all: [{ name: "Capacity Bottle", medsLeft: 25, daysLeft: 25 }] as Coverage[],
});
});
// User sets total to 149 pills.
// User corrects current stock to 70 pills.
act(() => {
result.current.setEditStockPartialBlisterPills(149);
result.current.setEditStockPartialBlisterPills(70);
});
await act(async () => {
@@ -376,7 +550,8 @@ describe("useRefill", () => {
);
expect(fetchCall).toBeDefined();
const body = JSON.parse(fetchCall![1].body as string);
expect(body.stockAdjustment).toBe(-1); // NOT -2 (the old bug)
expect(body.stockAdjustment).toBe(50);
expect(body.looseTablets).toBeUndefined();
});
it("stock correction clamps blister totals to package size", async () => {
@@ -646,4 +646,58 @@ describe("MedicationsPage form interactions", () => {
expect(screen.getAllByText("form.enrichment.applied").length).toBeGreaterThanOrEqual(1);
expect(screen.getByText("form.enrichment.appliedStrength")).toBeInTheDocument();
});
it("shows liquid stock against configured multi-container capacity in the list", () => {
const liquidMed = {
...mockMeds[0],
id: 2,
name: "Liquid Multi",
genericName: "Liquid Generic",
packageType: "liquid_container" as const,
packCount: 4,
blistersPerPack: 1,
pillsPerBlister: 1,
packageAmountValue: 150,
packageAmountUnit: "ml" as const,
totalPills: 450,
looseTablets: 450,
};
mockContextValue = createMockContext({
meds: [liquidMed],
coverageByMed: {
"Liquid Multi": { medsLeft: 450 },
},
});
renderPage();
expect(screen.getByText(/medications\.details\.stock: 450 \/ 600 ml/i)).toBeInTheDocument();
expect(screen.queryByText(/medications\.details\.stock: 450 \/ 450 ml/i)).not.toBeInTheDocument();
});
it("shows bottle current stock against configured bottle capacity in the list", () => {
const bottleMed = {
...mockMeds[0],
id: 3,
name: "Bottle Capacity",
packageType: "bottle" as const,
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 100,
looseTablets: 20,
stockAdjustment: 50,
};
mockContextValue = createMockContext({
meds: [bottleMed],
coverageByMed: {
"Bottle Capacity": { medsLeft: 70 },
},
});
renderPage();
expect(screen.getByText(/medications\.details\.stock: 70 \/ 100 common\.pills/i)).toBeInTheDocument();
expect(screen.queryByText(/medications\.details\.stock: 100 \/ 100 common\.pills/i)).not.toBeInTheDocument();
});
});
+85 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { FIELD_LIMITS, getMedTotal, getPackageSize } from "../types";
import { FIELD_LIMITS, getMedTotal, getPackageSize, getStockDisplayCapacity } from "../types";
describe("getMedTotal", () => {
it("calculates total pills without stock adjustment", () => {
@@ -85,6 +85,20 @@ describe("getMedTotal", () => {
expect(getMedTotal(med)).toBe(140); // 150 + (-10) = 140
});
it("uses loose stock for bottle current total even when explicit capacity exists", () => {
const med = {
packageType: "bottle" as const,
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 100,
looseTablets: 20,
stockAdjustment: 50,
};
expect(getMedTotal(med)).toBe(70);
});
it("ignores blister fields for bottle type", () => {
const med = {
packageType: "bottle" as const,
@@ -158,6 +172,20 @@ describe("getPackageSize", () => {
expect(getPackageSize(med)).toBe(200);
});
it("returns explicit bottle capacity instead of current stock", () => {
const med = {
packageType: "bottle" as const,
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 100,
looseTablets: 70,
stockAdjustment: 25,
};
expect(getPackageSize(med)).toBe(100);
});
it("ignores blister fields for bottle type", () => {
const med = {
packageType: "bottle" as const,
@@ -195,6 +223,62 @@ describe("getPackageSize", () => {
});
});
describe("getStockDisplayCapacity", () => {
it("returns configured multi-container capacity for liquid containers", () => {
const liquid = {
packageType: "liquid_container" as const,
packCount: 4,
blistersPerPack: 1,
pillsPerBlister: 1,
packageAmountValue: 150,
totalPills: 450,
looseTablets: 450,
};
expect(getStockDisplayCapacity(liquid)).toBe(600);
});
it("returns configured multi-container capacity for tubes", () => {
const tube = {
packageType: "tube" as const,
packCount: 4,
blistersPerPack: 1,
pillsPerBlister: 1,
packageAmountValue: 150,
totalPills: 450,
looseTablets: 450,
};
expect(getStockDisplayCapacity(tube)).toBe(600);
});
it("falls back to current package size when amount metadata is missing", () => {
const liquid = {
packageType: "liquid_container" as const,
packCount: 4,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 450,
looseTablets: 450,
};
expect(getStockDisplayCapacity(liquid)).toBe(450);
});
it("keeps bottle semantics unchanged", () => {
const bottle = {
packageType: "bottle" as const,
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 100,
looseTablets: 80,
};
expect(getStockDisplayCapacity(bottle)).toBe(100);
});
});
describe("FIELD_LIMITS", () => {
it("has correct limits for name field", () => {
expect(FIELD_LIMITS.name.min).toBe(0);
+26 -2
View File
@@ -15,7 +15,7 @@ export {
} from "./package-profiles";
import type { PackageType } from "./package-profiles";
import { isAmountBasedPackageType } from "./package-profiles";
import { isAmountBasedPackageType, isLiquidContainerPackageType, isTubePackageType } from "./package-profiles";
// Common medication dose units
export type DoseUnit = "mg" | "g" | "mcg" | "ml" | "units";
@@ -379,7 +379,10 @@ export function getMedDisplayName(med: { name: string; genericName?: string | nu
// Helper Functions for Medication Calculations
// =============================================================================
type MedLike = Pick<Medication, "packCount" | "blistersPerPack" | "pillsPerBlister" | "looseTablets"> & {
type MedLike = Pick<
Medication,
"packCount" | "blistersPerPack" | "pillsPerBlister" | "looseTablets" | "packageAmountValue"
> & {
stockAdjustment?: number;
packageType?: PackageType;
totalPills?: number | null;
@@ -387,6 +390,10 @@ type MedLike = Pick<Medication, "packCount" | "blistersPerPack" | "pillsPerBlist
/** Calculate total pills including stockAdjustment */
export function getMedTotal(med: MedLike): number {
if (med.packageType === "bottle") {
return med.looseTablets + (med.stockAdjustment ?? 0);
}
// Amount-based package types store their current base stock directly
// in totalPills (fallback looseTablets for legacy rows).
if (isAmountBasedPackageType(med.packageType)) {
@@ -399,6 +406,10 @@ export function getMedTotal(med: MedLike): number {
/** Get the base package size (without stockAdjustment) */
export function getPackageSize(med: MedLike): number {
if (med.packageType === "bottle") {
return med.totalPills ?? med.looseTablets;
}
// Amount-based package types use totalPills as base capacity
if (isAmountBasedPackageType(med.packageType)) {
return med.totalPills ?? med.looseTablets;
@@ -406,3 +417,16 @@ export function getPackageSize(med: MedLike): number {
// For blister type, calculate from packs + loose
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
}
/** Get the configured structural capacity used for stock display/limits. */
export function getStockDisplayCapacity(med: MedLike): number {
if (isLiquidContainerPackageType(med.packageType) || isTubePackageType(med.packageType)) {
const packageCount = Math.max(1, med.packCount || 1);
const packageAmountValue = Number(med.packageAmountValue ?? 0);
if (Number.isFinite(packageAmountValue) && packageAmountValue > 0) {
return packageCount * packageAmountValue;
}
}
return getPackageSize(med);
}