import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook, waitFor, act } from "@testing-library/react"; import { useNotifications } from "./use-notifications"; import { NotificationProvider } from "../state/notifications"; vi.mock("../api/notifications", () => ({ getNotifications: vi.fn(), getUnreadCount: vi.fn(), markNotificationRead: vi.fn(), markAllNotificationsRead: vi.fn(), dismissNotification: vi.fn(), })); import { getNotifications, getUnreadCount, markNotificationRead, markAllNotificationsRead, dismissNotification, } from "../api/notifications"; import type { NotificationItem } from "../api/notifications"; const mockedGetNotifications = vi.mocked(getNotifications); const mockedGetUnreadCount = vi.mocked(getUnreadCount); const mockedMarkNotificationRead = vi.mocked(markNotificationRead); const mockedMarkAllNotificationsRead = vi.mocked(markAllNotificationsRead); const mockedDismissNotification = vi.mocked(dismissNotification); function wrapper({ children }: { children: React.ReactNode }) { return {children}; } const makeNotification = (id: string, overrides?: Record) => ({ id, user_id: "user-1", category: "instance", severity: "info" as const, title: "Test", message: null, source_type: null, source_id: null, metadata: {}, read_at: null, dismissed_at: null, created_at: "2026-05-29T10:00:00Z", ...overrides, }); describe("useNotifications", () => { beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); mockedGetNotifications.mockResolvedValue({ items: [], total: 0, limit: 20, offset: 0, }); mockedGetUnreadCount.mockResolvedValue(0); mockedMarkNotificationRead.mockResolvedValue( makeNotification("1", { read_at: "2026-05-29T10:01:00Z" }), ); mockedMarkAllNotificationsRead.mockResolvedValue(1); mockedDismissNotification.mockResolvedValue(undefined); }); afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); }); it("returns notifications and unreadCount from provider", async () => { mockedGetNotifications.mockResolvedValue({ items: [makeNotification("1")], total: 1, limit: 20, offset: 0, }); mockedGetUnreadCount.mockResolvedValue(3); const { result } = renderHook(() => useNotifications(), { wrapper }); await act(async () => { vi.advanceTimersByTime(100); }); await waitFor(() => { expect(result.current.notifications).toHaveLength(1); expect(result.current.unreadCount).toBe(3); }); }); it("optimistically updates on markRead", async () => { mockedGetNotifications.mockResolvedValue({ items: [makeNotification("1"), makeNotification("2")], total: 2, limit: 20, offset: 0, }); mockedGetUnreadCount.mockResolvedValue(2); const { result } = renderHook(() => useNotifications(), { wrapper }); await act(async () => { vi.advanceTimersByTime(100); }); await waitFor(() => expect(result.current.unreadCount).toBe(2)); let resolveApi: | ((value: NotificationItem | PromiseLike) => void) | undefined; mockedMarkNotificationRead.mockReturnValue( new Promise((resolve) => { resolveApi = resolve; }), ); act(() => { void result.current.markRead("1"); }); await waitFor(() => { const n = result.current.notifications.find((x) => x.id === "1"); expect(n?.read_at).not.toBeNull(); }); expect(result.current.unreadCount).toBe(1); act(() => { resolveApi?.(makeNotification("1", { read_at: "2026-05-29T10:01:00Z" })); }); }); it("reverts optimistic update on markRead failure", async () => { mockedGetNotifications.mockResolvedValue({ items: [makeNotification("1")], total: 1, limit: 20, offset: 0, }); mockedGetUnreadCount.mockResolvedValue(1); const { result } = renderHook(() => useNotifications(), { wrapper }); await act(async () => { vi.advanceTimersByTime(100); }); await waitFor(() => expect(result.current.unreadCount).toBe(1)); mockedMarkNotificationRead.mockRejectedValue(new Error("Network error")); await act(async () => { await result.current.markRead("1"); }); const n = result.current.notifications.find((x) => x.id === "1"); expect(n?.read_at).toBeNull(); expect(result.current.unreadCount).toBe(1); expect(result.current.error).toBeInstanceOf(Error); }); it("optimistically updates on dismiss", async () => { mockedGetNotifications.mockResolvedValue({ items: [makeNotification("1"), makeNotification("2")], total: 2, limit: 20, offset: 0, }); mockedGetUnreadCount.mockResolvedValue(2); const { result } = renderHook(() => useNotifications(), { wrapper }); await act(async () => { vi.advanceTimersByTime(100); }); await waitFor(() => expect(result.current.notifications).toHaveLength(2)); mockedDismissNotification.mockReturnValue(new Promise(() => {})); act(() => { void result.current.dismiss("1"); }); await waitFor(() => { expect(result.current.notifications).toHaveLength(1); }); expect(result.current.unreadCount).toBe(1); }); it("reverts optimistic update on dismiss failure", async () => { mockedGetNotifications.mockResolvedValue({ items: [makeNotification("1")], total: 1, limit: 20, offset: 0, }); mockedGetUnreadCount.mockResolvedValue(1); const { result } = renderHook(() => useNotifications(), { wrapper }); await act(async () => { vi.advanceTimersByTime(100); }); await waitFor(() => expect(result.current.notifications).toHaveLength(1)); mockedDismissNotification.mockRejectedValue(new Error("Network error")); await act(async () => { await result.current.dismiss("1"); }); expect(result.current.notifications).toHaveLength(1); expect(result.current.unreadCount).toBe(1); expect(result.current.error).toBeInstanceOf(Error); }); it("calls refreshList when invoked", async () => { mockedGetNotifications.mockResolvedValue({ items: [makeNotification("1")], total: 1, limit: 20, offset: 0, }); const { result } = renderHook(() => useNotifications(), { wrapper }); await act(async () => { vi.advanceTimersByTime(100); }); mockedGetNotifications.mockResolvedValue({ items: [makeNotification("1"), makeNotification("2")], total: 2, limit: 20, offset: 0, }); await act(async () => { await result.current.refreshList(); }); expect(mockedGetNotifications).toHaveBeenCalledTimes(2); await waitFor(() => expect(result.current.notifications).toHaveLength(2)); }); it("stops polling on 401", async () => { mockedGetUnreadCount.mockRejectedValue({ response: { status: 401 } }); renderHook(() => useNotifications(), { wrapper }); await act(async () => { vi.advanceTimersByTime(100); }); const callCountAfterFirst = mockedGetUnreadCount.mock.calls.length; await act(async () => { vi.advanceTimersByTime(60000); }); expect(mockedGetUnreadCount.mock.calls.length).toBe(callCountAfterFirst); }); it("pauses polling when document hidden", async () => { renderHook(() => useNotifications(), { wrapper }); await act(async () => { vi.advanceTimersByTime(100); }); const callCountBefore = mockedGetUnreadCount.mock.calls.length; act(() => { Object.defineProperty(document, "hidden", { value: true, writable: true, configurable: true, }); document.dispatchEvent(new Event("visibilitychange")); }); await act(async () => { vi.advanceTimersByTime(60000); }); expect(mockedGetUnreadCount.mock.calls.length).toBe(callCountBefore); act(() => { Object.defineProperty(document, "hidden", { value: false, writable: true, configurable: true, }); document.dispatchEvent(new Event("visibilitychange")); }); await act(async () => { vi.advanceTimersByTime(100); }); expect(mockedGetUnreadCount.mock.calls.length).toBeGreaterThan( callCountBefore, ); }); it("multiple markRead calls decrement correctly", async () => { mockedGetNotifications.mockResolvedValue({ items: [ makeNotification("1"), makeNotification("2"), makeNotification("3"), ], total: 3, limit: 20, offset: 0, }); mockedGetUnreadCount.mockResolvedValue(3); const { result } = renderHook(() => useNotifications(), { wrapper }); await act(async () => { vi.advanceTimersByTime(100); }); await waitFor(() => expect(result.current.unreadCount).toBe(3)); mockedMarkNotificationRead.mockResolvedValue( makeNotification("1", { read_at: "2026-05-29T10:01:00Z" }), ); await act(async () => { await result.current.markRead("1"); await result.current.markRead("2"); await result.current.markRead("3"); }); expect(result.current.unreadCount).toBe(0); }); });