328 lines
8.9 KiB
TypeScript
328 lines
8.9 KiB
TypeScript
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
import type React from "react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { useSettings } from "../../hooks/useSettings";
|
|
|
|
describe("useSettings", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
ok: true,
|
|
json: () => Promise.resolve({}),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("initializes with default settings", () => {
|
|
const { result } = renderHook(() => useSettings());
|
|
|
|
expect(result.current.settings.emailEnabled).toBe(false);
|
|
expect(result.current.settings.lowStockDays).toBe(30);
|
|
expect(result.current.settings.reminderDaysBefore).toBe(7);
|
|
expect(result.current.settingsLoading).toBe(true);
|
|
});
|
|
|
|
it("loads settings from API on mount", async () => {
|
|
const mockSettings = {
|
|
emailEnabled: true,
|
|
notificationEmail: "test@example.com",
|
|
lowStockDays: 14,
|
|
};
|
|
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve(mockSettings),
|
|
});
|
|
|
|
const { result } = renderHook(() => useSettings());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.settingsLoading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.settings.emailEnabled).toBe(true);
|
|
expect(result.current.settings.notificationEmail).toBe("test@example.com");
|
|
});
|
|
|
|
it("handles API error on load", async () => {
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("Network error"));
|
|
|
|
const { result } = renderHook(() => useSettings());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.settingsLoading).toBe(false);
|
|
});
|
|
});
|
|
|
|
it("saves settings to API", async () => {
|
|
(global.fetch as ReturnType<typeof vi.fn>)
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
|
|
.mockResolvedValueOnce({ ok: true });
|
|
|
|
const { result } = renderHook(() => useSettings());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.settingsLoading).toBe(false);
|
|
});
|
|
|
|
const mockEvent = { preventDefault: vi.fn() } as unknown as React.FormEvent;
|
|
|
|
await act(async () => {
|
|
await result.current.saveSettings(mockEvent);
|
|
});
|
|
|
|
expect(fetch).toHaveBeenCalledWith(
|
|
"/api/settings",
|
|
expect.objectContaining({
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
})
|
|
);
|
|
expect(result.current.settingsSaved).toBe(true);
|
|
});
|
|
|
|
it("keeps email channel enabled when recipient is non-empty", async () => {
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({}),
|
|
});
|
|
|
|
const { result } = renderHook(() => useSettings());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.settingsLoading).toBe(false);
|
|
});
|
|
|
|
// Set invalid email
|
|
act(() => {
|
|
result.current.setSettings((s) => ({
|
|
...s,
|
|
emailEnabled: true,
|
|
notificationEmail: "invalid-email",
|
|
}));
|
|
});
|
|
|
|
const mockEvent = { preventDefault: vi.fn() } as unknown as React.FormEvent;
|
|
|
|
await act(async () => {
|
|
await result.current.saveSettings(mockEvent);
|
|
});
|
|
|
|
expect(result.current.settings.emailEnabled).toBe(true);
|
|
});
|
|
|
|
it("tests email notification", async () => {
|
|
(global.fetch as ReturnType<typeof vi.fn>)
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
|
|
.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?.success).toBe(true);
|
|
expect(result.current.testingEmail).toBe(false);
|
|
});
|
|
|
|
it("handles test email failure", async () => {
|
|
(global.fetch as ReturnType<typeof vi.fn>)
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
|
|
.mockRejectedValueOnce(new Error("Network error"));
|
|
|
|
const { result } = renderHook(() => useSettings());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.settingsLoading).toBe(false);
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.testEmail();
|
|
});
|
|
|
|
expect(result.current.testEmailResult?.success).toBe(false);
|
|
});
|
|
|
|
it("tests shoutrrr notification", async () => {
|
|
(global.fetch as ReturnType<typeof vi.fn>)
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ message: "Notification sent!" }),
|
|
});
|
|
|
|
const { result } = renderHook(() => useSettings());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.settingsLoading).toBe(false);
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.testShoutrrr();
|
|
});
|
|
|
|
expect(result.current.testShoutrrrResult?.success).toBe(true);
|
|
expect(result.current.testingShoutrrr).toBe(false);
|
|
});
|
|
|
|
it("tracks unsaved changes", async () => {
|
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ lowStockDays: 30 }),
|
|
});
|
|
|
|
const { result } = renderHook(() => useSettings());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.settingsLoading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.hasUnsavedChanges).toBe(false);
|
|
|
|
act(() => {
|
|
result.current.setSettings((s) => ({ ...s, lowStockDays: 14 }));
|
|
});
|
|
|
|
expect(result.current.hasUnsavedChanges).toBe(true);
|
|
});
|
|
|
|
it("loadSettings can be called manually", async () => {
|
|
(global.fetch as ReturnType<typeof vi.fn>)
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: () => Promise.resolve({ lowStockDays: 14 }),
|
|
});
|
|
|
|
const { result } = renderHook(() => useSettings());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.settingsLoading).toBe(false);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.loadSettings();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.settings.lowStockDays).toBe(14);
|
|
});
|
|
});
|
|
|
|
it("auto-disables email when no recipient", async () => {
|
|
(global.fetch as ReturnType<typeof vi.fn>)
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
|
|
.mockResolvedValueOnce({ ok: true });
|
|
|
|
const { result } = renderHook(() => useSettings());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.settingsLoading).toBe(false);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.setSettings((s) => ({
|
|
...s,
|
|
emailEnabled: true,
|
|
notificationEmail: "",
|
|
}));
|
|
});
|
|
|
|
const mockEvent = { preventDefault: vi.fn() } as unknown as React.FormEvent;
|
|
|
|
await act(async () => {
|
|
await result.current.saveSettings(mockEvent);
|
|
});
|
|
|
|
// Local state preserves user choice; backend receives effective value via payload
|
|
expect(result.current.settings.emailEnabled).toBe(true);
|
|
});
|
|
|
|
it("auto-disables shoutrrr when URL is empty", async () => {
|
|
(global.fetch as ReturnType<typeof vi.fn>)
|
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}) })
|
|
.mockResolvedValueOnce({ ok: true });
|
|
|
|
const { result } = renderHook(() => useSettings());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.settingsLoading).toBe(false);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.setSettings((s) => ({
|
|
...s,
|
|
shoutrrrEnabled: true,
|
|
shoutrrrUrl: "",
|
|
}));
|
|
});
|
|
|
|
const mockEvent = { preventDefault: vi.fn() } as unknown as React.FormEvent;
|
|
|
|
await act(async () => {
|
|
await result.current.saveSettings(mockEvent);
|
|
});
|
|
|
|
// Local state preserves user choice; backend receives effective value via payload
|
|
expect(result.current.settings.shoutrrrEnabled).toBe(true);
|
|
});
|
|
|
|
it("refreshes reminder status on interval", 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({}) })
|
|
.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",
|
|
}),
|
|
});
|
|
|
|
const { result } = renderHook(() => useSettings());
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.settingsLoading).toBe(false);
|
|
});
|
|
|
|
expect(refreshCallback).not.toBeNull();
|
|
|
|
act(() => {
|
|
refreshCallback?.();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.settings.lastAutoEmailSent).toBe("2026-01-01T10:00:00.000Z");
|
|
expect(result.current.settings.lastNotificationType).toBe("stock");
|
|
expect(result.current.settings.lastStockReminderChannel).toBe("both");
|
|
});
|
|
});
|
|
});
|