Files
medassist-ng/frontend/src/test/hooks/useRefill.test.ts
T
Daniel Volz b91717fc19 fix: stock correction not working for bottle type and manual calculation mode (#133)
- Fix bottle type: submitStockCorrection used blister formula for baseTotal
  but getMedTotal uses only looseTablets for bottles. Now uses getPackageSize()
  which handles both types correctly.
- Fix manual mode: same-day taken doses were counted as consumed after a stock
  correction (>= comparison with date-only timestamps). Changed to > so doses
  on the correction day are excluded.
- Add agent instruction: only release-manager may create PRs/push/merge.
2026-02-08 15:12:17 +01:00

420 lines
12 KiB
TypeScript

import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useRefill } from "../../hooks/useRefill";
import type { Coverage, Medication } from "../../types";
describe("useRefill", () => {
beforeEach(() => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
});
vi.spyOn(window.history, "pushState").mockImplementation(() => {});
vi.spyOn(window.history, "back").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("initializes with default state", () => {
const { result } = renderHook(() => useRefill());
expect(result.current.showRefillModal).toBe(false);
expect(result.current.refillPacks).toBe(1);
expect(result.current.refillLoose).toBe(0);
expect(result.current.refillSaving).toBe(false);
expect(result.current.refillHistory).toEqual([]);
expect(result.current.refillHistoryExpanded).toBe(false);
expect(result.current.showEditStockModal).toBe(false);
});
it("loads refill history", async () => {
const mockHistory = [{ id: 1, packsAdded: 2, loosePillsAdded: 0, createdAt: "2024-03-15T10:00:00Z" }];
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockHistory),
});
const { result } = renderHook(() => useRefill());
await act(async () => {
await result.current.loadRefillHistory(1);
});
expect(result.current.refillHistory).toEqual(mockHistory);
});
it("handles refill history with refills wrapper", async () => {
const mockHistory = {
refills: [{ id: 1, packsAdded: 2, createdAt: "2024-03-15T10:00:00Z" }],
};
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockHistory),
});
const { result } = renderHook(() => useRefill());
await act(async () => {
await result.current.loadRefillHistory(1);
});
expect(result.current.refillHistory).toEqual(mockHistory.refills);
});
it("handles refill history error", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Network error"));
const { result } = renderHook(() => useRefill());
await act(async () => {
await result.current.loadRefillHistory(1);
});
expect(result.current.refillHistory).toEqual([]);
});
it("opens refill modal and pushes history", () => {
const { result } = renderHook(() => useRefill());
act(() => {
result.current.openRefillModal();
});
expect(result.current.showRefillModal).toBe(true);
expect(window.history.pushState).toHaveBeenCalledWith({ modal: "refill" }, "");
});
it("closes refill modal using history back", () => {
const { result } = renderHook(() => useRefill());
act(() => {
result.current.openRefillModal();
});
act(() => {
result.current.closeRefillModal();
});
expect(window.history.back).toHaveBeenCalled();
});
it("does not call history back when refill modal not open", () => {
const { result } = renderHook(() => useRefill());
act(() => {
result.current.closeRefillModal();
});
expect(window.history.back).not.toHaveBeenCalled();
});
it("submits refill successfully", async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ newStock: { packCount: 3, looseTablets: 5 } }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
});
const mockSetForm = vi.fn();
const mockLoadMeds = vi.fn();
const { result } = renderHook(() => useRefill());
// Open modal first
act(() => {
result.current.openRefillModal();
});
await act(async () => {
await result.current.submitRefill(1, 1, mockSetForm, mockLoadMeds);
});
expect(fetch).toHaveBeenCalledWith(
"/api/medications/1/refill",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ packsAdded: 1, loosePillsAdded: 0 }),
})
);
expect(mockSetForm).toHaveBeenCalled();
expect(mockLoadMeds).toHaveBeenCalled();
});
it("does not submit refill if both values are 0", async () => {
const { result } = renderHook(() => useRefill());
act(() => {
result.current.setRefillPacks(0);
result.current.setRefillLoose(0);
});
const mockSetForm = vi.fn();
const mockLoadMeds = vi.fn();
await act(async () => {
await result.current.submitRefill(1, 1, mockSetForm, mockLoadMeds);
});
expect(fetch).not.toHaveBeenCalled();
});
it("opens edit stock modal", () => {
const { result } = renderHook(() => useRefill());
const mockMed: Medication = {
id: 1,
name: "Test Med",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 5,
takenBy: [],
blisters: [],
updatedAt: null,
};
const mockCoverage = {
all: [{ name: "Test Med", medsLeft: 20, daysLeft: 10 }] as Coverage[],
};
act(() => {
result.current.openEditStockModal(mockMed, mockCoverage);
});
expect(result.current.showEditStockModal).toBe(true);
expect(window.history.pushState).toHaveBeenCalledWith({ modal: "editStock" }, "");
expect(result.current.editStockFullBlisters).toBe(2); // 20 / 10 = 2
expect(result.current.editStockPartialBlisterPills).toBe(0); // 20 % 10 = 0
});
it("closes edit stock modal using history back", () => {
const { result } = renderHook(() => useRefill());
const mockMed: Medication = {
id: 1,
name: "Test Med",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 5,
takenBy: [],
blisters: [],
updatedAt: null,
};
act(() => {
result.current.openEditStockModal(mockMed, { all: [] });
});
act(() => {
result.current.closeEditStockModal();
});
expect(window.history.back).toHaveBeenCalled();
});
it("submits stock correction", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
const mockMed: Medication = {
id: 1,
name: "Test Med",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 5,
takenBy: [],
blisters: [],
updatedAt: null,
};
const mockLoadMeds = vi.fn();
const { result } = renderHook(() => useRefill());
act(() => {
result.current.openEditStockModal(mockMed, { all: [] });
});
await act(async () => {
await result.current.submitStockCorrection(1, mockMed, mockLoadMeds);
});
expect(fetch).toHaveBeenCalledWith(
"/api/medications/1/stock-adjustment",
expect.objectContaining({ method: "PATCH" })
);
expect(mockLoadMeds).toHaveBeenCalled();
});
it("handles full blister conversion in stock correction", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
const mockMed: Medication = {
id: 1,
name: "Test Med",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
looseTablets: 5,
takenBy: [],
blisters: [],
updatedAt: null,
};
const mockLoadMeds = vi.fn();
const { result } = renderHook(() => useRefill());
act(() => {
result.current.openEditStockModal(mockMed, { all: [] });
// Set partial pills to equal a full blister
result.current.setEditStockPartialBlisterPills(10);
});
await act(async () => {
await result.current.submitStockCorrection(1, mockMed, mockLoadMeds);
});
expect(fetch).toHaveBeenCalled();
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.
(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,
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());
// Pre-fill: user sees 148 pills (148 / 1 = 148 full, 0 partial)
act(() => {
result.current.openEditStockModal(bottleMed, {
all: [{ name: "Pills in a Box", medsLeft: 148, daysLeft: 148 }] as Coverage[],
});
});
// User adds +1 → 149 full blisters (pillsPerBlister=1)
act(() => {
result.current.setEditStockFullBlisters(149);
result.current.setEditStockPartialBlisterPills(0);
});
await act(async () => {
await result.current.submitStockCorrection(4, bottleMed, mockLoadMeds);
});
// desiredTotal = 149 * 1 + 0 = 149
// baseTotal (fixed) = getPackageSize(bottle) = looseTablets = 150
// newStockAdjustment = 149 - 150 = -1
// → getMedTotal = 150 + (-1) = 149 ✓
const fetchCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.find(
(call: [string, RequestInit]) => call[0] === "/api/medications/4/stock-adjustment"
);
expect(fetchCall).toBeDefined();
const body = JSON.parse(fetchCall![1].body as string);
expect(body.stockAdjustment).toBe(-1); // NOT -2 (the old bug)
});
it("stock correction uses correct base for blister type medications", async () => {
// Ensure blister type still works correctly after the bottle fix
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true });
const blisterMed: Medication = {
id: 2,
name: "Blister Med",
packageType: "blister",
packCount: 1,
blistersPerPack: 5,
pillsPerBlister: 5,
looseTablets: 0,
stockAdjustment: 1,
takenBy: [],
blisters: [{ usage: 1, every: 1, start: "2026-01-30T21:07:00" }],
updatedAt: null,
};
// getMedTotal for blister = 1*5*5 + 0 + 1 = 26
// getPackageSize for blister = 1*5*5 + 0 = 25
const mockLoadMeds = vi.fn();
const { result } = renderHook(() => useRefill());
// User sees 26 pills → 5 full blisters (5pills each) + 1 partial
act(() => {
result.current.openEditStockModal(blisterMed, {
all: [{ name: "Blister Med", medsLeft: 26, daysLeft: 26 }] as Coverage[],
});
});
// User changes to 27 (+1): 5 full + 2 partial
act(() => {
result.current.setEditStockFullBlisters(5);
result.current.setEditStockPartialBlisterPills(2);
});
await act(async () => {
await result.current.submitStockCorrection(2, blisterMed, mockLoadMeds);
});
// desiredTotal = 5 * 5 + 2 = 27
// baseTotal = getPackageSize(blister) = 1*5*5 + 0 = 25
// newStockAdjustment = 27 - 25 = 2
// → getMedTotal = 25 + 2 = 27 ✓
const fetchCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls.find(
(call: [string, RequestInit]) => call[0] === "/api/medications/2/stock-adjustment"
);
expect(fetchCall).toBeDefined();
const body = JSON.parse(fetchCall![1].body as string);
expect(body.stockAdjustment).toBe(2);
});
it("allows setting state directly", () => {
const { result } = renderHook(() => useRefill());
act(() => {
result.current.setRefillPacks(5);
result.current.setRefillLoose(3);
result.current.setRefillHistoryExpanded(true);
result.current.setShowRefillModal(true);
result.current.setShowEditStockModal(true);
result.current.setEditStockFullBlisters(10);
result.current.setEditStockPartialBlisterPills(5);
});
expect(result.current.refillPacks).toBe(5);
expect(result.current.refillLoose).toBe(3);
expect(result.current.refillHistoryExpanded).toBe(true);
expect(result.current.showRefillModal).toBe(true);
expect(result.current.showEditStockModal).toBe(true);
expect(result.current.editStockFullBlisters).toBe(10);
expect(result.current.editStockPartialBlisterPills).toBe(5);
});
});