this repo has no description
1import { describe, it, expect, beforeEach, vi } from 'vitest'
2import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
3import Settings from '../routes/Settings.svelte'
4import {
5 setupFetchMock,
6 mockEndpoint,
7 jsonResponse,
8 errorResponse,
9 clearMocks,
10 setupAuthenticatedUser,
11 setupUnauthenticatedUser,
12} from './mocks'
13
14describe('Settings', () => {
15 beforeEach(() => {
16 clearMocks()
17 setupFetchMock()
18 window.confirm = vi.fn(() => true)
19 })
20
21 describe('authentication guard', () => {
22 it('redirects to login when not authenticated', async () => {
23 setupUnauthenticatedUser()
24 render(Settings)
25
26 await waitFor(() => {
27 expect(window.location.hash).toBe('#/login')
28 })
29 })
30 })
31
32 describe('page structure', () => {
33 beforeEach(() => {
34 setupAuthenticatedUser()
35 })
36
37 it('displays all page elements and sections', async () => {
38 render(Settings)
39
40 await waitFor(() => {
41 expect(screen.getByRole('heading', { name: /account settings/i, level: 1 })).toBeInTheDocument()
42 expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard')
43 expect(screen.getByRole('heading', { name: /change email/i })).toBeInTheDocument()
44 expect(screen.getByRole('heading', { name: /change handle/i })).toBeInTheDocument()
45 expect(screen.getByRole('heading', { name: /delete account/i })).toBeInTheDocument()
46 })
47 })
48 })
49
50 describe('email change', () => {
51 beforeEach(() => {
52 setupAuthenticatedUser()
53 })
54
55 it('displays current email and input field', async () => {
56 render(Settings)
57
58 await waitFor(() => {
59 expect(screen.getByText(/current: test@example.com/i)).toBeInTheDocument()
60 expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
61 })
62 })
63
64 it('calls requestEmailUpdate when submitting', async () => {
65 let requestCalled = false
66
67 mockEndpoint('com.atproto.server.requestEmailUpdate', () => {
68 requestCalled = true
69 return jsonResponse({ tokenRequired: true })
70 })
71
72 render(Settings)
73
74 await waitFor(() => {
75 expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
76 })
77
78 await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } })
79 await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
80
81 await waitFor(() => {
82 expect(requestCalled).toBe(true)
83 })
84 })
85
86 it('shows verification code input when token is required', async () => {
87 mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
88 jsonResponse({ tokenRequired: true })
89 )
90
91 render(Settings)
92
93 await waitFor(() => {
94 expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
95 })
96
97 await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } })
98 await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
99
100 await waitFor(() => {
101 expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
102 expect(screen.getByRole('button', { name: /confirm email change/i })).toBeInTheDocument()
103 })
104 })
105
106 it('calls updateEmail with token when confirming', async () => {
107 let updateCalled = false
108 let capturedBody: Record<string, string> | null = null
109
110 mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
111 jsonResponse({ tokenRequired: true })
112 )
113
114 mockEndpoint('com.atproto.server.updateEmail', (_url, options) => {
115 updateCalled = true
116 capturedBody = JSON.parse((options?.body as string) || '{}')
117 return jsonResponse({})
118 })
119
120 render(Settings)
121
122 await waitFor(() => {
123 expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
124 })
125
126 await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } })
127 await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
128
129 await waitFor(() => {
130 expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
131 })
132
133 await fireEvent.input(screen.getByLabelText(/verification code/i), { target: { value: '123456' } })
134 await fireEvent.click(screen.getByRole('button', { name: /confirm email change/i }))
135
136 await waitFor(() => {
137 expect(updateCalled).toBe(true)
138 expect(capturedBody?.email).toBe('newemail@example.com')
139 expect(capturedBody?.token).toBe('123456')
140 })
141 })
142
143 it('shows success message after email update', async () => {
144 mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
145 jsonResponse({ tokenRequired: true })
146 )
147
148 mockEndpoint('com.atproto.server.updateEmail', () =>
149 jsonResponse({})
150 )
151
152 render(Settings)
153
154 await waitFor(() => {
155 expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
156 })
157
158 await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'new@test.com' } })
159 await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
160
161 await waitFor(() => {
162 expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
163 })
164
165 await fireEvent.input(screen.getByLabelText(/verification code/i), { target: { value: '123456' } })
166 await fireEvent.click(screen.getByRole('button', { name: /confirm email change/i }))
167
168 await waitFor(() => {
169 expect(screen.getByText(/email updated successfully/i)).toBeInTheDocument()
170 })
171 })
172
173 it('shows cancel button to return to email form', async () => {
174 mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
175 jsonResponse({ tokenRequired: true })
176 )
177
178 render(Settings)
179
180 await waitFor(() => {
181 expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
182 })
183
184 await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'new@test.com' } })
185 await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
186
187 await waitFor(() => {
188 expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
189 })
190
191 await fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
192
193 await waitFor(() => {
194 expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
195 expect(screen.queryByLabelText(/verification code/i)).not.toBeInTheDocument()
196 })
197 })
198
199 it('shows error when email update fails', async () => {
200 mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
201 errorResponse('InvalidEmail', 'Invalid email format', 400)
202 )
203
204 render(Settings)
205
206 await waitFor(() => {
207 expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
208 })
209
210 await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'invalid@test.com' } })
211
212 await waitFor(() => {
213 expect(screen.getByRole('button', { name: /change email/i })).not.toBeDisabled()
214 })
215
216 await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
217
218 await waitFor(() => {
219 expect(screen.getByText(/invalid email format/i)).toBeInTheDocument()
220 })
221 })
222 })
223
224 describe('handle change', () => {
225 beforeEach(() => {
226 setupAuthenticatedUser()
227 })
228
229 it('displays current handle', async () => {
230 render(Settings)
231
232 await waitFor(() => {
233 expect(screen.getByText(/current: @testuser\.test\.bspds\.dev/i)).toBeInTheDocument()
234 })
235 })
236
237 it('calls updateHandle with new handle', async () => {
238 let capturedHandle: string | null = null
239
240 mockEndpoint('com.atproto.identity.updateHandle', (_url, options) => {
241 const body = JSON.parse((options?.body as string) || '{}')
242 capturedHandle = body.handle
243 return jsonResponse({})
244 })
245
246 render(Settings)
247
248 await waitFor(() => {
249 expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
250 })
251
252 await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'newhandle.bsky.social' } })
253 await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
254
255 await waitFor(() => {
256 expect(capturedHandle).toBe('newhandle.bsky.social')
257 })
258 })
259
260 it('shows success message after handle change', async () => {
261 mockEndpoint('com.atproto.identity.updateHandle', () =>
262 jsonResponse({})
263 )
264
265 render(Settings)
266
267 await waitFor(() => {
268 expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
269 })
270
271 await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'newhandle' } })
272 await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
273
274 await waitFor(() => {
275 expect(screen.getByText(/handle updated successfully/i)).toBeInTheDocument()
276 })
277 })
278
279 it('shows error when handle change fails', async () => {
280 mockEndpoint('com.atproto.identity.updateHandle', () =>
281 errorResponse('HandleNotAvailable', 'Handle is already taken', 400)
282 )
283
284 render(Settings)
285
286 await waitFor(() => {
287 expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
288 })
289
290 await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'taken' } })
291 await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
292
293 await waitFor(() => {
294 expect(screen.getByText(/handle is already taken/i)).toBeInTheDocument()
295 })
296 })
297 })
298
299 describe('account deletion', () => {
300 beforeEach(() => {
301 setupAuthenticatedUser()
302 mockEndpoint('com.atproto.server.deleteSession', () =>
303 jsonResponse({})
304 )
305 })
306
307 it('displays delete section with warning and request button', async () => {
308 render(Settings)
309
310 await waitFor(() => {
311 expect(screen.getByText(/this action is irreversible/i)).toBeInTheDocument()
312 expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
313 })
314 })
315
316 it('calls requestAccountDelete when clicking request', async () => {
317 let requestCalled = false
318
319 mockEndpoint('com.atproto.server.requestAccountDelete', () => {
320 requestCalled = true
321 return jsonResponse({})
322 })
323
324 render(Settings)
325
326 await waitFor(() => {
327 expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
328 })
329
330 await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
331
332 await waitFor(() => {
333 expect(requestCalled).toBe(true)
334 })
335 })
336
337 it('shows confirmation form after requesting deletion', async () => {
338 mockEndpoint('com.atproto.server.requestAccountDelete', () =>
339 jsonResponse({})
340 )
341
342 render(Settings)
343
344 await waitFor(() => {
345 expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
346 })
347
348 await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
349
350 await waitFor(() => {
351 expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
352 expect(screen.getByLabelText(/your password/i)).toBeInTheDocument()
353 expect(screen.getByRole('button', { name: /permanently delete account/i })).toBeInTheDocument()
354 })
355 })
356
357 it('shows confirmation dialog before final deletion', async () => {
358 const confirmSpy = vi.fn(() => false)
359 window.confirm = confirmSpy
360
361 mockEndpoint('com.atproto.server.requestAccountDelete', () =>
362 jsonResponse({})
363 )
364
365 render(Settings)
366
367 await waitFor(() => {
368 expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
369 })
370
371 await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
372
373 await waitFor(() => {
374 expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
375 })
376
377 await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'ABC123' } })
378 await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
379 await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
380
381 expect(confirmSpy).toHaveBeenCalledWith(
382 expect.stringContaining('absolutely sure')
383 )
384 })
385
386 it('calls deleteAccount with correct parameters', async () => {
387 window.confirm = vi.fn(() => true)
388 let capturedBody: Record<string, string> | null = null
389
390 mockEndpoint('com.atproto.server.requestAccountDelete', () =>
391 jsonResponse({})
392 )
393
394 mockEndpoint('com.atproto.server.deleteAccount', (_url, options) => {
395 capturedBody = JSON.parse((options?.body as string) || '{}')
396 return jsonResponse({})
397 })
398
399 render(Settings)
400
401 await waitFor(() => {
402 expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
403 })
404
405 await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
406
407 await waitFor(() => {
408 expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
409 })
410
411 await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'DEL123' } })
412 await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'mypassword' } })
413 await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
414
415 await waitFor(() => {
416 expect(capturedBody?.token).toBe('DEL123')
417 expect(capturedBody?.password).toBe('mypassword')
418 expect(capturedBody?.did).toBe('did:web:test.bspds.dev:u:testuser')
419 })
420 })
421
422 it('navigates to login after successful deletion', async () => {
423 window.confirm = vi.fn(() => true)
424
425 mockEndpoint('com.atproto.server.requestAccountDelete', () =>
426 jsonResponse({})
427 )
428
429 mockEndpoint('com.atproto.server.deleteAccount', () =>
430 jsonResponse({})
431 )
432
433 render(Settings)
434
435 await waitFor(() => {
436 expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
437 })
438
439 await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
440
441 await waitFor(() => {
442 expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
443 })
444
445 await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'DEL123' } })
446 await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
447 await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
448
449 await waitFor(() => {
450 expect(window.location.hash).toBe('#/login')
451 })
452 })
453
454 it('shows cancel button to return to request state', async () => {
455 mockEndpoint('com.atproto.server.requestAccountDelete', () =>
456 jsonResponse({})
457 )
458
459 render(Settings)
460
461 await waitFor(() => {
462 expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
463 })
464
465 await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
466
467 await waitFor(() => {
468 const cancelButtons = screen.getAllByRole('button', { name: /cancel/i })
469 expect(cancelButtons.length).toBeGreaterThan(0)
470 })
471
472 const deleteHeading = screen.getByRole('heading', { name: /delete account/i })
473 const deleteSection = deleteHeading.closest('section')
474 const cancelButton = deleteSection?.querySelector('button.secondary')
475 if (cancelButton) {
476 await fireEvent.click(cancelButton)
477 }
478
479 await waitFor(() => {
480 expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
481 })
482 })
483
484 it('shows error when deletion fails', async () => {
485 window.confirm = vi.fn(() => true)
486
487 mockEndpoint('com.atproto.server.requestAccountDelete', () =>
488 jsonResponse({})
489 )
490
491 mockEndpoint('com.atproto.server.deleteAccount', () =>
492 errorResponse('InvalidToken', 'Invalid confirmation code', 400)
493 )
494
495 render(Settings)
496
497 await waitFor(() => {
498 expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
499 })
500
501 await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
502
503 await waitFor(() => {
504 expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
505 })
506
507 await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'WRONG' } })
508 await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
509 await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
510
511 await waitFor(() => {
512 expect(screen.getByText(/invalid confirmation code/i)).toBeInTheDocument()
513 })
514 })
515 })
516})