Files
kandev/apps/web/components/settings/notifications-settings-actions.test.tsx
T
2026-09-09 20:05:46 +01:00

451 lines
16 KiB
TypeScript

import { act, render, renderHook, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { NotificationProvider } from "@/lib/types/http";
import {
useIsDirty,
useNotificationsActions,
useNotificationsState,
useSaveRequest,
} from "./notifications-settings-actions";
import {
DesktopNotificationsSection,
useNotificationPermission,
} from "./notification-permission-section";
const mocks = vi.hoisted(() => ({
createNotificationProvider: vi.fn(),
deleteNotificationProvider: vi.fn(),
testNotificationProvider: vi.fn(),
updateNotificationProvider: vi.fn(),
setNotificationProviders: vi.fn(),
nativeAvailable: vi.fn(),
nativePermissionGet: vi.fn(),
nativePermissionRequest: vi.fn(),
}));
const PAGER_URL = "json://pager";
const CLARIFICATION_EVENT = "session.clarification_requested";
const SAVED_EVENT = "task.completed";
const DRAFT_NAME = "Draft name";
const SEMANTIC_NOTIFICATION_EVENTS = [CLARIFICATION_EVENT, "session.turn_finished"];
vi.mock("@/lib/api", () => ({
createNotificationProvider: mocks.createNotificationProvider,
deleteNotificationProvider: mocks.deleteNotificationProvider,
testNotificationProvider: mocks.testNotificationProvider,
updateNotificationProvider: mocks.updateNotificationProvider,
}));
const savedProvider: NotificationProvider = {
id: "provider-1",
name: "Saved provider",
type: "apprise",
config: { urls: ["json://saved"] },
enabled: true,
events: [SAVED_EVENT],
created_at: "",
updated_at: "",
};
const localProvider: NotificationProvider = {
...savedProvider,
id: "provider-local",
name: "Local provider",
type: "local",
config: {},
};
let notificationProviders = [savedProvider];
let notificationEvents = [SAVED_EVENT];
let notificationProvidersLoaded = true;
let notificationAppriseAvailable = true;
const originalNotification = globalThis.Notification;
vi.mock("@/hooks/domains/settings/use-notification-providers", () => ({
useNotificationProviders: () => ({
providers: notificationProviders,
events: notificationEvents,
appriseAvailable: notificationAppriseAvailable,
loaded: notificationProvidersLoaded,
}),
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({ setNotificationProviders: mocks.setNotificationProviders }),
}));
vi.mock("@/lib/desktop/native-notification-client", () => ({
nativeNotifications: {
isAvailable: mocks.nativeAvailable,
permission: {
get: mocks.nativePermissionGet,
request: mocks.nativePermissionRequest,
},
},
}));
function useHarness() {
const state = useNotificationsState();
const saveRequest = useSaveRequest(state);
const actions = useNotificationsActions(state, vi.fn());
return { state, saveRequest, actions, isDirty: useIsDirty(state) };
}
const createdProvider: NotificationProvider = {
...savedProvider,
id: "provider-2",
name: "Pager",
config: { urls: [PAGER_URL] },
};
beforeEach(() => {
vi.clearAllMocks();
notificationProviders = [savedProvider];
notificationEvents = [SAVED_EVENT];
notificationProvidersLoaded = true;
notificationAppriseAvailable = true;
mocks.nativeAvailable.mockReturnValue(true);
mocks.createNotificationProvider.mockResolvedValue(createdProvider);
});
afterEach(() => {
Object.defineProperty(globalThis, "Notification", {
configurable: true,
value: originalNotification,
});
});
describe("notification provider draft hydration", () => {
it("hydrates a cold provider load into the draft", () => {
notificationProviders = [];
notificationEvents = [];
notificationProvidersLoaded = false;
const { result, rerender } = renderHook(useHarness);
notificationProviders = [savedProvider];
notificationEvents = SEMANTIC_NOTIFICATION_EVENTS;
notificationProvidersLoaded = true;
rerender();
expect(result.current.state.providers).toEqual([savedProvider]);
expect(result.current.state.baselineProviders).toEqual([savedProvider]);
expect(result.current.state.notificationEvents).toEqual(SEMANTIC_NOTIFICATION_EVENTS);
});
it("hydrates the first provider load while retaining a typed create draft", async () => {
notificationProviders = [];
notificationEvents = [];
notificationProvidersLoaded = false;
const { result, rerender } = renderHook(useHarness);
act(() => {
result.current.actions.openAppriseForm("create");
result.current.state.setAppriseName("Pager");
result.current.state.setAppriseUrls(PAGER_URL);
});
notificationProviders = [savedProvider];
notificationEvents = SEMANTIC_NOTIFICATION_EVENTS;
notificationProvidersLoaded = true;
rerender();
expect(result.current.state.providers).toEqual([savedProvider]);
expect(result.current.state.baselineProviders).toEqual([savedProvider]);
expect(result.current.state.appriseName).toBe("Pager");
expect(result.current.state.appriseUrls).toBe(PAGER_URL);
await act(() => result.current.saveRequest.run());
expect(result.current.state.providers).toEqual([savedProvider, createdProvider]);
expect(mocks.setNotificationProviders).toHaveBeenCalledWith(
expect.objectContaining({ items: [savedProvider, createdProvider] }),
);
});
it("does not replace an edited draft when providers refresh", () => {
const { result, rerender } = renderHook(useHarness);
act(() => result.current.actions.handleAppriseNameEdit(savedProvider.id, DRAFT_NAME));
const refreshedProvider = { ...savedProvider, name: "Server refresh" };
notificationProviders = [refreshedProvider];
rerender();
expect(result.current.state.providers[0]?.name).toBe(DRAFT_NAME);
expect(result.current.state.baselineProviders[0]?.name).toBe(savedProvider.name);
});
it("hydrates a deferred provider refresh after discarding the draft", () => {
const { result, rerender } = renderHook(useHarness);
act(() => result.current.actions.handleAppriseNameEdit(savedProvider.id, DRAFT_NAME));
const refreshedProvider = { ...savedProvider, name: "Server refresh" };
notificationProviders = [refreshedProvider];
rerender();
act(() => result.current.actions.discard());
expect(result.current.state.providers).toEqual([refreshedProvider]);
expect(result.current.state.baselineProviders).toEqual([refreshedProvider]);
});
it("updates availability without replacing dirty drafts or pending removals", () => {
notificationProviders = [savedProvider, localProvider];
const { result, rerender } = renderHook(useHarness);
act(() => {
result.current.actions.handleAppriseNameEdit(savedProvider.id, DRAFT_NAME);
result.current.actions.handleAppriseEdit(savedProvider.id, PAGER_URL);
result.current.actions.handleToggleEvent(savedProvider, CLARIFICATION_EVENT);
result.current.actions.handleDeleteProvider(localProvider.id);
result.current.actions.openAppriseForm("create");
result.current.state.setAppriseName("New provider");
result.current.state.setAppriseUrls("json://new");
});
notificationAppriseAvailable = false;
rerender();
expect(result.current.state.appriseAvailable).toBe(false);
expect(result.current.state.providers).toEqual([
expect.objectContaining({
id: savedProvider.id,
name: DRAFT_NAME,
config: { urls: [PAGER_URL] },
events: [SAVED_EVENT, CLARIFICATION_EVENT],
}),
]);
expect(result.current.state.pendingDeletes).toEqual(new Set([localProvider.id]));
expect(result.current.state.appriseName).toBe("New provider");
expect(result.current.state.appriseUrls).toBe("json://new");
});
});
describe("notification provider draft saving", () => {
it("stages a new Apprise provider until the shared save runs", async () => {
const { result } = renderHook(useHarness);
act(() => result.current.actions.openAppriseForm("create"));
expect(result.current.isDirty).toBe(true);
expect(mocks.createNotificationProvider).not.toHaveBeenCalled();
act(() => {
result.current.state.setAppriseName("Pager");
result.current.state.setAppriseUrls(PAGER_URL);
});
await act(() => result.current.saveRequest.run());
expect(mocks.createNotificationProvider).toHaveBeenCalledWith(
expect.objectContaining({
name: "Pager",
config: { urls: [PAGER_URL] },
events: [CLARIFICATION_EVENT],
}),
);
expect(result.current.state.providers).toContainEqual(createdProvider);
expect(result.current.state.showAppriseForm).toBe(false);
expect(result.current.isDirty).toBe(false);
});
it("keeps a failed create draft open and dirty", async () => {
mocks.createNotificationProvider.mockRejectedValueOnce(new Error("create unavailable"));
const { result } = renderHook(useHarness);
act(() => {
result.current.actions.openAppriseForm("create");
result.current.state.setAppriseName("Pager");
result.current.state.setAppriseUrls(PAGER_URL);
});
await act(async () => {
await expect(result.current.saveRequest.run()).rejects.toThrow("create unavailable");
});
expect(result.current.state.showAppriseForm).toBe(true);
expect(result.current.state.appriseName).toBe("Pager");
expect(result.current.state.appriseUrls).toBe(PAGER_URL);
expect(result.current.isDirty).toBe(true);
});
it("cancels a new draft without persisting it", () => {
const { result } = renderHook(useHarness);
act(() => {
result.current.actions.openAppriseForm("create");
result.current.state.setAppriseUrls(PAGER_URL);
});
act(() => result.current.actions.cancelAppriseForm());
expect(mocks.createNotificationProvider).not.toHaveBeenCalled();
expect(result.current.state.showAppriseForm).toBe(false);
expect(result.current.state.appriseUrls).toBe("");
expect(result.current.isDirty).toBe(false);
});
it("discards staged provider edits back to the loaded baseline", () => {
const { result } = renderHook(useHarness);
act(() => result.current.actions.handleAppriseNameEdit(savedProvider.id, "Changed"));
expect(result.current.isDirty).toBe(true);
act(() => result.current.actions.discard());
expect(result.current.state.providers).toEqual([savedProvider]);
expect(result.current.isDirty).toBe(false);
});
it("does not overwrite a rescan that completes during a provider save", async () => {
let resolveUpdate: (provider: NotificationProvider) => void = () => undefined;
const updatePromise = new Promise<NotificationProvider>((resolve) => {
resolveUpdate = resolve;
});
mocks.updateNotificationProvider.mockReturnValueOnce(updatePromise);
const { result, rerender } = renderHook(useHarness);
act(() => result.current.actions.handleAppriseNameEdit(savedProvider.id, DRAFT_NAME));
let savePromise: Promise<unknown>;
act(() => {
savePromise = result.current.saveRequest.run();
});
await waitFor(() => expect(mocks.updateNotificationProvider).toHaveBeenCalledOnce());
notificationAppriseAvailable = false;
rerender();
await act(async () => {
resolveUpdate({ ...savedProvider, name: DRAFT_NAME });
await savePromise;
});
const lastSnapshot = mocks.setNotificationProviders.mock.lastCall?.[0];
expect(lastSnapshot).toEqual({
items: [{ ...savedProvider, name: DRAFT_NAME }],
events: [SAVED_EVENT],
loaded: true,
loading: false,
});
});
});
describe("notification permission actions", () => {
it("surfaces a rejected native permission query as an actionable error state", async () => {
mocks.nativePermissionGet.mockRejectedValueOnce(new Error("native permission unavailable"));
const { result } = renderHook(() => useNotificationPermission());
await waitFor(() => expect(result.current.notificationPermission).toBe("error"));
});
it("renders actionable feedback when the permission state cannot be queried", () => {
render(
<DesktopNotificationsSection
notificationPermission="error"
onRequestPermission={vi.fn()}
onRefreshPermission={vi.fn()}
onTestNotification={vi.fn()}
/>,
);
expect(screen.getByText(/could not check notification permission/i)).toBeTruthy();
expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy();
});
it("requests native permission from the user action when Tauri is available", async () => {
mocks.nativePermissionRequest.mockResolvedValue("granted");
const refreshPermission = vi.fn();
const { result } = renderHook(() => {
const state = useNotificationsState();
return useNotificationsActions(state, refreshPermission);
});
await act(() => result.current.handleRequestPermission());
expect(mocks.nativePermissionRequest).toHaveBeenCalledOnce();
expect(refreshPermission).toHaveBeenCalledOnce();
});
it("handles a rejected native permission request without leaking the rejection", async () => {
mocks.nativePermissionRequest.mockRejectedValueOnce(new Error("native permission unavailable"));
const refreshPermission = vi.fn();
const { result } = renderHook(() => {
const state = useNotificationsState();
return useNotificationsActions(state, refreshPermission);
});
await expect(result.current.handleRequestPermission()).resolves.toBeUndefined();
expect(refreshPermission).toHaveBeenCalledWith(expect.any(Error));
});
it("reports a rejected browser permission request without leaking the rejection", async () => {
mocks.nativePermissionRequest.mockReset();
const originalNotification = globalThis.Notification;
Object.defineProperty(globalThis, "Notification", {
configurable: true,
value: {
permission: "default",
requestPermission: vi.fn().mockRejectedValue(new Error("blocked")),
},
});
const refreshPermission = vi.fn();
mocks.nativeAvailable.mockReturnValueOnce(false);
const { result } = renderHook(() => {
const state = useNotificationsState();
return useNotificationsActions(state, refreshPermission);
});
await expect(result.current.handleRequestPermission()).resolves.toBeUndefined();
expect(refreshPermission).toHaveBeenCalledWith(expect.any(Error));
Object.defineProperty(globalThis, "Notification", {
configurable: true,
value: originalNotification,
});
});
});
describe("notification permission transport behavior", () => {
it("requests browser permission alongside native permission for Office notification fallback", async () => {
mocks.nativePermissionRequest.mockResolvedValue("granted");
const requestPermission = vi.fn().mockResolvedValue("granted");
Object.defineProperty(globalThis, "Notification", {
configurable: true,
value: { permission: "default", requestPermission },
});
const refreshPermission = vi.fn();
const { result } = renderHook(() => {
const state = useNotificationsState();
return useNotificationsActions(state, refreshPermission);
});
await act(() => result.current.handleRequestPermission());
expect(mocks.nativePermissionRequest).toHaveBeenCalledOnce();
expect(requestPermission).toHaveBeenCalledOnce();
expect(refreshPermission).toHaveBeenCalledOnce();
});
it("directs denied native notifications to OS app settings", () => {
mocks.nativeAvailable.mockReturnValue(true);
const { container } = render(
<DesktopNotificationsSection
notificationPermission="denied"
onRequestPermission={vi.fn()}
onRefreshPermission={vi.fn()}
onTestNotification={vi.fn()}
/>,
);
expect(within(container).getByText(/OS app notification settings/i)).toBeTruthy();
});
it("directs denied browser notifications to site settings", () => {
mocks.nativeAvailable.mockReturnValue(false);
const { container } = render(
<DesktopNotificationsSection
notificationPermission="denied"
onRequestPermission={vi.fn()}
onRefreshPermission={vi.fn()}
onTestNotification={vi.fn()}
/>,
);
expect(within(container).getByText(/site settings/i)).toBeTruthy();
});
});