Barazo default frontend
barazo.forum
1/**
2 * Tests for notifications page.
3 */
4
5import { describe, it, expect, vi, beforeEach } from 'vitest'
6import { render, screen, act, cleanup } from '@testing-library/react'
7import userEvent from '@testing-library/user-event'
8import { axe } from 'vitest-axe'
9import NotificationsPage from './page'
10
11// Mock next/navigation
12vi.mock('next/navigation', () => ({
13 useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
14 usePathname: () => '/notifications',
15}))
16
17// Mock next-themes
18vi.mock('next-themes', () => ({
19 useTheme: () => ({ theme: 'dark', setTheme: vi.fn() }),
20 ThemeProvider: ({ children }: { children: React.ReactNode }) => children,
21}))
22
23// Mock next/image
24vi.mock('next/image', () => ({
25 default: (props: Record<string, unknown>) => {
26 // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text
27 return <img {...props} />
28 },
29}))
30
31// Mock next/link
32vi.mock('next/link', () => ({
33 default: ({
34 children,
35 href,
36 ...props
37 }: { children: React.ReactNode; href: string } & Record<string, unknown>) => (
38 <a href={href} {...props}>
39 {children}
40 </a>
41 ),
42}))
43
44// Mock API client
45vi.mock('@/lib/api/client', () => ({
46 getNotifications: vi.fn(),
47 markNotificationsRead: vi.fn(),
48 getPublicSettings: vi.fn().mockResolvedValue({
49 communityDid: 'did:plc:test-community-123',
50 communityName: 'Test Community',
51 maturityRating: 'safe',
52 communityDescription: null,
53 communityLogoUrl: null,
54 }),
55}))
56
57vi.mock('@/hooks/use-auth', () => {
58 const mockAuth = {
59 user: {
60 did: 'did:plc:user-jay-001',
61 handle: 'jay.bsky.team',
62 displayName: 'Jay',
63 avatarUrl: null,
64 },
65 isAuthenticated: true,
66 isLoading: false,
67 getAccessToken: () => 'mock-access-token',
68 login: vi.fn(),
69 logout: vi.fn(),
70 setSessionFromCallback: vi.fn(),
71 authFetch: vi.fn(),
72 }
73 return { useAuth: () => mockAuth }
74})
75
76import { getNotifications, markNotificationsRead } from '@/lib/api/client'
77
78const mockGetNotifications = vi.mocked(getNotifications)
79const mockMarkRead = vi.mocked(markNotificationsRead)
80
81const mockNotifications = [
82 {
83 id: 'notif-1',
84 type: 'reply' as const,
85 userDid: 'did:plc:user',
86 actorDid: 'did:plc:alex',
87 actorHandle: 'alex.bsky.team',
88 subjectUri: 'at://did:plc:user/forum.barazo.topic.post/abc',
89 subjectTitle: 'My Topic',
90 subjectAuthorDid: 'did:plc:user',
91 subjectAuthorHandle: 'jay.bsky.team',
92 message: 'alex.bsky.team replied to your topic',
93 read: false,
94 createdAt: '2026-02-14T12:00:00Z',
95 },
96 {
97 id: 'notif-2',
98 type: 'reaction' as const,
99 userDid: 'did:plc:user',
100 actorDid: 'did:plc:sam',
101 actorHandle: 'sam.example.com',
102 subjectUri: 'at://did:plc:user/forum.barazo.topic.post/abc',
103 subjectTitle: 'My Topic',
104 subjectAuthorDid: 'did:plc:user',
105 subjectAuthorHandle: 'jay.bsky.team',
106 message: 'sam.example.com reacted to your topic',
107 read: true,
108 createdAt: '2026-02-13T12:00:00Z',
109 },
110]
111
112describe('NotificationsPage', () => {
113 beforeEach(() => {
114 vi.clearAllMocks()
115 cleanup()
116 mockGetNotifications.mockResolvedValue({
117 notifications: mockNotifications,
118 cursor: null,
119 unreadCount: 1,
120 })
121 mockMarkRead.mockResolvedValue(undefined)
122 })
123
124 it('renders page heading', async () => {
125 render(<NotificationsPage />)
126 expect(screen.getByRole('heading', { level: 1, name: /notification/i })).toBeInTheDocument()
127 })
128
129 it('displays notifications from API', async () => {
130 render(<NotificationsPage />)
131
132 await act(async () => {
133 await new Promise((r) => setTimeout(r, 100))
134 })
135
136 expect(await screen.findByText(/alex\.bsky\.team replied/)).toBeInTheDocument()
137 expect(screen.getByText(/sam\.example\.com reacted/)).toBeInTheDocument()
138 })
139
140 it('shows unread indicator on unread notifications', async () => {
141 render(<NotificationsPage />)
142
143 await act(async () => {
144 await new Promise((r) => setTimeout(r, 100))
145 })
146
147 const items = screen.getAllByRole('article')
148 // First notification is unread
149 expect(items[0]).toHaveClass('border-l-primary')
150 })
151
152 it('renders mark all read button', async () => {
153 render(<NotificationsPage />)
154
155 await act(async () => {
156 await new Promise((r) => setTimeout(r, 100))
157 })
158
159 expect(screen.getByRole('button', { name: /mark all read/i })).toBeInTheDocument()
160 })
161
162 it('calls markNotificationsRead on mark all', async () => {
163 const user = userEvent.setup()
164 render(<NotificationsPage />)
165
166 await act(async () => {
167 await new Promise((r) => setTimeout(r, 100))
168 })
169
170 const button = screen.getByRole('button', { name: /mark all read/i })
171 await user.click(button)
172
173 expect(mockMarkRead).toHaveBeenCalled()
174 })
175
176 it('shows empty state when no notifications', async () => {
177 mockGetNotifications.mockResolvedValue({
178 notifications: [],
179 cursor: null,
180 unreadCount: 0,
181 })
182
183 render(<NotificationsPage />)
184
185 await act(async () => {
186 await new Promise((r) => setTimeout(r, 100))
187 })
188
189 expect(await screen.findByText(/no notifications/i)).toBeInTheDocument()
190 })
191
192 it('renders breadcrumbs', () => {
193 render(<NotificationsPage />)
194 const nav = screen.getByRole('navigation', { name: /breadcrumb/i })
195 expect(nav).toBeInTheDocument()
196 })
197
198 it('passes axe accessibility check', async () => {
199 const { container } = render(<NotificationsPage />)
200 const results = await axe(container)
201 expect(results).toHaveNoViolations()
202 })
203})