test: add unit tests for external API components and interactions

This commit is contained in:
CodingOnStar
2026-01-19 17:18:43 +08:00
parent 0791d204af
commit 63e9771ccc
4 changed files with 1252 additions and 0 deletions

View File

@ -0,0 +1,239 @@
import type { CreateExternalAPIReq, FormSchema } from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Form from './Form'
// Mock context for i18n doc link
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
describe('Form', () => {
const defaultFormSchemas: FormSchema[] = [
{
variable: 'name',
type: 'text',
label: { en_US: 'Name', zh_CN: '名称' },
required: true,
},
{
variable: 'endpoint',
type: 'text',
label: { en_US: 'API Endpoint', zh_CN: 'API 端点' },
required: true,
},
{
variable: 'api_key',
type: 'secret',
label: { en_US: 'API Key', zh_CN: 'API 密钥' },
required: true,
},
]
const defaultValue: CreateExternalAPIReq = {
name: '',
settings: {
endpoint: '',
api_key: '',
},
}
const defaultProps = {
value: defaultValue,
onChange: vi.fn(),
formSchemas: defaultFormSchemas,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = render(<Form {...defaultProps} />)
expect(container.querySelector('form')).toBeInTheDocument()
})
it('should render all form fields based on formSchemas', () => {
render(<Form {...defaultProps} />)
expect(screen.getByLabelText(/name/i)).toBeInTheDocument()
expect(screen.getByLabelText(/api endpoint/i)).toBeInTheDocument()
expect(screen.getByLabelText(/api key/i)).toBeInTheDocument()
})
it('should render required indicator for required fields', () => {
render(<Form {...defaultProps} />)
const labels = screen.getAllByText('*')
expect(labels.length).toBe(3) // All 3 fields are required
})
it('should render documentation link for endpoint field', () => {
render(<Form {...defaultProps} />)
const docLink = screen.getByText('dataset.externalAPIPanelDocumentation')
expect(docLink).toBeInTheDocument()
expect(docLink.closest('a')).toHaveAttribute('href', expect.stringContaining('docs.example.com'))
})
it('should render password type input for secret fields', () => {
render(<Form {...defaultProps} />)
const apiKeyInput = screen.getByLabelText(/api key/i)
expect(apiKeyInput).toHaveAttribute('type', 'password')
})
it('should render text type input for text fields', () => {
render(<Form {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
expect(nameInput).toHaveAttribute('type', 'text')
})
})
describe('Props', () => {
it('should apply custom className to form', () => {
const { container } = render(<Form {...defaultProps} className="custom-form-class" />)
expect(container.querySelector('form')).toHaveClass('custom-form-class')
})
it('should apply itemClassName to form items', () => {
const { container } = render(<Form {...defaultProps} itemClassName="custom-item-class" />)
const items = container.querySelectorAll('.custom-item-class')
expect(items.length).toBe(3)
})
it('should apply fieldLabelClassName to labels', () => {
const { container } = render(<Form {...defaultProps} fieldLabelClassName="custom-label-class" />)
const labels = container.querySelectorAll('label.custom-label-class')
expect(labels.length).toBe(3)
})
it('should apply inputClassName to inputs', () => {
render(<Form {...defaultProps} inputClassName="custom-input-class" />)
const inputs = screen.getAllByRole('textbox')
inputs.forEach((input) => {
expect(input).toHaveClass('custom-input-class')
})
})
it('should display initial values', () => {
const valueWithData: CreateExternalAPIReq = {
name: 'Test API',
settings: {
endpoint: 'https://api.example.com',
api_key: 'secret-key',
},
}
render(<Form {...defaultProps} value={valueWithData} />)
expect(screen.getByLabelText(/name/i)).toHaveValue('Test API')
expect(screen.getByLabelText(/api endpoint/i)).toHaveValue('https://api.example.com')
expect(screen.getByLabelText(/api key/i)).toHaveValue('secret-key')
})
})
describe('User Interactions', () => {
it('should call onChange when name field changes', () => {
const onChange = vi.fn()
render(<Form {...defaultProps} onChange={onChange} />)
const nameInput = screen.getByLabelText(/name/i)
fireEvent.change(nameInput, { target: { value: 'New API Name' } })
expect(onChange).toHaveBeenCalledWith({
name: 'New API Name',
settings: { endpoint: '', api_key: '' },
})
})
it('should call onChange when endpoint field changes', () => {
const onChange = vi.fn()
render(<Form {...defaultProps} onChange={onChange} />)
const endpointInput = screen.getByLabelText(/api endpoint/i)
fireEvent.change(endpointInput, { target: { value: 'https://new-api.example.com' } })
expect(onChange).toHaveBeenCalledWith({
name: '',
settings: { endpoint: 'https://new-api.example.com', api_key: '' },
})
})
it('should call onChange when api_key field changes', () => {
const onChange = vi.fn()
render(<Form {...defaultProps} onChange={onChange} />)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(apiKeyInput, { target: { value: 'new-secret-key' } })
expect(onChange).toHaveBeenCalledWith({
name: '',
settings: { endpoint: '', api_key: 'new-secret-key' },
})
})
it('should update settings without affecting name', () => {
const onChange = vi.fn()
const initialValue: CreateExternalAPIReq = {
name: 'Existing Name',
settings: { endpoint: '', api_key: '' },
}
render(<Form {...defaultProps} value={initialValue} onChange={onChange} />)
const endpointInput = screen.getByLabelText(/api endpoint/i)
fireEvent.change(endpointInput, { target: { value: 'https://api.example.com' } })
expect(onChange).toHaveBeenCalledWith({
name: 'Existing Name',
settings: { endpoint: 'https://api.example.com', api_key: '' },
})
})
})
describe('Edge Cases', () => {
it('should handle empty formSchemas', () => {
const { container } = render(<Form {...defaultProps} formSchemas={[]} />)
expect(container.querySelector('form')).toBeInTheDocument()
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
})
it('should handle optional field (required: false)', () => {
const schemasWithOptional: FormSchema[] = [
{
variable: 'description',
type: 'text',
label: { en_US: 'Description' },
required: false,
},
]
render(<Form {...defaultProps} formSchemas={schemasWithOptional} />)
expect(screen.queryByText('*')).not.toBeInTheDocument()
})
it('should fallback to en_US label when current language label is not available', () => {
const schemasWithEnOnly: FormSchema[] = [
{
variable: 'test',
type: 'text',
label: { en_US: 'Test Field' },
required: false,
},
]
render(<Form {...defaultProps} formSchemas={schemasWithEnOnly} />)
expect(screen.getByLabelText(/test field/i)).toBeInTheDocument()
})
it('should preserve existing settings when updating one field', () => {
const onChange = vi.fn()
const initialValue: CreateExternalAPIReq = {
name: '',
settings: { endpoint: 'https://existing.com', api_key: 'existing-key' },
}
render(<Form {...defaultProps} value={initialValue} onChange={onChange} />)
const endpointInput = screen.getByLabelText(/api endpoint/i)
fireEvent.change(endpointInput, { target: { value: 'https://new.com' } })
expect(onChange).toHaveBeenCalledWith({
name: '',
settings: { endpoint: 'https://new.com', api_key: 'existing-key' },
})
})
})
})

