fix(refill): stabilize stock and amount package semantics
This commit is contained in:
@@ -697,7 +697,7 @@ describe("MedDetailModal with refill history", () => {
|
||||
|
||||
it("shows refill history when expanded", () => {
|
||||
const refillHistory: RefillEntry[] = [
|
||||
{ id: 1, refillDate: new Date().toISOString(), packsAdded: 1, loosePillsAdded: 0 },
|
||||
{ id: 1, refillDate: new Date().toISOString(), packsAdded: 1, loosePillsAdded: 0, quantityAdded: 30 },
|
||||
];
|
||||
|
||||
render(<MedDetailModal {...defaultProps} refillHistory={refillHistory} refillHistoryExpanded={true} />);
|
||||
@@ -710,7 +710,7 @@ describe("MedDetailModal with refill history", () => {
|
||||
it("calls onRefillHistoryExpandedChange when toggle clicked", () => {
|
||||
const onRefillHistoryExpandedChange = vi.fn();
|
||||
const refillHistory: RefillEntry[] = [
|
||||
{ id: 1, refillDate: new Date().toISOString(), packsAdded: 1, loosePillsAdded: 0 },
|
||||
{ id: 1, refillDate: new Date().toISOString(), packsAdded: 1, loosePillsAdded: 0, quantityAdded: 30 },
|
||||
];
|
||||
|
||||
render(
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("ReportModal", () => {
|
||||
json: async () => ({
|
||||
1: {
|
||||
dosesTaken: 2,
|
||||
dosesDismissed: 0,
|
||||
dosesSkipped: 0,
|
||||
firstDoseAt: "2026-01-01T08:00:00.000Z",
|
||||
lastDoseAt: "2026-01-02T08:00:00.000Z",
|
||||
refills: [],
|
||||
@@ -74,7 +74,7 @@ describe("ReportModal", () => {
|
||||
1: {
|
||||
dosesTaken: 1,
|
||||
automaticDosesTaken: 0,
|
||||
dosesDismissed: 0,
|
||||
dosesSkipped: 0,
|
||||
firstDoseAt: "2026-02-03T12:00:00.000Z",
|
||||
lastDoseAt: null,
|
||||
refills: [],
|
||||
@@ -121,7 +121,7 @@ describe("ReportModal", () => {
|
||||
1: {
|
||||
dosesTaken: 0,
|
||||
automaticDosesTaken: 0,
|
||||
dosesDismissed: 0,
|
||||
dosesSkipped: 0,
|
||||
firstDoseAt: null,
|
||||
lastDoseAt: null,
|
||||
refills: [],
|
||||
@@ -183,13 +183,14 @@ describe("ReportModal", () => {
|
||||
1: {
|
||||
dosesTaken: 1,
|
||||
automaticDosesTaken: 0,
|
||||
dosesDismissed: 0,
|
||||
dosesSkipped: 0,
|
||||
firstDoseAt: "2026-03-03T12:00:00.000Z",
|
||||
lastDoseAt: null,
|
||||
refills: [
|
||||
{
|
||||
packsAdded: 1,
|
||||
loosePillsAdded: 0,
|
||||
quantityAdded: 20,
|
||||
usedPrescription: false,
|
||||
refillDate: "2026-03-04",
|
||||
},
|
||||
@@ -251,6 +252,81 @@ describe("ReportModal", () => {
|
||||
expect(screen.getByRole("button", { name: /report\.generate/i })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("sends the selected person filter with the report request and clears it for all people", async () => {
|
||||
const onClose = vi.fn();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
1: {
|
||||
dosesTaken: 2,
|
||||
automaticDosesTaken: 0,
|
||||
dosesSkipped: 1,
|
||||
firstDoseAt: "2026-01-01T08:00:00.000Z",
|
||||
lastDoseAt: "2026-01-02T08:00:00.000Z",
|
||||
refills: [],
|
||||
},
|
||||
2: {
|
||||
dosesTaken: 1,
|
||||
automaticDosesTaken: 0,
|
||||
dosesSkipped: 0,
|
||||
firstDoseAt: "2026-01-01T08:00:00.000Z",
|
||||
lastDoseAt: "2026-01-02T08:00:00.000Z",
|
||||
refills: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const firstRender = render(
|
||||
<ReportModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
medications={[
|
||||
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
|
||||
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Alice" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/medications/report-data",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ medicationIds: [1], takenByFilter: ["Alice"] }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockClear();
|
||||
firstRender.unmount();
|
||||
render(
|
||||
<ReportModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
medications={[
|
||||
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
|
||||
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/medications/report-data",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ medicationIds: [1, 2], takenByFilter: undefined }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("generates markdown report and keeps modal open on fetch error", async () => {
|
||||
const onClose = vi.fn();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: false });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SharedSchedule } from "../../components/SharedSchedule";
|
||||
@@ -168,10 +168,58 @@ function createSharedDataWithTodayDose(referenceNow: Date) {
|
||||
};
|
||||
}
|
||||
|
||||
function createSharedDoseFetchMock(options: {
|
||||
token?: string;
|
||||
sharedData: ReturnType<typeof createSharedDataWithTodayDose>;
|
||||
initialDoses?: Array<{ doseId: string; skipped?: boolean; dismissed?: boolean; takenSource?: string }>;
|
||||
}) {
|
||||
const token = options.token ?? "token-123";
|
||||
const doseState = new Map((options.initialDoses ?? []).map((dose) => [dose.doseId, { ...dose }]));
|
||||
const requests: Array<{ url: string; method: string; body?: unknown }> = [];
|
||||
|
||||
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const method = init?.method ?? "GET";
|
||||
const body =
|
||||
typeof init?.body === "string" && init.body.length > 0
|
||||
? (JSON.parse(init.body) as { doseId: string })
|
||||
: undefined;
|
||||
requests.push({ url, method, body });
|
||||
|
||||
if (url === `/api/share/${token}` && method === "GET") {
|
||||
return { ok: true, json: async () => options.sharedData };
|
||||
}
|
||||
|
||||
if (url === `/api/share/${token}/doses` && method === "GET") {
|
||||
return { ok: true, json: async () => ({ doses: Array.from(doseState.values()) }) };
|
||||
}
|
||||
|
||||
if (url === `/api/share/${token}/doses/skip` && method === "POST" && body?.doseId) {
|
||||
doseState.set(body.doseId, { doseId: body.doseId, skipped: true });
|
||||
return { ok: true, json: async () => ({}) };
|
||||
}
|
||||
|
||||
if (url === `/api/share/${token}/doses` && method === "POST" && body?.doseId) {
|
||||
doseState.set(body.doseId, { doseId: body.doseId, takenSource: "manual" });
|
||||
return { ok: true, json: async () => ({}) };
|
||||
}
|
||||
|
||||
if (url.startsWith(`/api/share/${token}/doses/skip/`) && method === "DELETE") {
|
||||
const doseId = decodeURIComponent(url.split("/").at(-1) ?? "");
|
||||
doseState.delete(doseId);
|
||||
return { ok: true, json: async () => ({}) };
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected request: ${method} ${url}`));
|
||||
});
|
||||
|
||||
return { fetchMock, requests, getDoses: () => Array.from(doseState.values()) };
|
||||
}
|
||||
|
||||
describe("SharedSchedule", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
globalThis.fetch = vi.fn() as unknown as typeof fetch;
|
||||
vi.spyOn(globalThis, "setInterval").mockImplementation(() => 1 as unknown as ReturnType<typeof setInterval>);
|
||||
vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {});
|
||||
});
|
||||
@@ -183,7 +231,7 @@ describe("SharedSchedule", () => {
|
||||
|
||||
it("renders shared schedule shell for valid token", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
|
||||
if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||
}
|
||||
if (url === "/api/share/token-123") {
|
||||
@@ -247,7 +295,7 @@ describe("SharedSchedule", () => {
|
||||
|
||||
it("renders generic error when loading share data fails", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
|
||||
if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||
}
|
||||
if (url === "/api/share/token-123") {
|
||||
@@ -270,7 +318,7 @@ describe("SharedSchedule", () => {
|
||||
const sharedData = createSharedDataWithTodayDose(referenceNow);
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
|
||||
if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () =>
|
||||
@@ -296,7 +344,7 @@ describe("SharedSchedule", () => {
|
||||
const sharedData = createSharedDataWithEmbeddedOverview();
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
|
||||
if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||
}
|
||||
if (url === "/api/share/token-123") {
|
||||
@@ -318,4 +366,90 @@ describe("SharedSchedule", () => {
|
||||
expect(screen.getAllByText("3 x 150 form.packageAmountUnitMl").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("share.noSchedule")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("skips a neutral shared dose via the skip endpoint", async () => {
|
||||
const referenceNow = new Date();
|
||||
referenceNow.setHours(12, 0, 0, 0);
|
||||
vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime());
|
||||
const sharedData = createSharedDataWithTodayDose(referenceNow);
|
||||
const { fetchMock, requests } = createSharedDoseFetchMock({ sharedData });
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
renderSharedSchedule("/share/token-123");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".dose-btn.skip")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("dose.skip"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requests).toContainEqual({
|
||||
url: "/api/share/token-123/doses/skip",
|
||||
method: "POST",
|
||||
body: { doseId: sharedData.automaticDoseId },
|
||||
});
|
||||
expect(document.querySelector(".dose-btn.undo.skip")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("undoes a skipped shared dose via the delete skip endpoint", async () => {
|
||||
const referenceNow = new Date();
|
||||
referenceNow.setHours(12, 0, 0, 0);
|
||||
vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime());
|
||||
const sharedData = createSharedDataWithTodayDose(referenceNow);
|
||||
const { fetchMock, requests } = createSharedDoseFetchMock({
|
||||
sharedData,
|
||||
initialDoses: [{ doseId: sharedData.automaticDoseId, skipped: true }],
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
renderSharedSchedule("/share/token-123");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".dose-btn.undo.skip")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("dose.undoSkip"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requests).toContainEqual({
|
||||
url: `/api/share/token-123/doses/skip/${sharedData.automaticDoseId}`,
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(document.querySelector(".dose-btn.skip")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("takes a skipped shared dose again via the take endpoint", async () => {
|
||||
const referenceNow = new Date();
|
||||
referenceNow.setHours(12, 0, 0, 0);
|
||||
vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime());
|
||||
const sharedData = createSharedDataWithTodayDose(referenceNow);
|
||||
const { fetchMock, requests, getDoses } = createSharedDoseFetchMock({
|
||||
sharedData,
|
||||
initialDoses: [{ doseId: sharedData.automaticDoseId, skipped: true }],
|
||||
});
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
renderSharedSchedule("/share/token-123");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(".dose-btn.undo.skip")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("dose.take"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requests).toContainEqual({
|
||||
url: "/api/share/token-123/doses",
|
||||
method: "POST",
|
||||
body: { doseId: sharedData.automaticDoseId },
|
||||
});
|
||||
expect(getDoses()).toEqual([
|
||||
expect.objectContaining({ doseId: sharedData.automaticDoseId, takenSource: "manual" }),
|
||||
]);
|
||||
expect(document.querySelector(".day-block.today")).toHaveClass("all-taken");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,7 +77,7 @@ describe("SharedSchedule today-only", () => {
|
||||
const sharedData = createSharedData();
|
||||
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (url === "/api/share/token-123/doses" && (!init || !init.method || init.method === "GET")) {
|
||||
if (url === "/api/share/token-123/doses" && (!init?.method || init.method === "GET")) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ doses: [] }) });
|
||||
}
|
||||
if (url === "/api/share/token-123") {
|
||||
|
||||
Reference in New Issue
Block a user