diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index 02a2760..1e74694 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -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; } diff --git a/backend/src/routes/refills.ts b/backend/src/routes/refills.ts index da2d2ad..d173ea9 100644 --- a/backend/src/routes/refills.ts +++ b/backend/src/routes/refills.ts @@ -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) { diff --git a/backend/src/test/e2e-routes.test.ts b/backend/src/test/e2e-routes.test.ts index e62751a..e368e79 100644 --- a/backend/src/test/e2e-routes.test.ts +++ b/backend/src/test/e2e-routes.test.ts @@ -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) => 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) => 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) => 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) => 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) => 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) => 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) => 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) => 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", diff --git a/backend/src/test/refills.test.ts b/backend/src/test/refills.test.ts deleted file mode 100644 index 5c917a0..0000000 --- a/backend/src/test/refills.test.ts +++ /dev/null @@ -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); - }); - }); -}); diff --git a/doku/memory_notes.md b/doku/memory_notes.md new file mode 100644 index 0000000..cc6f9fc --- /dev/null +++ b/doku/memory_notes.md @@ -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. diff --git a/doku/report.md b/doku/report.md new file mode 100644 index 0000000..5516504 --- /dev/null +++ b/doku/report.md @@ -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. diff --git a/frontend/src/components/MedDetailModal.tsx b/frontend/src/components/MedDetailModal.tsx index 1c553e6..2a4c10c 100644 --- a/frontend/src/components/MedDetailModal.tsx +++ b/frontend/src/components/MedDetailModal.tsx @@ -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 = (() => { diff --git a/frontend/src/components/ReportModal.tsx b/frontend/src/components/ReportModal.tsx index 8ee179f..e44cb6d 100644 --- a/frontend/src/components/ReportModal.tsx +++ b/frontend/src/components/ReportModal.tsx @@ -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 { diff --git a/frontend/src/components/UserFilterModal.tsx b/frontend/src/components/UserFilterModal.tsx index 23186a9..ce2cfd6 100644 --- a/frontend/src/components/UserFilterModal.tsx +++ b/frontend/src/components/UserFilterModal.tsx @@ -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 diff --git a/frontend/src/hooks/useRefill.ts b/frontend/src/hooks/useRefill.ts index acb87e6..c2260bb 100644 --- a/frontend/src/hooks/useRefill.ts +++ b/frontend/src/hooks/useRefill.ts @@ -70,12 +70,16 @@ export function useRefill(): UseRefillReturn { const [editStockSaving, setEditStockSaving] = useState(false); const [editStockMedication, setEditStockMedication] = useState(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) { diff --git a/frontend/src/pages/MedicationsPage.tsx b/frontend/src/pages/MedicationsPage.tsx index 922f1b4..17f01ad 100644 --- a/frontend/src/pages/MedicationsPage.tsx +++ b/frontend/src/pages/MedicationsPage.tsx @@ -33,8 +33,10 @@ import { DOSE_UNITS, FIELD_LIMITS, getMedDisplayName, + getMedTotal, getPackageProfile, getPackageSize, + getStockDisplayCapacity, isAmountBasedPackageType, isLiquidContainerPackageType, isTubePackageType, @@ -1392,23 +1394,35 @@ export function MedicationsPage() { )}
- {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) && ( - - {" "} - ⚠️ - - )} + {(() => { + const stockDisplayCapacity = getStockDisplayCapacity(med); + const currentStock = coverageByMed[getMedDisplayName(med)] + ? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft) + : getMedTotal(med); + + return currentStock > stockDisplayCapacity ? ( + + {" "} + ⚠️ + + ) : null; + })()}
diff --git a/frontend/src/test/components/MedDetailModal.test.tsx b/frontend/src/test/components/MedDetailModal.test.tsx index 6ee97cc..84d7eea 100644 --- a/frontend/src/test/components/MedDetailModal.test.tsx +++ b/frontend/src/test/components/MedDetailModal.test.tsx @@ -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(); + + 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, diff --git a/frontend/src/test/components/ReportModal.test.tsx b/frontend/src/test/components/ReportModal.test.tsx index dee32b4..6e47fed 100644 --- a/frontend/src/test/components/ReportModal.test.tsx +++ b/frontend/src/test/components/ReportModal.test.tsx @@ -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).mockResolvedValue({ + ok: true, + json: async () => ({ + 1: { + dosesTaken: 0, + automaticDosesTaken: 0, + dosesDismissed: 0, + firstDoseAt: null, + lastDoseAt: null, + refills: [], + }, + }), + }); + + render( + + ); + + 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).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(); diff --git a/frontend/src/test/components/UserFilterModal.test.tsx b/frontend/src/test/components/UserFilterModal.test.tsx index 6542e98..f66a7ad 100644 --- a/frontend/src/test/components/UserFilterModal.test.tsx +++ b/frontend/src/test/components/UserFilterModal.test.tsx @@ -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( + + ); + + 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(); diff --git a/frontend/src/test/hooks/useRefill.test.ts b/frontend/src/test/hooks/useRefill.test.ts index 46eb997..ddf2ee0 100644 --- a/frontend/src/test/hooks/useRefill.test.ts +++ b/frontend/src/test/hooks/useRefill.test.ts @@ -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).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).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).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).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).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).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).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 () => { diff --git a/frontend/src/test/pages/MedicationsPage.test.tsx b/frontend/src/test/pages/MedicationsPage.test.tsx index bc8c7e8..a1308c5 100644 --- a/frontend/src/test/pages/MedicationsPage.test.tsx +++ b/frontend/src/test/pages/MedicationsPage.test.tsx @@ -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(); + }); }); diff --git a/frontend/src/test/types.test.ts b/frontend/src/test/types.test.ts index 41c480f..431d4d3 100644 --- a/frontend/src/test/types.test.ts +++ b/frontend/src/test/types.test.ts @@ -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); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 2bf22da..3166de6 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 & { +type MedLike = Pick< + Medication, + "packCount" | "blistersPerPack" | "pillsPerBlister" | "looseTablets" | "packageAmountValue" +> & { stockAdjustment?: number; packageType?: PackageType; totalPills?: number | null; @@ -387,6 +390,10 @@ type MedLike = Pick 0) { + return packageCount * packageAmountValue; + } + } + + return getPackageSize(med); +}