feat: notification center frontend core (PR-3)
- 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
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useContext } from "react";
|
||||
import { NotificationContext } from "../state/notifications";
|
||||
|
||||
export function useNotifications() {
|
||||
const ctx = useContext(NotificationContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useNotifications must be used within NotificationProvider",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
Reference in New Issue
Block a user