this repo has no description
1import { describe, it, expect, beforeEach, vi } from 'vitest'
2import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
3import AppPasswords from '../routes/AppPasswords.svelte'
4import {
5 setupFetchMock,
6 mockEndpoint,
7 jsonResponse,
8 errorResponse,
9 mockData,
10 clearMocks,
11 setupAuthenticatedUser,
12 setupUnauthenticatedUser,
13} from './mocks'
14
15describe('AppPasswords', () => {
16 beforeEach(() => {
17 clearMocks()
18 setupFetchMock()
19 window.confirm = vi.fn(() => true)
20 })
21
22 describe('authentication guard', () => {
23 it('redirects to login when not authenticated', async () => {
24 setupUnauthenticatedUser()
25 render(AppPasswords)
26
27 await waitFor(() => {
28 expect(window.location.hash).toBe('#/login')
29 })
30 })
31 })
32
33 describe('page structure', () => {
34 beforeEach(() => {
35 setupAuthenticatedUser()
36 mockEndpoint('com.atproto.server.listAppPasswords', () =>
37 jsonResponse({ passwords: [] })
38 )
39 })
40
41 it('displays all page elements', async () => {
42 render(AppPasswords)
43
44 await waitFor(() => {
45 expect(screen.getByRole('heading', { name: /app passwords/i, level: 1 })).toBeInTheDocument()
46 expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard')
47 expect(screen.getByText(/third-party apps/i)).toBeInTheDocument()
48 })
49 })
50 })
51
52 describe('loading state', () => {
53 beforeEach(() => {
54 setupAuthenticatedUser()
55 })
56
57 it('shows loading text while fetching passwords', async () => {
58 mockEndpoint('com.atproto.server.listAppPasswords', async () => {
59 await new Promise(resolve => setTimeout(resolve, 100))
60 return jsonResponse({ passwords: [] })
61 })
62
63 render(AppPasswords)
64
65 expect(screen.getByText(/loading/i)).toBeInTheDocument()
66 })
67 })
68
69 describe('empty state', () => {
70 beforeEach(() => {
71 setupAuthenticatedUser()
72 mockEndpoint('com.atproto.server.listAppPasswords', () =>
73 jsonResponse({ passwords: [] })
74 )
75 })
76
77 it('shows empty message when no passwords exist', async () => {
78 render(AppPasswords)
79
80 await waitFor(() => {
81 expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument()
82 })
83 })
84 })
85
86 describe('password list', () => {
87 const testPasswords = [
88 mockData.appPassword({ name: 'Graysky', createdAt: '2024-01-15T10:00:00Z' }),
89 mockData.appPassword({ name: 'Skeets', createdAt: '2024-02-20T15:30:00Z' }),
90 ]
91
92 beforeEach(() => {
93 setupAuthenticatedUser()
94 mockEndpoint('com.atproto.server.listAppPasswords', () =>
95 jsonResponse({ passwords: testPasswords })
96 )
97 })
98
99 it('displays all app passwords with dates and revoke buttons', async () => {
100 render(AppPasswords)
101
102 await waitFor(() => {
103 expect(screen.getByText('Graysky')).toBeInTheDocument()
104 expect(screen.getByText('Skeets')).toBeInTheDocument()
105 expect(screen.getByText(/created.*1\/15\/2024/i)).toBeInTheDocument()
106 expect(screen.getByText(/created.*2\/20\/2024/i)).toBeInTheDocument()
107 expect(screen.getAllByRole('button', { name: /revoke/i })).toHaveLength(2)
108 })
109 })
110 })
111
112 describe('create app password', () => {
113 beforeEach(() => {
114 setupAuthenticatedUser()
115 mockEndpoint('com.atproto.server.listAppPasswords', () =>
116 jsonResponse({ passwords: [] })
117 )
118 })
119
120 it('displays create form with input and button', async () => {
121 render(AppPasswords)
122
123 await waitFor(() => {
124 expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
125 expect(screen.getByRole('button', { name: /create/i })).toBeInTheDocument()
126 })
127 })
128
129 it('disables create button when input is empty', async () => {
130 render(AppPasswords)
131
132 await waitFor(() => {
133 expect(screen.getByRole('button', { name: /create/i })).toBeDisabled()
134 })
135 })
136
137 it('enables create button when input has value', async () => {
138 render(AppPasswords)
139
140 await waitFor(() => {
141 expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
142 })
143
144 await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'My New App' } })
145
146 expect(screen.getByRole('button', { name: /create/i })).not.toBeDisabled()
147 })
148
149 it('calls createAppPassword with correct name', async () => {
150 let capturedName: string | null = null
151
152 mockEndpoint('com.atproto.server.createAppPassword', (_url, options) => {
153 const body = JSON.parse((options?.body as string) || '{}')
154 capturedName = body.name
155 return jsonResponse({
156 name: body.name,
157 password: 'xxxx-xxxx-xxxx-xxxx',
158 createdAt: new Date().toISOString(),
159 })
160 })
161
162 render(AppPasswords)
163
164 await waitFor(() => {
165 expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
166 })
167
168 await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Graysky' } })
169 await fireEvent.click(screen.getByRole('button', { name: /create/i }))
170
171 await waitFor(() => {
172 expect(capturedName).toBe('Graysky')
173 })
174 })
175
176 it('shows loading state while creating', async () => {
177 mockEndpoint('com.atproto.server.createAppPassword', async () => {
178 await new Promise(resolve => setTimeout(resolve, 100))
179 return jsonResponse({
180 name: 'Test',
181 password: 'xxxx-xxxx-xxxx-xxxx',
182 createdAt: new Date().toISOString(),
183 })
184 })
185
186 render(AppPasswords)
187
188 await waitFor(() => {
189 expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
190 })
191
192 await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Test' } })
193 await fireEvent.click(screen.getByRole('button', { name: /create/i }))
194
195 expect(screen.getByRole('button', { name: /creating/i })).toBeInTheDocument()
196 expect(screen.getByRole('button', { name: /creating/i })).toBeDisabled()
197 })
198
199 it('displays created password in success box and clears input', async () => {
200 mockEndpoint('com.atproto.server.createAppPassword', () =>
201 jsonResponse({
202 name: 'MyApp',
203 password: 'abcd-efgh-ijkl-mnop',
204 createdAt: new Date().toISOString(),
205 })
206 )
207
208 render(AppPasswords)
209
210 await waitFor(() => {
211 expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
212 })
213
214 const input = screen.getByPlaceholderText(/app name/i) as HTMLInputElement
215 await fireEvent.input(input, { target: { value: 'MyApp' } })
216 await fireEvent.click(screen.getByRole('button', { name: /create/i }))
217
218 await waitFor(() => {
219 expect(screen.getByText(/app password created/i)).toBeInTheDocument()
220 expect(screen.getByText('abcd-efgh-ijkl-mnop')).toBeInTheDocument()
221 expect(screen.getByText(/name: myapp/i)).toBeInTheDocument()
222 expect(input.value).toBe('')
223 })
224 })
225
226 it('dismisses created password box when clicking Done', async () => {
227 mockEndpoint('com.atproto.server.createAppPassword', () =>
228 jsonResponse({
229 name: 'Test',
230 password: 'xxxx-xxxx-xxxx-xxxx',
231 createdAt: new Date().toISOString(),
232 })
233 )
234
235 render(AppPasswords)
236
237 await waitFor(() => {
238 expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
239 })
240
241 await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Test' } })
242 await fireEvent.click(screen.getByRole('button', { name: /create/i }))
243
244 await waitFor(() => {
245 expect(screen.getByText(/app password created/i)).toBeInTheDocument()
246 })
247
248 await fireEvent.click(screen.getByRole('button', { name: /done/i }))
249
250 await waitFor(() => {
251 expect(screen.queryByText(/app password created/i)).not.toBeInTheDocument()
252 })
253 })
254
255 it('shows error when creation fails', async () => {
256 mockEndpoint('com.atproto.server.createAppPassword', () =>
257 errorResponse('InvalidRequest', 'Name already exists', 400)
258 )
259
260 render(AppPasswords)
261
262 await waitFor(() => {
263 expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
264 })
265
266 await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Duplicate' } })
267 await fireEvent.click(screen.getByRole('button', { name: /create/i }))
268
269 await waitFor(() => {
270 expect(screen.getByText(/name already exists/i)).toBeInTheDocument()
271 expect(screen.getByText(/name already exists/i)).toHaveClass('error')
272 })
273 })
274 })
275
276 describe('revoke app password', () => {
277 const testPassword = mockData.appPassword({ name: 'TestApp' })
278
279 beforeEach(() => {
280 setupAuthenticatedUser()
281 })
282
283 it('shows confirmation dialog before revoking', async () => {
284 const confirmSpy = vi.fn(() => false)
285 window.confirm = confirmSpy
286
287 mockEndpoint('com.atproto.server.listAppPasswords', () =>
288 jsonResponse({ passwords: [testPassword] })
289 )
290
291 render(AppPasswords)
292
293 await waitFor(() => {
294 expect(screen.getByText('TestApp')).toBeInTheDocument()
295 })
296
297 await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
298
299 expect(confirmSpy).toHaveBeenCalledWith(
300 expect.stringContaining('TestApp')
301 )
302 })
303
304 it('does not revoke when confirmation is cancelled', async () => {
305 window.confirm = vi.fn(() => false)
306 let revokeCalled = false
307
308 mockEndpoint('com.atproto.server.listAppPasswords', () =>
309 jsonResponse({ passwords: [testPassword] })
310 )
311
312 mockEndpoint('com.atproto.server.revokeAppPassword', () => {
313 revokeCalled = true
314 return jsonResponse({})
315 })
316
317 render(AppPasswords)
318
319 await waitFor(() => {
320 expect(screen.getByText('TestApp')).toBeInTheDocument()
321 })
322
323 await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
324
325 expect(revokeCalled).toBe(false)
326 })
327
328 it('calls revokeAppPassword with correct name', async () => {
329 window.confirm = vi.fn(() => true)
330 let capturedName: string | null = null
331
332 mockEndpoint('com.atproto.server.listAppPasswords', () =>
333 jsonResponse({ passwords: [testPassword] })
334 )
335
336 mockEndpoint('com.atproto.server.revokeAppPassword', (_url, options) => {
337 const body = JSON.parse((options?.body as string) || '{}')
338 capturedName = body.name
339 return jsonResponse({})
340 })
341
342 render(AppPasswords)
343
344 await waitFor(() => {
345 expect(screen.getByText('TestApp')).toBeInTheDocument()
346 })
347
348 await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
349
350 await waitFor(() => {
351 expect(capturedName).toBe('TestApp')
352 })
353 })
354
355 it('shows loading state while revoking', async () => {
356 window.confirm = vi.fn(() => true)
357
358 mockEndpoint('com.atproto.server.listAppPasswords', () =>
359 jsonResponse({ passwords: [testPassword] })
360 )
361
362 mockEndpoint('com.atproto.server.revokeAppPassword', async () => {
363 await new Promise(resolve => setTimeout(resolve, 100))
364 return jsonResponse({})
365 })
366
367 render(AppPasswords)
368
369 await waitFor(() => {
370 expect(screen.getByText('TestApp')).toBeInTheDocument()
371 })
372
373 await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
374
375 expect(screen.getByRole('button', { name: /revoking/i })).toBeInTheDocument()
376 expect(screen.getByRole('button', { name: /revoking/i })).toBeDisabled()
377 })
378
379 it('reloads password list after successful revocation', async () => {
380 window.confirm = vi.fn(() => true)
381 let listCallCount = 0
382
383 mockEndpoint('com.atproto.server.listAppPasswords', () => {
384 listCallCount++
385 if (listCallCount === 1) {
386 return jsonResponse({ passwords: [testPassword] })
387 }
388 return jsonResponse({ passwords: [] })
389 })
390
391 mockEndpoint('com.atproto.server.revokeAppPassword', () =>
392 jsonResponse({})
393 )
394
395 render(AppPasswords)
396
397 await waitFor(() => {
398 expect(screen.getByText('TestApp')).toBeInTheDocument()
399 })
400
401 await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
402
403 await waitFor(() => {
404 expect(screen.queryByText('TestApp')).not.toBeInTheDocument()
405 expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument()
406 })
407 })
408
409 it('shows error when revocation fails', async () => {
410 window.confirm = vi.fn(() => true)
411
412 mockEndpoint('com.atproto.server.listAppPasswords', () =>
413 jsonResponse({ passwords: [testPassword] })
414 )
415
416 mockEndpoint('com.atproto.server.revokeAppPassword', () =>
417 errorResponse('InternalError', 'Server error', 500)
418 )
419
420 render(AppPasswords)
421
422 await waitFor(() => {
423 expect(screen.getByText('TestApp')).toBeInTheDocument()
424 })
425
426 await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
427
428 await waitFor(() => {
429 expect(screen.getByText(/server error/i)).toBeInTheDocument()
430 expect(screen.getByText(/server error/i)).toHaveClass('error')
431 })
432 })
433 })
434
435 describe('error handling', () => {
436 beforeEach(() => {
437 setupAuthenticatedUser()
438 })
439
440 it('shows error when loading passwords fails', async () => {
441 mockEndpoint('com.atproto.server.listAppPasswords', () =>
442 errorResponse('InternalError', 'Database connection failed', 500)
443 )
444
445 render(AppPasswords)
446
447 await waitFor(() => {
448 expect(screen.getByText(/database connection failed/i)).toBeInTheDocument()
449 expect(screen.getByText(/database connection failed/i)).toHaveClass('error')
450 })
451 })
452 })
453})