import { describe, it, expect, beforeEach, vi } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/svelte' import AppPasswords from '../routes/AppPasswords.svelte' import { setupFetchMock, mockEndpoint, jsonResponse, errorResponse, mockData, clearMocks, setupAuthenticatedUser, setupUnauthenticatedUser, } from './mocks' describe('AppPasswords', () => { beforeEach(() => { clearMocks() setupFetchMock() window.confirm = vi.fn(() => true) }) describe('authentication guard', () => { it('redirects to login when not authenticated', async () => { setupUnauthenticatedUser() render(AppPasswords) await waitFor(() => { expect(window.location.hash).toBe('#/login') }) }) }) describe('page structure', () => { beforeEach(() => { setupAuthenticatedUser() mockEndpoint('com.atproto.server.listAppPasswords', () => jsonResponse({ passwords: [] }) ) }) it('displays all page elements', async () => { render(AppPasswords) await waitFor(() => { expect(screen.getByRole('heading', { name: /app passwords/i, level: 1 })).toBeInTheDocument() expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard') expect(screen.getByText(/third-party apps/i)).toBeInTheDocument() }) }) }) describe('loading state', () => { beforeEach(() => { setupAuthenticatedUser() }) it('shows loading text while fetching passwords', async () => { mockEndpoint('com.atproto.server.listAppPasswords', async () => { await new Promise(resolve => setTimeout(resolve, 100)) return jsonResponse({ passwords: [] }) }) render(AppPasswords) expect(screen.getByText(/loading/i)).toBeInTheDocument() }) }) describe('empty state', () => { beforeEach(() => { setupAuthenticatedUser() mockEndpoint('com.atproto.server.listAppPasswords', () => jsonResponse({ passwords: [] }) ) }) it('shows empty message when no passwords exist', async () => { render(AppPasswords) await waitFor(() => { expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument() }) }) }) describe('password list', () => { const testPasswords = [ mockData.appPassword({ name: 'Graysky', createdAt: '2024-01-15T10:00:00Z' }), mockData.appPassword({ name: 'Skeets', createdAt: '2024-02-20T15:30:00Z' }), ] beforeEach(() => { setupAuthenticatedUser() mockEndpoint('com.atproto.server.listAppPasswords', () => jsonResponse({ passwords: testPasswords }) ) }) it('displays all app passwords with dates and revoke buttons', async () => { render(AppPasswords) await waitFor(() => { expect(screen.getByText('Graysky')).toBeInTheDocument() expect(screen.getByText('Skeets')).toBeInTheDocument() expect(screen.getByText(/created.*1\/15\/2024/i)).toBeInTheDocument() expect(screen.getByText(/created.*2\/20\/2024/i)).toBeInTheDocument() expect(screen.getAllByRole('button', { name: /revoke/i })).toHaveLength(2) }) }) }) describe('create app password', () => { beforeEach(() => { setupAuthenticatedUser() mockEndpoint('com.atproto.server.listAppPasswords', () => jsonResponse({ passwords: [] }) ) }) it('displays create form with input and button', async () => { render(AppPasswords) await waitFor(() => { expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument() expect(screen.getByRole('button', { name: /create/i })).toBeInTheDocument() }) }) it('disables create button when input is empty', async () => { render(AppPasswords) await waitFor(() => { expect(screen.getByRole('button', { name: /create/i })).toBeDisabled() }) }) it('enables create button when input has value', async () => { render(AppPasswords) await waitFor(() => { expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument() }) await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'My New App' } }) expect(screen.getByRole('button', { name: /create/i })).not.toBeDisabled() }) it('calls createAppPassword with correct name', async () => { let capturedName: string | null = null mockEndpoint('com.atproto.server.createAppPassword', (_url, options) => { const body = JSON.parse((options?.body as string) || '{}') capturedName = body.name return jsonResponse({ name: body.name, password: 'xxxx-xxxx-xxxx-xxxx', createdAt: new Date().toISOString(), }) }) render(AppPasswords) await waitFor(() => { expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument() }) await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Graysky' } }) await fireEvent.click(screen.getByRole('button', { name: /create/i })) await waitFor(() => { expect(capturedName).toBe('Graysky') }) }) it('shows loading state while creating', async () => { mockEndpoint('com.atproto.server.createAppPassword', async () => { await new Promise(resolve => setTimeout(resolve, 100)) return jsonResponse({ name: 'Test', password: 'xxxx-xxxx-xxxx-xxxx', createdAt: new Date().toISOString(), }) }) render(AppPasswords) await waitFor(() => { expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument() }) await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Test' } }) await fireEvent.click(screen.getByRole('button', { name: /create/i })) expect(screen.getByRole('button', { name: /creating/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /creating/i })).toBeDisabled() }) it('displays created password in success box and clears input', async () => { mockEndpoint('com.atproto.server.createAppPassword', () => jsonResponse({ name: 'MyApp', password: 'abcd-efgh-ijkl-mnop', createdAt: new Date().toISOString(), }) ) render(AppPasswords) await waitFor(() => { expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument() }) const input = screen.getByPlaceholderText(/app name/i) as HTMLInputElement await fireEvent.input(input, { target: { value: 'MyApp' } }) await fireEvent.click(screen.getByRole('button', { name: /create/i })) await waitFor(() => { expect(screen.getByText(/app password created/i)).toBeInTheDocument() expect(screen.getByText('abcd-efgh-ijkl-mnop')).toBeInTheDocument() expect(screen.getByText(/name: myapp/i)).toBeInTheDocument() expect(input.value).toBe('') }) }) it('dismisses created password box when clicking Done', async () => { mockEndpoint('com.atproto.server.createAppPassword', () => jsonResponse({ name: 'Test', password: 'xxxx-xxxx-xxxx-xxxx', createdAt: new Date().toISOString(), }) ) render(AppPasswords) await waitFor(() => { expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument() }) await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Test' } }) await fireEvent.click(screen.getByRole('button', { name: /create/i })) await waitFor(() => { expect(screen.getByText(/app password created/i)).toBeInTheDocument() }) await fireEvent.click(screen.getByRole('button', { name: /done/i })) await waitFor(() => { expect(screen.queryByText(/app password created/i)).not.toBeInTheDocument() }) }) it('shows error when creation fails', async () => { mockEndpoint('com.atproto.server.createAppPassword', () => errorResponse('InvalidRequest', 'Name already exists', 400) ) render(AppPasswords) await waitFor(() => { expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument() }) await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Duplicate' } }) await fireEvent.click(screen.getByRole('button', { name: /create/i })) await waitFor(() => { expect(screen.getByText(/name already exists/i)).toBeInTheDocument() expect(screen.getByText(/name already exists/i)).toHaveClass('error') }) }) }) describe('revoke app password', () => { const testPassword = mockData.appPassword({ name: 'TestApp' }) beforeEach(() => { setupAuthenticatedUser() }) it('shows confirmation dialog before revoking', async () => { const confirmSpy = vi.fn(() => false) window.confirm = confirmSpy mockEndpoint('com.atproto.server.listAppPasswords', () => jsonResponse({ passwords: [testPassword] }) ) render(AppPasswords) await waitFor(() => { expect(screen.getByText('TestApp')).toBeInTheDocument() }) await fireEvent.click(screen.getByRole('button', { name: /revoke/i })) expect(confirmSpy).toHaveBeenCalledWith( expect.stringContaining('TestApp') ) }) it('does not revoke when confirmation is cancelled', async () => { window.confirm = vi.fn(() => false) let revokeCalled = false mockEndpoint('com.atproto.server.listAppPasswords', () => jsonResponse({ passwords: [testPassword] }) ) mockEndpoint('com.atproto.server.revokeAppPassword', () => { revokeCalled = true return jsonResponse({}) }) render(AppPasswords) await waitFor(() => { expect(screen.getByText('TestApp')).toBeInTheDocument() }) await fireEvent.click(screen.getByRole('button', { name: /revoke/i })) expect(revokeCalled).toBe(false) }) it('calls revokeAppPassword with correct name', async () => { window.confirm = vi.fn(() => true) let capturedName: string | null = null mockEndpoint('com.atproto.server.listAppPasswords', () => jsonResponse({ passwords: [testPassword] }) ) mockEndpoint('com.atproto.server.revokeAppPassword', (_url, options) => { const body = JSON.parse((options?.body as string) || '{}') capturedName = body.name return jsonResponse({}) }) render(AppPasswords) await waitFor(() => { expect(screen.getByText('TestApp')).toBeInTheDocument() }) await fireEvent.click(screen.getByRole('button', { name: /revoke/i })) await waitFor(() => { expect(capturedName).toBe('TestApp') }) }) it('shows loading state while revoking', async () => { window.confirm = vi.fn(() => true) mockEndpoint('com.atproto.server.listAppPasswords', () => jsonResponse({ passwords: [testPassword] }) ) mockEndpoint('com.atproto.server.revokeAppPassword', async () => { await new Promise(resolve => setTimeout(resolve, 100)) return jsonResponse({}) }) render(AppPasswords) await waitFor(() => { expect(screen.getByText('TestApp')).toBeInTheDocument() }) await fireEvent.click(screen.getByRole('button', { name: /revoke/i })) expect(screen.getByRole('button', { name: /revoking/i })).toBeInTheDocument() expect(screen.getByRole('button', { name: /revoking/i })).toBeDisabled() }) it('reloads password list after successful revocation', async () => { window.confirm = vi.fn(() => true) let listCallCount = 0 mockEndpoint('com.atproto.server.listAppPasswords', () => { listCallCount++ if (listCallCount === 1) { return jsonResponse({ passwords: [testPassword] }) } return jsonResponse({ passwords: [] }) }) mockEndpoint('com.atproto.server.revokeAppPassword', () => jsonResponse({}) ) render(AppPasswords) await waitFor(() => { expect(screen.getByText('TestApp')).toBeInTheDocument() }) await fireEvent.click(screen.getByRole('button', { name: /revoke/i })) await waitFor(() => { expect(screen.queryByText('TestApp')).not.toBeInTheDocument() expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument() }) }) it('shows error when revocation fails', async () => { window.confirm = vi.fn(() => true) mockEndpoint('com.atproto.server.listAppPasswords', () => jsonResponse({ passwords: [testPassword] }) ) mockEndpoint('com.atproto.server.revokeAppPassword', () => errorResponse('InternalError', 'Server error', 500) ) render(AppPasswords) await waitFor(() => { expect(screen.getByText('TestApp')).toBeInTheDocument() }) await fireEvent.click(screen.getByRole('button', { name: /revoke/i })) await waitFor(() => { expect(screen.getByText(/server error/i)).toBeInTheDocument() expect(screen.getByText(/server error/i)).toHaveClass('error') }) }) }) describe('error handling', () => { beforeEach(() => { setupAuthenticatedUser() }) it('shows error when loading passwords fails', async () => { mockEndpoint('com.atproto.server.listAppPasswords', () => errorResponse('InternalError', 'Database connection failed', 500) ) render(AppPasswords) await waitFor(() => { expect(screen.getByText(/database connection failed/i)).toBeInTheDocument() expect(screen.getByText(/database connection failed/i)).toHaveClass('error') }) }) }) })