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'
13describe('Settings', () => {
14 beforeEach(() => {
15 clearMocks()
16 setupFetchMock()
17 window.confirm = vi.fn(() => true)
18 })
19 describe('authentication guard', () => {
20 it('redirects to login when not authenticated', async () => {
21 setupUnauthenticatedUser()
22 render(Settings)
23 await waitFor(() => {
24 expect(window.location.hash).toBe('#/login')
25 })
26 })
27 })
28 describe('page structure', () => {
29 beforeEach(() => {
30 setupAuthenticatedUser()
31 })
32 it('displays all page elements and sections', async () => {
33 render(Settings)
34 await waitFor(() => {
35 expect(screen.getByRole('heading', { name: /account settings/i, level: 1 })).toBeInTheDocument()
36 expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard')
37 expect(screen.getByRole('heading', { name: /change email/i })).toBeInTheDocument()
38 expect(screen.getByRole('heading', { name: /change handle/i })).toBeInTheDocument()
39 expect(screen.getByRole('heading', { name: /delete account/i })).toBeInTheDocument()
40 })
41 })
42 })
43 describe('email change', () => {
44 beforeEach(() => {
45 setupAuthenticatedUser()
46 })
47 it('displays current email and input field', async () => {
48 render(Settings)
49 await waitFor(() => {
50 expect(screen.getByText(/current: test@example.com/i)).toBeInTheDocument()
51 expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
52 })
53 })
54 it('calls requestEmailUpdate when submitting', async () => {
55 let requestCalled = false
56 mockEndpoint('com.atproto.server.requestEmailUpdate', () => {
57 requestCalled = true
58 return jsonResponse({ tokenRequired: true })
59 })
60 render(Settings)
61 await waitFor(() => {
62 expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
63 })
64 await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } })
65 await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
66 await waitFor(() => {
67 expect(requestCalled).toBe(true)
68 })
69 })
70 it('shows verification code input when token is required', async () => {
71 mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
72 jsonResponse({ tokenRequired: true })
73 )
74 render(Settings)
75 await waitFor(() => {
76 expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
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 await waitFor(() => {
81 expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
82 expect(screen.getByRole('button', { name: /confirm email change/i })).toBeInTheDocument()
83 })
84 })
85 it('calls updateEmail with token when confirming', async () => {
86 let updateCalled = false
87 let capturedBody: Record<string, string> | null = null
88 mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
89 jsonResponse({ tokenRequired: true })
90 )
91 mockEndpoint('com.atproto.server.updateEmail', (_url, options) => {
92 updateCalled = true
93 capturedBody = JSON.parse((options?.body as string) || '{}')
94 return jsonResponse({})
95 })
96 render(Settings)
97 await waitFor(() => {
98 expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
99 })
100 await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } })
101 await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
102 await waitFor(() => {
103 expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
104 })
105 await fireEvent.input(screen.getByLabelText(/verification code/i), { target: { value: '123456' } })
106 await fireEvent.click(screen.getByRole('button', { name: /confirm email change/i }))
107 await waitFor(() => {
108 expect(updateCalled).toBe(true)
109 expect(capturedBody?.email).toBe('newemail@example.com')
110 expect(capturedBody?.token).toBe('123456')
111 })
112 })
113 it('shows success message after email update', async () => {
114 mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
115 jsonResponse({ tokenRequired: true })
116 )
117 mockEndpoint('com.atproto.server.updateEmail', () =>
118 jsonResponse({})
119 )
120 render(Settings)
121 await waitFor(() => {
122 expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
123 })
124 await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'new@test.com' } })
125 await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
126 await waitFor(() => {
127 expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
128 })
129 await fireEvent.input(screen.getByLabelText(/verification code/i), { target: { value: '123456' } })
130 await fireEvent.click(screen.getByRole('button', { name: /confirm email change/i }))
131 await waitFor(() => {
132 expect(screen.getByText(/email updated successfully/i)).toBeInTheDocument()
133 })
134 })
135 it('shows cancel button to return to email form', async () => {
136 mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
137 jsonResponse({ tokenRequired: true })
138 )
139 render(Settings)
140 await waitFor(() => {
141 expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
142 })
143 await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'new@test.com' } })
144 await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
145 await waitFor(() => {
146 expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
147 })
148 await fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
149 await waitFor(() => {
150 expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
151 expect(screen.queryByLabelText(/verification code/i)).not.toBeInTheDocument()
152 })
153 })
154 it('shows error when email update fails', async () => {
155 mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
156 errorResponse('InvalidEmail', 'Invalid email format', 400)
157 )
158 render(Settings)
159 await waitFor(() => {
160 expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
161 })
162 await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'invalid@test.com' } })
163 await waitFor(() => {
164 expect(screen.getByRole('button', { name: /change email/i })).not.toBeDisabled()
165 })
166 await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
167 await waitFor(() => {
168 expect(screen.getByText(/invalid email format/i)).toBeInTheDocument()
169 })
170 })
171 })
172 describe('handle change', () => {
173 beforeEach(() => {
174 setupAuthenticatedUser()
175 })
176 it('displays current handle', async () => {
177 render(Settings)
178 await waitFor(() => {
179 expect(screen.getByText(/current: @testuser\.test\.bspds\.dev/i)).toBeInTheDocument()
180 })
181 })
182 it('calls updateHandle with new handle', async () => {
183 let capturedHandle: string | null = null
184 mockEndpoint('com.atproto.identity.updateHandle', (_url, options) => {
185 const body = JSON.parse((options?.body as string) || '{}')
186 capturedHandle = body.handle
187 return jsonResponse({})
188 })
189 render(Settings)
190 await waitFor(() => {
191 expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
192 })
193 await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'newhandle.bsky.social' } })
194 await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
195 await waitFor(() => {
196 expect(capturedHandle).toBe('newhandle.bsky.social')
197 })
198 })
199 it('shows success message after handle change', async () => {
200 mockEndpoint('com.atproto.identity.updateHandle', () =>
201 jsonResponse({})
202 )
203 render(Settings)
204 await waitFor(() => {
205 expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
206 })
207 await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'newhandle' } })
208 await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
209 await waitFor(() => {
210 expect(screen.getByText(/handle updated successfully/i)).toBeInTheDocument()
211 })
212 })
213 it('shows error when handle change fails', async () => {
214 mockEndpoint('com.atproto.identity.updateHandle', () =>
215 errorResponse('HandleNotAvailable', 'Handle is already taken', 400)
216 )
217 render(Settings)
218 await waitFor(() => {
219 expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
220 })
221 await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'taken' } })
222 await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
223 await waitFor(() => {
224 expect(screen.getByText(/handle is already taken/i)).toBeInTheDocument()
225 })
226 })
227 })
228 describe('account deletion', () => {
229 beforeEach(() => {
230 setupAuthenticatedUser()
231 mockEndpoint('com.atproto.server.deleteSession', () =>
232 jsonResponse({})
233 )
234 })
235 it('displays delete section with warning and request button', async () => {
236 render(Settings)
237 await waitFor(() => {
238 expect(screen.getByText(/this action is irreversible/i)).toBeInTheDocument()
239 expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
240 })
241 })
242 it('calls requestAccountDelete when clicking request', async () => {
243 let requestCalled = false
244 mockEndpoint('com.atproto.server.requestAccountDelete', () => {
245 requestCalled = true
246 return jsonResponse({})
247 })
248 render(Settings)
249 await waitFor(() => {
250 expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
251 })
252 await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
253 await waitFor(() => {
254 expect(requestCalled).toBe(true)
255 })
256 })
257 it('shows confirmation form after requesting deletion', async () => {
258 mockEndpoint('com.atproto.server.requestAccountDelete', () =>
259 jsonResponse({})
260 )
261 render(Settings)
262 await waitFor(() => {
263 expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
264 })
265 await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
266 await waitFor(() => {
267 expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
268 expect(screen.getByLabelText(/your password/i)).toBeInTheDocument()
269 expect(screen.getByRole('button', { name: /permanently delete account/i })).toBeInTheDocument()
270 })
271 })
272 it('shows confirmation dialog before final deletion', async () => {
273 const confirmSpy = vi.fn(() => false)
274 window.confirm = confirmSpy
275 mockEndpoint('com.atproto.server.requestAccountDelete', () =>
276 jsonResponse({})
277 )
278 render(Settings)
279 await waitFor(() => {
280 expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
281 })
282 await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
283 await waitFor(() => {
284 expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
285 })
286 await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'ABC123' } })
287 await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
288 await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
289 expect(confirmSpy).toHaveBeenCalledWith(
290 expect.stringContaining('absolutely sure')
291 )
292 })
293 it('calls deleteAccount with correct parameters', async () => {
294 window.confirm = vi.fn(() => true)
295 let capturedBody: Record<string, string> | null = null
296 mockEndpoint('com.atproto.server.requestAccountDelete', () =>
297 jsonResponse({})
298 )
299 mockEndpoint('com.atproto.server.deleteAccount', (_url, options) => {
300 capturedBody = JSON.parse((options?.body as string) || '{}')
301 return jsonResponse({})
302 })
303 render(Settings)
304 await waitFor(() => {
305 expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
306 })
307 await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
308 await waitFor(() => {
309 expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
310 })
311 await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'DEL123' } })
312 await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'mypassword' } })
313 await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
314 await waitFor(() => {
315 expect(capturedBody?.token).toBe('DEL123')
316 expect(capturedBody?.password).toBe('mypassword')
317 expect(capturedBody?.did).toBe('did:web:test.bspds.dev:u:testuser')
318 })
319 })
320 it('navigates to login after successful deletion', async () => {
321 window.confirm = vi.fn(() => true)
322 mockEndpoint('com.atproto.server.requestAccountDelete', () =>
323 jsonResponse({})
324 )
325 mockEndpoint('com.atproto.server.deleteAccount', () =>
326 jsonResponse({})
327 )
328 render(Settings)
329 await waitFor(() => {
330 expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
331 })
332 await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
333 await waitFor(() => {
334 expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
335 })
336 await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'DEL123' } })
337 await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
338 await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
339 await waitFor(() => {
340 expect(window.location.hash).toBe('#/login')
341 })
342 })
343 it('shows cancel button to return to request state', async () => {
344 mockEndpoint('com.atproto.server.requestAccountDelete', () =>
345 jsonResponse({})
346 )
347 render(Settings)
348 await waitFor(() => {
349 expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
350 })
351 await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
352 await waitFor(() => {
353 const cancelButtons = screen.getAllByRole('button', { name: /cancel/i })
354 expect(cancelButtons.length).toBeGreaterThan(0)
355 })
356 const deleteHeading = screen.getByRole('heading', { name: /delete account/i })
357 const deleteSection = deleteHeading.closest('section')
358 const cancelButton = deleteSection?.querySelector('button.secondary')
359 if (cancelButton) {
360 await fireEvent.click(cancelButton)
361 }
362 await waitFor(() => {
363 expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
364 })
365 })
366 it('shows error when deletion fails', async () => {
367 window.confirm = vi.fn(() => true)
368 mockEndpoint('com.atproto.server.requestAccountDelete', () =>
369 jsonResponse({})
370 )
371 mockEndpoint('com.atproto.server.deleteAccount', () =>
372 errorResponse('InvalidToken', 'Invalid confirmation code', 400)
373 )
374 render(Settings)
375 await waitFor(() => {
376 expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
377 })
378 await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
379 await waitFor(() => {
380 expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
381 })
382 await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'WRONG' } })
383 await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
384 await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
385 await waitFor(() => {
386 expect(screen.getByText(/invalid confirmation code/i)).toBeInTheDocument()
387 })
388 })
389 })
390})