Implements a self-contained dialog that allows users to update credentials
for an existing account without deleting and re-adding it. Supports two
modes: token-based (Tab A) and credentials JSON (Tab B).
Also sets up web component test infrastructure: vitest now includes
apps/web/**/*.test.tsx files with happy-dom environment, @vitejs/plugin-react
for JSX, and @testing-library/{react,jest-dom,user-event} packages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
176 lines
6.5 KiB
TypeScript
176 lines
6.5 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import '@testing-library/jest-dom/vitest'
|
|
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
|
|
import { vi, describe, it, expect, beforeEach } from 'vitest'
|
|
import { UpdateCredentialsDialog } from './UpdateCredentialsDialog'
|
|
|
|
// Captured mutation options from the latest useMutation call
|
|
let capturedOnSuccess: (() => void) | undefined
|
|
let capturedOnError: ((err: { message: string }) => void) | undefined
|
|
|
|
const mockMutate = vi.fn()
|
|
const mockInvalidate = vi.fn()
|
|
|
|
vi.mock('@/lib/trpc', () => ({
|
|
trpc: {
|
|
updateAccountAuth: {
|
|
useMutation: vi.fn((opts?: {
|
|
onSuccess?: () => void
|
|
onError?: (err: { message: string }) => void
|
|
}) => {
|
|
capturedOnSuccess = opts?.onSuccess
|
|
capturedOnError = opts?.onError
|
|
return { mutate: mockMutate, isPending: false }
|
|
}),
|
|
},
|
|
useUtils: vi.fn(() => ({
|
|
systemHealthCheck: { invalidate: mockInvalidate },
|
|
})),
|
|
},
|
|
}))
|
|
|
|
vi.mock('sonner', () => ({
|
|
toast: { success: vi.fn() },
|
|
}))
|
|
|
|
import { toast } from 'sonner'
|
|
|
|
const account = {
|
|
id: 'acc-1',
|
|
email: 'alice@example.com',
|
|
provider: 'claude',
|
|
}
|
|
|
|
describe('UpdateCredentialsDialog', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
capturedOnSuccess = undefined
|
|
capturedOnError = undefined
|
|
})
|
|
|
|
// Test 1: Renders with read-only identity
|
|
it('renders account email and provider as non-editable', () => {
|
|
render(<UpdateCredentialsDialog open={true} onOpenChange={vi.fn()} account={account} />)
|
|
|
|
expect(screen.getByText('alice@example.com')).toBeInTheDocument()
|
|
expect(screen.getByText('claude')).toBeInTheDocument()
|
|
|
|
// They must not be editable inputs
|
|
const emailEl = screen.getByText('alice@example.com')
|
|
expect(emailEl.tagName).not.toBe('INPUT')
|
|
const providerEl = screen.getByText('claude')
|
|
expect(providerEl.tagName).not.toBe('INPUT')
|
|
})
|
|
|
|
// Test 2: Tab A submit — empty token — shows Required, no mutation
|
|
it('shows Required error when token is empty and does not call mutation', async () => {
|
|
render(<UpdateCredentialsDialog open={true} onOpenChange={vi.fn()} account={account} />)
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /update credentials/i }))
|
|
|
|
await waitFor(() => expect(screen.getByText('Required')).toBeInTheDocument())
|
|
expect(mockMutate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
// Test 3: Tab A submit — valid token — calls mutation with correct input
|
|
it('calls mutation with correct token input', async () => {
|
|
render(<UpdateCredentialsDialog open={true} onOpenChange={vi.fn()} account={account} />)
|
|
|
|
fireEvent.change(screen.getByLabelText(/setup token/i), { target: { value: 'test-token' } })
|
|
fireEvent.click(screen.getByRole('button', { name: /update credentials/i }))
|
|
|
|
await waitFor(() => expect(mockMutate).toHaveBeenCalledWith({
|
|
id: 'acc-1',
|
|
configJson: '{"hasCompletedOnboarding":true}',
|
|
credentials: '{"claudeAiOauth":{"accessToken":"test-token"}}',
|
|
}))
|
|
})
|
|
|
|
// Test 4: Tab B submit — invalid config JSON — shows "Invalid JSON", no mutation
|
|
it('shows Invalid JSON error for bad config JSON and does not call mutation', async () => {
|
|
render(<UpdateCredentialsDialog open={true} onOpenChange={vi.fn()} account={account} />)
|
|
|
|
fireEvent.click(screen.getByText('By credentials JSON'))
|
|
fireEvent.change(screen.getByLabelText(/config json/i), { target: { value: 'bad json' } })
|
|
fireEvent.click(screen.getByRole('button', { name: /update credentials/i }))
|
|
|
|
await waitFor(() => expect(screen.getByText('Invalid JSON')).toBeInTheDocument())
|
|
expect(mockMutate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
// Test 5: Tab B submit — empty textareas — calls mutation with '{}' defaults
|
|
it("calls mutation with '{}' defaults when credentials textareas are empty", async () => {
|
|
render(<UpdateCredentialsDialog open={true} onOpenChange={vi.fn()} account={account} />)
|
|
|
|
fireEvent.click(screen.getByText('By credentials JSON'))
|
|
fireEvent.click(screen.getByRole('button', { name: /update credentials/i }))
|
|
|
|
await waitFor(() => expect(mockMutate).toHaveBeenCalledWith({
|
|
id: 'acc-1',
|
|
configJson: '{}',
|
|
credentials: '{}',
|
|
}))
|
|
})
|
|
|
|
// Test 6: Server error — shows inline error, dialog stays open
|
|
it('shows server error inline and does not close dialog on error', async () => {
|
|
const onOpenChange = vi.fn()
|
|
render(<UpdateCredentialsDialog open={true} onOpenChange={onOpenChange} account={account} />)
|
|
|
|
fireEvent.change(screen.getByLabelText(/setup token/i), { target: { value: 'some-token' } })
|
|
fireEvent.click(screen.getByRole('button', { name: /update credentials/i }))
|
|
|
|
act(() => {
|
|
capturedOnError?.({ message: 'Token expired' })
|
|
})
|
|
|
|
await waitFor(() => expect(screen.getByText('Token expired')).toBeInTheDocument())
|
|
expect(onOpenChange).not.toHaveBeenCalled()
|
|
})
|
|
|
|
// Test 7: Success — toasts and closes dialog
|
|
it('shows success toast and closes dialog on success', async () => {
|
|
const onOpenChange = vi.fn()
|
|
render(<UpdateCredentialsDialog open={true} onOpenChange={onOpenChange} account={account} />)
|
|
|
|
fireEvent.change(screen.getByLabelText(/setup token/i), { target: { value: 'some-token' } })
|
|
fireEvent.click(screen.getByRole('button', { name: /update credentials/i }))
|
|
|
|
act(() => {
|
|
capturedOnSuccess?.()
|
|
})
|
|
|
|
await waitFor(() => {
|
|
expect(toast.success).toHaveBeenCalledWith('Credentials updated for alice@example.com.')
|
|
expect(onOpenChange).toHaveBeenCalledWith(false)
|
|
})
|
|
})
|
|
|
|
// Test 8: State reset on re-open
|
|
it('resets state when dialog is re-opened', async () => {
|
|
const { rerender } = render(
|
|
<UpdateCredentialsDialog open={true} onOpenChange={vi.fn()} account={account} />
|
|
)
|
|
|
|
// Type a token and trigger server error
|
|
fireEvent.change(screen.getByLabelText(/setup token/i), { target: { value: 'old-token' } })
|
|
fireEvent.click(screen.getByRole('button', { name: /update credentials/i }))
|
|
|
|
act(() => {
|
|
capturedOnError?.({ message: 'Server error' })
|
|
})
|
|
await waitFor(() => expect(screen.getByText('Server error')).toBeInTheDocument())
|
|
|
|
// Close dialog
|
|
rerender(<UpdateCredentialsDialog open={false} onOpenChange={vi.fn()} account={account} />)
|
|
// Re-open dialog
|
|
rerender(<UpdateCredentialsDialog open={true} onOpenChange={vi.fn()} account={account} />)
|
|
|
|
// Token should be cleared
|
|
const tokenInput = screen.getByLabelText(/setup token/i) as HTMLInputElement
|
|
expect(tokenInput.value).toBe('')
|
|
// Server error should be gone
|
|
expect(screen.queryByText('Server error')).not.toBeInTheDocument()
|
|
})
|
|
})
|