View File

@ -0,0 +1,424 @@
import type { CreateExternalAPIReq } from '../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import mocked service
import { createExternalAPI } from '@/service/datasets'
import AddExternalAPIModal from './index'
// Mock API service
vi.mock('@/service/datasets', () => ({
createExternalAPI: vi.fn(),
}))
// Mock toast context
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
describe('AddExternalAPIModal', () => {
const defaultProps = {
onSave: vi.fn(),
onCancel: vi.fn(),
isEditMode: false,
}
const initialData: CreateExternalAPIReq = {
name: 'Test API',
settings: {
endpoint: 'https://api.example.com',
api_key: 'test-key-12345',
},
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
render(<AddExternalAPIModal {...defaultProps} />)
expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
})
it('should render create title when not in edit mode', () => {
render(<AddExternalAPIModal {...defaultProps} isEditMode={false} />)
expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument()
})
it('should render edit title when in edit mode', () => {
render(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={initialData} />)
expect(screen.getByText('dataset.editExternalAPIFormTitle')).toBeInTheDocument()
})
it('should render form fields', () => {
render(<AddExternalAPIModal {...defaultProps} />)
expect(screen.getByLabelText(/name/i)).toBeInTheDocument()
expect(screen.getByLabelText(/api endpoint/i)).toBeInTheDocument()
expect(screen.getByLabelText(/api key/i)).toBeInTheDocument()
})
it('should render cancel and save buttons', () => {
render(<AddExternalAPIModal {...defaultProps} />)
expect(screen.getByText('dataset.externalAPIForm.cancel')).toBeInTheDocument()
expect(screen.getByText('dataset.externalAPIForm.save')).toBeInTheDocument()
})
it('should render encryption notice', () => {
render(<AddExternalAPIModal {...defaultProps} />)
expect(screen.getByText('PKCS1_OAEP')).toBeInTheDocument()
})
it('should render close button', () => {
render(<AddExternalAPIModal {...defaultProps} />)
// Close button is rendered in a portal
const closeButton = document.body.querySelector('.action-btn')
expect(closeButton).toBeInTheDocument()
})
})
describe('Edit Mode with Dataset Bindings', () => {
it('should show warning when editing with dataset bindings', () => {
const datasetBindings = [
{ id: 'ds-1', name: 'Dataset 1' },
{ id: 'ds-2', name: 'Dataset 2' },
]
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
/>,
)
expect(screen.getByText('dataset.editExternalAPIFormWarning.front')).toBeInTheDocument()
// Verify the count is displayed in the warning section
const warningElement = screen.getByText('dataset.editExternalAPIFormWarning.front').parentElement
expect(warningElement?.textContent).toContain('2')
})
it('should not show warning when no dataset bindings', () => {
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={[]}
/>,
)
expect(screen.queryByText('dataset.editExternalAPIFormWarning.front')).not.toBeInTheDocument()
})
})
describe('Form Interactions', () => {
it('should update form values when input changes', () => {
render(<AddExternalAPIModal {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
fireEvent.change(nameInput, { target: { value: 'New API Name' } })
expect(nameInput).toHaveValue('New API Name')
})
it('should initialize form with data in edit mode', () => {
render(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={initialData} />)
expect(screen.getByLabelText(/name/i)).toHaveValue('Test API')
expect(screen.getByLabelText(/api endpoint/i)).toHaveValue('https://api.example.com')
expect(screen.getByLabelText(/api key/i)).toHaveValue('test-key-12345')
})
it('should disable save button when form has empty inputs', () => {
render(<AddExternalAPIModal {...defaultProps} />)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')
expect(saveButton).toBeDisabled()
})
it('should enable save button when all fields are filled', () => {
render(<AddExternalAPIModal {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
const endpointInput = screen.getByLabelText(/api endpoint/i)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(nameInput, { target: { value: 'Test' } })
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
fireEvent.change(apiKeyInput, { target: { value: 'key12345' } })
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')
expect(saveButton).not.toBeDisabled()
})
})
describe('Create Mode - Save', () => {
it('should create API and call onSave on success', async () => {
const mockResponse = {
id: 'new-api-123',
tenant_id: 'tenant-1',
name: 'Test',
description: '',
settings: { endpoint: 'https://test.com', api_key: 'key12345' },
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
}
vi.mocked(createExternalAPI).mockResolvedValue(mockResponse)
const onSave = vi.fn()
const onCancel = vi.fn()
render(<AddExternalAPIModal {...defaultProps} onSave={onSave} onCancel={onCancel} />)
const nameInput = screen.getByLabelText(/name/i)
const endpointInput = screen.getByLabelText(/api endpoint/i)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(nameInput, { target: { value: 'Test' } })
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
fireEvent.change(apiKeyInput, { target: { value: 'key12345' } })
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(createExternalAPI).toHaveBeenCalledWith({
body: {
name: 'Test',
settings: { endpoint: 'https://test.com', api_key: 'key12345' },
},
})
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: 'External API saved successfully',
})
expect(onSave).toHaveBeenCalledWith(mockResponse)
expect(onCancel).toHaveBeenCalled()
})
})
it('should show error notification when API key is too short', async () => {
render(<AddExternalAPIModal {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
const endpointInput = screen.getByLabelText(/api endpoint/i)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(nameInput, { target: { value: 'Test' } })
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
fireEvent.change(apiKeyInput, { target: { value: 'key' } }) // Less than 5 characters
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'common.apiBasedExtension.modal.apiKey.lengthError',
})
})
})
it('should handle create API error', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(createExternalAPI).mockRejectedValue(new Error('Create failed'))
render(<AddExternalAPIModal {...defaultProps} />)
const nameInput = screen.getByLabelText(/name/i)
const endpointInput = screen.getByLabelText(/api endpoint/i)
const apiKeyInput = screen.getByLabelText(/api key/i)
fireEvent.change(nameInput, { target: { value: 'Test' } })
fireEvent.change(endpointInput, { target: { value: 'https://test.com' } })
fireEvent.change(apiKeyInput, { target: { value: 'key12345' } })
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'Failed to save/update External API',
})
})
consoleSpy.mockRestore()
})
})
describe('Edit Mode - Save', () => {
it('should call onEdit directly when editing without dataset bindings', async () => {
const onEdit = vi.fn().mockResolvedValue(undefined)
const onCancel = vi.fn()
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={[]}
onEdit={onEdit}
onCancel={onCancel}
/>,
)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
// When no datasetBindings, onEdit is called directly with original form data
expect(onEdit).toHaveBeenCalledWith({
name: 'Test API',
settings: {
endpoint: 'https://api.example.com',
api_key: 'test-key-12345',
},
})
})
})
it('should show confirm dialog when editing with dataset bindings', async () => {
const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }]
const onEdit = vi.fn().mockResolvedValue(undefined)
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
onEdit={onEdit}
/>,
)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
})
it('should proceed with save after confirming in edit mode with bindings', async () => {
vi.mocked(createExternalAPI).mockResolvedValue({
id: 'api-123',
tenant_id: 'tenant-1',
name: 'Test API',
description: '',
settings: { endpoint: 'https://api.example.com', api_key: 'test-key-12345' },
dataset_bindings: [],
created_by: 'user-1',
created_at: '2021-01-01T00:00:00Z',
})
const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }]
const onCancel = vi.fn()
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
onCancel={onCancel}
/>,
)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
const confirmButton = screen.getByRole('button', { name: /confirm/i })
fireEvent.click(confirmButton)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'success' }),
)
})
})
it('should close confirm dialog when cancel is clicked', async () => {
const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }]
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={datasetBindings}
/>,
)
const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')!
fireEvent.click(saveButton)
await waitFor(() => {
expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()
})
// There are multiple cancel buttons, find the one in the confirm dialog
const cancelButtons = screen.getAllByRole('button', { name: /cancel/i })
const confirmDialogCancelButton = cancelButtons[cancelButtons.length - 1]
fireEvent.click(confirmDialogCancelButton)
await waitFor(() => {
// Confirm button should be gone after canceling
expect(screen.queryAllByRole('button', { name: /confirm/i })).toHaveLength(0)
})
})
})
describe('Cancel', () => {
it('should call onCancel when cancel button is clicked', () => {
const onCancel = vi.fn()
render(<AddExternalAPIModal {...defaultProps} onCancel={onCancel} />)
const cancelButton = screen.getByText('dataset.externalAPIForm.cancel').closest('button')!
fireEvent.click(cancelButton)
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should call onCancel when close button is clicked', () => {
const onCancel = vi.fn()
render(<AddExternalAPIModal {...defaultProps} onCancel={onCancel} />)
// Close button is rendered in a portal
const closeButton = document.body.querySelector('.action-btn')!
fireEvent.click(closeButton)
expect(onCancel).toHaveBeenCalledTimes(1)
})
})
describe('Edge Cases', () => {
it('should handle undefined data in edit mode', () => {
render(<AddExternalAPIModal {...defaultProps} isEditMode={true} data={undefined} />)
expect(screen.getByLabelText(/name/i)).toHaveValue('')
})
it('should handle null datasetBindings', () => {
render(
<AddExternalAPIModal
{...defaultProps}
isEditMode={true}
data={initialData}
datasetBindings={undefined}
/>,
)
expect(screen.queryByText('dataset.editExternalAPIFormWarning.front')).not.toBeInTheDocument()
})
it('should render documentation link in encryption notice', () => {
render(<AddExternalAPIModal {...defaultProps} />)
const link = screen.getByRole('link', { name: 'PKCS1_OAEP' })
expect(link).toHaveAttribute('href', 'https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html')
expect(link).toHaveAttribute('target', '_blank')
})
})
})