test: improve frontend test coverage (#163)

- Export DashboardPage helper functions for testability
- Add new test files: App, SharedSchedule, AppContext, UnsavedChangesContext, useUnsavedChangesWarning
- Expand existing test coverage for Auth, MedDetailModal, MobileEditModal, DashboardPage, MedicationsPage, PlannerPage, and more
- Add edge case and error handling tests across components, hooks, and pages
This commit is contained in:
Daniel Volz
2026-02-13 18:34:19 +01:00
committed by GitHub
parent 0b0472f2f5
commit 5c09f97cb3
24 changed files with 4482 additions and 45 deletions
@@ -0,0 +1,532 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AppProvider, useAppContext } from "../../context/AppContext";
import type { Medication } from "../../types";
const mockUseAuth = vi.fn();
const mockUseMedications = vi.fn();
const mockUseSettings = vi.fn();
const mockUseDoses = vi.fn();
const mockUseCollapsedDays = vi.fn();
const mockUseShare = vi.fn();
const mockUseRefill = vi.fn();
const mockBuildSchedulePreview = vi.fn();
const mockCalculateCoverage = vi.fn();
const mockComputeMissedPastDoseIds = vi.fn();
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: "en" },
}),
}));
vi.mock("../../components/Auth", () => ({
useAuth: () => mockUseAuth(),
}));
vi.mock("../../hooks", () => ({
useMedications: () => mockUseMedications(),
useSettings: () => mockUseSettings(),
useDoses: () => mockUseDoses(),
useCollapsedDays: () => mockUseCollapsedDays(),
useShare: () => mockUseShare(),
useRefill: () => mockUseRefill(),
}));
vi.mock("../../utils/formatters", () => ({
getSystemLocale: () => "en-US",
}));
vi.mock("../../utils/schedule", () => ({
buildSchedulePreview: (...args: unknown[]) => mockBuildSchedulePreview(...args),
calculateCoverage: (...args: unknown[]) => mockCalculateCoverage(...args),
computeMissedPastDoseIds: (...args: unknown[]) => mockComputeMissedPastDoseIds(...args),
isDoseDismissed: vi.fn(() => false),
}));
const meds: Medication[] = [
{
id: 11,
name: "Aspirin",
takenBy: ["Max", "Anna"],
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 2,
blisters: [],
updatedAt: null,
},
];
const wrapper = ({ children }: { children: React.ReactNode }) => <AppProvider>{children}</AppProvider>;
describe("useAppContext", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(window.history, "pushState").mockImplementation(() => {});
vi.spyOn(window.history, "back").mockImplementation(() => {});
(window.localStorage.getItem as ReturnType<typeof vi.fn>).mockReturnValue("90");
const loadMeds = vi.fn();
const loadSettings = vi.fn();
const loadTakenDoses = vi.fn();
mockUseAuth.mockReturnValue({ user: { id: 7, username: "owner" } });
mockUseMedications.mockReturnValue({
meds,
setMeds: vi.fn(),
loading: false,
saving: false,
setSaving: vi.fn(),
uploadingImage: false,
loadMeds,
deleteMed: vi.fn(),
uploadMedImage: vi.fn(),
deleteMedImage: vi.fn(),
});
mockUseSettings.mockReturnValue({
settings: {
emailEnabled: false,
notificationEmail: "",
reminderDaysBefore: 7,
repeatDailyReminders: false,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
lowStockDays: 10,
normalStockDays: 30,
highStockDays: 60,
smtpHost: "",
smtpPort: 587,
smtpUser: "",
smtpPass: "",
smtpFrom: "",
smtpSecure: false,
hasSmtpPassword: false,
lastAutoEmailSent: null,
nextScheduledCheck: null,
lastNotificationType: null,
lastNotificationChannel: null,
lastReminderMedName: null,
lastReminderTakenBy: null,
lastStockReminderSent: null,
lastStockReminderChannel: null,
lastStockReminderMedNames: null,
shoutrrrEnabled: false,
shoutrrrUrl: "",
emailStockReminders: true,
emailIntakeReminders: true,
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
stockCalculationMode: "automatic",
shareStockStatus: true,
expiryWarningDays: 30,
},
setSettings: vi.fn(),
savedSettings: {
emailEnabled: false,
notificationEmail: "",
reminderDaysBefore: 7,
repeatDailyReminders: false,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
lowStockDays: 10,
normalStockDays: 30,
highStockDays: 60,
smtpHost: "",
smtpPort: 587,
smtpUser: "",
smtpPass: "",
smtpFrom: "",
smtpSecure: false,
hasSmtpPassword: false,
lastAutoEmailSent: null,
nextScheduledCheck: null,
lastNotificationType: null,
lastNotificationChannel: null,
lastReminderMedName: null,
lastReminderTakenBy: null,
lastStockReminderSent: null,
lastStockReminderChannel: null,
lastStockReminderMedNames: null,
shoutrrrEnabled: false,
shoutrrrUrl: "",
emailStockReminders: true,
emailIntakeReminders: true,
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
stockCalculationMode: "automatic",
shareStockStatus: true,
expiryWarningDays: 30,
},
settingsLoading: false,
settingsSaving: false,
settingsSaved: false,
testingEmail: false,
testEmailResult: null,
setTestEmailResult: vi.fn(),
testingShoutrrr: false,
testShoutrrrResult: null,
setTestShoutrrrResult: vi.fn(),
loadSettings,
saveSettings: vi.fn(),
testEmail: vi.fn(),
testShoutrrr: vi.fn(),
hasUnsavedChanges: false,
});
mockUseDoses.mockReturnValue({
takenDoses: new Set<string>(),
setTakenDoses: vi.fn(),
takenDoseTimestamps: new Map<string, number>(),
dismissedDoses: new Set<string>(),
showClearMissedConfirm: true,
setShowClearMissedConfirm: vi.fn(),
getDoseId: vi.fn((base: string, person: string | null) => (person ? `${base}-${person}` : base)),
countTakenDoses: vi.fn(() => ({ total: 0, taken: 0 })),
markDoseTaken: vi.fn(),
undoDoseTaken: vi.fn(),
loadTakenDoses,
});
mockUseCollapsedDays.mockReturnValue({
manuallyCollapsedDays: new Set<string>(),
manuallyExpandedDays: new Set<string>(),
toggleDayCollapse: vi.fn(),
});
mockUseShare.mockReturnValue({
showShareDialog: false,
sharePeople: ["Anna", "Max"],
shareSelectedPerson: "Anna",
setShareSelectedPerson: vi.fn(),
shareSelectedDays: 30,
setShareSelectedDays: vi.fn(),
shareGenerating: false,
shareLink: null,
setShareLink: vi.fn(),
shareCopied: false,
setShareCopied: vi.fn(),
openShareDialog: vi.fn(),
generateShareLink: vi.fn(),
copyShareLink: vi.fn(),
closeShareDialog: vi.fn(),
resetShareDialogState: vi.fn(),
});
mockUseRefill.mockReturnValue({
showRefillModal: false,
setShowRefillModal: vi.fn(),
refillPacks: 1,
setRefillPacks: vi.fn(),
refillLoose: 0,
setRefillLoose: vi.fn(),
refillSaving: false,
refillHistory: [],
refillHistoryExpanded: false,
setRefillHistoryExpanded: vi.fn(),
showEditStockModal: false,
setShowEditStockModal: vi.fn(),
editStockFullBlisters: 0,
setEditStockFullBlisters: vi.fn(),
editStockPartialBlisterPills: 0,
setEditStockPartialBlisterPills: vi.fn(),
editStockSaving: false,
loadRefillHistory: vi.fn(),
submitRefill: vi.fn(),
submitStockCorrection: vi.fn(),
openRefillModal: vi.fn(),
closeRefillModal: vi.fn(),
openEditStockModal: vi.fn(),
closeEditStockModal: vi.fn(),
});
mockBuildSchedulePreview.mockReturnValue({ events: [] });
mockCalculateCoverage.mockReturnValue({ all: [], low: [] });
mockComputeMissedPastDoseIds.mockReturnValue([]);
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
text: () => Promise.resolve('{"imported":{"medications":1,"doseHistory":2,"shareLinks":3}}'),
});
});
it("throws if used outside AppProvider", () => {
expect(() => renderHook(() => useAppContext())).toThrow("useAppContext must be used within an AppProvider");
});
it("loads initial values and composes computed fields", async () => {
const { result } = renderHook(() => useAppContext(), { wrapper });
await waitFor(() => {
expect(result.current.scheduleDays).toBe(90);
});
expect(mockUseMedications().loadMeds).toHaveBeenCalled();
expect(mockUseSettings().loadSettings).toHaveBeenCalled();
expect(result.current.existingPeople).toEqual(["Anna", "Max"]);
expect(result.current.stockThresholds.lowStockDays).toBe(10);
expect(result.current.settingsChanged).toBe(false);
});
it("wraps share dialog opener with current medications", async () => {
const { result } = renderHook(() => useAppContext(), { wrapper });
act(() => {
result.current.openShareDialog();
});
expect(mockUseShare().openShareDialog).toHaveBeenCalledWith(meds);
});
it("opens and closes modal helpers via browser history", async () => {
const { result } = renderHook(() => useAppContext(), { wrapper });
act(() => {
result.current.openImageLightbox();
result.current.openScheduleLightbox("image.png");
result.current.openUserFilter("Max");
result.current.openMedDetail(meds[0]);
});
expect(window.history.pushState).toHaveBeenCalled();
expect(mockUseRefill().loadRefillHistory).toHaveBeenCalledWith(11);
act(() => {
result.current.closeImageLightbox();
result.current.closeScheduleLightbox();
result.current.closeUserFilter();
result.current.closeMedDetail();
});
expect(window.history.back).toHaveBeenCalled();
});
it("dismisses missed doses and posts unique medication IDs", async () => {
const { result } = renderHook(() => useAppContext(), { wrapper });
await act(async () => {
await result.current.dismissMissedDoses(["11-0-1730000000000", "11-2-1730000100000", "12-0-1730000200000"]);
});
expect(fetch).toHaveBeenCalledWith(
"/api/medications/dismiss-until",
expect.objectContaining({
method: "POST",
credentials: "include",
})
);
const body = JSON.parse((fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body as string);
expect(body.medicationIds).toEqual([11, 12]);
expect(mockUseMedications().loadMeds).toHaveBeenCalled();
expect(mockUseDoses().setShowClearMissedConfirm).toHaveBeenCalledWith(false);
});
it("does not dismiss missed doses for empty/invalid IDs", async () => {
const { result } = renderHook(() => useAppContext(), { wrapper });
await act(async () => {
await result.current.dismissMissedDoses([]);
await result.current.dismissMissedDoses(["invalid-dose-id"]);
});
expect(fetch).not.toHaveBeenCalledWith("/api/medications/dismiss-until", expect.anything());
});
it("imports data and triggers reload plus import result state", async () => {
const { result } = renderHook(() => useAppContext(), { wrapper });
act(() => {
result.current.setPendingImportData({ version: "1", exportedAt: new Date().toISOString(), medications: [] });
});
await act(async () => {
await result.current.handleImportConfirm();
});
expect(fetch).toHaveBeenCalledWith(
"/api/import",
expect.objectContaining({
method: "POST",
credentials: "include",
})
);
expect(mockUseMedications().loadMeds).toHaveBeenCalled();
expect(mockUseSettings().loadSettings).toHaveBeenCalled();
expect(mockUseDoses().loadTakenDoses).toHaveBeenCalled();
expect(result.current.importResult).toEqual({ medications: 1, doses: 2, shares: 3 });
});
it("exports data and triggers JSON download", async () => {
const click = vi.fn();
const appendChild = vi.spyOn(document.body, "appendChild");
const removeChild = vi.spyOn(document.body, "removeChild");
const createObjectURL = vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:export-url");
const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tagName: string) => {
const element = originalCreateElement(tagName);
if (tagName === "a") {
Object.defineProperty(element, "click", { value: click, configurable: true });
}
return element;
});
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ version: "1", medications: [] }),
});
const { result } = renderHook(() => useAppContext(), { wrapper });
await act(async () => {
await result.current.handleExport(true);
});
expect(fetch).toHaveBeenCalledWith("/api/export?includeSensitive=true&includeImages=true", {
credentials: "include",
});
expect(createObjectURL).toHaveBeenCalled();
expect(click).toHaveBeenCalled();
expect(appendChild).toHaveBeenCalled();
expect(removeChild).toHaveBeenCalled();
expect(revokeObjectURL).toHaveBeenCalledWith("blob:export-url");
});
it("handles invalid import JSON file", () => {
const mockAlert = vi.fn();
global.alert = mockAlert;
class MockFileReader {
onload: ((event: ProgressEvent<FileReader>) => void) | null = null;
readAsText = vi.fn(() => {
this.onload?.({ target: { result: "not-json" } } as unknown as ProgressEvent<FileReader>);
});
}
vi.stubGlobal("FileReader", MockFileReader as unknown as typeof FileReader);
const { result } = renderHook(() => useAppContext(), { wrapper });
const file = new File(["bad"], "bad.json", { type: "application/json" });
act(() => {
result.current.handleImportFileSelect({
target: { files: [file], value: "file" },
} as unknown as React.ChangeEvent<HTMLInputElement>);
});
expect(mockAlert).toHaveBeenCalledWith("exportImport.invalidFile");
});
it("parses valid import file and opens confirm modal", () => {
class MockFileReader {
onload: ((event: ProgressEvent<FileReader>) => void) | null = null;
readAsText = vi.fn(() => {
this.onload?.({
target: {
result: JSON.stringify({ version: "1", exportedAt: "2026-01-01T00:00:00.000Z", medications: [] }),
},
} as unknown as ProgressEvent<FileReader>);
});
}
vi.stubGlobal("FileReader", MockFileReader as unknown as typeof FileReader);
const { result } = renderHook(() => useAppContext(), { wrapper });
const file = new File(["ok"], "ok.json", { type: "application/json" });
act(() => {
result.current.handleImportFileSelect({
target: { files: [file], value: "file" },
} as unknown as React.ChangeEvent<HTMLInputElement>);
});
expect(result.current.showImportConfirm).toBe(true);
expect(result.current.pendingImportData).toEqual({
version: "1",
exportedAt: "2026-01-01T00:00:00.000Z",
medications: [],
});
});
it("computes day stock status as warning and danger for low/out stock", async () => {
mockCalculateCoverage.mockReturnValue({
all: [
{
name: "Aspirin",
daysLeft: 2,
medsLeft: 5,
depletionTime: Date.now() + 100000,
},
{
name: "Vitamin C",
daysLeft: 0,
medsLeft: 0,
depletionTime: Date.now() - 100000,
},
],
low: [],
});
const { result } = renderHook(() => useAppContext(), { wrapper });
expect(result.current.getDayStockStatus([{ medName: "Aspirin", lastWhen: Date.now() }])).toBe("warning");
expect(result.current.getDayStockStatus([{ medName: "Vitamin C", lastWhen: Date.now() }])).toBe("danger");
});
it("does not navigate back when closing modals that are not open", () => {
const { result } = renderHook(() => useAppContext(), { wrapper });
act(() => {
result.current.closeImageLightbox();
result.current.closeScheduleLightbox();
result.current.closeUserFilter();
result.current.closeMedDetail();
});
expect(window.history.back).not.toHaveBeenCalled();
});
it("shows import error alert when import API returns non-ok response", async () => {
const mockAlert = vi.fn();
global.alert = mockAlert;
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 500,
text: () => Promise.resolve('{"error":"Import failed"}'),
});
const { result } = renderHook(() => useAppContext(), { wrapper });
act(() => {
result.current.setPendingImportData({ version: "1", exportedAt: new Date().toISOString(), medications: [] });
});
await act(async () => {
await result.current.handleImportConfirm();
});
expect(mockAlert).toHaveBeenCalledWith("exportImport.importError: Import failed");
});
it("keeps clear-missed confirm open when dismiss request fails", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("network"));
const { result } = renderHook(() => useAppContext(), { wrapper });
await act(async () => {
await result.current.dismissMissedDoses(["11-0-1730000000000"]);
});
expect(mockUseDoses().setShowClearMissedConfirm).not.toHaveBeenCalledWith(false);
});
});
@@ -0,0 +1,91 @@
import { fireEvent, render, renderHook, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { describe, expect, it } from "vitest";
import { UnsavedChangesProvider, useUnsavedChanges } from "../../context/UnsavedChangesContext";
function TestConsumer() {
const { hasUnsavedChanges, setHasUnsavedChanges, confirmNavigation } = useUnsavedChanges();
const [result, setResult] = useState("idle");
return (
<div>
<div data-testid="has-unsaved">{String(hasUnsavedChanges)}</div>
<div data-testid="result">{result}</div>
<button type="button" onClick={() => setHasUnsavedChanges(true)}>
set-unsaved
</button>
<button
type="button"
onClick={async () => {
const shouldProceed = await confirmNavigation();
setResult(String(shouldProceed));
}}
>
confirm-navigation
</button>
</div>
);
}
describe("UnsavedChangesContext", () => {
it("throws if used outside provider", () => {
expect(() => renderHook(() => useUnsavedChanges())).toThrow(
"useUnsavedChanges must be used within UnsavedChangesProvider"
);
});
it("resolves confirmNavigation immediately when there are no unsaved changes", async () => {
render(
<UnsavedChangesProvider>
<TestConsumer />
</UnsavedChangesProvider>
);
fireEvent.click(screen.getByText("confirm-navigation"));
await waitFor(() => {
expect(screen.getByTestId("result")).toHaveTextContent("true");
});
expect(screen.queryByText("common.unsavedChanges.title")).not.toBeInTheDocument();
});
it("opens confirmation modal and resolves false on cancel", async () => {
render(
<UnsavedChangesProvider>
<TestConsumer />
</UnsavedChangesProvider>
);
fireEvent.click(screen.getByText("set-unsaved"));
expect(screen.getByTestId("has-unsaved")).toHaveTextContent("true");
fireEvent.click(screen.getByText("confirm-navigation"));
expect(screen.getByText("common.unsavedChanges.title")).toBeInTheDocument();
fireEvent.click(screen.getByText("common.unsavedChanges.stay"));
await waitFor(() => {
expect(screen.getByTestId("result")).toHaveTextContent("false");
});
expect(screen.queryByText("common.unsavedChanges.title")).not.toBeInTheDocument();
});
it("opens confirmation modal and resolves true on confirm", async () => {
render(
<UnsavedChangesProvider>
<TestConsumer />
</UnsavedChangesProvider>
);
fireEvent.click(screen.getByText("set-unsaved"));
fireEvent.click(screen.getByText("confirm-navigation"));
expect(screen.getByText("common.unsavedChanges.title")).toBeInTheDocument();
fireEvent.click(screen.getByText("common.unsavedChanges.leave"));
await waitFor(() => {
expect(screen.getByTestId("result")).toHaveTextContent("true");
});
expect(screen.queryByText("common.unsavedChanges.title")).not.toBeInTheDocument();
});
});