feat: add shared overview and harden frontend session state (#407)

This commit is contained in:
Daniel Volz
2026-03-10 06:26:03 +01:00
committed by GitHub
parent 733fe2f38a
commit 105eb7bc0d
37 changed files with 3281 additions and 1138 deletions
+1
View File
@@ -57,6 +57,7 @@ vi.mock("../pages", () => ({
PlannerPage: () => <div>planner-page</div>,
SchedulePage: () => <div>schedule-page</div>,
SettingsPage: () => <div>settings-page</div>,
SharedOverviewPage: () => <div>shared-overview-page</div>,
}));
describe("App", () => {
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MedDetailModal } from "../../components/MedDetailModal";
import type { Coverage, Medication, RefillEntry, StockThresholds } from "../../types";
@@ -410,6 +410,112 @@ describe("MedDetailModal with refill modal", () => {
expect(screen.getByText("editStock.packageSize_150")).toBeInTheDocument();
});
it("shows bottles-based refill input for liquid container and preview in ml package amount", () => {
const liquidMed: Medication = {
...mockMedication,
name: "Liquid Med",
packageType: "liquid_container",
packCount: 1,
packageAmountValue: 150,
packageAmountUnit: "ml",
totalPills: 150,
looseTablets: 150,
};
render(<MedDetailModal {...defaultProps} selectedMed={liquidMed} showRefillModal={true} refillLoose={150} />);
const refillModal = document.querySelector(".refill-modal");
expect(refillModal).not.toBeNull();
expect(within(refillModal as HTMLElement).getByText(/form\.bottles/i)).toBeInTheDocument();
expect(screen.queryByText(/refill\.pillsToAdd/i)).not.toBeInTheDocument();
expect(screen.getByText(/\+150 form\.packageAmountUnitMl/i)).toBeInTheDocument();
});
it("maps liquid refill bottle input to package amount in ml", () => {
const liquidMed: Medication = {
...mockMedication,
name: "Liquid Med",
packageType: "liquid_container",
packCount: 1,
packageAmountValue: 150,
packageAmountUnit: "ml",
totalPills: 150,
looseTablets: 150,
};
const onRefillLooseChange = vi.fn();
const onRefillPacksChange = vi.fn();
render(
<MedDetailModal
{...defaultProps}
selectedMed={liquidMed}
showRefillModal={true}
onRefillLooseChange={onRefillLooseChange}
onRefillPacksChange={onRefillPacksChange}
refillLoose={0}
/>
);
const input = document.querySelector(".refill-modal input[type='number']") as HTMLInputElement;
fireEvent.change(input, { target: { value: "2" } });
expect(onRefillPacksChange).toHaveBeenCalledWith(2);
expect(onRefillLooseChange).toHaveBeenCalledWith(300);
});
it("shows tubes-based refill input for tube package and preview in g package amount", () => {
const tubeMed: Medication = {
...mockMedication,
name: "Tube Med",
packageType: "tube",
packCount: 4,
packageAmountValue: 150,
packageAmountUnit: "g",
totalPills: 600,
looseTablets: 600,
};
render(<MedDetailModal {...defaultProps} selectedMed={tubeMed} showRefillModal={true} refillLoose={150} />);
const refillModal = document.querySelector(".refill-modal");
expect(refillModal).not.toBeNull();
expect(within(refillModal as HTMLElement).getByText(/form\.tubes/i)).toBeInTheDocument();
expect(screen.queryByText(/refill\.pillsToAdd/i)).not.toBeInTheDocument();
expect(screen.getByText(/\+150 form\.packageAmountUnitG/i)).toBeInTheDocument();
});
it("maps tube refill count input to package amount in g", () => {
const tubeMed: Medication = {
...mockMedication,
name: "Tube Med",
packageType: "tube",
packCount: 4,
packageAmountValue: 150,
packageAmountUnit: "g",
totalPills: 600,
looseTablets: 600,
};
const onRefillLooseChange = vi.fn();
const onRefillPacksChange = vi.fn();
render(
<MedDetailModal
{...defaultProps}
selectedMed={tubeMed}
showRefillModal={true}
onRefillLooseChange={onRefillLooseChange}
onRefillPacksChange={onRefillPacksChange}
refillLoose={0}
/>
);
const input = document.querySelector(".refill-modal input[type='number']") as HTMLInputElement;
fireEvent.change(input, { target: { value: "2" } });
expect(onRefillPacksChange).toHaveBeenCalledWith(2);
expect(onRefillLooseChange).toHaveBeenCalledWith(300);
});
});
describe("MedDetailModal actions", () => {
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ShareDialog } from "../../components/ShareDialog";
@@ -68,8 +68,9 @@ describe("ShareDialog", () => {
it("shows generated link", () => {
render(<ShareDialog {...defaultProps} shareLink="http://example.com/share/abc123" />);
const input = screen.getByRole("textbox");
expect(input).toHaveValue("http://example.com/share/abc123");
const inputs = screen.getAllByRole("textbox") as HTMLInputElement[];
expect(inputs[0]).toHaveValue("http://example.com/share/abc123");
expect(inputs[1]).toHaveValue("http://example.com/share/abc123/overview");
});
it("calls onCopyShareLink when copy button is clicked", () => {
@@ -85,13 +86,23 @@ describe("ShareDialog", () => {
it("selects link text when input is clicked", () => {
render(<ShareDialog {...defaultProps} shareLink="http://example.com/share/abc123" />);
const input = screen.getByRole("textbox") as HTMLInputElement;
const input = screen.getAllByRole("textbox")[0] as HTMLInputElement;
const selectMock = vi.fn();
input.select = selectMock;
fireEvent.click(input);
expect(selectMock).toHaveBeenCalled();
});
it("copies overview link when overview copy button is clicked", async () => {
render(<ShareDialog {...defaultProps} shareLink="http://example.com/share/abc123" />);
fireEvent.click(screen.getByRole("button", { name: /share\.copyOverviewLink/i }));
await waitFor(() => {
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("http://example.com/share/abc123/overview");
});
});
it("calls person and period change callbacks", () => {
render(<ShareDialog {...defaultProps} />);
@@ -294,4 +294,143 @@ describe("UserFilterModal", () => {
expect(screen.queryByText("Med2")).not.toBeInTheDocument();
expect(screen.getByText("Med3")).toBeInTheDocument();
});
it("renders tube intakes as applications and stock in g", () => {
const onClose = vi.fn();
const onOpenMedDetail = vi.fn();
const tubeMedication: Medication = {
...mockMedication,
id: 10,
name: "Tube Med",
genericName: "Tube Generic",
packageType: "tube",
totalPills: 600,
looseTablets: 600,
intakes: [
{
usage: 1,
every: 1,
start: "2024-01-01T21:04:00",
takenBy: "John",
intakeRemindersEnabled: true,
},
],
};
const tubeCoverage: Coverage = {
name: "Tube Med",
medsLeft: 600,
daysLeft: null,
depletionDate: null,
depletionTime: null,
nextDose: null,
};
render(
<UserFilterModal
selectedUser="John"
meds={[tubeMedication]}
coverage={{ all: [tubeCoverage] }}
settings={defaultSettings}
onClose={onClose}
onClearUser={vi.fn()}
onOpenMedDetail={onOpenMedDetail}
/>
);
expect(screen.getByText(/form\.blisters\.applications_1/)).toBeInTheDocument();
expect(screen.getByText("600/600 form.packageAmountUnitG")).toBeInTheDocument();
expect(screen.queryByText(/600\/600 .*common\.pills/)).not.toBeInTheDocument();
});
it("renders liquid container intakes and stock in ml", () => {
const onClose = vi.fn();
const onOpenMedDetail = vi.fn();
const liquidMedication: Medication = {
...mockMedication,
id: 11,
name: "Liquid Container",
genericName: "Liquid Generic",
packageType: "liquid_container",
totalPills: 150,
looseTablets: 150,
intakes: [
{
usage: 2,
every: 1,
start: "2024-01-01T09:32:00",
intakeUnit: "ml",
takenBy: "John",
intakeRemindersEnabled: true,
},
],
};
const liquidCoverage: Coverage = {
name: "Liquid Container",
medsLeft: 0,
daysLeft: 0,
depletionDate: null,
depletionTime: null,
nextDose: null,
};
render(
<UserFilterModal
selectedUser="John"
meds={[liquidMedication]}
coverage={{ all: [liquidCoverage] }}
settings={defaultSettings}
onClose={onClose}
onClearUser={vi.fn()}
onOpenMedDetail={onOpenMedDetail}
/>
);
expect(screen.getByText(/2 form\.packageAmountUnitMl common\.daily/)).toBeInTheDocument();
expect(screen.getByText("0/150 form.packageAmountUnitMl")).toBeInTheDocument();
expect(screen.queryByText(/0\/150 .*common\.pills/)).not.toBeInTheDocument();
});
it("renders medicationForm liquid as ml in modal fallback", () => {
const onClose = vi.fn();
const onOpenMedDetail = vi.fn();
const legacyLiquidMedication: Medication = {
...mockMedication,
id: 12,
name: "Legacy Liquid",
medicationForm: "liquid",
packageType: "bottle",
totalPills: 100,
looseTablets: 100,
blisters: [{ usage: 1, every: 1, start: "2024-01-01T10:00:00" }],
};
const legacyLiquidCoverage: Coverage = {
name: "Legacy Liquid",
medsLeft: 40,
daysLeft: 10,
depletionDate: null,
depletionTime: null,
nextDose: null,
};
render(
<UserFilterModal
selectedUser="John"
meds={[legacyLiquidMedication]}
coverage={{ all: [legacyLiquidCoverage] }}
settings={defaultSettings}
onClose={onClose}
onClearUser={vi.fn()}
onOpenMedDetail={onOpenMedDetail}
/>
);
expect(screen.getByText(/1 form\.packageAmountUnitMl common\.daily/)).toBeInTheDocument();
expect(screen.getByText("40/100 form.packageAmountUnitMl")).toBeInTheDocument();
});
});
@@ -88,6 +88,7 @@ describe("useAppContext", () => {
saving: false,
setSaving: vi.fn(),
uploadingImage: false,
clearMedicationsState: vi.fn(),
loadMeds,
deleteMed: vi.fn(),
uploadMedImage: vi.fn(),
@@ -173,6 +174,7 @@ describe("useAppContext", () => {
expiryWarningDays: 30,
},
settingsLoading: false,
settingsLoadError: null,
settingsSaving: false,
settingsSaved: false,
testingEmail: false,
@@ -186,6 +188,7 @@ describe("useAppContext", () => {
testEmail: vi.fn(),
testShoutrrr: vi.fn(),
hasUnsavedChanges: false,
resetSettingsState: vi.fn(),
});
mockUseDoses.mockReturnValue({
@@ -195,7 +198,9 @@ describe("useAppContext", () => {
dismissedDoses: new Set<string>(),
showClearMissedConfirm: true,
setShowClearMissedConfirm: vi.fn(),
clearDosesState: vi.fn(),
getDoseId: vi.fn((base: string, person: string | null) => (person ? `${base}-${person}` : base)),
isDoseTakenAutomatically: vi.fn(() => false),
countTakenDoses: vi.fn(() => ({ total: 0, taken: 0 })),
markDoseTaken: vi.fn(),
undoDoseTaken: vi.fn(),
@@ -234,6 +239,8 @@ describe("useAppContext", () => {
setRefillPacks: vi.fn(),
refillLoose: 0,
setRefillLoose: vi.fn(),
usePrescriptionRefill: false,
setUsePrescriptionRefill: vi.fn(),
refillSaving: false,
refillHistory: [],
refillHistoryExpanded: false,
@@ -244,7 +251,11 @@ describe("useAppContext", () => {
setEditStockFullBlisters: vi.fn(),
editStockPartialBlisterPills: 0,
setEditStockPartialBlisterPills: vi.fn(),
editStockLoosePills: 0,
setEditStockLoosePills: vi.fn(),
editStockSaving: false,
editStockMedication: null,
clearRefillState: vi.fn(),
loadRefillHistory: vi.fn(),
submitRefill: vi.fn(),
submitStockCorrection: vi.fn(),
@@ -283,6 +294,55 @@ describe("useAppContext", () => {
expect(result.current.settingsChanged).toBe(false);
});
it("exposes the settings load error from useSettings", async () => {
const settingsValue = mockUseSettings();
mockUseSettings.mockReturnValue({
...settingsValue,
settingsLoadError: "forbidden",
});
const { result } = renderHook(() => useAppContext(), { wrapper });
await waitFor(() => {
expect(result.current.settingsLoadError).toBe("forbidden");
});
});
it("clears user-scoped state and reloads data when authenticated user changes", async () => {
const { result, rerender } = renderHook(() => useAppContext(), { wrapper });
act(() => {
result.current.openImageLightbox();
result.current.openUserFilter("Max");
result.current.setShowImportConfirm(true);
result.current.setPendingImportData({ version: "1" });
result.current.setImportResult({ medications: 1, doses: 1, refills: 0, shares: 0 });
});
const clearMedicationsStateBefore = mockUseMedications().clearMedicationsState.mock.calls.length;
const resetSettingsStateBefore = mockUseSettings().resetSettingsState.mock.calls.length;
const clearDosesStateBefore = mockUseDoses().clearDosesState.mock.calls.length;
const clearRefillStateBefore = mockUseRefill().clearRefillState.mock.calls.length;
const resetShareDialogStateBefore = mockUseShare().resetShareDialogState.mock.calls.length;
mockUseAuth.mockReturnValue({ user: { id: 8, username: "other-user" } });
rerender();
await waitFor(() => {
expect(mockUseMedications().clearMedicationsState).toHaveBeenCalledTimes(clearMedicationsStateBefore + 1);
expect(mockUseSettings().resetSettingsState).toHaveBeenCalledTimes(resetSettingsStateBefore + 1);
expect(mockUseDoses().clearDosesState).toHaveBeenCalledTimes(clearDosesStateBefore + 1);
expect(mockUseRefill().clearRefillState).toHaveBeenCalledTimes(clearRefillStateBefore + 1);
expect(mockUseShare().resetShareDialogState).toHaveBeenCalledTimes(resetShareDialogStateBefore + 1);
});
expect(result.current.selectedUser).toBeNull();
expect(result.current.showImageLightbox).toBe(false);
expect(result.current.showImportConfirm).toBe(false);
expect(result.current.pendingImportData).toBeNull();
expect(result.current.importResult).toBeNull();
});
it("wraps share dialog opener with current medications", async () => {
const { result } = renderHook(() => useAppContext(), { wrapper });
+26
View File
@@ -304,4 +304,30 @@ describe("useDoses", () => {
expect(fetch).toHaveBeenCalledWith("/api/doses/taken/dose%201%2Fa", expect.objectContaining({ method: "DELETE" }));
});
it("clears dose state when API returns 401", async () => {
const mockDoses = {
doses: [{ doseId: "dose-1", takenAt: Date.now(), dismissed: false }],
};
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockDoses) })
.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) });
const { result } = renderHook(() => useDoses());
await waitFor(() => {
expect(result.current.takenDoses.has("dose-1")).toBe(true);
});
await act(async () => {
await result.current.loadTakenDoses();
});
await waitFor(() => {
expect(result.current.takenDoses.size).toBe(0);
expect(result.current.dismissedDoses.size).toBe(0);
expect(result.current.takenDoseTimestamps.size).toBe(0);
});
});
});
+303
View File
@@ -14,6 +14,7 @@ describe("useSettings", () => {
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
it("initializes with default settings", () => {
@@ -52,9 +53,61 @@ describe("useSettings", () => {
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settingsLoadError).toBe("request");
});
});
it("maps a failed authenticated settings load to an auth error state", async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) })
.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) });
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settingsLoadError).toBe("auth");
});
expect(result.current.settings.emailEnabled).toBe(false);
expect(result.current.settings.notificationEmail).toBe("");
});
it("maps a forbidden settings load to a forbidden error state", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
status: 403,
json: () => Promise.resolve({}),
});
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settingsLoadError).toBe("forbidden");
});
});
it("retries loading settings after a successful refresh", async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ emailEnabled: true, notificationEmail: "refreshed@example.com" }),
});
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settingsLoading).toBe(false);
});
expect(result.current.settings.notificationEmail).toBe("refreshed@example.com");
expect(global.fetch).toHaveBeenNthCalledWith(
2,
"/api/auth/refresh",
expect.objectContaining({ method: "POST", credentials: "include" })
);
});
it("saves settings to API", async () => {
@@ -154,6 +207,28 @@ describe("useSettings", () => {
expect(result.current.testEmailResult?.success).toBe(false);
});
it("uses backend error messages for failed test email responses", async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
.mockResolvedValueOnce({
ok: false,
status: 400,
json: () => Promise.resolve({ message: "Recipient rejected" }),
});
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settingsLoading).toBe(false);
});
await act(async () => {
await result.current.testEmail();
});
expect(result.current.testEmailResult).toEqual({ success: false, message: "Recipient rejected" });
});
it("tests shoutrrr notification", async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
@@ -176,6 +251,28 @@ describe("useSettings", () => {
expect(result.current.testingShoutrrr).toBe(false);
});
it("uses backend error messages for failed test notifications", async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
.mockResolvedValueOnce({
ok: false,
status: 500,
json: () => Promise.resolve({ message: "Push target rejected" }),
});
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settingsLoading).toBe(false);
});
await act(async () => {
await result.current.testShoutrrr();
});
expect(result.current.testShoutrrrResult).toEqual({ success: false, message: "Push target rejected" });
});
it("tracks unsaved changes", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
@@ -278,6 +375,68 @@ describe("useSettings", () => {
expect(result.current.settings.shoutrrrEnabled).toBe(true);
});
it("reloads backend state when saving settings fails", async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
.mockResolvedValueOnce({ ok: false, status: 500, json: () => Promise.resolve({}) })
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ lowStockDays: 14, notificationEmail: "server@example.com" }),
});
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settingsLoading).toBe(false);
});
act(() => {
result.current.setSettings((current) => ({ ...current, lowStockDays: 99 }));
});
await act(async () => {
await result.current.saveSettings();
});
await waitFor(() => {
expect(result.current.settings.lowStockDays).toBe(14);
});
expect(result.current.settingsSaved).toBe(false);
expect(result.current.settings.notificationEmail).toBe("server@example.com");
});
it("resets all transient state back to defaults", async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ notificationEmail: "test@example.com" }) })
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ message: "Email sent!" }),
});
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settingsLoading).toBe(false);
});
await act(async () => {
await result.current.testEmail();
});
expect(result.current.testEmailResult).toEqual({ success: true, message: "Email sent!" });
act(() => {
result.current.resetSettingsState();
});
expect(result.current.settings.notificationEmail).toBe("");
expect(result.current.savedSettings.notificationEmail).toBe("");
expect(result.current.testEmailResult).toBeNull();
expect(result.current.settingsSaved).toBe(false);
expect(result.current.settingsLoadError).toBeNull();
});
it("refreshes reminder status on interval", async () => {
let refreshCallback: (() => void) | null = null;
const nativeSetInterval = global.setInterval;
@@ -324,4 +483,148 @@ describe("useSettings", () => {
expect(result.current.settings.lastStockReminderChannel).toBe("both");
});
});
it("clears reminder metadata when refresh returns explicit null values", async () => {
let refreshCallback: (() => void) | null = null;
const nativeSetInterval = global.setInterval;
vi.spyOn(global, "setInterval").mockImplementation(((handler: TimerHandler, timeout?: number) => {
if (timeout === 30000) {
refreshCallback = handler as () => void;
return 1 as unknown as ReturnType<typeof setInterval>;
}
return nativeSetInterval(handler, timeout);
}) as typeof setInterval);
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
lastAutoEmailSent: "2026-01-01T10:00:00.000Z",
lastNotificationType: "stock",
lastNotificationChannel: "email",
lastReminderMedName: "Aspirin",
lastReminderTakenBy: "Max",
lastStockReminderSent: "2026-01-01T09:00:00.000Z",
lastStockReminderChannel: "both",
lastStockReminderMedNames: "Aspirin",
}),
})
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
lastAutoEmailSent: null,
lastNotificationType: null,
lastNotificationChannel: null,
lastReminderMedName: null,
lastReminderTakenBy: null,
lastStockReminderSent: null,
lastStockReminderChannel: null,
lastStockReminderMedNames: null,
lastPrescriptionReminderSent: null,
lastPrescriptionReminderChannel: null,
lastPrescriptionReminderMedNames: null,
}),
});
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settingsLoading).toBe(false);
});
expect(result.current.settings.lastNotificationType).toBe("stock");
expect(refreshCallback).not.toBeNull();
act(() => {
refreshCallback?.();
});
await waitFor(() => {
expect(result.current.settings.lastAutoEmailSent).toBeNull();
expect(result.current.settings.lastNotificationType).toBeNull();
expect(result.current.settings.lastNotificationChannel).toBeNull();
expect(result.current.settings.lastReminderMedName).toBeNull();
expect(result.current.settings.lastStockReminderSent).toBeNull();
});
});
it("clears reminder metadata when refresh returns 401", async () => {
let refreshCallback: (() => void) | null = null;
const nativeSetInterval = global.setInterval;
vi.spyOn(global, "setInterval").mockImplementation(((handler: TimerHandler, timeout?: number) => {
if (timeout === 30000) {
refreshCallback = handler as () => void;
return 1 as unknown as ReturnType<typeof setInterval>;
}
return nativeSetInterval(handler, timeout);
}) as typeof setInterval);
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
lastAutoEmailSent: "2026-01-01T10:00:00.000Z",
lastNotificationType: "stock",
lastNotificationChannel: "email",
lastReminderMedName: "Aspirin",
lastReminderTakenBy: "Max",
lastStockReminderSent: "2026-01-01T09:00:00.000Z",
lastStockReminderChannel: "both",
lastStockReminderMedNames: "Aspirin",
}),
})
.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) })
.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) });
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settingsLoading).toBe(false);
});
expect(result.current.settings.lastNotificationType).toBe("stock");
expect(refreshCallback).not.toBeNull();
act(() => {
refreshCallback?.();
});
await waitFor(() => {
expect(result.current.settings.lastAutoEmailSent).toBeNull();
expect(result.current.settings.lastNotificationType).toBeNull();
expect(result.current.settings.lastNotificationChannel).toBeNull();
});
});
it("resets to defaults when loadSettings gets 401", async () => {
(global.fetch as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ emailEnabled: true, notificationEmail: "test@example.com" }),
})
.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) })
.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) });
const { result } = renderHook(() => useSettings());
await waitFor(() => {
expect(result.current.settingsLoading).toBe(false);
});
expect(result.current.settings.emailEnabled).toBe(true);
act(() => {
result.current.loadSettings();
});
await waitFor(() => {
expect(result.current.settingsLoadError).toBe("auth");
});
expect(result.current.settings.emailEnabled).toBe(false);
expect(result.current.settings.notificationEmail).toBe("");
});
});
@@ -64,6 +64,7 @@ const createMockContext = (overrides = {}) => ({
},
setSettings: vi.fn(),
settingsLoading: false,
settingsLoadError: null,
settingsSaving: false,
settingsSaved: false,
saveSettings: vi.fn((e?: Event) => e?.preventDefault?.()),
@@ -292,6 +293,41 @@ describe("SettingsPage", () => {
expect(testEmail).toHaveBeenCalledTimes(1);
});
it("shows the settings load failure reason in the email section", () => {
mockContextValue = createMockContext({
settingsLoadError: "forbidden",
settings: {
...createMockContext().settings,
smtpHost: "smtp.example.com",
},
});
renderPage();
expect(screen.getByText("settings.email.loadErrorForbidden")).toBeInTheDocument();
expect(screen.queryByText("settings.email.serverNotConfigured")).not.toBeInTheDocument();
});
it("keeps the email toggle enabled when SMTP host is present", () => {
mockContextValue = createMockContext({
settings: {
...createMockContext().settings,
smtpHost: "smtp.example.com",
},
});
renderPage();
expect(screen.queryByText("settings.email.serverNotConfigured")).not.toBeInTheDocument();
const emailHeading = screen
.getAllByText("settings.notifications.email")
.find((element) => element.tagName === "H3");
expect(emailHeading).toBeDefined();
const emailToggle = emailHeading?.parentElement?.querySelector('input[type="checkbox"]') as HTMLInputElement | null;
expect(emailToggle).not.toBeNull();
expect(emailToggle).not.toBeDisabled();
});
it("calls testShoutrrr when push test button is clicked", () => {
const testShoutrrr = vi.fn();
mockContextValue = createMockContext({
@@ -0,0 +1,110 @@
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SharedOverviewPage } from "../../pages/SharedOverviewPage";
function renderSharedOverview(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/share/:token/overview" element={<SharedOverviewPage />} />
</Routes>
</MemoryRouter>
);
}
describe("SharedOverviewPage", () => {
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
});
it("renders medication overview for valid token", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
json: () =>
Promise.resolve({
takenBy: "Max",
sharedBy: "Owner",
generatedAt: "2026-03-06T10:00:00.000Z",
medications: [
{
name: "Aspirin",
genericName: "Acetylsalicylic Acid",
imageUrl: null,
packageType: "blister",
packCount: 1,
blistersPerPack: 2,
pillsPerBlister: 10,
totalPills: null,
looseTablets: 0,
currentStock: 18,
capacity: 20,
daysLeft: 18,
nextIntakeDate: "2026-03-07",
depletionDate: "2026-03-24",
priority: "normal",
expiryDate: null,
medicationStartDate: null,
prescriptionEnabled: false,
prescriptionRemainingRefills: null,
},
],
}),
});
renderSharedOverview("/share/abcdef0123456789/overview");
await waitFor(() => {
expect(screen.getByText("sharedOverview.title")).toBeInTheDocument();
expect(screen.getAllByText("Aspirin").length).toBeGreaterThan(0);
expect(screen.getAllByText("Acetylsalicylic Acid").length).toBeGreaterThan(0);
});
expect(globalThis.fetch).toHaveBeenCalledWith("/api/share/abcdef0123456789/overview");
});
it("renders not found state for missing token", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 404,
json: () => Promise.resolve({ error: "not_found" }),
});
renderSharedOverview("/share/abcdef0123456789/overview");
await waitFor(() => {
expect(screen.getByText("sharedOverview.error.notFound")).toBeInTheDocument();
});
});
it("renders expired state for expired token", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 410,
json: () => Promise.resolve({ error: "expired", expiredAt: "2026-03-01T10:00:00.000Z" }),
});
renderSharedOverview("/share/abcdef0123456789/overview");
await waitFor(() => {
expect(screen.getByText("sharedOverview.error.expired")).toBeInTheDocument();
expect(screen.getByText("sharedOverview.expiredOn")).toBeInTheDocument();
});
});
it("renders rate-limit error state", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 429,
json: () => Promise.resolve({ error: "rate_limited" }),
});
renderSharedOverview("/share/abcdef0123456789/overview");
await waitFor(() => {
expect(screen.getByText("sharedOverview.error.rateLimit")).toBeInTheDocument();
});
});
});
+14 -10
View File
@@ -59,18 +59,22 @@ Object.defineProperty(window, "history", {
});
// Mock react-i18next globally
const mockT = (key: string, options?: Record<string, unknown>) => {
if (options?.count !== undefined) return `${key}_${options.count}`;
if (options?.max !== undefined) return `Max ${options.max} chars`;
if (options?.days !== undefined) return `${key} (${options.days} days)`;
return key;
};
const mockI18n = {
language: "en",
changeLanguage: vi.fn(),
};
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (options?.count !== undefined) return `${key}_${options.count}`;
if (options?.max !== undefined) return `Max ${options.max} chars`;
if (options?.days !== undefined) return `${key} (${options.days} days)`;
return key;
},
i18n: {
language: "en",
changeLanguage: vi.fn(),
},
t: mockT,
i18n: mockI18n,
}),
I18nextProvider: ({ children }: { children: React.ReactNode }) => children,
initReactI18next: { type: "3rdParty", init: vi.fn() },