this repo has no description
1import { describe, it, expect, beforeEach } from 'vitest'
2import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
3import Notifications from '../routes/Notifications.svelte'
4import {
5 setupFetchMock,
6 mockEndpoint,
7 jsonResponse,
8 errorResponse,
9 mockData,
10 clearMocks,
11 setupAuthenticatedUser,
12 setupUnauthenticatedUser,
13} from './mocks'
14
15describe('Notifications', () => {
16 beforeEach(() => {
17 clearMocks()
18 setupFetchMock()
19 })
20
21 describe('authentication guard', () => {
22 it('redirects to login when not authenticated', async () => {
23 setupUnauthenticatedUser()
24 render(Notifications)
25
26 await waitFor(() => {
27 expect(window.location.hash).toBe('#/login')
28 })
29 })
30 })
31
32 describe('page structure', () => {
33 beforeEach(() => {
34 setupAuthenticatedUser()
35 mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
36 jsonResponse(mockData.notificationPrefs())
37 )
38 })
39
40 it('displays all page elements and sections', async () => {
41 render(Notifications)
42
43 await waitFor(() => {
44 expect(screen.getByRole('heading', { name: /notification preferences/i, level: 1 })).toBeInTheDocument()
45 expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard')
46 expect(screen.getByText(/password resets/i)).toBeInTheDocument()
47 expect(screen.getByRole('heading', { name: /preferred channel/i })).toBeInTheDocument()
48 expect(screen.getByRole('heading', { name: /channel configuration/i })).toBeInTheDocument()
49 })
50 })
51 })
52
53 describe('loading state', () => {
54 beforeEach(() => {
55 setupAuthenticatedUser()
56 })
57
58 it('shows loading text while fetching preferences', async () => {
59 mockEndpoint('com.bspds.account.getNotificationPrefs', async () => {
60 await new Promise(resolve => setTimeout(resolve, 100))
61 return jsonResponse(mockData.notificationPrefs())
62 })
63
64 render(Notifications)
65
66 expect(screen.getByText(/loading/i)).toBeInTheDocument()
67 })
68 })
69
70 describe('channel options', () => {
71 beforeEach(() => {
72 setupAuthenticatedUser()
73 })
74
75 it('displays all four channel options', async () => {
76 mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
77 jsonResponse(mockData.notificationPrefs())
78 )
79
80 render(Notifications)
81
82 await waitFor(() => {
83 expect(screen.getByRole('radio', { name: /email/i })).toBeInTheDocument()
84 expect(screen.getByRole('radio', { name: /discord/i })).toBeInTheDocument()
85 expect(screen.getByRole('radio', { name: /telegram/i })).toBeInTheDocument()
86 expect(screen.getByRole('radio', { name: /signal/i })).toBeInTheDocument()
87 })
88 })
89
90 it('email channel is always selectable', async () => {
91 mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
92 jsonResponse(mockData.notificationPrefs())
93 )
94
95 render(Notifications)
96
97 await waitFor(() => {
98 const emailRadio = screen.getByRole('radio', { name: /email/i })
99 expect(emailRadio).not.toBeDisabled()
100 })
101 })
102
103 it('discord channel is disabled when not configured', async () => {
104 mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
105 jsonResponse(mockData.notificationPrefs({ discordId: null }))
106 )
107
108 render(Notifications)
109
110 await waitFor(() => {
111 const discordRadio = screen.getByRole('radio', { name: /discord/i })
112 expect(discordRadio).toBeDisabled()
113 })
114 })
115
116 it('discord channel is enabled when configured', async () => {
117 mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
118 jsonResponse(mockData.notificationPrefs({ discordId: '123456789' }))
119 )
120
121 render(Notifications)
122
123 await waitFor(() => {
124 const discordRadio = screen.getByRole('radio', { name: /discord/i })
125 expect(discordRadio).not.toBeDisabled()
126 })
127 })
128
129 it('shows hint for disabled channels', async () => {
130 mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
131 jsonResponse(mockData.notificationPrefs())
132 )
133
134 render(Notifications)
135
136 await waitFor(() => {
137 expect(screen.getAllByText(/configure below to enable/i).length).toBeGreaterThan(0)
138 })
139 })
140
141 it('selects current preferred channel', async () => {
142 mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
143 jsonResponse(mockData.notificationPrefs({ preferredChannel: 'email' }))
144 )
145
146 render(Notifications)
147
148 await waitFor(() => {
149 const emailRadio = screen.getByRole('radio', { name: /email/i }) as HTMLInputElement
150 expect(emailRadio.checked).toBe(true)
151 })
152 })
153 })
154
155 describe('channel configuration', () => {
156 beforeEach(() => {
157 setupAuthenticatedUser()
158 })
159
160 it('displays email as readonly with current value', async () => {
161 mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
162 jsonResponse(mockData.notificationPrefs())
163 )
164
165 render(Notifications)
166
167 await waitFor(() => {
168 const emailInput = screen.getByLabelText(/^email$/i) as HTMLInputElement
169 expect(emailInput).toBeDisabled()
170 expect(emailInput.value).toBe('test@example.com')
171 })
172 })
173
174 it('displays all channel inputs with current values', async () => {
175 mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
176 jsonResponse(mockData.notificationPrefs({
177 discordId: '123456789',
178 telegramUsername: 'testuser',
179 signalNumber: '+1234567890',
180 }))
181 )
182
183 render(Notifications)
184
185 await waitFor(() => {
186 expect((screen.getByLabelText(/discord user id/i) as HTMLInputElement).value).toBe('123456789')
187 expect((screen.getByLabelText(/telegram username/i) as HTMLInputElement).value).toBe('testuser')
188 expect((screen.getByLabelText(/signal phone number/i) as HTMLInputElement).value).toBe('+1234567890')
189 })
190 })
191 })
192
193 describe('verification status badges', () => {
194 beforeEach(() => {
195 setupAuthenticatedUser()
196 })
197
198 it('shows Primary badge for email', async () => {
199 mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
200 jsonResponse(mockData.notificationPrefs())
201 )
202
203 render(Notifications)
204
205 await waitFor(() => {
206 expect(screen.getByText('Primary')).toBeInTheDocument()
207 })
208 })
209
210 it('shows Verified badge for verified discord', async () => {
211 mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
212 jsonResponse(mockData.notificationPrefs({
213 discordId: '123456789',
214 discordVerified: true,
215 }))
216 )
217
218 render(Notifications)
219
220 await waitFor(() => {
221 const verifiedBadges = screen.getAllByText('Verified')
222 expect(verifiedBadges.length).toBeGreaterThan(0)
223 })
224 })
225
226 it('shows Not verified badge for unverified discord', async () => {
227 mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
228 jsonResponse(mockData.notificationPrefs({
229 discordId: '123456789',
230 discordVerified: false,
231 }))
232 )
233
234 render(Notifications)
235
236 await waitFor(() => {
237 expect(screen.getByText('Not verified')).toBeInTheDocument()
238 })
239 })
240
241 it('does not show badge when channel not configured', async () => {
242 mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
243 jsonResponse(mockData.notificationPrefs())
244 )
245
246 render(Notifications)
247
248 await waitFor(() => {
249 expect(screen.getByText('Primary')).toBeInTheDocument()
250 expect(screen.queryByText('Not verified')).not.toBeInTheDocument()
251 })
252 })
253 })
254
255 describe('save preferences', () => {
256 beforeEach(() => {
257 setupAuthenticatedUser()
258 })
259
260 it('calls updateNotificationPrefs with correct data', async () => {
261 let capturedBody: Record<string, unknown> | null = null
262
263 mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
264 jsonResponse(mockData.notificationPrefs())
265 )
266
267 mockEndpoint('com.bspds.account.updateNotificationPrefs', (_url, options) => {
268 capturedBody = JSON.parse((options?.body as string) || '{}')
269 return jsonResponse({ success: true })
270 })
271
272 render(Notifications)
273
274 await waitFor(() => {
275 expect(screen.getByLabelText(/discord user id/i)).toBeInTheDocument()
276 })
277
278 await fireEvent.input(screen.getByLabelText(/discord user id/i), { target: { value: '999888777' } })
279 await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
280
281 await waitFor(() => {
282 expect(capturedBody).not.toBeNull()
283 expect(capturedBody?.discordId).toBe('999888777')
284 expect(capturedBody?.preferredChannel).toBe('email')
285 })
286 })
287
288 it('shows loading state while saving', async () => {
289 mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
290 jsonResponse(mockData.notificationPrefs())
291 )
292
293 mockEndpoint('com.bspds.account.updateNotificationPrefs', async () => {
294 await new Promise(resolve => setTimeout(resolve, 100))
295 return jsonResponse({ success: true })
296 })
297
298 render(Notifications)
299
300 await waitFor(() => {
301 expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
302 })
303
304 await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
305
306 expect(screen.getByRole('button', { name: /saving/i })).toBeInTheDocument()
307 expect(screen.getByRole('button', { name: /saving/i })).toBeDisabled()
308 })
309
310 it('shows success message after saving', async () => {
311 mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
312 jsonResponse(mockData.notificationPrefs())
313 )
314
315 mockEndpoint('com.bspds.account.updateNotificationPrefs', () =>
316 jsonResponse({ success: true })
317 )
318
319 render(Notifications)
320
321 await waitFor(() => {
322 expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
323 })
324
325 await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
326
327 await waitFor(() => {
328 expect(screen.getByText(/notification preferences saved/i)).toBeInTheDocument()
329 })
330 })
331
332 it('shows error when save fails', async () => {
333 mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
334 jsonResponse(mockData.notificationPrefs())
335 )
336
337 mockEndpoint('com.bspds.account.updateNotificationPrefs', () =>
338 errorResponse('InvalidRequest', 'Invalid channel configuration', 400)
339 )
340
341 render(Notifications)
342
343 await waitFor(() => {
344 expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
345 })
346
347 await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
348
349 await waitFor(() => {
350 expect(screen.getByText(/invalid channel configuration/i)).toBeInTheDocument()
351 expect(screen.getByText(/invalid channel configuration/i).closest('.message')).toHaveClass('error')
352 })
353 })
354
355 it('reloads preferences after successful save', async () => {
356 let loadCount = 0
357
358 mockEndpoint('com.bspds.account.getNotificationPrefs', () => {
359 loadCount++
360 return jsonResponse(mockData.notificationPrefs())
361 })
362
363 mockEndpoint('com.bspds.account.updateNotificationPrefs', () =>
364 jsonResponse({ success: true })
365 )
366
367 render(Notifications)
368
369 await waitFor(() => {
370 expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
371 })
372
373 const initialLoadCount = loadCount
374 await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
375
376 await waitFor(() => {
377 expect(loadCount).toBeGreaterThan(initialLoadCount)
378 })
379 })
380 })
381
382 describe('channel selection interaction', () => {
383 beforeEach(() => {
384 setupAuthenticatedUser()
385 })
386
387 it('enables discord channel after entering discord ID', async () => {
388 mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
389 jsonResponse(mockData.notificationPrefs())
390 )
391
392 render(Notifications)
393
394 await waitFor(() => {
395 expect(screen.getByRole('radio', { name: /discord/i })).toBeDisabled()
396 })
397
398 await fireEvent.input(screen.getByLabelText(/discord user id/i), { target: { value: '123456789' } })
399
400 await waitFor(() => {
401 expect(screen.getByRole('radio', { name: /discord/i })).not.toBeDisabled()
402 })
403 })
404
405 it('allows selecting a configured channel', async () => {
406 mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
407 jsonResponse(mockData.notificationPrefs({
408 discordId: '123456789',
409 discordVerified: true,
410 }))
411 )
412
413 render(Notifications)
414
415 await waitFor(() => {
416 expect(screen.getByRole('radio', { name: /discord/i })).not.toBeDisabled()
417 })
418
419 await fireEvent.click(screen.getByRole('radio', { name: /discord/i }))
420
421 const discordRadio = screen.getByRole('radio', { name: /discord/i }) as HTMLInputElement
422 expect(discordRadio.checked).toBe(true)
423 })
424 })
425
426 describe('error handling', () => {
427 beforeEach(() => {
428 setupAuthenticatedUser()
429 })
430
431 it('shows error when loading preferences fails', async () => {
432 mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
433 errorResponse('InternalError', 'Database connection failed', 500)
434 )
435
436 render(Notifications)
437
438 await waitFor(() => {
439 expect(screen.getByText(/database connection failed/i)).toBeInTheDocument()
440 })
441 })
442 })
443})