2bec205a30
- Bell icon in icon registry (Phosphor Bell) - NotificationProvider context with polling (15s unread / 30s list) - useNotifications() hook with optimistic updates - NotificationCenter component: bell + badge + dropdown panel - NotificationItem component: severity icon, title, relative time, actions - AppShell integration: mount in header-actions, hidden on mobile - CSS styles: dropdown, items, unread/read states, empty state - formatRelativeTime utility (custom, no new deps) - 25 frontend tests (9 hook + 6 item + 10 center) Quality gates: vitest 25 passed, tsc clean, eslint clean
338 lines
8.6 KiB
TypeScript
338 lines
8.6 KiB
TypeScript
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 <NotificationProvider>{children}</NotificationProvider>;
|
|
}
|
|
|
|
const makeNotification = (id: string, overrides?: Record<string, unknown>) => ({
|
|
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<NotificationItem>) => 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);
|
|
});
|
|
});
|