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