Merge commit '657eeb65' into sandboxed-agent-rebase

Made-with: Cursor

# Conflicts:
#	api/core/agent/cot_chat_agent_runner.py
#	api/core/agent/fc_agent_runner.py
#	api/core/memory/token_buffer_memory.py
#	api/core/variables/segments.py
#	api/core/workflow/file/file_manager.py
#	api/core/workflow/nodes/agent/agent_node.py
#	api/core/workflow/nodes/llm/llm_utils.py
#	api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py
#	api/core/workflow/workflow_entry.py
#	api/factories/variable_factory.py
#	api/pyproject.toml
#	api/services/variable_truncator.py
#	api/uv.lock
#	web/app/components/app/app-publisher/index.tsx
#	web/app/components/app/overview/settings/index.tsx
#	web/app/components/apps/app-card.tsx
#	web/app/components/apps/index.tsx
#	web/app/components/apps/list.tsx
#	web/app/components/base/chat/chat-with-history/header-in-mobile.tsx
#	web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx
#	web/app/components/base/features/new-feature-panel/file-upload/setting-content.tsx
#	web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx
#	web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx
#	web/app/components/base/message-log-modal/index.tsx
#	web/app/components/base/switch/index.tsx
#	web/app/components/base/tab-slider-plain/index.tsx
#	web/app/components/explore/try-app/app-info/index.tsx
#	web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx
#	web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/required-switch.tsx
#	web/app/components/workflow/nodes/llm/panel.tsx
#	web/contract/router.ts
#	web/eslint-suppressions.json
#	web/i18n/fa-IR/workflow.json
This commit is contained in:
Novice
2026-03-19 17:38:56 +08:00
509 changed files with 39588 additions and 3422 deletions

View File

@ -0,0 +1,126 @@
import type { AccountIntegrate } from '@/models/common'
import { render, screen } from '@testing-library/react'
import { useAccountIntegrates } from '@/service/use-common'
import IntegrationsPage from './index'
vi.mock('@/service/use-common', () => ({
useAccountIntegrates: vi.fn(),
}))
describe('IntegrationsPage', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering connected integrations', () => {
it('should render connected integrations when list is provided', () => {
// Arrange
const mockData: AccountIntegrate[] = [
{ provider: 'google', is_bound: true, link: '', created_at: 1678888888 },
{ provider: 'github', is_bound: true, link: '', created_at: 1678888888 },
]
vi.mocked(useAccountIntegrates).mockReturnValue({
data: {
data: mockData,
},
isPending: false,
isError: false,
} as unknown as ReturnType<typeof useAccountIntegrates>)
// Act
render(<IntegrationsPage />)
// Assert
expect(screen.getByText('common.integrations.connected')).toBeInTheDocument()
expect(screen.getByText('common.integrations.google')).toBeInTheDocument()
expect(screen.getByText('common.integrations.github')).toBeInTheDocument()
// Connect link should not be present when bound
expect(screen.queryByText('common.integrations.connect')).not.toBeInTheDocument()
})
})
describe('Unbound integrations', () => {
it('should render connect link for unbound integrations', () => {
// Arrange
const mockData: AccountIntegrate[] = [
{ provider: 'google', is_bound: false, link: 'https://google.com', created_at: 1678888888 },
]
vi.mocked(useAccountIntegrates).mockReturnValue({
data: {
data: mockData,
},
isPending: false,
isError: false,
} as unknown as ReturnType<typeof useAccountIntegrates>)
// Act
render(<IntegrationsPage />)
// Assert
expect(screen.getByText('common.integrations.google')).toBeInTheDocument()
const connectLink = screen.getByText('common.integrations.connect')
expect(connectLink).toBeInTheDocument()
expect(connectLink.closest('a')).toHaveAttribute('href', 'https://google.com')
})
})
describe('Edge cases', () => {
it('should render nothing when no integrations are provided', () => {
// Arrange
vi.mocked(useAccountIntegrates).mockReturnValue({
data: {
data: [],
},
isPending: false,
isError: false,
} as unknown as ReturnType<typeof useAccountIntegrates>)
// Act
render(<IntegrationsPage />)
// Assert
expect(screen.getByText('common.integrations.connected')).toBeInTheDocument()
expect(screen.queryByText('common.integrations.google')).not.toBeInTheDocument()
expect(screen.queryByText('common.integrations.github')).not.toBeInTheDocument()
})
it('should handle unknown providers gracefully', () => {
// Arrange
const mockData = [
{ provider: 'unknown', is_bound: false, link: '', created_at: 1678888888 } as unknown as AccountIntegrate,
]
vi.mocked(useAccountIntegrates).mockReturnValue({
data: {
data: mockData,
},
isPending: false,
isError: false,
} as unknown as ReturnType<typeof useAccountIntegrates>)
// Act
render(<IntegrationsPage />)
// Assert
expect(screen.queryByText('common.integrations.connect')).not.toBeInTheDocument()
})
it('should handle undefined data gracefully', () => {
// Arrange
vi.mocked(useAccountIntegrates).mockReturnValue({
data: undefined,
isPending: false,
isError: false,
} as unknown as ReturnType<typeof useAccountIntegrates>)
// Act
render(<IntegrationsPage />)
// Assert
expect(screen.getByText('common.integrations.connected')).toBeInTheDocument()
expect(screen.queryByText('common.integrations.google')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,18 @@
import { render, screen } from '@testing-library/react'
import Empty from './empty'
describe('Empty State', () => {
describe('Rendering', () => {
it('should render title and documentation link', () => {
// Act
render(<Empty />)
// Assert
expect(screen.getByText('common.apiBasedExtension.title')).toBeInTheDocument()
const link = screen.getByText('common.apiBasedExtension.link')
expect(link).toBeInTheDocument()
// The real useDocLink includes the language prefix (defaulting to /en in tests)
expect(link.closest('a')).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/workspace/api-extension/api-extension')
})
})
})

View File

@ -0,0 +1,151 @@
import type { SetStateAction } from 'react'
import type { ModalContextState, ModalState } from '@/context/modal-context'
import type { ApiBasedExtension } from '@/models/common'
import { fireEvent, render, screen } from '@testing-library/react'
import { useModalContext } from '@/context/modal-context'
import { useApiBasedExtensions } from '@/service/use-common'
import ApiBasedExtensionPage from './index'
vi.mock('@/service/use-common', () => ({
useApiBasedExtensions: vi.fn(),
}))
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
describe('ApiBasedExtensionPage', () => {
const mockRefetch = vi.fn<() => void>()
const mockSetShowApiBasedExtensionModal = vi.fn<(value: SetStateAction<ModalState<ApiBasedExtension> | null>) => void>()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useModalContext).mockReturnValue({
setShowApiBasedExtensionModal: mockSetShowApiBasedExtensionModal,
} as unknown as ModalContextState)
})
describe('Rendering', () => {
it('should render empty state when no data exists', () => {
// Arrange
vi.mocked(useApiBasedExtensions).mockReturnValue({
data: [],
isPending: false,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useApiBasedExtensions>)
// Act
render(<ApiBasedExtensionPage />)
// Assert
expect(screen.getByText('common.apiBasedExtension.title')).toBeInTheDocument()
})
it('should render list of extensions when data exists', () => {
// Arrange
const mockData = [
{ id: '1', name: 'Extension 1', api_endpoint: 'url1' },
{ id: '2', name: 'Extension 2', api_endpoint: 'url2' },
]
vi.mocked(useApiBasedExtensions).mockReturnValue({
data: mockData,
isPending: false,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useApiBasedExtensions>)
// Act
render(<ApiBasedExtensionPage />)
// Assert
expect(screen.getByText('Extension 1')).toBeInTheDocument()
expect(screen.getByText('url1')).toBeInTheDocument()
expect(screen.getByText('Extension 2')).toBeInTheDocument()
expect(screen.getByText('url2')).toBeInTheDocument()
})
it('should handle loading state', () => {
// Arrange
vi.mocked(useApiBasedExtensions).mockReturnValue({
data: null,
isPending: true,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useApiBasedExtensions>)
// Act
render(<ApiBasedExtensionPage />)
// Assert
expect(screen.queryByText('common.apiBasedExtension.title')).not.toBeInTheDocument()
expect(screen.getByText('common.apiBasedExtension.add')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should open modal when clicking add button', () => {
// Arrange
vi.mocked(useApiBasedExtensions).mockReturnValue({
data: [],
isPending: false,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useApiBasedExtensions>)
// Act
render(<ApiBasedExtensionPage />)
fireEvent.click(screen.getByText('common.apiBasedExtension.add'))
// Assert
expect(mockSetShowApiBasedExtensionModal).toHaveBeenCalledWith(expect.objectContaining({
payload: {},
}))
})
it('should call refetch when onSaveCallback is executed from the modal', () => {
// Arrange
vi.mocked(useApiBasedExtensions).mockReturnValue({
data: [],
isPending: false,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useApiBasedExtensions>)
// Act
render(<ApiBasedExtensionPage />)
fireEvent.click(screen.getByText('common.apiBasedExtension.add'))
// Trigger callback manually from the mock call
const callArgs = mockSetShowApiBasedExtensionModal.mock.calls[0][0]
if (typeof callArgs === 'object' && callArgs !== null && 'onSaveCallback' in callArgs) {
if (callArgs.onSaveCallback) {
callArgs.onSaveCallback()
// Assert
expect(mockRefetch).toHaveBeenCalled()
}
}
})
it('should call refetch when an item is updated', () => {
// Arrange
const mockData = [{ id: '1', name: 'Extension 1', api_endpoint: 'url1' }]
vi.mocked(useApiBasedExtensions).mockReturnValue({
data: mockData,
isPending: false,
refetch: mockRefetch,
} as unknown as ReturnType<typeof useApiBasedExtensions>)
render(<ApiBasedExtensionPage />)
// Act - Click edit on the rendered item
fireEvent.click(screen.getByText('common.operation.edit'))
// Retrieve the onSaveCallback from the modal call and execute it
const callArgs = mockSetShowApiBasedExtensionModal.mock.calls[0][0]
if (typeof callArgs === 'object' && callArgs !== null && 'onSaveCallback' in callArgs) {
if (callArgs.onSaveCallback)
callArgs.onSaveCallback()
}
// Assert
expect(mockRefetch).toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,190 @@
import type { TFunction } from 'i18next'
import type { ModalContextState } from '@/context/modal-context'
import type { ApiBasedExtension } from '@/models/common'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import * as reactI18next from 'react-i18next'
import { useModalContext } from '@/context/modal-context'
import { deleteApiBasedExtension } from '@/service/common'
import Item from './item'
// Mock dependencies
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
vi.mock('@/service/common', () => ({
deleteApiBasedExtension: vi.fn(),
}))
describe('Item Component', () => {
const mockData: ApiBasedExtension = {
id: '1',
name: 'Test Extension',
api_endpoint: 'https://api.example.com',
api_key: 'test-api-key',
}
const mockOnUpdate = vi.fn()
const mockSetShowApiBasedExtensionModal = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useModalContext).mockReturnValue({
setShowApiBasedExtensionModal: mockSetShowApiBasedExtensionModal,
} as unknown as ModalContextState)
})
describe('Rendering', () => {
it('should render extension data correctly', () => {
// Act
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
// Assert
expect(screen.getByText('Test Extension')).toBeInTheDocument()
expect(screen.getByText('https://api.example.com')).toBeInTheDocument()
})
it('should render with minimal extension data', () => {
// Arrange
const minimalData: ApiBasedExtension = { id: '2' }
// Act
render(<Item data={minimalData} onUpdate={mockOnUpdate} />)
// Assert
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
})
})
describe('Modal Interactions', () => {
it('should open edit modal with correct payload when clicking edit button', () => {
// Act
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.operation.edit'))
// Assert
expect(mockSetShowApiBasedExtensionModal).toHaveBeenCalledWith(expect.objectContaining({
payload: mockData,
}))
const lastCall = mockSetShowApiBasedExtensionModal.mock.calls[0][0]
if (typeof lastCall === 'object' && lastCall !== null && 'onSaveCallback' in lastCall)
expect(lastCall.onSaveCallback).toBeInstanceOf(Function)
})
it('should execute onUpdate callback when edit modal save callback is invoked', () => {
// Act
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.operation.edit'))
// Assert
const modalCallArg = mockSetShowApiBasedExtensionModal.mock.calls[0][0]
if (typeof modalCallArg === 'object' && modalCallArg !== null && 'onSaveCallback' in modalCallArg) {
const onSaveCallback = modalCallArg.onSaveCallback
if (onSaveCallback) {
onSaveCallback()
expect(mockOnUpdate).toHaveBeenCalledTimes(1)
}
}
})
})
describe('Deletion', () => {
it('should show delete confirmation dialog when clicking delete button', () => {
// Act
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.operation.delete'))
// Assert
expect(screen.getByText(/common\.operation\.delete.*Test Extension.*\?/i)).toBeInTheDocument()
})
it('should call delete API and triggers onUpdate when confirming deletion', async () => {
// Arrange
vi.mocked(deleteApiBasedExtension).mockResolvedValue({ result: 'success' })
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
// Act
fireEvent.click(screen.getByText('common.operation.delete'))
const dialog = screen.getByTestId('confirm-overlay')
const confirmButton = within(dialog).getByText('common.operation.delete')
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(deleteApiBasedExtension).toHaveBeenCalledWith('/api-based-extension/1')
expect(mockOnUpdate).toHaveBeenCalledTimes(1)
})
})
it('should hide delete confirmation dialog after successful deletion', async () => {
// Arrange
vi.mocked(deleteApiBasedExtension).mockResolvedValue({ result: 'success' })
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
// Act
fireEvent.click(screen.getByText('common.operation.delete'))
const dialog = screen.getByTestId('confirm-overlay')
const confirmButton = within(dialog).getByText('common.operation.delete')
fireEvent.click(confirmButton)
// Assert
await waitFor(() => {
expect(screen.queryByText(/common\.operation\.delete.*Test Extension.*\?/i)).not.toBeInTheDocument()
})
})
it('should close delete confirmation when clicking cancel button', () => {
// Act
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.operation.delete'))
fireEvent.click(screen.getByText('common.operation.cancel'))
// Assert
expect(screen.queryByText(/common\.operation\.delete.*Test Extension.*\?/i)).not.toBeInTheDocument()
})
it('should not call delete API when canceling deletion', () => {
// Act
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.operation.delete'))
fireEvent.click(screen.getByText('common.operation.cancel'))
// Assert
expect(deleteApiBasedExtension).not.toHaveBeenCalled()
expect(mockOnUpdate).not.toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should still show confirmation modal when operation.delete translation is missing', () => {
// Arrange
const useTranslationSpy = vi.spyOn(reactI18next, 'useTranslation')
const originalValue = useTranslationSpy.getMockImplementation()?.() || {
t: (key: string) => key,
i18n: { language: 'en', changeLanguage: vi.fn() },
}
useTranslationSpy.mockReturnValue({
...originalValue,
t: vi.fn().mockImplementation((key: string) => {
if (key === 'operation.delete')
return ''
return key
}) as unknown as TFunction,
} as unknown as ReturnType<typeof reactI18next.useTranslation>)
// Act
render(<Item data={mockData} onUpdate={mockOnUpdate} />)
const allButtons = screen.getAllByRole('button')
const editBtn = screen.getByText('operation.edit')
const deleteBtn = allButtons.find(btn => btn !== editBtn)
if (deleteBtn)
fireEvent.click(deleteBtn)
// Assert
expect(screen.getByText(/.*Test Extension.*\?/i)).toBeInTheDocument()
useTranslationSpy.mockRestore()
})
})
})

View File

@ -0,0 +1,223 @@
import type { TFunction } from 'i18next'
import type { IToastProps } from '@/app/components/base/toast'
import { fireEvent, render as RTLRender, screen, waitFor } from '@testing-library/react'
import * as reactI18next from 'react-i18next'
import { ToastContext } from '@/app/components/base/toast'
import { useDocLink } from '@/context/i18n'
import { addApiBasedExtension, updateApiBasedExtension } from '@/service/common'
import ApiBasedExtensionModal from './modal'
vi.mock('@/context/i18n', () => ({
useDocLink: vi.fn(),
}))
vi.mock('@/service/common', () => ({
addApiBasedExtension: vi.fn(),
updateApiBasedExtension: vi.fn(),
}))
describe('ApiBasedExtensionModal', () => {
const mockOnCancel = vi.fn()
const mockOnSave = vi.fn()
const mockNotify = vi.fn()
const mockDocLink = vi.fn((path?: string) => `https://docs.dify.ai${path || ''}`)
const render = (ui: React.ReactElement) => RTLRender(
<ToastContext.Provider value={{
notify: mockNotify as unknown as (props: IToastProps) => void,
close: vi.fn(),
}}
>
{ui}
</ToastContext.Provider>,
)
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useDocLink).mockReturnValue(mockDocLink)
})
describe('Rendering', () => {
it('should render correctly for adding a new extension', () => {
// Act
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} onSave={mockOnSave} />)
// Assert
expect(screen.getByText('common.apiBasedExtension.modal.title')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder')).toBeInTheDocument()
expect(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder')).toBeInTheDocument()
})
it('should render correctly for editing an existing extension', () => {
// Arrange
const data = { id: '1', name: 'Existing', api_endpoint: 'url', api_key: 'key' }
// Act
render(<ApiBasedExtensionModal data={data} onCancel={mockOnCancel} onSave={mockOnSave} />)
// Assert
expect(screen.getByText('common.apiBasedExtension.modal.editTitle')).toBeInTheDocument()
expect(screen.getByDisplayValue('Existing')).toBeInTheDocument()
expect(screen.getByDisplayValue('url')).toBeInTheDocument()
expect(screen.getByDisplayValue('key')).toBeInTheDocument()
})
})
describe('Form Submissions', () => {
it('should call addApiBasedExtension on save for new extension', async () => {
// Arrange
vi.mocked(addApiBasedExtension).mockResolvedValue({ id: 'new-id' })
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} onSave={mockOnSave} />)
// Act
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } })
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), { target: { value: 'https://api.test' } })
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), { target: { value: 'secret-key' } })
fireEvent.click(screen.getByText('common.operation.save'))
// Assert
await waitFor(() => {
expect(addApiBasedExtension).toHaveBeenCalledWith({
url: '/api-based-extension',
body: {
name: 'New Ext',
api_endpoint: 'https://api.test',
api_key: 'secret-key',
},
})
expect(mockOnSave).toHaveBeenCalledWith({ id: 'new-id' })
})
})
it('should call updateApiBasedExtension on save for existing extension', async () => {
// Arrange
const data = { id: '1', name: 'Existing', api_endpoint: 'url', api_key: 'long-secret-key' }
vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...data, name: 'Updated' })
render(<ApiBasedExtensionModal data={data} onCancel={mockOnCancel} onSave={mockOnSave} />)
// Act
fireEvent.change(screen.getByDisplayValue('Existing'), { target: { value: 'Updated' } })
fireEvent.click(screen.getByText('common.operation.save'))
// Assert
await waitFor(() => {
expect(updateApiBasedExtension).toHaveBeenCalledWith({
url: '/api-based-extension/1',
body: expect.objectContaining({
id: '1',
name: 'Updated',
api_endpoint: 'url',
api_key: '[__HIDDEN__]',
}),
})
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'common.actionMsg.modifiedSuccessfully' })
expect(mockOnSave).toHaveBeenCalled()
})
})
it('should call updateApiBasedExtension with new api_key when key is changed', async () => {
// Arrange
const data = { id: '1', name: 'Existing', api_endpoint: 'url', api_key: 'old-key' }
vi.mocked(updateApiBasedExtension).mockResolvedValue({ ...data, api_key: 'new-longer-key' })
render(<ApiBasedExtensionModal data={data} onCancel={mockOnCancel} onSave={mockOnSave} />)
// Act
fireEvent.change(screen.getByDisplayValue('old-key'), { target: { value: 'new-longer-key' } })
fireEvent.click(screen.getByText('common.operation.save'))
// Assert
await waitFor(() => {
expect(updateApiBasedExtension).toHaveBeenCalledWith({
url: '/api-based-extension/1',
body: expect.objectContaining({
api_key: 'new-longer-key',
}),
})
})
})
})
describe('Validation', () => {
it('should show error if api key is too short', async () => {
// Arrange
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} onSave={mockOnSave} />)
// Act
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'Ext' } })
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), { target: { value: 'url' } })
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), { target: { value: '123' } })
fireEvent.click(screen.getByText('common.operation.save'))
// Assert
expect(mockNotify).toHaveBeenCalledWith({ type: 'error', message: 'common.apiBasedExtension.modal.apiKey.lengthError' })
expect(addApiBasedExtension).not.toHaveBeenCalled()
})
})
describe('Interactions', () => {
it('should work when onSave is not provided', async () => {
// Arrange
vi.mocked(addApiBasedExtension).mockResolvedValue({ id: 'new-id' })
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} />)
// Act
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.name.placeholder'), { target: { value: 'New Ext' } })
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiEndpoint.placeholder'), { target: { value: 'https://api.test' } })
fireEvent.change(screen.getByPlaceholderText('common.apiBasedExtension.modal.apiKey.placeholder'), { target: { value: 'secret-key' } })
fireEvent.click(screen.getByText('common.operation.save'))
// Assert
await waitFor(() => {
expect(addApiBasedExtension).toHaveBeenCalled()
})
})
it('should call onCancel when clicking cancel button', () => {
// Arrange
render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} onSave={mockOnSave} />)
// Act
fireEvent.click(screen.getByText('common.operation.cancel'))
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle missing translations for placeholders gracefully', () => {
// Arrange
const useTranslationSpy = vi.spyOn(reactI18next, 'useTranslation')
const originalValue = useTranslationSpy.getMockImplementation()?.() || {
t: (key: string) => key,
i18n: { language: 'en', changeLanguage: vi.fn() },
}
useTranslationSpy.mockReturnValue({
...originalValue,
t: vi.fn().mockImplementation((key: string) => {
const missingKeys = [
'apiBasedExtension.modal.name.placeholder',
'apiBasedExtension.modal.apiEndpoint.placeholder',
'apiBasedExtension.modal.apiKey.placeholder',
]
if (missingKeys.some(k => key.includes(k)))
return ''
return key
}) as unknown as TFunction,
} as unknown as ReturnType<typeof reactI18next.useTranslation>)
// Act
const { container } = render(<ApiBasedExtensionModal data={{}} onCancel={mockOnCancel} />)
// Assert
const inputs = container.querySelectorAll('input')
inputs.forEach((input) => {
expect(input.placeholder).toBe('')
})
useTranslationSpy.mockRestore()
})
})
})

View File

@ -0,0 +1,123 @@
import type { UseQueryResult } from '@tanstack/react-query'
import type { ModalContextState } from '@/context/modal-context'
import type { ApiBasedExtension } from '@/models/common'
import { fireEvent, render, screen } from '@testing-library/react'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useModalContext } from '@/context/modal-context'
import { useApiBasedExtensions } from '@/service/use-common'
import ApiBasedExtensionSelector from './selector'
vi.mock('@/context/modal-context', () => ({
useModalContext: vi.fn(),
}))
vi.mock('@/service/use-common', () => ({
useApiBasedExtensions: vi.fn(),
}))
describe('ApiBasedExtensionSelector', () => {
const mockOnChange = vi.fn()
const mockSetShowAccountSettingModal = vi.fn()
const mockSetShowApiBasedExtensionModal = vi.fn()
const mockRefetch = vi.fn()
const mockData: ApiBasedExtension[] = [
{ id: '1', name: 'Extension 1', api_endpoint: 'https://api1.test' },
{ id: '2', name: 'Extension 2', api_endpoint: 'https://api2.test' },
]
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useModalContext).mockReturnValue({
setShowAccountSettingModal: mockSetShowAccountSettingModal,
setShowApiBasedExtensionModal: mockSetShowApiBasedExtensionModal,
} as unknown as ModalContextState)
vi.mocked(useApiBasedExtensions).mockReturnValue({
data: mockData,
refetch: mockRefetch,
isPending: false,
isError: false,
} as unknown as UseQueryResult<ApiBasedExtension[], Error>)
})
describe('Rendering', () => {
it('should render placeholder when no value is selected', () => {
// Act
render(<ApiBasedExtensionSelector value="" onChange={mockOnChange} />)
// Assert
expect(screen.getByText('common.apiBasedExtension.selector.placeholder')).toBeInTheDocument()
})
it('should render selected item name', async () => {
// Act
render(<ApiBasedExtensionSelector value="1" onChange={mockOnChange} />)
// Assert
expect(screen.getByText('Extension 1')).toBeInTheDocument()
})
})
describe('Dropdown Interactions', () => {
it('should open dropdown when clicked', async () => {
// Act
render(<ApiBasedExtensionSelector value="" onChange={mockOnChange} />)
const trigger = screen.getByText('common.apiBasedExtension.selector.placeholder')
fireEvent.click(trigger)
// Assert
expect(await screen.findByText('common.apiBasedExtension.selector.title')).toBeInTheDocument()
})
it('should call onChange and closes dropdown when an extension is selected', async () => {
// Act
render(<ApiBasedExtensionSelector value="" onChange={mockOnChange} />)
fireEvent.click(screen.getByText('common.apiBasedExtension.selector.placeholder'))
const option = await screen.findByText('Extension 2')
fireEvent.click(option)
// Assert
expect(mockOnChange).toHaveBeenCalledWith('2')
})
})
describe('Manage and Add Extensions', () => {
it('should open account settings when clicking manage', async () => {
// Act
render(<ApiBasedExtensionSelector value="" onChange={mockOnChange} />)
fireEvent.click(screen.getByText('common.apiBasedExtension.selector.placeholder'))
const manageButton = await screen.findByText('common.apiBasedExtension.selector.manage')
fireEvent.click(manageButton)
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: ACCOUNT_SETTING_TAB.API_BASED_EXTENSION,
})
})
it('should open add modal when clicking add button and refetches on save', async () => {
// Act
render(<ApiBasedExtensionSelector value="" onChange={mockOnChange} />)
fireEvent.click(screen.getByText('common.apiBasedExtension.selector.placeholder'))
const addButton = await screen.findByText('common.operation.add')
fireEvent.click(addButton)
// Assert
expect(mockSetShowApiBasedExtensionModal).toHaveBeenCalledWith(expect.objectContaining({
payload: {},
}))
// Trigger callback
const lastCall = mockSetShowApiBasedExtensionModal.mock.calls[0][0]
if (typeof lastCall === 'object' && lastCall !== null && 'onSaveCallback' in lastCall) {
if (lastCall.onSaveCallback) {
lastCall.onSaveCallback()
expect(mockRefetch).toHaveBeenCalled()
}
}
})
})
})

View File

@ -0,0 +1,121 @@
import type { IItem } from './index'
import { fireEvent, render, screen } from '@testing-library/react'
import Collapse from './index'
describe('Collapse', () => {
const mockItems: IItem[] = [
{ key: '1', name: 'Item 1' },
{ key: '2', name: 'Item 2' },
]
const mockRenderItem = (item: IItem) => (
<div data-testid={`item-${item.key}`}>
{item.name}
</div>
)
const mockOnSelect = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render title and initially closed state', () => {
// Act
const { container } = render(
<Collapse
title="Test Title"
items={mockItems}
renderItem={mockRenderItem}
/>,
)
// Assert
expect(screen.getByText('Test Title')).toBeInTheDocument()
expect(screen.queryByTestId('item-1')).not.toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should apply custom wrapperClassName', () => {
// Act
const { container } = render(
<Collapse
title="Test Title"
items={[]}
renderItem={mockRenderItem}
wrapperClassName="custom-class"
/>,
)
// Assert
expect(container.firstChild).toHaveClass('custom-class')
})
})
describe('Interactions', () => {
it('should toggle content open and closed', () => {
// Act & Assert
render(
<Collapse
title="Test Title"
items={mockItems}
renderItem={mockRenderItem}
/>,
)
// Initially closed
expect(screen.queryByTestId('item-1')).not.toBeInTheDocument()
// Click to open
fireEvent.click(screen.getByText('Test Title'))
expect(screen.getByTestId('item-1')).toBeInTheDocument()
expect(screen.getByTestId('item-2')).toBeInTheDocument()
// Click to close
fireEvent.click(screen.getByText('Test Title'))
expect(screen.queryByTestId('item-1')).not.toBeInTheDocument()
})
it('should handle item selection', () => {
// Arrange
render(
<Collapse
title="Test Title"
items={mockItems}
renderItem={mockRenderItem}
onSelect={mockOnSelect}
/>,
)
// Act
fireEvent.click(screen.getByText('Test Title'))
const item1 = screen.getByTestId('item-1')
fireEvent.click(item1)
// Assert
expect(mockOnSelect).toHaveBeenCalledTimes(1)
expect(mockOnSelect).toHaveBeenCalledWith(mockItems[0])
})
it('should not crash when onSelect is undefined and item is clicked', () => {
// Arrange
render(
<Collapse
title="Test Title"
items={mockItems}
renderItem={mockRenderItem}
/>,
)
// Act
fireEvent.click(screen.getByText('Test Title'))
const item1 = screen.getByTestId('item-1')
fireEvent.click(item1)
// Assert
// Should not throw
expect(screen.getByTestId('item-1')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,44 @@
import {
ACCOUNT_SETTING_MODAL_ACTION,
ACCOUNT_SETTING_TAB,
DEFAULT_ACCOUNT_SETTING_TAB,
isValidAccountSettingTab,
} from './constants'
describe('AccountSetting Constants', () => {
it('should have correct ACCOUNT_SETTING_MODAL_ACTION', () => {
expect(ACCOUNT_SETTING_MODAL_ACTION).toBe('showSettings')
})
it('should have correct ACCOUNT_SETTING_TAB values', () => {
expect(ACCOUNT_SETTING_TAB.SANDBOX_PROVIDER).toBe('sandbox-provider')
expect(ACCOUNT_SETTING_TAB.MODEL_PROVIDER).toBe('model-provider')
expect(ACCOUNT_SETTING_TAB.MEMBERS).toBe('members')
expect(ACCOUNT_SETTING_TAB.BILLING).toBe('billing')
expect(ACCOUNT_SETTING_TAB.DATA_SOURCE).toBe('data-source')
expect(ACCOUNT_SETTING_TAB.API_BASED_EXTENSION).toBe('api-based-extension')
expect(ACCOUNT_SETTING_TAB.CUSTOM).toBe('custom')
expect(ACCOUNT_SETTING_TAB.LANGUAGE).toBe('language')
})
it('should have correct DEFAULT_ACCOUNT_SETTING_TAB', () => {
expect(DEFAULT_ACCOUNT_SETTING_TAB).toBe(ACCOUNT_SETTING_TAB.MEMBERS)
})
it('isValidAccountSettingTab should return true for valid tabs', () => {
expect(isValidAccountSettingTab('sandbox-provider')).toBe(true)
expect(isValidAccountSettingTab('model-provider')).toBe(true)
expect(isValidAccountSettingTab('members')).toBe(true)
expect(isValidAccountSettingTab('billing')).toBe(true)
expect(isValidAccountSettingTab('data-source')).toBe(true)
expect(isValidAccountSettingTab('api-based-extension')).toBe(true)
expect(isValidAccountSettingTab('custom')).toBe(true)
expect(isValidAccountSettingTab('language')).toBe(true)
})
it('isValidAccountSettingTab should return false for invalid tabs', () => {
expect(isValidAccountSettingTab(null)).toBe(false)
expect(isValidAccountSettingTab('')).toBe(false)
expect(isValidAccountSettingTab('invalid')).toBe(false)
})
})

View File

@ -0,0 +1,363 @@
import type { DataSourceAuth } from './types'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { FormTypeEnum } from '@/app/components/base/form/types'
import { usePluginAuthAction } from '@/app/components/plugins/plugin-auth'
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
import { CollectionType } from '@/app/components/tools/types'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import { openOAuthPopup } from '@/hooks/use-oauth'
import { useGetDataSourceOAuthUrl, useInvalidDataSourceAuth, useInvalidDataSourceListAuth, useInvalidDefaultDataSourceListAuth } from '@/service/use-datasource'
import { useInvalidDataSourceList } from '@/service/use-pipeline'
import Card from './card'
import { useDataSourceAuthUpdate } from './hooks'
vi.mock('@/app/components/plugins/plugin-auth', () => ({
ApiKeyModal: vi.fn(({ onClose, onUpdate, onRemove, disabled, editValues }: { onClose: () => void, onUpdate: () => void, onRemove: () => void, disabled: boolean, editValues: Record<string, unknown> }) => (
<div data-testid="mock-api-key-modal" data-disabled={disabled}>
<button data-testid="modal-close" onClick={onClose}>Close</button>
<button data-testid="modal-update" onClick={onUpdate}>Update</button>
<button data-testid="modal-remove" onClick={onRemove}>Remove</button>
<div data-testid="edit-values">{JSON.stringify(editValues)}</div>
</div>
)),
usePluginAuthAction: vi.fn(),
AuthCategory: {
datasource: 'datasource',
},
AddApiKeyButton: ({ onUpdate }: { onUpdate: () => void }) => <button onClick={onUpdate}>Add API Key</button>,
AddOAuthButton: ({ onUpdate }: { onUpdate: () => void }) => <button onClick={onUpdate}>Add OAuth</button>,
}))
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: vi.fn(),
}))
vi.mock('@/hooks/use-oauth', () => ({
openOAuthPopup: vi.fn(),
}))
vi.mock('@/service/use-datasource', () => ({
useGetDataSourceOAuthUrl: vi.fn(),
useInvalidDataSourceAuth: vi.fn(() => vi.fn()),
useInvalidDataSourceListAuth: vi.fn(() => vi.fn()),
useInvalidDefaultDataSourceListAuth: vi.fn(() => vi.fn()),
}))
vi.mock('./hooks', () => ({
useDataSourceAuthUpdate: vi.fn(),
}))
vi.mock('@/service/use-pipeline', () => ({
useInvalidDataSourceList: vi.fn(() => vi.fn()),
}))
type UsePluginAuthActionReturn = ReturnType<typeof usePluginAuthAction>
type UseGetDataSourceOAuthUrlReturn = ReturnType<typeof useGetDataSourceOAuthUrl>
type UseRenderI18nObjectReturn = ReturnType<typeof useRenderI18nObject>
describe('Card Component', () => {
const mockGetPluginOAuthUrl = vi.fn()
const mockRenderI18nObjectResult = vi.fn((obj: Record<string, string>) => obj.en_US)
const mockInvalidateDataSourceListAuth = vi.fn()
const mockInvalidDefaultDataSourceListAuth = vi.fn()
const mockInvalidateDataSourceList = vi.fn()
const mockInvalidateDataSourceAuth = vi.fn()
const mockHandleAuthUpdate = vi.fn(() => {
mockInvalidateDataSourceListAuth()
mockInvalidDefaultDataSourceListAuth()
mockInvalidateDataSourceList()
mockInvalidateDataSourceAuth()
})
const createMockPluginAuthActionReturn = (overrides: Partial<UsePluginAuthActionReturn> = {}): UsePluginAuthActionReturn => ({
deleteCredentialId: null,
doingAction: false,
handleConfirm: vi.fn(),
handleEdit: vi.fn(),
handleRemove: vi.fn(),
handleRename: vi.fn(),
handleSetDefault: vi.fn(),
handleSetDoingAction: vi.fn(),
setDeleteCredentialId: vi.fn(),
editValues: null,
setEditValues: vi.fn(),
openConfirm: vi.fn(),
closeConfirm: vi.fn(),
pendingOperationCredentialId: { current: null },
...overrides,
})
const mockItem: DataSourceAuth = {
author: 'Test Author',
provider: 'test-provider',
plugin_id: 'test-plugin-id',
plugin_unique_identifier: 'test-unique-id',
icon: 'test-icon-url',
name: 'test-name',
label: {
en_US: 'Test Label',
zh_Hans: '',
},
description: {
en_US: 'Test Description',
zh_Hans: '',
},
credentials_list: [
{
id: 'c1',
name: 'Credential 1',
credential: { apiKey: 'key1' },
type: CredentialTypeEnum.API_KEY,
is_default: true,
avatar_url: 'avatar1',
},
],
}
let mockPluginAuthActionReturn: UsePluginAuthActionReturn
beforeEach(() => {
vi.clearAllMocks()
mockPluginAuthActionReturn = createMockPluginAuthActionReturn()
vi.mocked(useDataSourceAuthUpdate).mockReturnValue({ handleAuthUpdate: mockHandleAuthUpdate })
vi.mocked(useInvalidDataSourceListAuth).mockReturnValue(mockInvalidateDataSourceListAuth)
vi.mocked(useInvalidDefaultDataSourceListAuth).mockReturnValue(mockInvalidDefaultDataSourceListAuth)
vi.mocked(useInvalidDataSourceList).mockReturnValue(mockInvalidateDataSourceList)
vi.mocked(useInvalidDataSourceAuth).mockReturnValue(mockInvalidateDataSourceAuth)
vi.mocked(usePluginAuthAction).mockReturnValue(mockPluginAuthActionReturn)
vi.mocked(useRenderI18nObject).mockReturnValue(mockRenderI18nObjectResult as unknown as UseRenderI18nObjectReturn)
vi.mocked(useGetDataSourceOAuthUrl).mockReturnValue({ mutateAsync: mockGetPluginOAuthUrl } as unknown as UseGetDataSourceOAuthUrlReturn)
})
const expectAuthUpdated = () => {
expect(mockInvalidateDataSourceListAuth).toHaveBeenCalled()
expect(mockInvalidDefaultDataSourceListAuth).toHaveBeenCalled()
expect(mockInvalidateDataSourceList).toHaveBeenCalled()
expect(mockInvalidateDataSourceAuth).toHaveBeenCalled()
}
describe('Rendering', () => {
it('should render the card with provided item data and initialize hooks correctly', () => {
// Act
render(<Card item={mockItem} />)
// Assert
expect(screen.getByText('Test Label')).toBeInTheDocument()
expect(screen.getByText(/Test Author/)).toBeInTheDocument()
expect(screen.getByText(/test-name/)).toBeInTheDocument()
expect(screen.getByRole('img')).toHaveAttribute('src', 'test-icon-url')
expect(screen.getByText('Credential 1')).toBeInTheDocument()
expect(usePluginAuthAction).toHaveBeenCalledWith(
expect.objectContaining({
category: 'datasource',
provider: 'test-plugin-id/test-name',
providerType: CollectionType.datasource,
}),
mockHandleAuthUpdate,
)
})
it('should render empty state when credentials_list is empty', () => {
// Arrange
const emptyItem = { ...mockItem, credentials_list: [] }
// Act
render(<Card item={emptyItem} />)
// Assert
expect(screen.getByText(/plugin.auth.emptyAuth/)).toBeInTheDocument()
})
})
describe('Actions', () => {
const openDropdown = (text: string) => {
const item = screen.getByText(text).closest('.flex')
const trigger = within(item as HTMLElement).getByRole('button')
fireEvent.click(trigger)
}
it('should handle "edit" action from Item component', async () => {
// Act
render(<Card item={mockItem} />)
openDropdown('Credential 1')
fireEvent.click(screen.getByText(/operation.edit/))
// Assert
expect(mockPluginAuthActionReturn.handleEdit).toHaveBeenCalledWith('c1', {
apiKey: 'key1',
__name__: 'Credential 1',
__credential_id__: 'c1',
})
})
it('should handle "delete" action from Item component', async () => {
// Act
render(<Card item={mockItem} />)
openDropdown('Credential 1')
fireEvent.click(screen.getByText(/operation.remove/))
// Assert
expect(mockPluginAuthActionReturn.openConfirm).toHaveBeenCalledWith('c1')
})
it('should handle "setDefault" action from Item component', async () => {
// Act
render(<Card item={mockItem} />)
openDropdown('Credential 1')
fireEvent.click(screen.getByText(/auth.setDefault/))
// Assert
expect(mockPluginAuthActionReturn.handleSetDefault).toHaveBeenCalledWith('c1')
})
it('should handle "rename" action from Item component', async () => {
// Arrange
const oAuthItem = {
...mockItem,
credentials_list: [{
...mockItem.credentials_list[0],
type: CredentialTypeEnum.OAUTH2,
}],
}
render(<Card item={oAuthItem} />)
// Act
openDropdown('Credential 1')
fireEvent.click(screen.getByText(/operation.rename/))
// Now it should show an input
const input = screen.getByPlaceholderText(/placeholder.input/)
fireEvent.change(input, { target: { value: 'New Name' } })
fireEvent.click(screen.getByText(/operation.save/))
// Assert
expect(mockPluginAuthActionReturn.handleRename).toHaveBeenCalledWith({
credential_id: 'c1',
name: 'New Name',
})
})
it('should handle "change" action and trigger OAuth flow', async () => {
// Arrange
const oAuthItem = {
...mockItem,
credentials_list: [{
...mockItem.credentials_list[0],
type: CredentialTypeEnum.OAUTH2,
}],
}
mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: 'https://oauth.url' })
render(<Card item={oAuthItem} />)
// Act
openDropdown('Credential 1')
fireEvent.click(screen.getByText(/dataSource.notion.changeAuthorizedPages/))
// Assert
await waitFor(() => {
expect(mockGetPluginOAuthUrl).toHaveBeenCalledWith('c1')
expect(openOAuthPopup).toHaveBeenCalledWith('https://oauth.url', mockHandleAuthUpdate)
})
})
it('should not trigger OAuth flow if authorization_url is missing', async () => {
// Arrange
const oAuthItem = {
...mockItem,
credentials_list: [{
...mockItem.credentials_list[0],
type: CredentialTypeEnum.OAUTH2,
}],
}
mockGetPluginOAuthUrl.mockResolvedValue({ authorization_url: '' })
render(<Card item={oAuthItem} />)
// Act
openDropdown('Credential 1')
fireEvent.click(screen.getByText(/dataSource.notion.changeAuthorizedPages/))
// Assert
await waitFor(() => {
expect(mockGetPluginOAuthUrl).toHaveBeenCalledWith('c1')
})
expect(openOAuthPopup).not.toHaveBeenCalled()
})
})
describe('Modals', () => {
it('should show Confirm dialog when deleteCredentialId is set and handle its actions', () => {
// Arrange
const mockReturn = createMockPluginAuthActionReturn({ deleteCredentialId: 'c1', doingAction: false })
vi.mocked(usePluginAuthAction).mockReturnValue(mockReturn)
// Act
render(<Card item={mockItem} />)
// Assert
expect(screen.getByText(/list.delete.title/)).toBeInTheDocument()
const confirmButton = screen.getByText(/operation.confirm/).closest('button')
expect(confirmButton).toBeEnabled()
// Act - Cancel
fireEvent.click(screen.getByText(/operation.cancel/))
expect(mockReturn.closeConfirm).toHaveBeenCalled()
// Act - Confirm (even if disabled in UI, fireEvent still works unless we check)
fireEvent.click(screen.getByText(/operation.confirm/))
expect(mockReturn.handleConfirm).toHaveBeenCalled()
})
it('should show ApiKeyModal when editValues is set and handle its actions', () => {
// Arrange
const mockReturn = createMockPluginAuthActionReturn({ editValues: { some: 'value' }, doingAction: false })
vi.mocked(usePluginAuthAction).mockReturnValue(mockReturn)
render(<Card item={mockItem} disabled={false} />)
// Assert
expect(screen.getByTestId('mock-api-key-modal')).toBeInTheDocument()
expect(screen.getByTestId('mock-api-key-modal')).toHaveAttribute('data-disabled', 'false')
// Act
fireEvent.click(screen.getByTestId('modal-close'))
expect(mockReturn.setEditValues).toHaveBeenCalledWith(null)
fireEvent.click(screen.getByTestId('modal-remove'))
expect(mockReturn.handleRemove).toHaveBeenCalled()
})
it('should disable ApiKeyModal when doingAction is true', () => {
// Arrange
const mockReturnDoing = createMockPluginAuthActionReturn({ editValues: { some: 'value' }, doingAction: true })
vi.mocked(usePluginAuthAction).mockReturnValue(mockReturnDoing)
// Act
render(<Card item={mockItem} disabled={false} />)
// Assert
expect(screen.getByTestId('mock-api-key-modal')).toHaveAttribute('data-disabled', 'true')
})
})
describe('Integration', () => {
it('should call handleAuthUpdate when Configure component triggers update', async () => {
// Arrange
const configurableItem: DataSourceAuth = {
...mockItem,
credential_schema: [{ name: 'api_key', type: FormTypeEnum.textInput, label: 'API Key', required: true }],
}
// Act
render(<Card item={configurableItem} />)
fireEvent.click(screen.getByText(/dataSource.configure/))
// Find the add API key button and click it
fireEvent.click(screen.getByText('Add API Key'))
// Assert
expectAuthUpdated()
})
})
})

View File

@ -0,0 +1,256 @@
import type { DataSourceAuth } from './types'
import type { FormSchema } from '@/app/components/base/form/types'
import type { AddApiKeyButtonProps, AddOAuthButtonProps, PluginPayload } from '@/app/components/plugins/plugin-auth/types'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { FormTypeEnum } from '@/app/components/base/form/types'
import { AuthCategory } from '@/app/components/plugins/plugin-auth/types'
import Configure from './configure'
/**
* Configure Component Tests
* Using Unit approach to ensure 100% coverage and stable tests.
*/
// Mock plugin auth components to isolate the unit test for Configure.
vi.mock('@/app/components/plugins/plugin-auth', () => ({
AddApiKeyButton: vi.fn(({ onUpdate, disabled, buttonText }: AddApiKeyButtonProps & { onUpdate: () => void }) => (
<button data-testid="add-api-key" onClick={onUpdate} disabled={disabled}>{buttonText}</button>
)),
AddOAuthButton: vi.fn(({ onUpdate, disabled, buttonText }: AddOAuthButtonProps & { onUpdate: () => void }) => (
<button data-testid="add-oauth" onClick={onUpdate} disabled={disabled}>{buttonText}</button>
)),
}))
describe('Configure Component', () => {
const mockOnUpdate = vi.fn()
const mockPluginPayload: PluginPayload = {
category: AuthCategory.datasource,
provider: 'test-provider',
}
const mockItemBase: DataSourceAuth = {
author: 'Test Author',
provider: 'test-provider',
plugin_id: 'test-plugin-id',
plugin_unique_identifier: 'test-unique-id',
icon: 'test-icon-url',
name: 'test-name',
label: { en_US: 'Test Label', zh_Hans: 'zh_hans' },
description: { en_US: 'Test Description', zh_Hans: 'zh_hans' },
credentials_list: [],
}
const mockFormSchema: FormSchema = {
name: 'api_key',
label: { en_US: 'API Key', zh_Hans: 'zh_hans' },
type: FormTypeEnum.textInput,
required: true,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Open State Management', () => {
it('should toggle and manage the open state correctly', () => {
// Arrange
// Add a schema so we can detect if it's open by checking for button presence
const itemWithApiKey: DataSourceAuth = {
...mockItemBase,
credential_schema: [mockFormSchema],
}
render(<Configure item={itemWithApiKey} pluginPayload={mockPluginPayload} />)
const trigger = screen.getByRole('button', { name: /dataSource.configure/i })
// Assert: Initially closed (button from content should not be present)
expect(screen.queryByTestId('add-api-key')).not.toBeInTheDocument()
// Act: Click to open
fireEvent.click(trigger)
// Assert: Now open
expect(screen.getByTestId('add-api-key')).toBeInTheDocument()
// Act: Click again to close
fireEvent.click(trigger)
// Assert: Now closed
expect(screen.queryByTestId('add-api-key')).not.toBeInTheDocument()
})
})
describe('Conditional Rendering', () => {
it('should render AddApiKeyButton when credential_schema is non-empty', () => {
// Arrange
const itemWithApiKey: DataSourceAuth = {
...mockItemBase,
credential_schema: [mockFormSchema],
}
// Act
render(<Configure item={itemWithApiKey} pluginPayload={mockPluginPayload} />)
fireEvent.click(screen.getByRole('button', { name: /dataSource.configure/i }))
// Assert
expect(screen.getByTestId('add-api-key')).toBeInTheDocument()
expect(screen.queryByTestId('add-oauth')).not.toBeInTheDocument()
})
it('should render AddOAuthButton when oauth_schema with client_schema is non-empty', () => {
// Arrange
const itemWithOAuth: DataSourceAuth = {
...mockItemBase,
oauth_schema: {
client_schema: [mockFormSchema],
},
}
// Act
render(<Configure item={itemWithOAuth} pluginPayload={mockPluginPayload} />)
fireEvent.click(screen.getByRole('button', { name: /dataSource.configure/i }))
// Assert
expect(screen.getByTestId('add-oauth')).toBeInTheDocument()
expect(screen.queryByTestId('add-api-key')).not.toBeInTheDocument()
})
it('should render both buttons and the OR divider when both schemes are available', () => {
// Arrange
const itemWithBoth: DataSourceAuth = {
...mockItemBase,
credential_schema: [mockFormSchema],
oauth_schema: {
client_schema: [mockFormSchema],
},
}
// Act
render(<Configure item={itemWithBoth} pluginPayload={mockPluginPayload} />)
fireEvent.click(screen.getByRole('button', { name: /dataSource.configure/i }))
// Assert
expect(screen.getByTestId('add-api-key')).toBeInTheDocument()
expect(screen.getByTestId('add-oauth')).toBeInTheDocument()
expect(screen.getByText('OR')).toBeInTheDocument()
})
})
describe('Update Handling', () => {
it('should call onUpdate and close the portal when an update is triggered', () => {
// Arrange
const itemWithApiKey: DataSourceAuth = {
...mockItemBase,
credential_schema: [mockFormSchema],
}
render(<Configure item={itemWithApiKey} pluginPayload={mockPluginPayload} onUpdate={mockOnUpdate} />)
// Act: Open and click update
fireEvent.click(screen.getByRole('button', { name: /dataSource.configure/i }))
fireEvent.click(screen.getByTestId('add-api-key'))
// Assert
expect(mockOnUpdate).toHaveBeenCalledTimes(1)
expect(screen.queryByTestId('add-api-key')).not.toBeInTheDocument()
})
it('should handle missing onUpdate callback gracefully', () => {
// Arrange
const itemWithBoth: DataSourceAuth = {
...mockItemBase,
credential_schema: [mockFormSchema],
oauth_schema: {
client_schema: [mockFormSchema],
},
}
render(<Configure item={itemWithBoth} pluginPayload={mockPluginPayload} />)
// Act & Assert
fireEvent.click(screen.getByRole('button', { name: /dataSource.configure/i }))
fireEvent.click(screen.getByTestId('add-api-key'))
expect(screen.queryByTestId('add-api-key')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /dataSource.configure/i }))
fireEvent.click(screen.getByTestId('add-oauth'))
expect(screen.queryByTestId('add-oauth')).not.toBeInTheDocument()
})
})
describe('Props and Edge Cases', () => {
it('should pass the disabled prop to both configuration buttons', () => {
// Arrange
const itemWithBoth: DataSourceAuth = {
...mockItemBase,
credential_schema: [mockFormSchema],
oauth_schema: {
client_schema: [mockFormSchema],
},
}
// Act: Open the configuration menu
render(<Configure item={itemWithBoth} pluginPayload={mockPluginPayload} disabled={true} />)
fireEvent.click(screen.getByRole('button', { name: /dataSource.configure/i }))
// Assert
expect(screen.getByTestId('add-api-key')).toBeDisabled()
expect(screen.getByTestId('add-oauth')).toBeDisabled()
})
it('should handle edge cases for missing, empty, or partial item data', () => {
// Act & Assert (Missing schemas)
const { rerender } = render(<Configure item={mockItemBase} pluginPayload={mockPluginPayload} />)
fireEvent.click(screen.getByRole('button', { name: /dataSource.configure/i }))
expect(screen.queryByTestId('add-api-key')).not.toBeInTheDocument()
expect(screen.queryByTestId('add-oauth')).not.toBeInTheDocument()
// Arrange (Empty schemas)
const itemEmpty: DataSourceAuth = {
...mockItemBase,
credential_schema: [],
oauth_schema: { client_schema: [] },
}
// Act
rerender(<Configure item={itemEmpty} pluginPayload={mockPluginPayload} />)
// Already open from previous click if rerender doesn't reset state
// But it's better to be sure
expect(screen.queryByTestId('add-api-key')).not.toBeInTheDocument()
expect(screen.queryByTestId('add-oauth')).not.toBeInTheDocument()
// Arrange (Partial OAuth schema)
const itemPartialOAuth: DataSourceAuth = {
...mockItemBase,
oauth_schema: {
is_oauth_custom_client_enabled: true,
},
}
// Act
rerender(<Configure item={itemPartialOAuth} pluginPayload={mockPluginPayload} />)
// Assert
expect(screen.queryByTestId('add-oauth')).not.toBeInTheDocument()
})
it('should reach the unreachable branch on line 95 for 100% coverage', async () => {
// Specialized test to reach the '|| []' part: canOAuth must be truthy but client_schema falsy on second call
let count = 0
const itemWithGlitchedSchema = {
...mockItemBase,
oauth_schema: {
get client_schema() {
count++
if (count % 2 !== 0)
return [mockFormSchema]
return undefined
},
is_oauth_custom_client_enabled: false,
is_system_oauth_params_exists: false,
oauth_custom_client_params: {},
redirect_uri: '',
},
} as unknown as DataSourceAuth
render(<Configure item={itemWithGlitchedSchema} pluginPayload={mockPluginPayload} />)
fireEvent.click(screen.getByRole('button', { name: /dataSource.configure/i }))
await waitFor(() => {
expect(screen.getByTestId('add-oauth')).toBeInTheDocument()
})
})
})
})

View File

@ -0,0 +1,84 @@
import { act, renderHook } from '@testing-library/react'
import {
useInvalidDataSourceAuth,
useInvalidDataSourceListAuth,
useInvalidDefaultDataSourceListAuth,
} from '@/service/use-datasource'
import { useInvalidDataSourceList } from '@/service/use-pipeline'
import { useDataSourceAuthUpdate } from './use-data-source-auth-update'
/**
* useDataSourceAuthUpdate Hook Tests
* This hook manages the invalidation of various data source related queries.
*/
vi.mock('@/service/use-datasource', () => ({
useInvalidDataSourceAuth: vi.fn(),
useInvalidDataSourceListAuth: vi.fn(),
useInvalidDefaultDataSourceListAuth: vi.fn(),
}))
vi.mock('@/service/use-pipeline', () => ({
useInvalidDataSourceList: vi.fn(),
}))
describe('useDataSourceAuthUpdate', () => {
const mockInvalidateDataSourceAuth = vi.fn()
const mockInvalidateDataSourceListAuth = vi.fn()
const mockInvalidDefaultDataSourceListAuth = vi.fn()
const mockInvalidateDataSourceList = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useInvalidDataSourceAuth).mockReturnValue(mockInvalidateDataSourceAuth)
vi.mocked(useInvalidDataSourceListAuth).mockReturnValue(mockInvalidateDataSourceListAuth)
vi.mocked(useInvalidDefaultDataSourceListAuth).mockReturnValue(mockInvalidDefaultDataSourceListAuth)
vi.mocked(useInvalidDataSourceList).mockReturnValue(mockInvalidateDataSourceList)
})
describe('handleAuthUpdate', () => {
it('should call all invalidate functions when handleAuthUpdate is invoked', () => {
// Arrange
const pluginId = 'test-plugin-id'
const provider = 'test-provider'
const { result } = renderHook(() => useDataSourceAuthUpdate({
pluginId,
provider,
}))
// Assert Initialization
expect(useInvalidDataSourceAuth).toHaveBeenCalledWith({ pluginId, provider })
// Act
act(() => {
result.current.handleAuthUpdate()
})
// Assert Invalidation
expect(mockInvalidateDataSourceListAuth).toHaveBeenCalledTimes(1)
expect(mockInvalidDefaultDataSourceListAuth).toHaveBeenCalledTimes(1)
expect(mockInvalidateDataSourceList).toHaveBeenCalledTimes(1)
expect(mockInvalidateDataSourceAuth).toHaveBeenCalledTimes(1)
})
it('should maintain stable handleAuthUpdate reference if dependencies do not change', () => {
// Arrange
const props = {
pluginId: 'stable-plugin',
provider: 'stable-provider',
}
const { result, rerender } = renderHook(
({ pluginId, provider }) => useDataSourceAuthUpdate({ pluginId, provider }),
{ initialProps: props },
)
const firstHandleAuthUpdate = result.current.handleAuthUpdate
// Act
rerender(props)
// Assert
expect(result.current.handleAuthUpdate).toBe(firstHandleAuthUpdate)
})
})
})

View File

@ -0,0 +1,181 @@
import type { Plugin } from '@/app/components/plugins/types'
import { renderHook } from '@testing-library/react'
import {
useMarketplacePlugins,
useMarketplacePluginsByCollectionId,
} from '@/app/components/plugins/marketplace/hooks'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useMarketplaceAllPlugins } from './use-marketplace-all-plugins'
/**
* useMarketplaceAllPlugins Hook Tests
* This hook combines search results and collection-specific plugins from the marketplace.
*/
type UseMarketplacePluginsReturn = ReturnType<typeof useMarketplacePlugins>
type UseMarketplacePluginsByCollectionIdReturn = ReturnType<typeof useMarketplacePluginsByCollectionId>
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
useMarketplacePlugins: vi.fn(),
useMarketplacePluginsByCollectionId: vi.fn(),
}))
describe('useMarketplaceAllPlugins', () => {
const mockQueryPlugins = vi.fn()
const mockQueryPluginsWithDebounced = vi.fn()
const mockResetPlugins = vi.fn()
const mockCancelQueryPluginsWithDebounced = vi.fn()
const mockFetchNextPage = vi.fn()
const createBasePluginsMock = (overrides: Partial<UseMarketplacePluginsReturn> = {}): UseMarketplacePluginsReturn => ({
plugins: [],
total: 0,
resetPlugins: mockResetPlugins,
queryPlugins: mockQueryPlugins,
queryPluginsWithDebounced: mockQueryPluginsWithDebounced,
cancelQueryPluginsWithDebounced: mockCancelQueryPluginsWithDebounced,
isLoading: false,
isFetchingNextPage: false,
hasNextPage: false,
fetchNextPage: mockFetchNextPage,
page: 1,
...overrides,
} as UseMarketplacePluginsReturn)
const createBaseCollectionMock = (overrides: Partial<UseMarketplacePluginsByCollectionIdReturn> = {}): UseMarketplacePluginsByCollectionIdReturn => ({
plugins: [],
isLoading: false,
isSuccess: true,
...overrides,
} as UseMarketplacePluginsByCollectionIdReturn)
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useMarketplacePlugins).mockReturnValue(createBasePluginsMock())
vi.mocked(useMarketplacePluginsByCollectionId).mockReturnValue(createBaseCollectionMock())
})
describe('Search Interactions', () => {
it('should call queryPlugins when no searchText is provided', () => {
// Arrange
const providers = [{ plugin_id: 'p1' }]
const searchText = ''
// Act
renderHook(() => useMarketplaceAllPlugins(providers, searchText))
// Assert
expect(mockQueryPlugins).toHaveBeenCalledWith({
query: '',
category: PluginCategoryEnum.datasource,
type: 'plugin',
page_size: 1000,
exclude: ['p1'],
sort_by: 'install_count',
sort_order: 'DESC',
})
})
it('should call queryPluginsWithDebounced when searchText is provided', () => {
// Arrange
const providers = [{ plugin_id: 'p1' }]
const searchText = 'search term'
// Act
renderHook(() => useMarketplaceAllPlugins(providers, searchText))
// Assert
expect(mockQueryPluginsWithDebounced).toHaveBeenCalledWith({
query: 'search term',
category: PluginCategoryEnum.datasource,
exclude: ['p1'],
type: 'plugin',
sort_by: 'install_count',
sort_order: 'DESC',
})
})
})
describe('Plugin Filtering and Combination', () => {
it('should combine collection plugins and search results, filtering duplicates and bundles', () => {
// Arrange
const providers = [{ plugin_id: 'p-excluded' }]
const searchText = ''
const p1 = { plugin_id: 'p1', type: 'plugin' } as Plugin
const pExcluded = { plugin_id: 'p-excluded', type: 'plugin' } as Plugin
const p2 = { plugin_id: 'p2', type: 'plugin' } as Plugin
const p3Bundle = { plugin_id: 'p3', type: 'bundle' } as Plugin
const collectionPlugins = [p1, pExcluded]
const searchPlugins = [p1, p2, p3Bundle]
vi.mocked(useMarketplacePluginsByCollectionId).mockReturnValue(
createBaseCollectionMock({ plugins: collectionPlugins }),
)
vi.mocked(useMarketplacePlugins).mockReturnValue(
createBasePluginsMock({ plugins: searchPlugins }),
)
// Act
const { result } = renderHook(() => useMarketplaceAllPlugins(providers, searchText))
// Assert: pExcluded is removed, p1 is duplicated (so kept once), p2 is added, p3 is bundle (skipped)
expect(result.current.plugins).toHaveLength(2)
expect(result.current.plugins.map(p => p.plugin_id)).toEqual(['p1', 'p2'])
})
it('should handle undefined plugins gracefully', () => {
// Arrange
vi.mocked(useMarketplacePlugins).mockReturnValue(
createBasePluginsMock({ plugins: undefined as unknown as Plugin[] }),
)
// Act
const { result } = renderHook(() => useMarketplaceAllPlugins([], ''))
// Assert
expect(result.current.plugins).toEqual([])
})
})
describe('Loading State Management', () => {
it('should return isLoading true if either hook is loading', () => {
// Case 1: Collection hook is loading
vi.mocked(useMarketplacePluginsByCollectionId).mockReturnValue(
createBaseCollectionMock({ isLoading: true }),
)
vi.mocked(useMarketplacePlugins).mockReturnValue(createBasePluginsMock({ isLoading: false }))
const { result, rerender } = renderHook(
({ providers, searchText }) => useMarketplaceAllPlugins(providers, searchText),
{
initialProps: { providers: [] as { plugin_id: string }[], searchText: '' },
},
)
expect(result.current.isLoading).toBe(true)
// Case 2: Plugins hook is loading
vi.mocked(useMarketplacePluginsByCollectionId).mockReturnValue(
createBaseCollectionMock({ isLoading: false }),
)
vi.mocked(useMarketplacePlugins).mockReturnValue(createBasePluginsMock({ isLoading: true }))
rerender({ providers: [], searchText: '' })
expect(result.current.isLoading).toBe(true)
// Case 3: Both hooks are loading
vi.mocked(useMarketplacePluginsByCollectionId).mockReturnValue(
createBaseCollectionMock({ isLoading: true }),
)
rerender({ providers: [], searchText: '' })
expect(result.current.isLoading).toBe(true)
// Case 4: Neither hook is loading
vi.mocked(useMarketplacePluginsByCollectionId).mockReturnValue(
createBaseCollectionMock({ isLoading: false }),
)
vi.mocked(useMarketplacePlugins).mockReturnValue(createBasePluginsMock({ isLoading: false }))
rerender({ providers: [], searchText: '' })
expect(result.current.isLoading).toBe(false)
})
})
})

View File

@ -0,0 +1,219 @@
import type { UseQueryResult } from '@tanstack/react-query'
import type { DataSourceAuth } from './types'
import { render, screen } from '@testing-library/react'
import { useTheme } from 'next-themes'
import { usePluginAuthAction } from '@/app/components/plugins/plugin-auth'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useRenderI18nObject } from '@/hooks/use-i18n'
import { useGetDataSourceListAuth, useGetDataSourceOAuthUrl } from '@/service/use-datasource'
import { defaultSystemFeatures } from '@/types/feature'
import { useDataSourceAuthUpdate, useMarketplaceAllPlugins } from './hooks'
import DataSourcePage from './index'
/**
* DataSourcePage Component Tests
* Using Unit approach to focus on page-level layout and conditional rendering.
*/
// Mock external dependencies
vi.mock('next-themes', () => ({
useTheme: vi.fn(),
}))
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: vi.fn(),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: vi.fn(),
}))
vi.mock('@/service/use-datasource', () => ({
useGetDataSourceListAuth: vi.fn(),
useGetDataSourceOAuthUrl: vi.fn(),
}))
vi.mock('./hooks', () => ({
useDataSourceAuthUpdate: vi.fn(),
useMarketplaceAllPlugins: vi.fn(),
}))
vi.mock('@/app/components/plugins/plugin-auth', () => ({
usePluginAuthAction: vi.fn(),
ApiKeyModal: () => <div data-testid="mock-api-key-modal" />,
AuthCategory: { datasource: 'datasource' },
}))
describe('DataSourcePage Component', () => {
const mockProviders: DataSourceAuth[] = [
{
author: 'Dify',
provider: 'dify',
plugin_id: 'plugin-1',
plugin_unique_identifier: 'unique-1',
icon: 'icon-1',
name: 'Dify Source',
label: { en_US: 'Dify Source', zh_Hans: 'zh_hans_dify_source' },
description: { en_US: 'Dify Description', zh_Hans: 'zh_hans_dify_description' },
credentials_list: [],
},
{
author: 'Partner',
provider: 'partner',
plugin_id: 'plugin-2',
plugin_unique_identifier: 'unique-2',
icon: 'icon-2',
name: 'Partner Source',
label: { en_US: 'Partner Source', zh_Hans: 'zh_hans_partner_source' },
description: { en_US: 'Partner Description', zh_Hans: 'zh_hans_partner_description' },
credentials_list: [],
},
]
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useTheme).mockReturnValue({ theme: 'light' } as unknown as ReturnType<typeof useTheme>)
vi.mocked(useRenderI18nObject).mockReturnValue((obj: Record<string, string>) => obj?.en_US || '')
vi.mocked(useGetDataSourceOAuthUrl).mockReturnValue({ mutateAsync: vi.fn() } as unknown as ReturnType<typeof useGetDataSourceOAuthUrl>)
vi.mocked(useDataSourceAuthUpdate).mockReturnValue({ handleAuthUpdate: vi.fn() })
vi.mocked(useMarketplaceAllPlugins).mockReturnValue({ plugins: [], isLoading: false })
vi.mocked(usePluginAuthAction).mockReturnValue({
deleteCredentialId: null,
doingAction: false,
handleConfirm: vi.fn(),
handleEdit: vi.fn(),
handleRemove: vi.fn(),
handleRename: vi.fn(),
handleSetDefault: vi.fn(),
editValues: null,
setEditValues: vi.fn(),
openConfirm: vi.fn(),
closeConfirm: vi.fn(),
pendingOperationCredentialId: { current: null },
} as unknown as ReturnType<typeof usePluginAuthAction>)
})
describe('Initial View Rendering', () => {
it('should render an empty view when no data is available and marketplace is disabled', () => {
// Arrange
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
selector({
systemFeatures: { ...defaultSystemFeatures, enable_marketplace: false },
}),
)
vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: undefined,
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act
render(<DataSourcePage />)
// Assert
expect(screen.queryByText('Dify Source')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.installDataSourceProvider')).not.toBeInTheDocument()
})
})
describe('Data Source List Rendering', () => {
it('should render Card components for each data source returned from the API', () => {
// Arrange
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
selector({
systemFeatures: { ...defaultSystemFeatures, enable_marketplace: false },
}),
)
vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: { result: mockProviders },
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act
render(<DataSourcePage />)
// Assert
expect(screen.getByText('Dify Source')).toBeInTheDocument()
expect(screen.getByText('Partner Source')).toBeInTheDocument()
})
})
describe('Marketplace Integration', () => {
it('should render the InstallFromMarketplace component when enable_marketplace feature is enabled', () => {
// Arrange
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
selector({
systemFeatures: { ...defaultSystemFeatures, enable_marketplace: true },
}),
)
vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: { result: mockProviders },
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act
render(<DataSourcePage />)
// Assert
expect(screen.getByText('common.modelProvider.installDataSourceProvider')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.discoverMore')).toBeInTheDocument()
})
it('should pass an empty array to InstallFromMarketplace if data result is missing but marketplace is enabled', () => {
// Arrange
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
selector({
systemFeatures: { ...defaultSystemFeatures, enable_marketplace: true },
}),
)
vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: undefined,
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act
render(<DataSourcePage />)
// Assert
expect(screen.getByText('common.modelProvider.installDataSourceProvider')).toBeInTheDocument()
})
it('should handle the case where data exists but result is an empty array', () => {
// Arrange
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
selector({
systemFeatures: { ...defaultSystemFeatures, enable_marketplace: true },
}),
)
vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: { result: [] },
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act
render(<DataSourcePage />)
// Assert
expect(screen.queryByText('Dify Source')).not.toBeInTheDocument()
expect(screen.getByText('common.modelProvider.installDataSourceProvider')).toBeInTheDocument()
})
it('should handle the case where systemFeatures is missing (edge case for coverage)', () => {
// Arrange
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useGlobalPublicStore).mockImplementation((selector: any) =>
selector({
systemFeatures: {},
}),
)
vi.mocked(useGetDataSourceListAuth).mockReturnValue({
data: { result: [] },
} as unknown as UseQueryResult<{ result: DataSourceAuth[] }, Error>)
// Act
render(<DataSourcePage />)
// Assert
expect(screen.queryByText('common.modelProvider.installDataSourceProvider')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,177 @@
import type { DataSourceAuth } from './types'
import type { Plugin } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { useTheme } from 'next-themes'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useMarketplaceAllPlugins } from './hooks'
import InstallFromMarketplace from './install-from-marketplace'
/**
* InstallFromMarketplace Component Tests
* Using Unit approach to focus on the component's internal state and conditional rendering.
*/
// Mock external dependencies
vi.mock('next-themes', () => ({
useTheme: vi.fn(),
}))
vi.mock('next/link', () => ({
default: ({ children, href }: { children: React.ReactNode, href: string }) => (
<a href={href} data-testid="mock-link">{children}</a>
),
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: vi.fn((path: string, { theme }: { theme: string }) => `https://marketplace.url${path}?theme=${theme}`),
}))
// Mock marketplace components
vi.mock('@/app/components/plugins/marketplace/list', () => ({
default: ({ plugins, cardRender, cardContainerClassName, emptyClassName }: {
plugins: Plugin[]
cardRender: (p: Plugin) => React.ReactNode
cardContainerClassName?: string
emptyClassName?: string
}) => (
<div data-testid="mock-list" className={cardContainerClassName}>
{plugins.length === 0 && <div className={emptyClassName} aria-label="empty-state" />}
{plugins.map(plugin => (
<div key={plugin.plugin_id} data-testid={`list-item-${plugin.plugin_id}`}>
{cardRender(plugin)}
</div>
))}
</div>
),
}))
vi.mock('@/app/components/plugins/provider-card', () => ({
default: ({ payload }: { payload: Plugin }) => (
<div data-testid={`mock-provider-card-${payload.plugin_id}`}>
{payload.name}
</div>
),
}))
vi.mock('./hooks', () => ({
useMarketplaceAllPlugins: vi.fn(),
}))
describe('InstallFromMarketplace Component', () => {
const mockProviders: DataSourceAuth[] = [
{
author: 'Author',
provider: 'provider',
plugin_id: 'p1',
plugin_unique_identifier: 'u1',
icon: 'icon',
name: 'name',
label: { en_US: 'Label', zh_Hans: '标签' },
description: { en_US: 'Desc', zh_Hans: '描述' },
credentials_list: [],
},
]
const mockPlugins: Plugin[] = [
{
type: 'plugin',
plugin_id: 'plugin-1',
name: 'Plugin 1',
category: PluginCategoryEnum.datasource,
// ...other minimal fields
} as Plugin,
{
type: 'bundle',
plugin_id: 'bundle-1',
name: 'Bundle 1',
category: PluginCategoryEnum.datasource,
} as Plugin,
]
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useTheme).mockReturnValue({
theme: 'light',
setTheme: vi.fn(),
themes: ['light', 'dark'],
systemTheme: 'light',
resolvedTheme: 'light',
} as unknown as ReturnType<typeof useTheme>)
})
describe('Rendering', () => {
it('should render correctly when not loading and not collapsed', () => {
// Arrange
vi.mocked(useMarketplaceAllPlugins).mockReturnValue({
plugins: mockPlugins,
isLoading: false,
})
// Act
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
// Assert
expect(screen.getByText('common.modelProvider.installDataSourceProvider')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.discoverMore')).toBeInTheDocument()
expect(screen.getByTestId('mock-link')).toHaveAttribute('href', 'https://marketplace.url?theme=light')
expect(screen.getByTestId('mock-list')).toBeInTheDocument()
expect(screen.getByTestId('mock-provider-card-plugin-1')).toBeInTheDocument()
expect(screen.queryByTestId('mock-provider-card-bundle-1')).not.toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('should show loading state when marketplace plugins are loading and component is not collapsed', () => {
// Arrange
vi.mocked(useMarketplaceAllPlugins).mockReturnValue({
plugins: [],
isLoading: true,
})
// Act
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
// Assert
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.queryByTestId('mock-list')).not.toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should toggle collapse state when clicking the header', () => {
// Arrange
vi.mocked(useMarketplaceAllPlugins).mockReturnValue({
plugins: mockPlugins,
isLoading: false,
})
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
const toggleHeader = screen.getByText('common.modelProvider.installDataSourceProvider')
// Act (Collapse)
fireEvent.click(toggleHeader)
// Assert
expect(screen.queryByTestId('mock-list')).not.toBeInTheDocument()
// Act (Expand)
fireEvent.click(toggleHeader)
// Assert
expect(screen.getByTestId('mock-list')).toBeInTheDocument()
})
it('should not show loading state even if isLoading is true when component is collapsed', () => {
// Arrange
vi.mocked(useMarketplaceAllPlugins).mockReturnValue({
plugins: [],
isLoading: true,
})
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
const toggleHeader = screen.getByText('common.modelProvider.installDataSourceProvider')
// Act (Collapse)
fireEvent.click(toggleHeader)
// Assert
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,153 @@
import type { DataSourceCredential } from './types'
import { fireEvent, render, screen } from '@testing-library/react'
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
import Item from './item'
/**
* Item Component Tests
* Using Unit approach to focus on the renaming logic and view state.
*/
// Helper to trigger rename via the real Operator component's dropdown
const triggerRename = async () => {
const dropdownTrigger = screen.getByRole('button')
fireEvent.click(dropdownTrigger)
const renameOption = await screen.findByText('common.operation.rename')
fireEvent.click(renameOption)
}
describe('Item Component', () => {
const mockOnAction = vi.fn()
const mockCredentialItem: DataSourceCredential = {
id: 'test-id',
name: 'Test Credential',
credential: {},
type: CredentialTypeEnum.OAUTH2,
is_default: false,
avatar_url: '',
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Initial View Mode', () => {
it('should render the credential name and "connected" status', () => {
// Act
render(<Item credentialItem={mockCredentialItem} onAction={mockOnAction} />)
// Assert
expect(screen.getByText('Test Credential')).toBeInTheDocument()
expect(screen.getByText('connected')).toBeInTheDocument()
expect(screen.getByRole('button')).toBeInTheDocument() // Dropdown trigger
})
})
describe('Rename Mode Interactions', () => {
it('should switch to rename mode when Trigger Rename is clicked', async () => {
// Arrange
render(<Item credentialItem={mockCredentialItem} onAction={mockOnAction} />)
// Act
await triggerRename()
expect(screen.getByPlaceholderText('common.placeholder.input')).toBeInTheDocument()
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
})
it('should update rename input value when changed', async () => {
// Arrange
render(<Item credentialItem={mockCredentialItem} onAction={mockOnAction} />)
await triggerRename()
const input = screen.getByPlaceholderText('common.placeholder.input')
// Act
fireEvent.change(input, { target: { value: 'Updated Name' } })
// Assert
expect(input).toHaveValue('Updated Name')
})
it('should call onAction with "rename" and correct payload when Save is clicked', async () => {
// Arrange
render(<Item credentialItem={mockCredentialItem} onAction={mockOnAction} />)
await triggerRename()
const input = screen.getByPlaceholderText('common.placeholder.input')
fireEvent.change(input, { target: { value: 'New Name' } })
// Act
fireEvent.click(screen.getByText('common.operation.save'))
// Assert
expect(mockOnAction).toHaveBeenCalledWith(
'rename',
mockCredentialItem,
{
credential_id: 'test-id',
name: 'New Name',
},
)
// Should switch back to view mode
expect(screen.queryByPlaceholderText('common.placeholder.input')).not.toBeInTheDocument()
expect(screen.getByText('Test Credential')).toBeInTheDocument()
})
it('should exit rename mode without calling onAction when Cancel is clicked', async () => {
// Arrange
render(<Item credentialItem={mockCredentialItem} onAction={mockOnAction} />)
await triggerRename()
const input = screen.getByPlaceholderText('common.placeholder.input')
fireEvent.change(input, { target: { value: 'Cancelled Name' } })
// Act
fireEvent.click(screen.getByText('common.operation.cancel'))
// Assert
expect(mockOnAction).not.toHaveBeenCalled()
// Should switch back to view mode
expect(screen.queryByPlaceholderText('common.placeholder.input')).not.toBeInTheDocument()
expect(screen.getByText('Test Credential')).toBeInTheDocument()
})
})
describe('Event Bubbling', () => {
it('should stop event propagation when interacting with rename mode elements', async () => {
// Arrange
const parentClick = vi.fn()
render(
<div onClick={parentClick}>
<Item credentialItem={mockCredentialItem} onAction={mockOnAction} />
</div>,
)
// Act & Assert
// We need to enter rename mode first
await triggerRename()
parentClick.mockClear()
fireEvent.click(screen.getByPlaceholderText('common.placeholder.input'))
expect(parentClick).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('common.operation.save'))
expect(parentClick).not.toHaveBeenCalled()
// Re-enter rename mode for cancel test
await triggerRename()
parentClick.mockClear()
fireEvent.click(screen.getByText('common.operation.cancel'))
expect(parentClick).not.toHaveBeenCalled()
})
})
describe('Error Handling', () => {
it('should not throw if onAction is missing', async () => {
// Arrange & Act
// @ts-expect-error - Testing runtime tolerance for missing prop
render(<Item credentialItem={mockCredentialItem} onAction={undefined} />)
await triggerRename()
// Assert
expect(() => fireEvent.click(screen.getByText('common.operation.save'))).not.toThrow()
})
})
})

View File

@ -0,0 +1,145 @@
import type { DataSourceCredential } from './types'
import { fireEvent, render, screen } from '@testing-library/react'
import { CredentialTypeEnum } from '@/app/components/plugins/plugin-auth/types'
import Operator from './operator'
/**
* Operator Component Tests
* Using Unit approach with mocked Dropdown to isolate item rendering logic.
*/
// Helper to open dropdown
const openDropdown = () => {
fireEvent.click(screen.getByRole('button'))
}
describe('Operator Component', () => {
const mockOnAction = vi.fn()
const mockOnRename = vi.fn()
const createMockCredential = (type: CredentialTypeEnum): DataSourceCredential => ({
id: 'test-id',
name: 'Test Credential',
credential: {},
type,
is_default: false,
avatar_url: '',
})
beforeEach(() => {
vi.clearAllMocks()
})
describe('Conditional Action Rendering', () => {
it('should render correct actions for API_KEY type', async () => {
// Arrange
const credential = createMockCredential(CredentialTypeEnum.API_KEY)
// Act
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
openDropdown()
// Assert
expect(await screen.findByText('plugin.auth.setDefault')).toBeInTheDocument()
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
expect(screen.getByText('common.operation.remove')).toBeInTheDocument()
expect(screen.queryByText('common.operation.rename')).not.toBeInTheDocument()
expect(screen.queryByText('common.dataSource.notion.changeAuthorizedPages')).not.toBeInTheDocument()
})
it('should render correct actions for OAUTH2 type', async () => {
// Arrange
const credential = createMockCredential(CredentialTypeEnum.OAUTH2)
// Act
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
openDropdown()
// Assert
expect(await screen.findByText('plugin.auth.setDefault')).toBeInTheDocument()
expect(screen.getByText('common.operation.rename')).toBeInTheDocument()
expect(screen.getByText('common.dataSource.notion.changeAuthorizedPages')).toBeInTheDocument()
expect(screen.getByText('common.operation.remove')).toBeInTheDocument()
expect(screen.queryByText('common.operation.edit')).not.toBeInTheDocument()
})
})
describe('Action Callbacks', () => {
it('should call onRename when "rename" action is selected', async () => {
// Arrange
const credential = createMockCredential(CredentialTypeEnum.OAUTH2)
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
// Act
openDropdown()
fireEvent.click(await screen.findByText('common.operation.rename'))
// Assert
expect(mockOnRename).toHaveBeenCalledTimes(1)
expect(mockOnAction).not.toHaveBeenCalled()
})
it('should handle missing onRename gracefully when "rename" action is selected', async () => {
// Arrange
const credential = createMockCredential(CredentialTypeEnum.OAUTH2)
render(<Operator credentialItem={credential} onAction={mockOnAction} />)
// Act & Assert
openDropdown()
const renameBtn = await screen.findByText('common.operation.rename')
expect(() => fireEvent.click(renameBtn)).not.toThrow()
})
it('should call onAction for "setDefault" action', async () => {
// Arrange
const credential = createMockCredential(CredentialTypeEnum.API_KEY)
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
// Act
openDropdown()
fireEvent.click(await screen.findByText('plugin.auth.setDefault'))
// Assert
expect(mockOnAction).toHaveBeenCalledWith('setDefault', credential)
})
it('should call onAction for "edit" action', async () => {
// Arrange
const credential = createMockCredential(CredentialTypeEnum.API_KEY)
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
// Act
openDropdown()
fireEvent.click(await screen.findByText('common.operation.edit'))
// Assert
expect(mockOnAction).toHaveBeenCalledWith('edit', credential)
})
it('should call onAction for "change" action', async () => {
// Arrange
const credential = createMockCredential(CredentialTypeEnum.OAUTH2)
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
// Act
openDropdown()
fireEvent.click(await screen.findByText('common.dataSource.notion.changeAuthorizedPages'))
// Assert
expect(mockOnAction).toHaveBeenCalledWith('change', credential)
})
it('should call onAction for "delete" action', async () => {
// Arrange
const credential = createMockCredential(CredentialTypeEnum.API_KEY)
render(<Operator credentialItem={credential} onAction={mockOnAction} onRename={mockOnRename} />)
// Act
openDropdown()
fireEvent.click(await screen.findByText('common.operation.remove'))
// Assert
expect(mockOnAction).toHaveBeenCalledWith('delete', credential)
})
})
})

View File

@ -0,0 +1,466 @@
import type { UseQueryResult } from '@tanstack/react-query'
import type { AppContextValue } from '@/context/app-context'
import type { DataSourceNotion as TDataSourceNotion } from '@/models/common'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { useAppContext } from '@/context/app-context'
import { useDataSourceIntegrates, useInvalidDataSourceIntegrates, useNotionConnection } from '@/service/use-common'
import DataSourceNotion from './index'
/**
* DataSourceNotion Component Tests
* Using Unit approach with real Panel and sibling components to test Notion integration logic.
*/
type MockQueryResult<T> = UseQueryResult<T, Error>
// Mock dependencies
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/service/common', () => ({
syncDataSourceNotion: vi.fn(),
updateDataSourceNotionAction: vi.fn(),
}))
vi.mock('@/service/use-common', () => ({
useDataSourceIntegrates: vi.fn(),
useNotionConnection: vi.fn(),
useInvalidDataSourceIntegrates: vi.fn(),
}))
describe('DataSourceNotion Component', () => {
const mockWorkspaces: TDataSourceNotion[] = [
{
id: 'ws-1',
provider: 'notion',
is_bound: true,
source_info: {
workspace_name: 'Workspace 1',
workspace_icon: 'https://example.com/icon-1.png',
workspace_id: 'notion-ws-1',
total: 10,
pages: [],
},
},
]
const baseAppContext: AppContextValue = {
userProfile: { id: 'test-user-id', name: 'test-user', email: 'test@example.com', avatar: '', avatar_url: '', is_password_set: true },
mutateUserProfile: vi.fn(),
currentWorkspace: { id: 'ws-id', name: 'Workspace', plan: 'basic', status: 'normal', created_at: 0, role: 'owner', providers: [], trial_credits: 0, trial_credits_used: 0, next_credit_reset_date: 0 },
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceDatasetOperator: false,
mutateCurrentWorkspace: vi.fn(),
langGeniusVersionInfo: { current_version: '0.1.0', latest_version: '0.1.1', version: '0.1.1', release_date: '', release_notes: '', can_auto_update: false, current_env: 'test' },
useSelector: vi.fn(),
isLoadingCurrentWorkspace: false,
isValidatingCurrentWorkspace: false,
}
/* eslint-disable-next-line ts/no-explicit-any */
const mockQuerySuccess = <T,>(data: T): MockQueryResult<T> => ({ data, isSuccess: true, isError: false, isLoading: false, isPending: false, status: 'success', error: null, fetchStatus: 'idle' } as any)
/* eslint-disable-next-line ts/no-explicit-any */
const mockQueryPending = <T,>(): MockQueryResult<T> => ({ data: undefined, isSuccess: false, isError: false, isLoading: true, isPending: true, status: 'pending', error: null, fetchStatus: 'fetching' } as any)
const originalLocation = window.location
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue(baseAppContext)
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: [] }))
vi.mocked(useNotionConnection).mockReturnValue(mockQueryPending())
vi.mocked(useInvalidDataSourceIntegrates).mockReturnValue(vi.fn())
const locationMock = { href: '', assign: vi.fn() }
Object.defineProperty(window, 'location', { value: locationMock, writable: true, configurable: true })
// Clear document body to avoid toast leaks between tests
document.body.innerHTML = ''
})
afterEach(() => {
Object.defineProperty(window, 'location', { value: originalLocation, writable: true, configurable: true })
})
const getWorkspaceItem = (name: string) => {
const nameEl = screen.getByText(name)
return (nameEl.closest('div[class*="workspace-item"]') || nameEl.parentElement) as HTMLElement
}
describe('Rendering', () => {
it('should render with no workspaces initially and call integration hook', () => {
// Act
render(<DataSourceNotion />)
// Assert
expect(screen.getByText('common.dataSource.notion.title')).toBeInTheDocument()
expect(screen.queryByText('common.dataSource.notion.connectedWorkspace')).not.toBeInTheDocument()
expect(useDataSourceIntegrates).toHaveBeenCalledWith({ initialData: undefined })
})
it('should render with provided workspaces and pass initialData to hook', () => {
// Arrange
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: mockWorkspaces }))
// Act
render(<DataSourceNotion workspaces={mockWorkspaces} />)
// Assert
expect(screen.getByText('common.dataSource.notion.connectedWorkspace')).toBeInTheDocument()
expect(screen.getByText('Workspace 1')).toBeInTheDocument()
expect(screen.getByText('common.dataSource.notion.connected')).toBeInTheDocument()
expect(screen.getByAltText('workspace icon')).toHaveAttribute('src', 'https://example.com/icon-1.png')
expect(useDataSourceIntegrates).toHaveBeenCalledWith({ initialData: { data: mockWorkspaces } })
})
it('should handle workspaces prop being an empty array', () => {
// Act
render(<DataSourceNotion workspaces={[]} />)
// Assert
expect(screen.queryByText('common.dataSource.notion.connectedWorkspace')).not.toBeInTheDocument()
expect(useDataSourceIntegrates).toHaveBeenCalledWith({ initialData: { data: [] } })
})
it('should handle optional workspaces configurations', () => {
// Branch: workspaces passed as undefined
const { rerender } = render(<DataSourceNotion workspaces={undefined} />)
expect(useDataSourceIntegrates).toHaveBeenCalledWith({ initialData: undefined })
// Branch: workspaces passed as null
/* eslint-disable-next-line ts/no-explicit-any */
rerender(<DataSourceNotion workspaces={null as any} />)
expect(useDataSourceIntegrates).toHaveBeenCalledWith({ initialData: undefined })
// Branch: workspaces passed as []
rerender(<DataSourceNotion workspaces={[]} />)
expect(useDataSourceIntegrates).toHaveBeenCalledWith({ initialData: { data: [] } })
})
it('should handle cases where integrates data is loading or broken', () => {
// Act (Loading)
const { rerender } = render(<DataSourceNotion />)
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQueryPending())
rerender(<DataSourceNotion />)
// Assert
expect(screen.queryByText('common.dataSource.notion.connectedWorkspace')).not.toBeInTheDocument()
// Act (Broken)
const brokenData = {} as { data: TDataSourceNotion[] }
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess(brokenData))
rerender(<DataSourceNotion />)
// Assert
expect(screen.queryByText('common.dataSource.notion.connectedWorkspace')).not.toBeInTheDocument()
})
it('should handle integrates being nullish', () => {
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useDataSourceIntegrates).mockReturnValue({ data: undefined, isSuccess: true } as any)
render(<DataSourceNotion />)
expect(screen.queryByText('common.dataSource.notion.connectedWorkspace')).not.toBeInTheDocument()
})
it('should handle integrates data being nullish', () => {
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useDataSourceIntegrates).mockReturnValue({ data: { data: null }, isSuccess: true } as any)
render(<DataSourceNotion />)
expect(screen.queryByText('common.dataSource.notion.connectedWorkspace')).not.toBeInTheDocument()
})
it('should handle integrates data being valid', () => {
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useDataSourceIntegrates).mockReturnValue({ data: { data: [{ id: '1', is_bound: true, source_info: { workspace_name: 'W', workspace_icon: 'https://example.com/i.png', total: 1, pages: [] } }] }, isSuccess: true } as any)
render(<DataSourceNotion />)
expect(screen.getByText('common.dataSource.notion.connectedWorkspace')).toBeInTheDocument()
})
it('should cover all possible falsy/nullish branches for integrates and workspaces', () => {
/* eslint-disable-next-line ts/no-explicit-any */
const { rerender } = render(<DataSourceNotion workspaces={null as any} />)
const integratesCases = [
undefined,
null,
{},
{ data: null },
{ data: undefined },
{ data: [] },
{ data: [mockWorkspaces[0]] },
{ data: false },
{ data: 0 },
{ data: '' },
123,
'string',
false,
]
integratesCases.forEach((val) => {
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useDataSourceIntegrates).mockReturnValue({ data: val, isSuccess: true } as any)
/* eslint-disable-next-line ts/no-explicit-any */
rerender(<DataSourceNotion workspaces={null as any} />)
})
expect(useDataSourceIntegrates).toHaveBeenCalled()
})
})
describe('User Permissions', () => {
it('should pass readOnly as false when user is a manager', () => {
// Arrange
vi.mocked(useAppContext).mockReturnValue({ ...baseAppContext, isCurrentWorkspaceManager: true })
// Act
render(<DataSourceNotion />)
// Assert
expect(screen.getByText('common.dataSource.notion.title').closest('div')).not.toHaveClass('grayscale')
})
it('should pass readOnly as true when user is NOT a manager', () => {
// Arrange
vi.mocked(useAppContext).mockReturnValue({ ...baseAppContext, isCurrentWorkspaceManager: false })
// Act
render(<DataSourceNotion />)
// Assert
expect(screen.getByText('common.dataSource.connect')).toHaveClass('opacity-50', 'grayscale')
})
})
describe('Configure and Auth Actions', () => {
it('should handle configure action when user is workspace manager', () => {
// Arrange
render(<DataSourceNotion />)
// Act
fireEvent.click(screen.getByText('common.dataSource.connect'))
// Assert
expect(useNotionConnection).toHaveBeenCalledWith(true)
})
it('should block configure action when user is NOT workspace manager', () => {
// Arrange
vi.mocked(useAppContext).mockReturnValue({ ...baseAppContext, isCurrentWorkspaceManager: false })
render(<DataSourceNotion />)
// Act
fireEvent.click(screen.getByText('common.dataSource.connect'))
// Assert
expect(useNotionConnection).toHaveBeenCalledWith(false)
})
it('should redirect if auth URL is available when "Auth Again" is clicked', async () => {
// Arrange
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: mockWorkspaces }))
vi.mocked(useNotionConnection).mockReturnValue(mockQuerySuccess({ data: 'http://auth-url' }))
render(<DataSourceNotion />)
// Act
const workspaceItem = getWorkspaceItem('Workspace 1')
const actionBtn = within(workspaceItem).getByRole('button')
fireEvent.click(actionBtn)
const authAgainBtn = await screen.findByText('common.dataSource.notion.changeAuthorizedPages')
fireEvent.click(authAgainBtn)
// Assert
expect(window.location.href).toBe('http://auth-url')
})
it('should trigger connection flow if URL is missing when "Auth Again" is clicked', async () => {
// Arrange
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: mockWorkspaces }))
render(<DataSourceNotion />)
// Act
const workspaceItem = getWorkspaceItem('Workspace 1')
const actionBtn = within(workspaceItem).getByRole('button')
fireEvent.click(actionBtn)
const authAgainBtn = await screen.findByText('common.dataSource.notion.changeAuthorizedPages')
fireEvent.click(authAgainBtn)
// Assert
expect(useNotionConnection).toHaveBeenCalledWith(true)
})
})
describe('Side Effects (Redirection and Toast)', () => {
it('should redirect automatically when connection data returns an http URL', async () => {
// Arrange
vi.mocked(useNotionConnection).mockReturnValue(mockQuerySuccess({ data: 'http://redirect-url' }))
// Act
render(<DataSourceNotion />)
// Assert
await waitFor(() => {
expect(window.location.href).toBe('http://redirect-url')
})
})
it('should show toast notification when connection data is "internal"', async () => {
// Arrange
vi.mocked(useNotionConnection).mockReturnValue(mockQuerySuccess({ data: 'internal' }))
// Act
render(<DataSourceNotion />)
// Assert
expect(await screen.findByText('common.dataSource.notion.integratedAlert')).toBeInTheDocument()
})
it('should handle various data types and missing properties in connection data correctly', async () => {
// Arrange & Act (Unknown string)
const { rerender } = render(<DataSourceNotion />)
vi.mocked(useNotionConnection).mockReturnValue(mockQuerySuccess({ data: 'unknown' }))
rerender(<DataSourceNotion />)
// Assert
await waitFor(() => {
expect(window.location.href).toBe('')
expect(screen.queryByText('common.dataSource.notion.integratedAlert')).not.toBeInTheDocument()
})
// Act (Broken object)
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useNotionConnection).mockReturnValue(mockQuerySuccess({} as any))
rerender(<DataSourceNotion />)
// Assert
await waitFor(() => {
expect(window.location.href).toBe('')
})
// Act (Non-string)
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useNotionConnection).mockReturnValue(mockQuerySuccess({ data: 123 } as any))
rerender(<DataSourceNotion />)
// Assert
await waitFor(() => {
expect(window.location.href).toBe('')
})
})
it('should redirect if data starts with "http" even if it is just "http"', async () => {
// Arrange
vi.mocked(useNotionConnection).mockReturnValue(mockQuerySuccess({ data: 'http' }))
// Act
render(<DataSourceNotion />)
// Assert
await waitFor(() => {
expect(window.location.href).toBe('http')
})
})
it('should skip side effect logic if connection data is an object but missing the "data" property', async () => {
// Arrange
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useNotionConnection).mockReturnValue({} as any)
// Act
render(<DataSourceNotion />)
// Assert
await waitFor(() => {
expect(window.location.href).toBe('')
})
})
it('should skip side effect logic if data.data is falsy', async () => {
// Arrange
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useNotionConnection).mockReturnValue({ data: { data: null } } as any)
// Act
render(<DataSourceNotion />)
// Assert
await waitFor(() => {
expect(window.location.href).toBe('')
})
})
})
describe('Additional Action Edge Cases', () => {
it('should cover all possible falsy/nullish branches for connection data in handleAuthAgain and useEffect', async () => {
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: mockWorkspaces }))
render(<DataSourceNotion />)
const connectionCases = [
undefined,
null,
{},
{ data: undefined },
{ data: null },
{ data: '' },
{ data: 0 },
{ data: false },
{ data: 'http' },
{ data: 'internal' },
{ data: 'unknown' },
]
for (const val of connectionCases) {
/* eslint-disable-next-line ts/no-explicit-any */
vi.mocked(useNotionConnection).mockReturnValue({ data: val, isSuccess: true } as any)
// Trigger handleAuthAgain with these values
const workspaceItem = getWorkspaceItem('Workspace 1')
const actionBtn = within(workspaceItem).getByRole('button')
fireEvent.click(actionBtn)
const authAgainBtn = await screen.findByText('common.dataSource.notion.changeAuthorizedPages')
fireEvent.click(authAgainBtn)
}
await waitFor(() => expect(useNotionConnection).toHaveBeenCalled())
})
})
describe('Edge Cases in Workspace Data', () => {
it('should render correctly with missing source_info optional fields', async () => {
// Arrange
const workspaceWithMissingInfo: TDataSourceNotion = {
id: 'ws-2',
provider: 'notion',
is_bound: false,
source_info: { workspace_name: 'Workspace 2', workspace_id: 'notion-ws-2', workspace_icon: null, pages: [] },
}
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: [workspaceWithMissingInfo] }))
// Act
render(<DataSourceNotion />)
// Assert
expect(screen.getByText('Workspace 2')).toBeInTheDocument()
const workspaceItem = getWorkspaceItem('Workspace 2')
const actionBtn = within(workspaceItem).getByRole('button')
fireEvent.click(actionBtn)
expect(await screen.findByText('0 common.dataSource.notion.pagesAuthorized')).toBeInTheDocument()
})
it('should display inactive status correctly for unbound workspaces', () => {
// Arrange
const inactiveWS: TDataSourceNotion = {
id: 'ws-3',
provider: 'notion',
is_bound: false,
source_info: { workspace_name: 'Workspace 3', workspace_icon: 'https://example.com/icon-3.png', workspace_id: 'notion-ws-3', total: 5, pages: [] },
}
vi.mocked(useDataSourceIntegrates).mockReturnValue(mockQuerySuccess({ data: [inactiveWS] }))
// Act
render(<DataSourceNotion />)
// Assert
expect(screen.getByText('common.dataSource.notion.disconnected')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,137 @@
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { syncDataSourceNotion, updateDataSourceNotionAction } from '@/service/common'
import { useInvalidDataSourceIntegrates } from '@/service/use-common'
import Operate from './index'
/**
* Operate Component (Notion) Tests
* This component provides actions like Sync, Change Pages, and Remove for Notion data sources.
*/
// Mock services and toast
vi.mock('@/service/common', () => ({
syncDataSourceNotion: vi.fn(),
updateDataSourceNotionAction: vi.fn(),
}))
vi.mock('@/service/use-common', () => ({
useInvalidDataSourceIntegrates: vi.fn(),
}))
describe('Operate Component (Notion)', () => {
const mockPayload = {
id: 'test-notion-id',
total: 5,
}
const mockOnAuthAgain = vi.fn()
const mockInvalidate = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useInvalidDataSourceIntegrates).mockReturnValue(mockInvalidate)
vi.mocked(syncDataSourceNotion).mockResolvedValue({ result: 'success' })
vi.mocked(updateDataSourceNotionAction).mockResolvedValue({ result: 'success' })
})
describe('Rendering', () => {
it('should render the menu button initially', () => {
// Act
const { container } = render(<Operate payload={mockPayload} onAuthAgain={mockOnAuthAgain} />)
// Assert
const menuButton = within(container).getByRole('button')
expect(menuButton).toBeInTheDocument()
expect(menuButton).not.toHaveClass('bg-state-base-hover')
})
it('should open the menu and show all options when clicked', async () => {
// Arrange
const { container } = render(<Operate payload={mockPayload} onAuthAgain={mockOnAuthAgain} />)
const menuButton = within(container).getByRole('button')
// Act
fireEvent.click(menuButton)
// Assert
expect(await screen.findByText('common.dataSource.notion.changeAuthorizedPages')).toBeInTheDocument()
expect(screen.getByText('common.dataSource.notion.sync')).toBeInTheDocument()
expect(screen.getByText('common.dataSource.notion.remove')).toBeInTheDocument()
expect(screen.getByText(/5/)).toBeInTheDocument()
expect(screen.getByText(/common.dataSource.notion.pagesAuthorized/)).toBeInTheDocument()
expect(menuButton).toHaveClass('bg-state-base-hover')
})
})
describe('Menu Actions', () => {
it('should call onAuthAgain when Change Authorized Pages is clicked', async () => {
// Arrange
const { container } = render(<Operate payload={mockPayload} onAuthAgain={mockOnAuthAgain} />)
fireEvent.click(within(container).getByRole('button'))
const option = await screen.findByText('common.dataSource.notion.changeAuthorizedPages')
// Act
fireEvent.click(option)
// Assert
expect(mockOnAuthAgain).toHaveBeenCalledTimes(1)
})
it('should call handleSync, show success toast, and invalidate cache when Sync is clicked', async () => {
// Arrange
const { container } = render(<Operate payload={mockPayload} onAuthAgain={mockOnAuthAgain} />)
fireEvent.click(within(container).getByRole('button'))
const syncBtn = await screen.findByText('common.dataSource.notion.sync')
// Act
fireEvent.click(syncBtn)
// Assert
await waitFor(() => {
expect(syncDataSourceNotion).toHaveBeenCalledWith({
url: `/oauth/data-source/notion/${mockPayload.id}/sync`,
})
})
expect((await screen.findAllByText('common.api.success')).length).toBeGreaterThan(0)
expect(mockInvalidate).toHaveBeenCalledTimes(1)
})
it('should call handleRemove, show success toast, and invalidate cache when Remove is clicked', async () => {
// Arrange
const { container } = render(<Operate payload={mockPayload} onAuthAgain={mockOnAuthAgain} />)
fireEvent.click(within(container).getByRole('button'))
const removeBtn = await screen.findByText('common.dataSource.notion.remove')
// Act
fireEvent.click(removeBtn)
// Assert
await waitFor(() => {
expect(updateDataSourceNotionAction).toHaveBeenCalledWith({
url: `/data-source/integrates/${mockPayload.id}/disable`,
})
})
expect((await screen.findAllByText('common.api.success')).length).toBeGreaterThan(0)
expect(mockInvalidate).toHaveBeenCalledTimes(1)
})
})
describe('State Transitions', () => {
it('should toggle the open class on the button based on menu visibility', async () => {
// Arrange
const { container } = render(<Operate payload={mockPayload} onAuthAgain={mockOnAuthAgain} />)
const menuButton = within(container).getByRole('button')
// Act (Open)
fireEvent.click(menuButton)
// Assert
expect(menuButton).toHaveClass('bg-state-base-hover')
// Act (Close - click again)
fireEvent.click(menuButton)
// Assert
await waitFor(() => {
expect(menuButton).not.toHaveClass('bg-state-base-hover')
})
})
})
})

View File

@ -0,0 +1,204 @@
import type { CommonResponse } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createDataSourceApiKeyBinding } from '@/service/datasets'
import ConfigFirecrawlModal from './config-firecrawl-modal'
/**
* ConfigFirecrawlModal Component Tests
* Tests validation, save logic, and basic rendering for the Firecrawl configuration modal.
*/
vi.mock('@/service/datasets', () => ({
createDataSourceApiKeyBinding: vi.fn(),
}))
describe('ConfigFirecrawlModal Component', () => {
const mockOnCancel = vi.fn()
const mockOnSaved = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Initial Rendering', () => {
it('should render the modal with all fields and buttons', () => {
// Act
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Assert
expect(screen.getByText('datasetCreation.firecrawl.configFirecrawl')).toBeInTheDocument()
expect(screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder')).toBeInTheDocument()
expect(screen.getByPlaceholderText('https://api.firecrawl.dev')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.operation\.save/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.operation\.cancel/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /datasetCreation\.firecrawl\.getApiKeyLinkText/i })).toHaveAttribute('href', 'https://www.firecrawl.dev/account')
})
})
describe('Form Interactions', () => {
it('should update state when input fields change', async () => {
// Arrange
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder')
const baseUrlInput = screen.getByPlaceholderText('https://api.firecrawl.dev')
// Act
fireEvent.change(apiKeyInput, { target: { value: 'firecrawl-key' } })
fireEvent.change(baseUrlInput, { target: { value: 'https://custom.firecrawl.dev' } })
// Assert
expect(apiKeyInput).toHaveValue('firecrawl-key')
expect(baseUrlInput).toHaveValue('https://custom.firecrawl.dev')
})
it('should call onCancel when cancel button is clicked', async () => {
const user = userEvent.setup()
// Arrange
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
})
describe('Validation', () => {
it('should show error when saving without API Key', async () => {
const user = userEvent.setup()
// Arrange
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(screen.getByText('common.errorMsg.fieldRequired:{"field":"API Key"}')).toBeInTheDocument()
})
expect(createDataSourceApiKeyBinding).not.toHaveBeenCalled()
})
it('should show error for invalid Base URL format', async () => {
const user = userEvent.setup()
// Arrange
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
const baseUrlInput = screen.getByPlaceholderText('https://api.firecrawl.dev')
// Act
await user.type(baseUrlInput, 'ftp://invalid-url.com')
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(screen.getByText('common.errorMsg.urlError')).toBeInTheDocument()
})
expect(createDataSourceApiKeyBinding).not.toHaveBeenCalled()
})
})
describe('Saving Logic', () => {
it('should save successfully with valid API Key and custom URL', async () => {
const user = userEvent.setup()
// Arrange
vi.mocked(createDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' })
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.type(screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder'), 'valid-key')
await user.type(screen.getByPlaceholderText('https://api.firecrawl.dev'), 'http://my-firecrawl.com')
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(createDataSourceApiKeyBinding).toHaveBeenCalledWith({
category: 'website',
provider: 'firecrawl',
credentials: {
auth_type: 'bearer',
config: {
api_key: 'valid-key',
base_url: 'http://my-firecrawl.com',
},
},
})
})
await waitFor(() => {
expect(screen.getByText('common.api.success')).toBeInTheDocument()
expect(mockOnSaved).toHaveBeenCalled()
})
})
it('should use default Base URL if none is provided during save', async () => {
const user = userEvent.setup()
// Arrange
vi.mocked(createDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' })
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.type(screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder'), 'test-key')
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(createDataSourceApiKeyBinding).toHaveBeenCalledWith(expect.objectContaining({
credentials: expect.objectContaining({
config: expect.objectContaining({
base_url: 'https://api.firecrawl.dev',
}),
}),
}))
})
})
it('should ignore multiple save clicks while saving is in progress', async () => {
const user = userEvent.setup()
// Arrange
let resolveSave: (value: CommonResponse) => void
const savePromise = new Promise<CommonResponse>((resolve) => {
resolveSave = resolve
})
vi.mocked(createDataSourceApiKeyBinding).mockReturnValue(savePromise)
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
await user.type(screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder'), 'test-key')
const saveBtn = screen.getByRole('button', { name: /common\.operation\.save/i })
// Act
await user.click(saveBtn)
await user.click(saveBtn)
// Assert
expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1)
// Cleanup
resolveSave!({ result: 'success' })
await waitFor(() => expect(mockOnSaved).toHaveBeenCalledTimes(1))
})
it('should accept base_url starting with https://', async () => {
const user = userEvent.setup()
// Arrange
vi.mocked(createDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' })
render(<ConfigFirecrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.type(screen.getByPlaceholderText('datasetCreation.firecrawl.apiKeyPlaceholder'), 'test-key')
await user.type(screen.getByPlaceholderText('https://api.firecrawl.dev'), 'https://secure-firecrawl.com')
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(createDataSourceApiKeyBinding).toHaveBeenCalledWith(expect.objectContaining({
credentials: expect.objectContaining({
config: expect.objectContaining({
base_url: 'https://secure-firecrawl.com',
}),
}),
}))
})
})
})
})

View File

@ -0,0 +1,138 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { DataSourceProvider } from '@/models/common'
import { createDataSourceApiKeyBinding } from '@/service/datasets'
import ConfigJinaReaderModal from './config-jina-reader-modal'
/**
* ConfigJinaReaderModal Component Tests
* Tests validation, save logic, and basic rendering for the Jina Reader configuration modal.
*/
vi.mock('@/service/datasets', () => ({
createDataSourceApiKeyBinding: vi.fn(),
}))
describe('ConfigJinaReaderModal Component', () => {
const mockOnCancel = vi.fn()
const mockOnSaved = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Initial Rendering', () => {
it('should render the modal with API Key field and buttons', () => {
// Act
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Assert
expect(screen.getByText('datasetCreation.jinaReader.configJinaReader')).toBeInTheDocument()
expect(screen.getByPlaceholderText('datasetCreation.jinaReader.apiKeyPlaceholder')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.operation\.save/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.operation\.cancel/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /datasetCreation\.jinaReader\.getApiKeyLinkText/i })).toHaveAttribute('href', 'https://jina.ai/reader/')
})
})
describe('Form Interactions', () => {
it('should update state when API Key field changes', async () => {
const user = userEvent.setup()
// Arrange
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.jinaReader.apiKeyPlaceholder')
// Act
await user.type(apiKeyInput, 'jina-test-key')
// Assert
expect(apiKeyInput).toHaveValue('jina-test-key')
})
it('should call onCancel when cancel button is clicked', async () => {
const user = userEvent.setup()
// Arrange
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
})
describe('Validation', () => {
it('should show error when saving without API Key', async () => {
const user = userEvent.setup()
// Arrange
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(screen.getByText('common.errorMsg.fieldRequired:{"field":"API Key"}')).toBeInTheDocument()
})
expect(createDataSourceApiKeyBinding).not.toHaveBeenCalled()
})
})
describe('Saving Logic', () => {
it('should save successfully with valid API Key', async () => {
const user = userEvent.setup()
// Arrange
vi.mocked(createDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' })
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.jinaReader.apiKeyPlaceholder')
// Act
await user.type(apiKeyInput, 'valid-jina-key')
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(createDataSourceApiKeyBinding).toHaveBeenCalledWith({
category: 'website',
provider: DataSourceProvider.jinaReader,
credentials: {
auth_type: 'bearer',
config: {
api_key: 'valid-jina-key',
},
},
})
})
await waitFor(() => {
expect(screen.getByText('common.api.success')).toBeInTheDocument()
expect(mockOnSaved).toHaveBeenCalled()
})
})
it('should ignore multiple save clicks while saving is in progress', async () => {
const user = userEvent.setup()
// Arrange
let resolveSave: (value: { result: 'success' }) => void
const savePromise = new Promise<{ result: 'success' }>((resolve) => {
resolveSave = resolve
})
vi.mocked(createDataSourceApiKeyBinding).mockReturnValue(savePromise)
render(<ConfigJinaReaderModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
await user.type(screen.getByPlaceholderText('datasetCreation.jinaReader.apiKeyPlaceholder'), 'test-key')
const saveBtn = screen.getByRole('button', { name: /common\.operation\.save/i })
// Act
await user.click(saveBtn)
await user.click(saveBtn)
// Assert
expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1)
// Cleanup
resolveSave!({ result: 'success' })
await waitFor(() => expect(mockOnSaved).toHaveBeenCalledTimes(1))
})
})
})

View File

@ -0,0 +1,204 @@
import type { CommonResponse } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createDataSourceApiKeyBinding } from '@/service/datasets'
import ConfigWatercrawlModal from './config-watercrawl-modal'
/**
* ConfigWatercrawlModal Component Tests
* Tests validation, save logic, and basic rendering for the Watercrawl configuration modal.
*/
vi.mock('@/service/datasets', () => ({
createDataSourceApiKeyBinding: vi.fn(),
}))
describe('ConfigWatercrawlModal Component', () => {
const mockOnCancel = vi.fn()
const mockOnSaved = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Initial Rendering', () => {
it('should render the modal with all fields and buttons', () => {
// Act
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Assert
expect(screen.getByText('datasetCreation.watercrawl.configWatercrawl')).toBeInTheDocument()
expect(screen.getByPlaceholderText('datasetCreation.watercrawl.apiKeyPlaceholder')).toBeInTheDocument()
expect(screen.getByPlaceholderText('https://app.watercrawl.dev')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.operation\.save/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.operation\.cancel/i })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /datasetCreation\.watercrawl\.getApiKeyLinkText/i })).toHaveAttribute('href', 'https://app.watercrawl.dev/')
})
})
describe('Form Interactions', () => {
it('should update state when input fields change', async () => {
// Arrange
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
const apiKeyInput = screen.getByPlaceholderText('datasetCreation.watercrawl.apiKeyPlaceholder')
const baseUrlInput = screen.getByPlaceholderText('https://app.watercrawl.dev')
// Act
fireEvent.change(apiKeyInput, { target: { value: 'water-key' } })
fireEvent.change(baseUrlInput, { target: { value: 'https://custom.watercrawl.dev' } })
// Assert
expect(apiKeyInput).toHaveValue('water-key')
expect(baseUrlInput).toHaveValue('https://custom.watercrawl.dev')
})
it('should call onCancel when cancel button is clicked', async () => {
const user = userEvent.setup()
// Arrange
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
})
describe('Validation', () => {
it('should show error when saving without API Key', async () => {
const user = userEvent.setup()
// Arrange
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(screen.getByText('common.errorMsg.fieldRequired:{"field":"API Key"}')).toBeInTheDocument()
})
expect(createDataSourceApiKeyBinding).not.toHaveBeenCalled()
})
it('should show error for invalid Base URL format', async () => {
const user = userEvent.setup()
// Arrange
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
const baseUrlInput = screen.getByPlaceholderText('https://app.watercrawl.dev')
// Act
await user.type(baseUrlInput, 'ftp://invalid-url.com')
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(screen.getByText('common.errorMsg.urlError')).toBeInTheDocument()
})
expect(createDataSourceApiKeyBinding).not.toHaveBeenCalled()
})
})
describe('Saving Logic', () => {
it('should save successfully with valid API Key and custom URL', async () => {
const user = userEvent.setup()
// Arrange
vi.mocked(createDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' })
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.type(screen.getByPlaceholderText('datasetCreation.watercrawl.apiKeyPlaceholder'), 'valid-key')
await user.type(screen.getByPlaceholderText('https://app.watercrawl.dev'), 'http://my-watercrawl.com')
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(createDataSourceApiKeyBinding).toHaveBeenCalledWith({
category: 'website',
provider: 'watercrawl',
credentials: {
auth_type: 'x-api-key',
config: {
api_key: 'valid-key',
base_url: 'http://my-watercrawl.com',
},
},
})
})
await waitFor(() => {
expect(screen.getByText('common.api.success')).toBeInTheDocument()
expect(mockOnSaved).toHaveBeenCalled()
})
})
it('should use default Base URL if none is provided during save', async () => {
const user = userEvent.setup()
// Arrange
vi.mocked(createDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' })
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.type(screen.getByPlaceholderText('datasetCreation.watercrawl.apiKeyPlaceholder'), 'test-api-key')
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(createDataSourceApiKeyBinding).toHaveBeenCalledWith(expect.objectContaining({
credentials: expect.objectContaining({
config: expect.objectContaining({
base_url: 'https://app.watercrawl.dev',
}),
}),
}))
})
})
it('should ignore multiple save clicks while saving is in progress', async () => {
const user = userEvent.setup()
// Arrange
let resolveSave: (value: CommonResponse) => void
const savePromise = new Promise<CommonResponse>((resolve) => {
resolveSave = resolve
})
vi.mocked(createDataSourceApiKeyBinding).mockReturnValue(savePromise)
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
await user.type(screen.getByPlaceholderText('datasetCreation.watercrawl.apiKeyPlaceholder'), 'test-api-key')
const saveBtn = screen.getByRole('button', { name: /common\.operation\.save/i })
// Act
await user.click(saveBtn)
await user.click(saveBtn)
// Assert
expect(createDataSourceApiKeyBinding).toHaveBeenCalledTimes(1)
// Cleanup
resolveSave!({ result: 'success' })
await waitFor(() => expect(mockOnSaved).toHaveBeenCalledTimes(1))
})
it('should accept base_url starting with https://', async () => {
const user = userEvent.setup()
// Arrange
vi.mocked(createDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' })
render(<ConfigWatercrawlModal onCancel={mockOnCancel} onSaved={mockOnSaved} />)
// Act
await user.type(screen.getByPlaceholderText('datasetCreation.watercrawl.apiKeyPlaceholder'), 'test-api-key')
await user.type(screen.getByPlaceholderText('https://app.watercrawl.dev'), 'https://secure-watercrawl.com')
await user.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(createDataSourceApiKeyBinding).toHaveBeenCalledWith(expect.objectContaining({
credentials: expect.objectContaining({
config: expect.objectContaining({
base_url: 'https://secure-watercrawl.com',
}),
}),
}))
})
})
})
})

View File

@ -0,0 +1,198 @@
import type { AppContextValue } from '@/context/app-context'
import type { CommonResponse } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useAppContext } from '@/context/app-context'
import { DataSourceProvider } from '@/models/common'
import { fetchDataSources, removeDataSourceApiKeyBinding } from '@/service/datasets'
import DataSourceWebsite from './index'
/**
* DataSourceWebsite Component Tests
* Tests integration of multiple website scraping providers (Firecrawl, WaterCrawl, Jina Reader).
*/
type DataSourcesResponse = CommonResponse & {
sources: Array<{ id: string, provider: DataSourceProvider }>
}
// Mock App Context
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
// Mock Service calls
vi.mock('@/service/datasets', () => ({
fetchDataSources: vi.fn(),
removeDataSourceApiKeyBinding: vi.fn(),
createDataSourceApiKeyBinding: vi.fn(),
}))
describe('DataSourceWebsite Component', () => {
const mockSources = [
{ id: '1', provider: DataSourceProvider.fireCrawl },
{ id: '2', provider: DataSourceProvider.waterCrawl },
{ id: '3', provider: DataSourceProvider.jinaReader },
]
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue({ isCurrentWorkspaceManager: true } as unknown as AppContextValue)
vi.mocked(fetchDataSources).mockResolvedValue({ result: 'success', sources: [] } as DataSourcesResponse)
})
// Helper to render and wait for initial fetch to complete
const renderAndWait = async (provider: DataSourceProvider) => {
const result = render(<DataSourceWebsite provider={provider} />)
await waitFor(() => expect(fetchDataSources).toHaveBeenCalled())
return result
}
describe('Data Initialization', () => {
it('should fetch data sources on mount and reflect configured status', async () => {
// Arrange
vi.mocked(fetchDataSources).mockResolvedValue({ result: 'success', sources: mockSources } as DataSourcesResponse)
// Act
await renderAndWait(DataSourceProvider.fireCrawl)
// Assert
expect(screen.getByText('common.dataSource.website.configuredCrawlers')).toBeInTheDocument()
})
it('should pass readOnly status based on workspace manager permissions', async () => {
// Arrange
vi.mocked(useAppContext).mockReturnValue({ isCurrentWorkspaceManager: false } as unknown as AppContextValue)
// Act
await renderAndWait(DataSourceProvider.fireCrawl)
// Assert
expect(screen.getByText('common.dataSource.configure')).toHaveClass('cursor-default')
})
})
describe('Provider Specific Rendering', () => {
it('should render correct logo and name for Firecrawl', async () => {
// Arrange
vi.mocked(fetchDataSources).mockResolvedValue({ result: 'success', sources: [mockSources[0]] } as DataSourcesResponse)
// Act
await renderAndWait(DataSourceProvider.fireCrawl)
// Assert
expect(await screen.findByText('Firecrawl')).toBeInTheDocument()
expect(screen.getByText('🔥')).toBeInTheDocument()
})
it('should render correct logo and name for WaterCrawl', async () => {
// Arrange
vi.mocked(fetchDataSources).mockResolvedValue({ result: 'success', sources: [mockSources[1]] } as DataSourcesResponse)
// Act
await renderAndWait(DataSourceProvider.waterCrawl)
// Assert
const elements = await screen.findAllByText('WaterCrawl')
expect(elements.length).toBeGreaterThanOrEqual(1)
})
it('should render correct logo and name for Jina Reader', async () => {
// Arrange
vi.mocked(fetchDataSources).mockResolvedValue({ result: 'success', sources: [mockSources[2]] } as DataSourcesResponse)
// Act
await renderAndWait(DataSourceProvider.jinaReader)
// Assert
const elements = await screen.findAllByText('Jina Reader')
expect(elements.length).toBeGreaterThanOrEqual(1)
})
})
describe('Modal Interactions', () => {
it('should manage opening and closing of configuration modals', async () => {
// Arrange
await renderAndWait(DataSourceProvider.fireCrawl)
// Act (Open)
fireEvent.click(screen.getByText('common.dataSource.configure'))
// Assert
expect(screen.getByText('datasetCreation.firecrawl.configFirecrawl')).toBeInTheDocument()
// Act (Cancel)
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.cancel/i }))
// Assert
expect(screen.queryByText('datasetCreation.firecrawl.configFirecrawl')).not.toBeInTheDocument()
})
it('should re-fetch sources after saving configuration (Watercrawl)', async () => {
// Arrange
await renderAndWait(DataSourceProvider.waterCrawl)
fireEvent.click(screen.getByText('common.dataSource.configure'))
vi.mocked(fetchDataSources).mockClear()
// Act
fireEvent.change(screen.getByPlaceholderText('datasetCreation.watercrawl.apiKeyPlaceholder'), { target: { value: 'test-key' } })
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(fetchDataSources).toHaveBeenCalled()
expect(screen.queryByText('datasetCreation.watercrawl.configWatercrawl')).not.toBeInTheDocument()
})
})
it('should re-fetch sources after saving configuration (Jina Reader)', async () => {
// Arrange
await renderAndWait(DataSourceProvider.jinaReader)
fireEvent.click(screen.getByText('common.dataSource.configure'))
vi.mocked(fetchDataSources).mockClear()
// Act
fireEvent.change(screen.getByPlaceholderText('datasetCreation.jinaReader.apiKeyPlaceholder'), { target: { value: 'test-key' } })
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.save/i }))
// Assert
await waitFor(() => {
expect(fetchDataSources).toHaveBeenCalled()
expect(screen.queryByText('datasetCreation.jinaReader.configJinaReader')).not.toBeInTheDocument()
})
})
})
describe('Management Actions', () => {
it('should handle successful data source removal with toast notification', async () => {
// Arrange
vi.mocked(fetchDataSources).mockResolvedValue({ result: 'success', sources: [mockSources[0]] } as DataSourcesResponse)
vi.mocked(removeDataSourceApiKeyBinding).mockResolvedValue({ result: 'success' } as CommonResponse)
await renderAndWait(DataSourceProvider.fireCrawl)
await waitFor(() => expect(screen.getByText('common.dataSource.website.configuredCrawlers')).toBeInTheDocument())
// Act
const removeBtn = screen.getByText('Firecrawl').parentElement?.querySelector('svg')?.parentElement
if (removeBtn)
fireEvent.click(removeBtn)
// Assert
await waitFor(() => {
expect(removeDataSourceApiKeyBinding).toHaveBeenCalledWith('1')
expect(screen.getByText('common.api.remove')).toBeInTheDocument()
})
expect(screen.queryByText('common.dataSource.website.configuredCrawlers')).not.toBeInTheDocument()
})
it('should skip removal API call if no data source ID is present', async () => {
// Arrange
await renderAndWait(DataSourceProvider.fireCrawl)
// Act
const removeBtn = screen.queryByText('Firecrawl')?.parentElement?.querySelector('svg')?.parentElement
if (removeBtn)
fireEvent.click(removeBtn)
// Assert
expect(removeDataSourceApiKeyBinding).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,213 @@
import type { ConfigItemType } from './config-item'
import { fireEvent, render, screen } from '@testing-library/react'
import ConfigItem from './config-item'
import { DataSourceType } from './types'
/**
* ConfigItem Component Tests
* Tests rendering of individual configuration items for Notion and Website data sources.
*/
// Mock Operate component to isolate ConfigItem unit tests.
vi.mock('../data-source-notion/operate', () => ({
default: ({ onAuthAgain, payload }: { onAuthAgain: () => void, payload: { id: string, total: number } }) => (
<div data-testid="mock-operate">
<button onClick={onAuthAgain} data-testid="operate-auth-btn">Auth Again</button>
<span data-testid="operate-payload">{JSON.stringify(payload)}</span>
</div>
),
}))
describe('ConfigItem Component', () => {
const mockOnRemove = vi.fn()
const mockOnChangeAuthorizedPage = vi.fn()
const MockLogo = (props: React.SVGProps<SVGSVGElement>) => <svg data-testid="mock-logo" {...props} />
const baseNotionPayload: ConfigItemType = {
id: 'notion-1',
logo: MockLogo,
name: 'Notion Workspace',
isActive: true,
notionConfig: { total: 5 },
}
const baseWebsitePayload: ConfigItemType = {
id: 'website-1',
logo: MockLogo,
name: 'My Website',
isActive: true,
}
afterEach(() => {
vi.clearAllMocks()
})
describe('Notion Configuration', () => {
it('should render active Notion config item with connected status and operator', () => {
// Act
render(
<ConfigItem
type={DataSourceType.notion}
payload={baseNotionPayload}
onRemove={mockOnRemove}
notionActions={{ onChangeAuthorizedPage: mockOnChangeAuthorizedPage }}
readOnly={false}
/>,
)
// Assert
expect(screen.getByTestId('mock-logo')).toBeInTheDocument()
expect(screen.getByText('Notion Workspace')).toBeInTheDocument()
const statusText = screen.getByText('common.dataSource.notion.connected')
expect(statusText).toHaveClass('text-util-colors-green-green-600')
expect(screen.getByTestId('operate-payload')).toHaveTextContent(JSON.stringify({ id: 'notion-1', total: 5 }))
})
it('should render inactive Notion config item with disconnected status', () => {
// Arrange
const inactivePayload = { ...baseNotionPayload, isActive: false }
// Act
render(
<ConfigItem
type={DataSourceType.notion}
payload={inactivePayload}
onRemove={mockOnRemove}
readOnly={false}
/>,
)
// Assert
const statusText = screen.getByText('common.dataSource.notion.disconnected')
expect(statusText).toHaveClass('text-util-colors-warning-warning-600')
})
it('should handle auth action through the Operate component', () => {
// Arrange
render(
<ConfigItem
type={DataSourceType.notion}
payload={baseNotionPayload}
onRemove={mockOnRemove}
notionActions={{ onChangeAuthorizedPage: mockOnChangeAuthorizedPage }}
readOnly={false}
/>,
)
// Act
fireEvent.click(screen.getByTestId('operate-auth-btn'))
// Assert
expect(mockOnChangeAuthorizedPage).toHaveBeenCalled()
})
it('should fallback to 0 total if notionConfig is missing', () => {
// Arrange
const payloadNoConfig = { ...baseNotionPayload, notionConfig: undefined }
// Act
render(
<ConfigItem
type={DataSourceType.notion}
payload={payloadNoConfig}
onRemove={mockOnRemove}
readOnly={false}
/>,
)
// Assert
expect(screen.getByTestId('operate-payload')).toHaveTextContent(JSON.stringify({ id: 'notion-1', total: 0 }))
})
it('should handle missing notionActions safely without crashing', () => {
// Arrange
render(
<ConfigItem
type={DataSourceType.notion}
payload={baseNotionPayload}
onRemove={mockOnRemove}
readOnly={false}
/>,
)
// Act & Assert
expect(() => fireEvent.click(screen.getByTestId('operate-auth-btn'))).not.toThrow()
})
})
describe('Website Configuration', () => {
it('should render active Website config item and hide operator', () => {
// Act
render(
<ConfigItem
type={DataSourceType.website}
payload={baseWebsitePayload}
onRemove={mockOnRemove}
readOnly={false}
/>,
)
// Assert
expect(screen.getByText('common.dataSource.website.active')).toBeInTheDocument()
expect(screen.queryByTestId('mock-operate')).not.toBeInTheDocument()
})
it('should render inactive Website config item', () => {
// Arrange
const inactivePayload = { ...baseWebsitePayload, isActive: false }
// Act
render(
<ConfigItem
type={DataSourceType.website}
payload={inactivePayload}
onRemove={mockOnRemove}
readOnly={false}
/>,
)
// Assert
const statusText = screen.getByText('common.dataSource.website.inactive')
expect(statusText).toHaveClass('text-util-colors-warning-warning-600')
})
it('should show remove button and trigger onRemove when clicked (not read-only)', () => {
// Arrange
const { container } = render(
<ConfigItem
type={DataSourceType.website}
payload={baseWebsitePayload}
onRemove={mockOnRemove}
readOnly={false}
/>,
)
// Note: This selector is brittle but necessary since the delete button lacks
// accessible attributes (data-testid, aria-label). Ideally, the component should
// be updated to include proper accessibility attributes.
const deleteBtn = container.querySelector('div[class*="cursor-pointer"]') as HTMLElement
// Act
fireEvent.click(deleteBtn)
// Assert
expect(mockOnRemove).toHaveBeenCalled()
})
it('should hide remove button in read-only mode', () => {
// Arrange
const { container } = render(
<ConfigItem
type={DataSourceType.website}
payload={baseWebsitePayload}
onRemove={mockOnRemove}
readOnly={true}
/>,
)
// Assert
const deleteBtn = container.querySelector('div[class*="cursor-pointer"]')
expect(deleteBtn).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,226 @@
import type { ConfigItemType } from './config-item'
import { fireEvent, render, screen } from '@testing-library/react'
import { DataSourceProvider } from '@/models/common'
import Panel from './index'
import { DataSourceType } from './types'
/**
* Panel Component Tests
* Tests layout, conditional rendering, and interactions for data source panels (Notion and Website).
*/
vi.mock('../data-source-notion/operate', () => ({
default: () => <div data-testid="mock-operate" />,
}))
describe('Panel Component', () => {
const onConfigure = vi.fn()
const onRemove = vi.fn()
const mockConfiguredList: ConfigItemType[] = [
{ id: '1', name: 'Item 1', isActive: true, logo: () => null },
{ id: '2', name: 'Item 2', isActive: false, logo: () => null },
]
beforeEach(() => {
vi.clearAllMocks()
})
describe('Notion Panel Rendering', () => {
it('should render Notion panel when not configured and isSupportList is true', () => {
// Act
render(
<Panel
type={DataSourceType.notion}
isConfigured={false}
onConfigure={onConfigure}
readOnly={false}
configuredList={[]}
onRemove={onRemove}
isSupportList={true}
/>,
)
// Assert
expect(screen.getByText('common.dataSource.notion.title')).toBeInTheDocument()
expect(screen.getByText('common.dataSource.notion.description')).toBeInTheDocument()
const connectBtn = screen.getByText('common.dataSource.connect')
expect(connectBtn).toBeInTheDocument()
// Act
fireEvent.click(connectBtn)
// Assert
expect(onConfigure).toHaveBeenCalled()
})
it('should render Notion panel in readOnly mode when not configured', () => {
// Act
render(
<Panel
type={DataSourceType.notion}
isConfigured={false}
onConfigure={onConfigure}
readOnly={true}
configuredList={[]}
onRemove={onRemove}
isSupportList={true}
/>,
)
// Assert
const connectBtn = screen.getByText('common.dataSource.connect')
expect(connectBtn).toHaveClass('cursor-default opacity-50 grayscale')
})
it('should render Notion panel when configured with list of items', () => {
// Act
render(
<Panel
type={DataSourceType.notion}
isConfigured={true}
onConfigure={onConfigure}
readOnly={false}
configuredList={mockConfiguredList}
onRemove={onRemove}
/>,
)
// Assert
expect(screen.getByRole('button', { name: 'common.dataSource.configure' })).toBeInTheDocument()
expect(screen.getByText('common.dataSource.notion.connectedWorkspace')).toBeInTheDocument()
expect(screen.getByText('Item 1')).toBeInTheDocument()
expect(screen.getByText('Item 2')).toBeInTheDocument()
})
it('should hide connect button for Notion if isSupportList is false', () => {
// Act
render(
<Panel
type={DataSourceType.notion}
isConfigured={false}
onConfigure={onConfigure}
readOnly={false}
configuredList={[]}
onRemove={onRemove}
isSupportList={false}
/>,
)
// Assert
expect(screen.queryByText('common.dataSource.connect')).not.toBeInTheDocument()
})
it('should disable Notion configure button in readOnly mode (configured state)', () => {
// Act
render(
<Panel
type={DataSourceType.notion}
isConfigured={true}
onConfigure={onConfigure}
readOnly={true}
configuredList={mockConfiguredList}
onRemove={onRemove}
/>,
)
// Assert
const btn = screen.getByRole('button', { name: 'common.dataSource.configure' })
expect(btn).toBeDisabled()
})
})
describe('Website Panel Rendering', () => {
it('should show correct provider names and handle configuration when not configured', () => {
// Arrange
const { rerender } = render(
<Panel
type={DataSourceType.website}
provider={DataSourceProvider.fireCrawl}
isConfigured={false}
onConfigure={onConfigure}
readOnly={false}
configuredList={[]}
onRemove={onRemove}
/>,
)
// Assert Firecrawl
expect(screen.getByText('🔥 Firecrawl')).toBeInTheDocument()
// Rerender for WaterCrawl
rerender(
<Panel
type={DataSourceType.website}
provider={DataSourceProvider.waterCrawl}
isConfigured={false}
onConfigure={onConfigure}
readOnly={false}
configuredList={[]}
onRemove={onRemove}
/>,
)
expect(screen.getByText('WaterCrawl')).toBeInTheDocument()
// Rerender for Jina Reader
rerender(
<Panel
type={DataSourceType.website}
provider={DataSourceProvider.jinaReader}
isConfigured={false}
onConfigure={onConfigure}
readOnly={false}
configuredList={[]}
onRemove={onRemove}
/>,
)
expect(screen.getByText('Jina Reader')).toBeInTheDocument()
// Act
const configBtn = screen.getByText('common.dataSource.configure')
fireEvent.click(configBtn)
// Assert
expect(onConfigure).toHaveBeenCalled()
})
it('should handle readOnly mode for Website configuration button', () => {
// Act
render(
<Panel
type={DataSourceType.website}
isConfigured={false}
onConfigure={onConfigure}
readOnly={true}
configuredList={[]}
onRemove={onRemove}
/>,
)
// Assert
const configBtn = screen.getByText('common.dataSource.configure')
expect(configBtn).toHaveClass('cursor-default opacity-50 grayscale')
// Act
fireEvent.click(configBtn)
// Assert
expect(onConfigure).not.toHaveBeenCalled()
})
it('should render Website panel correctly when configured with crawlers', () => {
// Act
render(
<Panel
type={DataSourceType.website}
isConfigured={true}
onConfigure={onConfigure}
readOnly={false}
configuredList={mockConfiguredList}
onRemove={onRemove}
/>,
)
// Assert
expect(screen.getByText('common.dataSource.website.configuredCrawlers')).toBeInTheDocument()
expect(screen.getByText('Item 1')).toBeInTheDocument()
expect(screen.getByText('Item 2')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,334 @@
import type { AppContextValue } from '@/context/app-context'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import { useAppContext } from '@/context/app-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { ACCOUNT_SETTING_TAB } from './constants'
import AccountSetting from './index'
vi.mock('@/context/provider-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/provider-context')>()
return {
...actual,
useProviderContext: vi.fn(),
}
})
vi.mock('@/context/app-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/app-context')>()
return {
...actual,
useAppContext: vi.fn(),
}
})
vi.mock('next/navigation', () => ({
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
prefetch: vi.fn(),
})),
usePathname: vi.fn(() => '/'),
useParams: vi.fn(() => ({})),
useSearchParams: vi.fn(() => ({ get: vi.fn() })),
}))
vi.mock('@/hooks/use-breakpoints', () => ({
MediaType: {
mobile: 'mobile',
tablet: 'tablet',
pc: 'pc',
},
default: vi.fn(),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useDefaultModel: vi.fn(() => ({ data: null, isLoading: false })),
useUpdateDefaultModel: vi.fn(() => ({ trigger: vi.fn() })),
useUpdateModelList: vi.fn(() => vi.fn()),
useModelList: vi.fn(() => ({ data: [], isLoading: false })),
useSystemDefaultModelAndModelList: vi.fn(() => [null, vi.fn()]),
}))
vi.mock('@/service/use-datasource', () => ({
useGetDataSourceListAuth: vi.fn(() => ({ data: { result: [] } })),
}))
vi.mock('@/service/use-common', () => ({
useApiBasedExtensions: vi.fn(() => ({ data: [], isPending: false })),
useMembers: vi.fn(() => ({ data: { accounts: [] }, refetch: vi.fn() })),
useProviderContext: vi.fn(),
}))
const baseAppContextValue: AppContextValue = {
userProfile: {
id: '1',
name: 'Test User',
email: 'test@example.com',
avatar: '',
avatar_url: '',
is_password_set: false,
},
mutateUserProfile: vi.fn(),
currentWorkspace: {
id: '1',
name: 'Workspace',
plan: '',
status: '',
created_at: 0,
role: 'owner',
providers: [],
trial_credits: 0,
trial_credits_used: 0,
next_credit_reset_date: 0,
},
isCurrentWorkspaceManager: true,
isCurrentWorkspaceOwner: true,
isCurrentWorkspaceEditor: true,
isCurrentWorkspaceDatasetOperator: false,
mutateCurrentWorkspace: vi.fn(),
langGeniusVersionInfo: {
current_env: 'testing',
current_version: '0.1.0',
latest_version: '0.1.0',
release_date: '',
release_notes: '',
version: '0.1.0',
can_auto_update: false,
},
useSelector: vi.fn(),
isLoadingCurrentWorkspace: false,
isValidatingCurrentWorkspace: false,
}
describe('AccountSetting', () => {
const mockOnCancel = vi.fn()
const mockOnTabChange = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
enableBilling: true,
enableReplaceWebAppLogo: true,
})
vi.mocked(useAppContext).mockReturnValue(baseAppContextValue)
vi.mocked(useBreakpoints).mockReturnValue(MediaType.pc)
})
describe('Rendering', () => {
it('should render the sidebar with correct menu items', () => {
// Act
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
// Assert
expect(screen.getByText('common.userProfile.settings')).toBeInTheDocument()
expect(screen.getByText('common.settings.provider')).toBeInTheDocument()
expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(0)
expect(screen.getByText('common.settings.billing')).toBeInTheDocument()
expect(screen.getByText('common.settings.dataSource')).toBeInTheDocument()
expect(screen.getByText('common.settings.apiBasedExtension')).toBeInTheDocument()
expect(screen.getByText('custom.custom')).toBeInTheDocument()
expect(screen.getAllByText('common.settings.language').length).toBeGreaterThan(0)
})
it('should respect the activeTab prop', () => {
// Act
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} activeTab={ACCOUNT_SETTING_TAB.DATA_SOURCE} />
</QueryClientProvider>,
)
// Assert
// Check that the active item title is Data Source
const titles = screen.getAllByText('common.settings.dataSource')
// One in sidebar, one in header.
expect(titles.length).toBeGreaterThan(1)
})
it('should hide sidebar labels on mobile', () => {
// Arrange
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
// Act
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
// Assert
// On mobile, the labels should not be rendered as per the implementation
expect(screen.queryByText('common.settings.provider')).not.toBeInTheDocument()
})
it('should filter items for dataset operator', () => {
// Arrange
vi.mocked(useAppContext).mockReturnValue({
...baseAppContextValue,
isCurrentWorkspaceDatasetOperator: true,
})
// Act
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
// Assert
expect(screen.queryByText('common.settings.provider')).not.toBeInTheDocument()
expect(screen.queryByText('common.settings.members')).not.toBeInTheDocument()
expect(screen.getByText('common.settings.language')).toBeInTheDocument()
})
it('should hide billing and custom tabs when disabled', () => {
// Arrange
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
enableBilling: false,
enableReplaceWebAppLogo: false,
})
// Act
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
// Assert
expect(screen.queryByText('common.settings.billing')).not.toBeInTheDocument()
expect(screen.queryByText('custom.custom')).not.toBeInTheDocument()
})
})
describe('Tab Navigation', () => {
it('should change active tab when clicking on menu item', () => {
// Arrange
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} onTabChange={mockOnTabChange} />
</QueryClientProvider>,
)
// Act
fireEvent.click(screen.getByText('common.settings.provider'))
// Assert
expect(mockOnTabChange).toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.MODEL_PROVIDER)
// Check for content from ModelProviderPage
expect(screen.getByText('common.modelProvider.models')).toBeInTheDocument()
})
it('should navigate through various tabs and show correct details', () => {
// Act & Assert
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
// Billing
fireEvent.click(screen.getByText('common.settings.billing'))
// Billing Page renders plansCommon.plan if data is loaded, or generic text.
// Checking for title in header which is always there
expect(screen.getAllByText('common.settings.billing').length).toBeGreaterThan(1)
// Data Source
fireEvent.click(screen.getByText('common.settings.dataSource'))
expect(screen.getAllByText('common.settings.dataSource').length).toBeGreaterThan(1)
// API Based Extension
fireEvent.click(screen.getByText('common.settings.apiBasedExtension'))
expect(screen.getAllByText('common.settings.apiBasedExtension').length).toBeGreaterThan(1)
// Custom
fireEvent.click(screen.getByText('custom.custom'))
// Custom Page uses 'custom.custom' key as well.
expect(screen.getAllByText('custom.custom').length).toBeGreaterThan(1)
// Language
fireEvent.click(screen.getAllByText('common.settings.language')[0])
expect(screen.getAllByText('common.settings.language').length).toBeGreaterThan(1)
// Members
fireEvent.click(screen.getAllByText('common.settings.members')[0])
expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(1)
})
})
describe('Interactions', () => {
it('should call onCancel when clicking close button', () => {
// Act
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0])
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
it('should call onCancel when pressing Escape key', () => {
// Act
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
fireEvent.keyDown(document, { key: 'Escape' })
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
it('should update search value in provider tab', () => {
// Arrange
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
fireEvent.click(screen.getByText('common.settings.provider'))
// Act
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'test-search' } })
// Assert
expect(input).toHaveValue('test-search')
expect(screen.getByText('common.modelProvider.models')).toBeInTheDocument()
})
it('should handle scroll event in panel', () => {
// Act
render(
<QueryClientProvider client={new QueryClient()}>
<AccountSetting onCancel={mockOnCancel} />
</QueryClientProvider>,
)
const scrollContainer = screen.getByRole('dialog').querySelector('.overflow-y-auto')
// Assert
expect(scrollContainer).toBeInTheDocument()
if (scrollContainer) {
// Scroll down
fireEvent.scroll(scrollContainer, { target: { scrollTop: 100 } })
expect(scrollContainer).toHaveClass('overflow-y-auto')
// Scroll back up
fireEvent.scroll(scrollContainer, { target: { scrollTop: 0 } })
}
})
})
})

View File

@ -0,0 +1,106 @@
import type { ComponentProps } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { ValidatedStatus } from './declarations'
import KeyInput from './KeyInput'
type Props = ComponentProps<typeof KeyInput>
const createProps = (overrides: Partial<Props> = {}): Props => ({
name: 'API key',
placeholder: 'Enter API key',
value: 'initial-value',
onChange: vi.fn(),
onFocus: undefined,
validating: false,
validatedStatusState: {},
...overrides,
})
describe('KeyInput', () => {
it('shows the label and placeholder value', () => {
const props = createProps()
render(<KeyInput {...props} />)
expect(screen.getByText('API key')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Enter API key')).toHaveValue('initial-value')
})
it('updates the visible input value when user types', () => {
const ControlledKeyInput = () => {
const [value, setValue] = useState('initial-value')
return (
<KeyInput
{...createProps({
value,
onChange: setValue,
})}
/>
)
}
render(<ControlledKeyInput />)
fireEvent.change(screen.getByPlaceholderText('Enter API key'), { target: { value: 'updated' } })
expect(screen.getByPlaceholderText('Enter API key')).toHaveValue('updated')
})
it('cycles through validating and error messaging', () => {
const props = createProps()
const { rerender } = render(
<KeyInput {...props} validating validatedStatusState={{}} />,
)
expect(screen.getByText('common.provider.validating')).toBeInTheDocument()
rerender(
<KeyInput
{...props}
validating={false}
validatedStatusState={{ status: ValidatedStatus.Error, message: 'bad-request' }}
/>,
)
expect(screen.getByText('common.provider.validatedErrorbad-request')).toBeInTheDocument()
})
it('does not show an error tip for exceed status', () => {
render(
<KeyInput
{...createProps({
validating: false,
validatedStatusState: { status: ValidatedStatus.Exceed, message: 'quota' },
})}
/>,
)
expect(screen.queryByText(/common\.provider\.validatedError/i)).toBeNull()
})
it('does not show validating or error text for success status', () => {
render(
<KeyInput
{...createProps({
validating: false,
validatedStatusState: { status: ValidatedStatus.Success },
})}
/>,
)
expect(screen.queryByText('common.provider.validating')).toBeNull()
expect(screen.queryByText(/common\.provider\.validatedError/i)).toBeNull()
})
it('shows fallback error text when error message is missing', () => {
render(
<KeyInput
{...createProps({
validating: false,
validatedStatusState: { status: ValidatedStatus.Error },
})}
/>,
)
expect(screen.getByText('common.provider.validatedError')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,83 @@
import { render, screen } from '@testing-library/react'
import Operate from './Operate'
describe('Operate', () => {
it('renders cancel and save when editing', () => {
render(
<Operate
isOpen
status="add"
onAdd={vi.fn()}
onCancel={vi.fn()}
onEdit={vi.fn()}
onSave={vi.fn()}
/>,
)
expect(screen.getByText('common.operation.cancel')).toBeInTheDocument()
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
})
it('shows add key prompt when closed', () => {
render(
<Operate
isOpen={false}
status="add"
onAdd={vi.fn()}
onCancel={vi.fn()}
onEdit={vi.fn()}
onSave={vi.fn()}
/>,
)
expect(screen.getByText('common.provider.addKey')).toBeInTheDocument()
})
it('shows invalid state indicator and edit prompt when status is fail', () => {
render(
<Operate
isOpen={false}
status="fail"
onAdd={vi.fn()}
onCancel={vi.fn()}
onEdit={vi.fn()}
onSave={vi.fn()}
/>,
)
expect(screen.getByText('common.provider.invalidApiKey')).toBeInTheDocument()
expect(screen.getByText('common.provider.editKey')).toBeInTheDocument()
})
it('shows edit prompt without error text when status is success', () => {
render(
<Operate
isOpen={false}
status="success"
onAdd={vi.fn()}
onCancel={vi.fn()}
onEdit={vi.fn()}
onSave={vi.fn()}
/>,
)
expect(screen.getByText('common.provider.editKey')).toBeInTheDocument()
expect(screen.queryByText('common.provider.invalidApiKey')).toBeNull()
})
it('shows no actions for unsupported status', () => {
render(
<Operate
isOpen={false}
status={'unknown' as never}
onAdd={vi.fn()}
onCancel={vi.fn()}
onEdit={vi.fn()}
onSave={vi.fn()}
/>,
)
expect(screen.queryByText('common.provider.addKey')).toBeNull()
expect(screen.queryByText('common.provider.editKey')).toBeNull()
})
})

View File

@ -0,0 +1,35 @@
import { render, screen } from '@testing-library/react'
import {
ValidatedErrorIcon,
ValidatedErrorMessage,
ValidatedSuccessIcon,
ValidatingTip,
} from './ValidateStatus'
describe('ValidateStatus', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should show validating text while validation is running', () => {
render(<ValidatingTip />)
expect(screen.getByText('common.provider.validating')).toBeInTheDocument()
})
it('should show translated error text with the backend message', () => {
render(<ValidatedErrorMessage errorMessage="invalid-token" />)
expect(screen.getByText('common.provider.validatedErrorinvalid-token')).toBeInTheDocument()
})
it('should render decorative icon for success and error states', () => {
const { container, rerender } = render(<ValidatedSuccessIcon />)
expect(container.firstElementChild).toBeTruthy()
rerender(<ValidatedErrorIcon />)
expect(container.firstElementChild).toBeTruthy()
})
})

View File

@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { ValidatedStatus } from './declarations'
describe('declarations', () => {
describe('ValidatedStatus', () => {
it('should expose expected status values', () => {
expect(ValidatedStatus.Success).toBe('success')
expect(ValidatedStatus.Error).toBe('error')
expect(ValidatedStatus.Exceed).toBe('exceed')
})
})
})

View File

@ -0,0 +1,82 @@
import { act, renderHook } from '@testing-library/react'
import { ValidatedStatus } from './declarations'
import { useValidate } from './hooks'
describe('useValidate', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('should clear validation state when before returns false', async () => {
const { result } = renderHook(() => useValidate({ apiKey: 'value' }))
act(() => {
result.current[0]({ before: () => false })
})
await act(async () => {
await vi.advanceTimersByTimeAsync(1000)
})
expect(result.current[1]).toBe(false)
expect(result.current[2]).toEqual({})
})
it('should expose success status after a successful validation', async () => {
const run = vi.fn().mockResolvedValue({ status: ValidatedStatus.Success })
const { result } = renderHook(() => useValidate({ apiKey: 'value' }))
act(() => {
result.current[0]({
before: () => true,
run,
})
})
await act(async () => {
await vi.advanceTimersByTimeAsync(1000)
})
expect(result.current[1]).toBe(false)
expect(result.current[2]).toEqual({ status: ValidatedStatus.Success })
})
it('should expose error status and message when validation fails', async () => {
const run = vi.fn().mockResolvedValue({ status: ValidatedStatus.Error, message: 'bad-key' })
const { result } = renderHook(() => useValidate({ apiKey: 'value' }))
act(() => {
result.current[0]({
before: () => true,
run,
})
})
await act(async () => {
await vi.advanceTimersByTimeAsync(1000)
})
expect(result.current[1]).toBe(false)
expect(result.current[2]).toEqual({ status: ValidatedStatus.Error, message: 'bad-key' })
})
it('should keep validating state true when run is not provided', async () => {
const { result } = renderHook(() => useValidate({ apiKey: 'value' }))
act(() => {
result.current[0]({ before: () => true })
})
await act(async () => {
await vi.advanceTimersByTimeAsync(1000)
})
expect(result.current[1]).toBe(true)
expect(result.current[2]).toEqual({})
})
})

View File

@ -0,0 +1,162 @@
import type { ComponentProps } from 'react'
import type { Form } from './declarations'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import KeyValidator from './index'
let subscriptionCallback: ((value: string) => void) | null = null
const mockEmit = vi.fn((value: string) => {
subscriptionCallback?.(value)
})
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
emit: mockEmit,
useSubscription: (cb: (value: string) => void) => {
subscriptionCallback = cb
},
},
}),
}))
const mockValidate = vi.fn()
const mockUseValidate = vi.fn()
vi.mock('./hooks', () => ({
useValidate: (...args: unknown[]) => mockUseValidate(...args),
}))
describe('KeyValidator', () => {
const formValidate = {
before: () => true,
}
const forms: Form[] = [
{
key: 'apiKey',
title: 'API key',
placeholder: 'Enter API key',
value: 'initial-key',
validate: formValidate,
handleFocus: (_value, setValue) => {
setValue(prev => ({ ...prev, apiKey: 'focused-key' }))
},
},
]
const createProps = (overrides: Partial<ComponentProps<typeof KeyValidator>> = {}) => ({
type: 'test-provider',
title: <div>Provider key</div>,
status: 'add' as const,
forms,
keyFrom: {
text: 'Get key',
link: 'https://example.com/key',
},
onSave: vi.fn().mockResolvedValue(true),
disabled: false,
...overrides,
})
beforeEach(() => {
vi.clearAllMocks()
subscriptionCallback = null
mockValidate.mockImplementation((config?: { before?: () => boolean }) => config?.before?.())
mockUseValidate.mockReturnValue([mockValidate, false, {}])
})
it('should open and close the editor from add and cancel actions', () => {
render(<KeyValidator {...createProps()} />)
fireEvent.click(screen.getByText('common.provider.addKey'))
expect(screen.getByPlaceholderText('Enter API key')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Get key' })).toBeInTheDocument()
fireEvent.click(screen.getByText('common.operation.cancel'))
expect(screen.queryByPlaceholderText('Enter API key')).toBeNull()
})
it('should submit the updated value when save is clicked', async () => {
render(<KeyValidator {...createProps()} />)
fireEvent.click(screen.getByText('common.provider.addKey'))
const input = screen.getByPlaceholderText('Enter API key')
fireEvent.focus(input)
expect(input).toHaveValue('focused-key')
fireEvent.change(input, {
target: { value: 'updated-key' },
})
fireEvent.click(screen.getByText('common.operation.save'))
await waitFor(() => {
expect(screen.queryByPlaceholderText('Enter API key')).toBeNull()
})
})
it('should keep the editor open when save does not succeed', async () => {
const formsWithoutValidation: Form[] = [
{
key: 'apiKey',
title: 'API key',
placeholder: 'Enter API key',
},
]
const props = createProps({
forms: formsWithoutValidation,
onSave: vi.fn().mockResolvedValue(false),
})
render(<KeyValidator {...props} />)
fireEvent.click(screen.getByText('common.provider.addKey'))
const input = screen.getByPlaceholderText('Enter API key')
expect(input).toHaveValue('')
fireEvent.focus(input)
fireEvent.change(input, {
target: { value: 'typed-without-validator' },
})
fireEvent.click(screen.getByText('common.operation.save'))
expect(screen.getByPlaceholderText('Enter API key')).toBeInTheDocument()
})
it('should close and reset edited values when another validator emits a trigger', () => {
render(<KeyValidator {...createProps()} />)
fireEvent.click(screen.getByText('common.provider.addKey'))
fireEvent.change(screen.getByPlaceholderText('Enter API key'), {
target: { value: 'changed' },
})
act(() => {
subscriptionCallback?.('plugins/another-provider')
})
expect(screen.queryByPlaceholderText('Enter API key')).toBeNull()
fireEvent.click(screen.getByText('common.provider.addKey'))
expect(screen.getByPlaceholderText('Enter API key')).toHaveValue('initial-key')
})
it('should prevent opening key editor when disabled', () => {
render(<KeyValidator {...createProps()} disabled />)
fireEvent.click(screen.getByText('common.provider.addKey'))
expect(screen.queryByPlaceholderText('Enter API key')).toBeNull()
})
it('should open the editor from edit action when validator is in success state', () => {
render(<KeyValidator {...createProps({ status: 'success' })} />)
fireEvent.click(screen.getByText('common.provider.editKey'))
expect(screen.getByPlaceholderText('Enter API key')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,221 @@
import type { UserProfileResponse } from '@/models/common'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { ToastProvider } from '@/app/components/base/toast'
import { languages } from '@/i18n-config/language'
import { updateUserProfile } from '@/service/common'
import { timezones } from '@/utils/timezone'
import LanguagePage from './index'
const mockRefresh = vi.fn()
const mockMutateUserProfile = vi.fn()
let mockLocale: string | undefined = 'en-US'
let mockUserProfile: UserProfileResponse
vi.mock('@/app/components/base/select', async () => {
const React = await import('react')
return {
SimpleSelect: ({
items = [],
defaultValue,
onSelect,
disabled,
}: {
items?: Array<{ value: string | number, name: string }>
defaultValue?: string | number
onSelect: (item: { value: string | number, name: string }) => void
disabled?: boolean
}) => {
const [open, setOpen] = React.useState(false)
const [selectedValue, setSelectedValue] = React.useState<string | number | undefined>(defaultValue)
const selected = items.find(item => item.value === selectedValue)
?? items.find(item => item.value === defaultValue)
?? null
return (
<div>
<button type="button" disabled={disabled} onClick={() => setOpen(prev => !prev)}>
{selected?.name ?? ''}
</button>
{open && (
<div>
{items.map(item => (
<button
key={item.value}
type="button"
role="option"
onClick={() => {
setSelectedValue(item.value)
onSelect(item)
setOpen(false)
}}
>
{item.name}
</button>
))}
</div>
)}
</div>
)
},
}
})
vi.mock('next/navigation', () => ({
useRouter: () => ({ refresh: mockRefresh }),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
userProfile: mockUserProfile,
mutateUserProfile: mockMutateUserProfile,
}),
}))
vi.mock('@/context/i18n', () => ({
useLocale: () => mockLocale,
}))
vi.mock('@/service/common', () => ({
updateUserProfile: vi.fn(),
}))
vi.mock('@/i18n-config', () => ({
setLocaleOnClient: vi.fn(),
}))
const updateUserProfileMock = vi.mocked(updateUserProfile)
const createUserProfile = (overrides: Partial<UserProfileResponse> = {}): UserProfileResponse => ({
id: 'user-id',
name: 'Test User',
email: 'test@example.com',
avatar: '',
avatar_url: null,
is_password_set: false,
interface_language: 'en-US',
timezone: 'Pacific/Niue',
...overrides,
})
const renderPage = () => {
render(
<ToastProvider>
<LanguagePage />
</ToastProvider>,
)
}
const getSectionByLabel = (sectionLabel: string) => {
const label = screen.getByText(sectionLabel)
const section = label.closest('div')?.parentElement
if (!section)
throw new Error(`Missing select section: ${sectionLabel}`)
return section
}
const selectOption = async (sectionLabel: string, optionName: string) => {
const section = getSectionByLabel(sectionLabel)
await act(async () => {
fireEvent.click(within(section).getByRole('button'))
})
await act(async () => {
fireEvent.click(await within(section).findByRole('option', { name: optionName }))
})
}
const getLanguageOption = (value: string) => {
const option = languages.find(item => item.value === value)
if (!option)
throw new Error(`Missing language option: ${value}`)
return option
}
const getTimezoneOption = (value: string) => {
const option = timezones.find(item => item.value === value)
if (!option)
throw new Error(`Missing timezone option: ${value}`)
return option
}
beforeEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
mockLocale = 'en-US'
mockUserProfile = createUserProfile()
})
// Rendering
describe('LanguagePage - Rendering', () => {
it('should render default language and timezone labels', () => {
const english = getLanguageOption('en-US')
const niueTimezone = getTimezoneOption('Pacific/Niue')
mockLocale = undefined
mockUserProfile = createUserProfile({
interface_language: english.value.toString(),
timezone: niueTimezone.value.toString(),
})
renderPage()
expect(screen.getByText('common.language.displayLanguage')).toBeInTheDocument()
expect(screen.getByText('common.language.timezone')).toBeInTheDocument()
expect(screen.getByRole('button', { name: english.name })).toBeInTheDocument()
expect(screen.getByRole('button', { name: niueTimezone.name })).toBeInTheDocument()
})
})
// Interactions
describe('LanguagePage - Interactions', () => {
it('should show success toast when language updates', async () => {
const chinese = getLanguageOption('zh-Hans')
mockUserProfile = createUserProfile({ interface_language: 'en-US' })
updateUserProfileMock.mockResolvedValueOnce({ result: 'success' })
renderPage()
await selectOption('common.language.displayLanguage', chinese.name)
expect(await screen.findByText('common.actionMsg.modifiedSuccessfully')).toBeInTheDocument()
await waitFor(() => {
expect(updateUserProfileMock).toHaveBeenCalledWith({
url: '/account/interface-language',
body: { interface_language: chinese.value },
})
})
})
it('should show error toast when language update fails', async () => {
const chinese = getLanguageOption('zh-Hans')
updateUserProfileMock.mockRejectedValueOnce(new Error('Update failed'))
renderPage()
await selectOption('common.language.displayLanguage', chinese.name)
expect(await screen.findByText('Update failed')).toBeInTheDocument()
})
it('should show success toast when timezone updates', async () => {
const midwayTimezone = getTimezoneOption('Pacific/Midway')
updateUserProfileMock.mockResolvedValueOnce({ result: 'success' })
renderPage()
await selectOption('common.language.timezone', midwayTimezone.name)
expect(await screen.findByText('common.actionMsg.modifiedSuccessfully')).toBeInTheDocument()
expect(screen.getByRole('button', { name: midwayTimezone.name })).toBeInTheDocument()
}, 15000)
it('should show error toast when timezone update fails', async () => {
const midwayTimezone = getTimezoneOption('Pacific/Midway')
updateUserProfileMock.mockRejectedValueOnce(new Error('Timezone failed'))
renderPage()
await selectOption('common.language.timezone', midwayTimezone.name)
expect(await screen.findByText('Timezone failed')).toBeInTheDocument()
}, 15000)
})

View File

@ -0,0 +1,103 @@
import type { AppContextValue } from '@/context/app-context'
import type { ICurrentWorkspace } from '@/models/common'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast'
import { useAppContext } from '@/context/app-context'
import { updateWorkspaceInfo } from '@/service/common'
import EditWorkspaceModal from './index'
vi.mock('@/context/app-context')
vi.mock('@/service/common')
describe('EditWorkspaceModal', () => {
const mockOnCancel = vi.fn()
const mockNotify = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue({
currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace,
isCurrentWorkspaceOwner: true,
} as unknown as AppContextValue)
})
afterEach(() => {
vi.unstubAllGlobals()
})
const renderModal = () => render(
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
<EditWorkspaceModal onCancel={mockOnCancel} />
</ToastContext.Provider>,
)
it('should show current workspace name in the input', async () => {
renderModal()
expect(await screen.findByDisplayValue('Test Workspace')).toBeInTheDocument()
})
it('should let user edit workspace name', async () => {
const user = userEvent.setup()
renderModal()
const input = screen.getByPlaceholderText(/account\.workspaceNamePlaceholder/i)
await user.clear(input)
await user.type(input, 'New Workspace Name')
expect(input).toHaveValue('New Workspace Name')
})
it('should submit update when confirming as owner', async () => {
const user = userEvent.setup()
const mockAssign = vi.fn()
vi.stubGlobal('location', { ...window.location, assign: mockAssign })
vi.mocked(updateWorkspaceInfo).mockResolvedValue({} as ICurrentWorkspace)
renderModal()
const input = screen.getByPlaceholderText(/account\.workspaceNamePlaceholder/i)
await user.clear(input)
await user.type(input, 'Renamed Workspace')
await user.click(screen.getByRole('button', { name: /operation\.confirm/i }))
await waitFor(() => {
expect(updateWorkspaceInfo).toHaveBeenCalledWith({
url: '/workspaces/info',
body: { name: 'Renamed Workspace' },
})
expect(mockAssign).toHaveBeenCalled()
})
})
it('should show error toast when update fails', async () => {
const user = userEvent.setup()
vi.mocked(updateWorkspaceInfo).mockRejectedValue(new Error('update failed'))
renderModal()
await user.click(screen.getByRole('button', { name: /operation\.confirm/i }))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})
it('should disable confirm button for non-owners', async () => {
vi.mocked(useAppContext).mockReturnValue({
currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace,
isCurrentWorkspaceOwner: false,
} as unknown as AppContextValue)
renderModal()
expect(await screen.findByRole('button', { name: /operation\.confirm/i })).toBeDisabled()
})
})

View File

@ -0,0 +1,194 @@
import type { AppContextValue } from '@/context/app-context'
import type { ICurrentWorkspace, Member } from '@/models/common'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { useMembers } from '@/service/use-common'
import MembersPage from './index'
vi.mock('@/context/app-context')
vi.mock('@/context/global-public-context')
vi.mock('@/context/provider-context')
vi.mock('@/hooks/use-format-time-from-now')
vi.mock('@/service/use-common')
vi.mock('./edit-workspace-modal', () => ({
default: ({ onCancel }: { onCancel: () => void }) => (
<div>
<div>Edit Workspace Modal</div>
<button onClick={onCancel}>Close Edit Workspace</button>
</div>
),
}))
vi.mock('./invite-button', () => ({
default: ({ onClick, disabled }: { onClick: () => void, disabled: boolean }) => (
<button onClick={onClick} disabled={disabled}>Invite</button>
),
}))
vi.mock('./invite-modal', () => ({
default: ({ onCancel, onSend }: { onCancel: () => void, onSend: (results: Array<{ email: string, status: 'success', url: string }>) => void }) => (
<div>
<div>Invite Modal</div>
<button onClick={onCancel}>Close Invite Modal</button>
<button onClick={() => onSend([{ email: 'sent@example.com', status: 'success', url: 'http://invite/link' }])}>Send Invite Results</button>
</div>
),
}))
vi.mock('./invited-modal', () => ({
default: ({ onCancel }: { onCancel: () => void }) => (
<div>
<div>Invited Modal</div>
<button onClick={onCancel}>Close Invited Modal</button>
</div>
),
}))
vi.mock('./operation', () => ({
default: () => <div>Member Operation</div>,
}))
vi.mock('./operation/transfer-ownership', () => ({
default: ({ onOperate }: { onOperate: () => void }) => <button onClick={onOperate}>Transfer ownership</button>,
}))
vi.mock('./transfer-ownership-modal', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div>
<div>Transfer Ownership Modal</div>
<button onClick={onClose}>Close Transfer Modal</button>
</div>
),
}))
describe('MembersPage', () => {
const mockRefetch = vi.fn()
const mockFormatTimeFromNow = vi.fn(() => 'just now')
const mockAccounts: Member[] = [
{
id: '1',
name: 'Owner User',
email: 'owner@example.com',
avatar: '',
avatar_url: '',
role: 'owner',
last_active_at: '1731000000',
last_login_at: '1731000000',
created_at: '1731000000',
status: 'active',
},
{
id: '2',
name: 'Admin User',
email: 'admin@example.com',
avatar: '',
avatar_url: '',
role: 'admin',
last_active_at: '1731000000',
last_login_at: '1731000000',
created_at: '1731000000',
status: 'active',
},
]
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue({
userProfile: { email: 'owner@example.com' },
currentWorkspace: { name: 'Test Workspace', role: 'owner' } as ICurrentWorkspace,
isCurrentWorkspaceOwner: true,
isCurrentWorkspaceManager: true,
} as unknown as AppContextValue)
vi.mocked(useMembers).mockReturnValue({
data: { accounts: mockAccounts },
refetch: mockRefetch,
} as unknown as ReturnType<typeof useMembers>)
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
systemFeatures: { is_email_setup: true },
} as unknown as Parameters<typeof selector>[0]))
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
enableBilling: false,
isAllowTransferWorkspace: true,
}))
vi.mocked(useFormatTimeFromNow).mockReturnValue({
formatTimeFromNow: mockFormatTimeFromNow,
})
})
it('should render workspace and member information', () => {
render(<MembersPage />)
expect(screen.getByText('Test Workspace')).toBeInTheDocument()
expect(screen.getByText('Owner User')).toBeInTheDocument()
expect(screen.getByText('Admin User')).toBeInTheDocument()
})
it('should open and close invite modal', async () => {
const user = userEvent.setup()
render(<MembersPage />)
await user.click(screen.getByRole('button', { name: /invite/i }))
expect(screen.getByText('Invite Modal')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Close Invite Modal' }))
expect(screen.queryByText('Invite Modal')).not.toBeInTheDocument()
})
it('should open invited modal after invite results are sent', async () => {
const user = userEvent.setup()
render(<MembersPage />)
await user.click(screen.getByRole('button', { name: /invite/i }))
await user.click(screen.getByRole('button', { name: 'Send Invite Results' }))
expect(screen.getByText('Invited Modal')).toBeInTheDocument()
expect(mockRefetch).toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'Close Invited Modal' }))
expect(screen.queryByText('Invited Modal')).not.toBeInTheDocument()
})
it('should open transfer ownership modal when transfer action is used', async () => {
const user = userEvent.setup()
render(<MembersPage />)
await user.click(screen.getByRole('button', { name: /transfer ownership/i }))
expect(screen.getByText('Transfer Ownership Modal')).toBeInTheDocument()
})
it('should show non-interactive owner role when transfer ownership is not allowed', () => {
vi.mocked(useProviderContext).mockReturnValue(createMockProviderContextValue({
enableBilling: false,
isAllowTransferWorkspace: false,
}))
render(<MembersPage />)
expect(screen.getByText('common.members.owner')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /transfer ownership/i })).not.toBeInTheDocument()
})
it('should hide manager controls for non-owner non-manager users', () => {
vi.mocked(useAppContext).mockReturnValue({
userProfile: { email: 'admin@example.com' },
currentWorkspace: { name: 'Test Workspace', role: 'admin' } as ICurrentWorkspace,
isCurrentWorkspaceOwner: false,
isCurrentWorkspaceManager: false,
} as unknown as AppContextValue)
render(<MembersPage />)
expect(screen.queryByRole('button', { name: /invite/i })).not.toBeInTheDocument()
expect(screen.queryByText('Transfer ownership')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,71 @@
import type { AppContextValue } from '@/context/app-context'
import type { ICurrentWorkspace } from '@/models/common'
import { render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useWorkspacePermissions } from '@/service/use-workspace'
import InviteButton from './invite-button'
vi.mock('@/context/app-context')
vi.mock('@/context/global-public-context')
vi.mock('@/service/use-workspace')
describe('InviteButton', () => {
const setupMocks = ({
brandingEnabled,
isFetching,
allowInvite,
}: {
brandingEnabled: boolean
isFetching: boolean
allowInvite?: boolean
}) => {
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
systemFeatures: { branding: { enabled: brandingEnabled } },
} as unknown as Parameters<typeof selector>[0]))
vi.mocked(useWorkspacePermissions).mockReturnValue({
data: allowInvite === undefined ? null : { allow_member_invite: allowInvite },
isFetching,
} as unknown as ReturnType<typeof useWorkspacePermissions>)
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue({
currentWorkspace: { id: 'workspace-id' } as ICurrentWorkspace,
} as unknown as AppContextValue)
})
it('should show invite button when branding is disabled', () => {
setupMocks({ brandingEnabled: false, isFetching: false })
render(<InviteButton />)
expect(screen.getByRole('button', { name: /members\.invite/i })).toBeInTheDocument()
})
it('should show loading status while permissions are loading', () => {
setupMocks({ brandingEnabled: true, isFetching: true })
render(<InviteButton />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should hide invite button when permission is denied', () => {
setupMocks({ brandingEnabled: true, isFetching: false, allowInvite: false })
render(<InviteButton />)
expect(screen.queryByRole('button', { name: /members\.invite/i })).not.toBeInTheDocument()
})
it('should show invite button when permission is granted', () => {
setupMocks({ brandingEnabled: true, isFetching: false, allowInvite: true })
render(<InviteButton />)
expect(screen.getByRole('button', { name: /members\.invite/i })).toBeInTheDocument()
})
})

View File

@ -0,0 +1,118 @@
import type { InvitationResponse } from '@/models/common'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast'
import { useProviderContextSelector } from '@/context/provider-context'
import { inviteMember } from '@/service/common'
import InviteModal from './index'
vi.mock('@/context/provider-context', () => ({
useProviderContextSelector: vi.fn(),
useProviderContext: vi.fn(() => ({
datasetOperatorEnabled: true,
})),
}))
vi.mock('@/service/common')
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
describe('InviteModal', () => {
const mockOnCancel = vi.fn()
const mockOnSend = vi.fn()
const mockRefreshLicenseLimit = vi.fn()
const mockNotify = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 5, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]))
})
const renderModal = (isEmailSetup = true) => render(
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
<InviteModal isEmailSetup={isEmailSetup} onCancel={mockOnCancel} onSend={mockOnSend} />
</ToastContext.Provider>,
)
it('should render invite modal content', async () => {
renderModal()
expect(await screen.findByText(/members\.inviteTeamMember$/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeDisabled()
})
it('should show warning when email service is not configured', async () => {
renderModal(false)
expect(await screen.findByText(/members\.emailNotSetup$/i)).toBeInTheDocument()
})
it('should enable send button after entering an email', async () => {
const user = userEvent.setup()
renderModal()
const input = screen.getByRole('textbox')
await user.type(input, 'user@example.com{enter}')
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeEnabled()
})
it('should not close modal when invite request fails', async () => {
const user = userEvent.setup()
vi.mocked(inviteMember).mockRejectedValue(new Error('request failed'))
renderModal()
await user.type(screen.getByRole('textbox'), 'user@example.com{enter}')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
expect(inviteMember).toHaveBeenCalled()
expect(mockOnCancel).not.toHaveBeenCalled()
expect(mockOnSend).not.toHaveBeenCalled()
})
})
it('should send invites and close modal on successful submission', async () => {
const user = userEvent.setup()
vi.mocked(inviteMember).mockResolvedValue({
result: 'success',
invitation_results: [],
} as InvitationResponse)
renderModal()
const input = screen.getByRole('textbox')
await user.type(input, 'user@example.com{enter}')
await user.click(screen.getByRole('button', { name: /members\.sendInvite/i }))
await waitFor(() => {
expect(inviteMember).toHaveBeenCalled()
expect(mockRefreshLicenseLimit).toHaveBeenCalled()
expect(mockOnCancel).toHaveBeenCalled()
expect(mockOnSend).toHaveBeenCalled()
})
})
it('should keep send button disabled when license limit is exceeded', async () => {
const user = userEvent.setup()
vi.mocked(useProviderContextSelector).mockImplementation(selector => selector({
licenseLimit: { workspace_members: { size: 10, limit: 10 } },
refreshLicenseLimit: mockRefreshLicenseLimit,
} as unknown as Parameters<typeof selector>[0]))
renderModal()
const input = screen.getByRole('textbox')
await user.type(input, 'user@example.com{enter}')
expect(screen.getByRole('button', { name: /members\.sendInvite/i })).toBeDisabled()
})
})

View File

@ -0,0 +1,61 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { vi } from 'vitest'
import { useProviderContext } from '@/context/provider-context'
import RoleSelector from './role-selector'
vi.mock('@/context/provider-context')
type WrapperProps = {
initialRole?: 'normal' | 'editor' | 'admin' | 'dataset_operator'
}
const RoleSelectorWrapper = ({ initialRole = 'normal' }: WrapperProps) => {
const [role, setRole] = useState<'normal' | 'editor' | 'admin' | 'dataset_operator'>(initialRole)
return <RoleSelector value={role} onChange={setRole} />
}
describe('RoleSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useProviderContext).mockReturnValue({
datasetOperatorEnabled: true,
} as unknown as ReturnType<typeof useProviderContext>)
})
it('should show current role in trigger text', () => {
render(<RoleSelectorWrapper initialRole="admin" />)
expect(screen.getByText(/members\.invitedAsRole/i)).toBeInTheDocument()
})
it.each([
'common.members.admin',
'common.members.editor',
'common.members.datasetOperator',
])('should update selected role after user chooses %s', async (nextRoleLabel) => {
const user = userEvent.setup()
render(<RoleSelectorWrapper initialRole="normal" />)
await user.click(screen.getByText(/members\.invitedAsRole/i))
await user.click(screen.getByText(nextRoleLabel))
expect(screen.getByText(new RegExp(nextRoleLabel.replace('.', '\\.'), 'i'))).toBeInTheDocument()
})
it('should hide dataset operator option when feature is disabled', async () => {
const user = userEvent.setup()
vi.mocked(useProviderContext).mockReturnValue({
datasetOperatorEnabled: false,
} as unknown as ReturnType<typeof useProviderContext>)
render(<RoleSelectorWrapper />)
await user.click(screen.getByText(/members\.invitedAsRole/i))
expect(screen.queryByText('common.members.datasetOperator')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,24 @@
import type { InvitationResult } from '@/models/common'
import { render, screen } from '@testing-library/react'
import InvitedModal from './index'
vi.mock('@/config', () => ({
IS_CE_EDITION: true,
}))
describe('InvitedModal', () => {
const mockOnCancel = vi.fn()
const results: InvitationResult[] = [
{ email: 'success@example.com', status: 'success', url: 'http://invite.com/1' },
{ email: 'failed@example.com', status: 'failed', message: 'Error msg' },
]
it('should show success and failed invitation sections', async () => {
render(<InvitedModal invitationResults={results} onCancel={mockOnCancel} />)
expect(await screen.findByText(/members\.invitationSent$/i)).toBeInTheDocument()
expect(await screen.findByText(/members\.invitationLink/i)).toBeInTheDocument()
expect(screen.getByText('http://invite.com/1')).toBeInTheDocument()
expect(screen.getByText('failed@example.com')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,30 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import InvitationLink from './invitation-link'
describe('InvitationLink', () => {
const value = { email: 'test@example.com', status: 'success' as const, url: '/invite/123' }
it('should render invitation url and keep it visible after click', async () => {
const user = userEvent.setup()
render(<InvitationLink value={value} />)
const url = screen.getByText('/invite/123')
await user.click(url)
expect(url).toBeInTheDocument()
})
it('should keep link visible after copy feedback timeout passes', async () => {
const user = userEvent.setup()
render(<InvitationLink value={value} />)
await user.click(screen.getByText('/invite/123'))
await waitFor(() => {
expect(screen.getByText('/invite/123')).toBeInTheDocument()
}, { timeout: 1500 })
})
})

View File

@ -1,5 +1,6 @@
import type { Member } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast'
import Operation from './index'
@ -55,20 +56,45 @@ describe('Operation', () => {
})
it('shows dataset operator option when the feature flag is enabled', async () => {
const user = userEvent.setup()
mockUseProviderContext.mockReturnValue({ datasetOperatorEnabled: true })
renderOperation()
fireEvent.click(screen.getByText('common.members.editor'))
await user.click(screen.getByText('common.members.editor'))
expect(await screen.findByText('common.members.datasetOperator')).toBeInTheDocument()
})
it('shows owner-allowed role options for admin operators', async () => {
const user = userEvent.setup()
renderOperation({}, 'admin')
await user.click(screen.getByText('common.members.editor'))
expect(screen.queryByText('common.members.admin')).not.toBeInTheDocument()
expect(screen.getByText('common.members.normal')).toBeInTheDocument()
})
it('does not show role options for unsupported operators', async () => {
const user = userEvent.setup()
renderOperation({}, 'normal')
await user.click(screen.getByText('common.members.editor'))
expect(screen.queryByText('common.members.normal')).not.toBeInTheDocument()
expect(screen.getByText('common.members.removeFromTeam')).toBeInTheDocument()
})
it('calls updateMemberRole and onOperate when selecting another role', async () => {
const user = userEvent.setup()
const onOperate = vi.fn()
renderOperation({}, 'owner', onOperate)
fireEvent.click(screen.getByText('common.members.editor'))
fireEvent.click(await screen.findByText('common.members.normal'))
await user.click(screen.getByText('common.members.editor'))
await user.click(await screen.findByText('common.members.normal'))
await waitFor(() => {
expect(mockUpdateMemberRole).toHaveBeenCalled()
@ -77,11 +103,12 @@ describe('Operation', () => {
})
it('calls deleteMemberOrCancelInvitation when removing the member', async () => {
const user = userEvent.setup()
const onOperate = vi.fn()
renderOperation({}, 'owner', onOperate)
fireEvent.click(screen.getByText('common.members.editor'))
fireEvent.click(await screen.findByText('common.members.removeFromTeam'))
await user.click(screen.getByText('common.members.editor'))
await user.click(await screen.findByText('common.members.removeFromTeam'))
await waitFor(() => {
expect(mockDeleteMemberOrCancelInvitation).toHaveBeenCalled()

View File

@ -0,0 +1,89 @@
import type { AppContextValue } from '@/context/app-context'
import type { ICurrentWorkspace } from '@/models/common'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useWorkspacePermissions } from '@/service/use-workspace'
import TransferOwnership from './transfer-ownership'
vi.mock('@/context/app-context')
vi.mock('@/context/global-public-context')
vi.mock('@/service/use-workspace')
describe('TransferOwnership', () => {
const setupMocks = ({
brandingEnabled,
isFetching,
allowOwnerTransfer,
}: {
brandingEnabled: boolean
isFetching: boolean
allowOwnerTransfer?: boolean
}) => {
vi.mocked(useGlobalPublicStore).mockImplementation(selector => selector({
systemFeatures: { branding: { enabled: brandingEnabled } },
} as unknown as Parameters<typeof selector>[0]))
vi.mocked(useWorkspacePermissions).mockReturnValue({
data: allowOwnerTransfer === undefined ? null : { allow_owner_transfer: allowOwnerTransfer },
isFetching,
} as unknown as ReturnType<typeof useWorkspacePermissions>)
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useAppContext).mockReturnValue({
currentWorkspace: { id: 'workspace-id' } as ICurrentWorkspace,
} as unknown as AppContextValue)
})
it('should show loading status while permissions are loading', () => {
setupMocks({ brandingEnabled: true, isFetching: true })
render(<TransferOwnership onOperate={vi.fn()} />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should show owner text without transfer menu when transfer is forbidden', () => {
setupMocks({ brandingEnabled: true, isFetching: false, allowOwnerTransfer: false })
render(<TransferOwnership onOperate={vi.fn()} />)
expect(screen.getByText(/members\.owner/i)).toBeInTheDocument()
expect(screen.queryByText(/members\.transferOwnership/i)).toBeNull()
})
it('should open transfer dialog when transfer option is selected', async () => {
const user = userEvent.setup()
const onOperate = vi.fn()
setupMocks({ brandingEnabled: true, isFetching: false, allowOwnerTransfer: true })
render(<TransferOwnership onOperate={onOperate} />)
await user.click(screen.getByRole('button', { name: /members\.owner/i }))
const transferOptionText = await screen.findByText(/members\.transferOwnership/i)
const transferOption = transferOptionText.closest('div.cursor-pointer')
if (!transferOption)
throw new Error('Transfer option container not found')
fireEvent.click(transferOption)
await waitFor(() => {
expect(onOperate).toHaveBeenCalledTimes(1)
})
})
it('should allow transfer menu when branding is disabled', async () => {
const user = userEvent.setup()
setupMocks({ brandingEnabled: false, isFetching: false })
render(<TransferOwnership onOperate={vi.fn()} />)
await user.click(screen.getByRole('button', { name: /members\.owner/i }))
expect(screen.getByText(/members\.transferOwnership/i)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,149 @@
import type { AppContextValue } from '@/context/app-context'
import type { ICurrentWorkspace } from '@/models/common'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast'
import { useAppContext } from '@/context/app-context'
import { ownershipTransfer, sendOwnerEmail, verifyOwnerEmail } from '@/service/common'
import TransferOwnershipModal from './index'
vi.mock('@/context/app-context')
vi.mock('@/service/common')
vi.mock('./member-selector', () => ({
default: ({ onSelect }: { onSelect: (id: string) => void }) => (
<button onClick={() => onSelect('new-owner-id')}>Select member</button>
),
}))
describe('TransferOwnershipModal', () => {
const mockOnClose = vi.fn()
const mockNotify = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(globalThis, 'setInterval').mockImplementation(() => 0 as unknown as ReturnType<typeof setInterval>)
vi.spyOn(globalThis, 'clearInterval').mockImplementation(() => {})
vi.mocked(useAppContext).mockReturnValue({
currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace,
userProfile: { email: 'owner@example.com', id: 'owner-id' },
} as unknown as AppContextValue)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
const renderModal = () => render(
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
<TransferOwnershipModal show onClose={mockOnClose} />
</ToastContext.Provider>,
)
const mockEmailVerification = ({
isValid = true,
token = 'final-token',
}: {
isValid?: boolean
token?: string
} = {}) => {
vi.mocked(sendOwnerEmail).mockResolvedValue({
data: 'step-token',
result: 'success',
} as Awaited<ReturnType<typeof sendOwnerEmail>>)
vi.mocked(verifyOwnerEmail).mockResolvedValue({
is_valid: isValid,
token,
result: 'success',
} as Awaited<ReturnType<typeof verifyOwnerEmail>>)
}
const goToTransferStep = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i }))
await user.type(screen.getByPlaceholderText(/members\.transferModal\.codePlaceholder/i), '123456')
await user.click(screen.getByRole('button', { name: /members\.transferModal\.continue/i }))
}
const selectNewOwnerAndSubmit = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getByRole('button', { name: /select member/i }))
await user.click(screen.getByRole('button', { name: /members\.transferModal\.transfer$/i }))
}
it('should complete ownership transfer flow through all steps', async () => {
const user = userEvent.setup()
mockEmailVerification()
vi.mocked(ownershipTransfer).mockResolvedValue({
result: 'success',
} as Awaited<ReturnType<typeof ownershipTransfer>>)
const mockReload = vi.fn()
vi.stubGlobal('location', { ...window.location, reload: mockReload })
renderModal()
await goToTransferStep(user)
expect(await screen.findByText(/members\.transferModal\.transferLabel/i)).toBeInTheDocument()
await selectNewOwnerAndSubmit(user)
await waitFor(() => {
expect(ownershipTransfer).toHaveBeenCalledWith('new-owner-id', { token: 'final-token' })
expect(mockReload).toHaveBeenCalled()
})
}, 15000)
it('should show error when email verification returns invalid code', async () => {
const user = userEvent.setup()
mockEmailVerification({ isValid: false, token: 'step-token' })
renderModal()
await goToTransferStep(user)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})
it('should show error when sending verification email fails', async () => {
const user = userEvent.setup()
vi.mocked(sendOwnerEmail).mockRejectedValue(new Error('network error'))
renderModal()
await user.click(screen.getByRole('button', { name: /members\.transferModal\.sendVerifyCode/i }))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})
it('should show error when ownership transfer fails', async () => {
const user = userEvent.setup()
mockEmailVerification()
vi.mocked(ownershipTransfer).mockRejectedValue(new Error('transfer failed'))
renderModal()
await goToTransferStep(user)
await selectNewOwnerAndSubmit(user)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'error',
}))
})
})
})

View File

@ -0,0 +1,107 @@
import type { Member } from '@/models/common'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { vi } from 'vitest'
import { useMembers } from '@/service/use-common'
import MemberSelector from './member-selector'
vi.mock('@/service/use-common')
const MemberSelectorHarness = ({ initialValue = '', exclude = [] as string[] }: { initialValue?: string, exclude?: string[] }) => {
const [selected, setSelected] = useState<string>(initialValue)
return (
<>
<MemberSelector value={selected} onSelect={setSelected} exclude={exclude} />
{selected && (
<div>
Selected:
{' '}
{selected}
</div>
)}
</>
)
}
describe('MemberSelector', () => {
const mockMembers = [
{ id: '1', name: 'User 1', email: 'user1@example.com', role: 'admin' },
{ id: '2', name: 'User 2', email: 'user2@example.com', role: 'normal' },
] as Member[]
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(useMembers).mockReturnValue({
data: { accounts: mockMembers },
} as unknown as ReturnType<typeof useMembers>)
})
it('should show member options when selector is opened', async () => {
const user = userEvent.setup()
render(<MemberSelectorHarness />)
await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i))
expect(screen.getByPlaceholderText(/common\.operation\.search/i)).toBeInTheDocument()
expect(screen.getByText('User 1')).toBeInTheDocument()
expect(screen.getByText('User 2')).toBeInTheDocument()
})
it('should filter displayed members by search term', async () => {
const user = userEvent.setup()
render(<MemberSelectorHarness />)
await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i))
await user.type(screen.getByPlaceholderText(/common\.operation\.search/i), 'User 2')
expect(screen.queryByText('User 1')).not.toBeInTheDocument()
expect(screen.getByText('User 2')).toBeInTheDocument()
})
it('should show selected member after clicking an option', async () => {
const user = userEvent.setup()
render(<MemberSelectorHarness />)
await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i))
await user.click(screen.getByText('User 1'))
expect(screen.getByText('Selected: 1')).toBeInTheDocument()
})
it('should show selected value details when an initial value is provided', () => {
render(<MemberSelectorHarness initialValue="2" />)
expect(screen.getByText('User 2')).toBeInTheDocument()
expect(screen.getByText('user2@example.com')).toBeInTheDocument()
})
it('should hide excluded members from options', async () => {
const user = userEvent.setup()
render(<MemberSelectorHarness exclude={['1']} />)
await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i))
expect(screen.queryByText('User 1')).not.toBeInTheDocument()
expect(screen.getByText('User 2')).toBeInTheDocument()
})
it('should render empty options when member data is unavailable', async () => {
const user = userEvent.setup()
vi.mocked(useMembers).mockReturnValue({
data: undefined,
} as unknown as ReturnType<typeof useMembers>)
render(<MemberSelectorHarness />)
await user.click(screen.getByText(/members\.transferModal\.transferPlaceholder/i))
expect(screen.queryByText('User 1')).not.toBeInTheDocument()
expect(screen.queryByText('User 2')).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,94 @@
import { fireEvent, render, screen } from '@testing-library/react'
import MenuDialog from './menu-dialog'
describe('MenuDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render children when show is true', () => {
// Act
render(
<MenuDialog show={true} onClose={vi.fn()}>
<div data-testid="dialog-content">Content</div>
</MenuDialog>,
)
// Assert
expect(screen.getByTestId('dialog-content')).toBeInTheDocument()
})
it('should not render children when show is false', () => {
// Act
render(
<MenuDialog show={false} onClose={vi.fn()}>
<div data-testid="dialog-content">Content</div>
</MenuDialog>,
)
// Assert
expect(screen.queryByTestId('dialog-content')).not.toBeInTheDocument()
})
it('should apply custom className', () => {
// Act
render(
<MenuDialog show={true} onClose={vi.fn()} className="custom-class">
<div data-testid="dialog-content">Content</div>
</MenuDialog>,
)
// Assert
const panel = screen.getByRole('dialog').querySelector('.custom-class')
expect(panel).toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should call onClose when Escape key is pressed', () => {
// Arrange
const onClose = vi.fn()
render(
<MenuDialog show={true} onClose={onClose}>
<div>Content</div>
</MenuDialog>,
)
// Act
fireEvent.keyDown(document, { key: 'Escape' })
// Assert
expect(onClose).toHaveBeenCalled()
})
it('should not call onClose when a key other than Escape is pressed', () => {
// Arrange
const onClose = vi.fn()
render(
<MenuDialog show={true} onClose={onClose}>
<div>Content</div>
</MenuDialog>,
)
// Act
fireEvent.keyDown(document, { key: 'Enter' })
// Assert
expect(onClose).not.toHaveBeenCalled()
})
it('should not crash when Escape is pressed and onClose is not provided', () => {
// Arrange
render(
<MenuDialog show={true}>
<div data-testid="dialog-content">Content</div>
</MenuDialog>,
)
// Act & Assert
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.getByTestId('dialog-content')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,99 @@
import type { CustomModel, ModelCredential, ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { ConfigurationMethodEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import AddCredentialInLoadBalancing from './add-credential-in-load-balancing'
vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({
Authorized: ({
renderTrigger,
authParams,
items,
onItemClick,
}: {
renderTrigger: (open?: boolean) => React.ReactNode
authParams?: { onUpdate?: (payload?: unknown, formValues?: Record<string, unknown>) => void }
items: Array<{ credentials: Array<{ credential_id: string, credential_name: string }> }>
onItemClick?: (credential: { credential_id: string, credential_name: string }) => void
}) => (
<div>
{renderTrigger(false)}
<button onClick={() => authParams?.onUpdate?.({ provider: 'x' }, { key: 'value' })}>Run update</button>
<button onClick={() => onItemClick?.(items[0].credentials[0])}>Select first</button>
</div>
),
}))
describe('AddCredentialInLoadBalancing', () => {
const provider = {
provider: 'openai',
allow_custom_token: true,
} as ModelProvider
const model = {
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
} as CustomModel
const modelCredential = {
available_credentials: [
{ credential_id: 'cred-1', credential_name: 'Key 1' },
],
credentials: {},
load_balancing: { enabled: false, configs: [] },
} as ModelCredential
beforeEach(() => {
vi.clearAllMocks()
})
it('should render add credential label', () => {
render(
<AddCredentialInLoadBalancing
provider={provider}
model={model}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
modelCredential={modelCredential}
onSelectCredential={vi.fn()}
/>,
)
expect(screen.getByText(/modelProvider.auth.addCredential/i)).toBeInTheDocument()
})
it('should forward update payload when update action happens', () => {
const onUpdate = vi.fn()
render(
<AddCredentialInLoadBalancing
provider={provider}
model={model}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
modelCredential={modelCredential}
onSelectCredential={vi.fn()}
onUpdate={onUpdate}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Run update' }))
expect(onUpdate).toHaveBeenCalledWith({ provider: 'x' }, { key: 'value' })
})
it('should call onSelectCredential when user picks a credential', () => {
const onSelectCredential = vi.fn()
render(
<AddCredentialInLoadBalancing
provider={provider}
model={model}
configurationMethod={ConfigurationMethodEnum.customizableModel}
modelCredential={modelCredential}
onSelectCredential={onSelectCredential}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'Select first' }))
expect(onSelectCredential).toHaveBeenCalledWith(modelCredential.available_credentials[0])
})
})

View File

@ -0,0 +1,165 @@
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { ConfigurationMethodEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import AddCustomModel from './add-custom-model'
// Mock hooks
const mockHandleOpenModalForAddNewCustomModel = vi.fn()
const mockHandleOpenModalForAddCustomModelToModelList = vi.fn()
vi.mock('./hooks/use-auth', () => ({
useAuth: (_provider: unknown, _configMethod: unknown, _fixedFields: unknown, options: { mode: string }) => {
if (options.mode === 'config-custom-model') {
return { handleOpenModal: mockHandleOpenModalForAddNewCustomModel }
}
if (options.mode === 'add-custom-model-to-model-list') {
return { handleOpenModal: mockHandleOpenModalForAddCustomModelToModelList }
}
return { handleOpenModal: vi.fn() }
},
}))
let mockCanAddedModels: { model: string, model_type: string }[] = []
vi.mock('./hooks/use-custom-models', () => ({
useCanAddedModels: () => mockCanAddedModels,
}))
// Mock components
vi.mock('../model-icon', () => ({
default: () => <div data-testid="model-icon" />,
}))
vi.mock('@remixicon/react', () => ({
RiAddCircleFill: () => <div data-testid="add-circle-icon" />,
RiAddLine: () => <div data-testid="add-line-icon" />,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
<div data-testid="tooltip-mock">
{children}
<div>{popupContent}</div>
</div>
),
}))
// Mock portal components to avoid async/jsdom issues (consistent with sibling tests)
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean, onOpenChange: (open: boolean) => void }) => (
<div data-testid="portal" data-open={open}>
{children}
</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode, open?: boolean }) => {
// In many tests, we need to find elements inside the content even if "closed" in state
// but not yet "removed" from DOM. However, to avoid multiple elements issues,
// we should be careful.
// For AddCustomModel, we need the content to be present when we click a model.
return <div data-testid="portal-content" style={{ display: 'block' }}>{children}</div>
},
}))
describe('AddCustomModel', () => {
const mockProvider = {
provider: 'openai',
allow_custom_token: true,
} as unknown as ModelProvider
beforeEach(() => {
vi.clearAllMocks()
mockCanAddedModels = []
})
it('should render the add model button', () => {
render(
<AddCustomModel
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
/>,
)
expect(screen.getByText(/modelProvider.addModel/)).toBeInTheDocument()
expect(screen.getByTestId('add-circle-icon')).toBeInTheDocument()
})
it('should call handleOpenModal directly when no models available and allowed', () => {
mockCanAddedModels = []
render(
<AddCustomModel
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(mockHandleOpenModalForAddNewCustomModel).toHaveBeenCalled()
})
it('should show models list when models are available', () => {
mockCanAddedModels = [{ model: 'gpt-4', model_type: 'llm' }]
render(
<AddCustomModel
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
// The portal should be "open"
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'true')
expect(screen.getByText('gpt-4')).toBeInTheDocument()
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
})
it('should call handleOpenModalForAddCustomModelToModelList when clicking a model', () => {
const model = { model: 'gpt-4', model_type: 'llm' }
mockCanAddedModels = [model]
render(
<AddCustomModel
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText('gpt-4'))
expect(mockHandleOpenModalForAddCustomModelToModelList).toHaveBeenCalledWith(undefined, model)
})
it('should call handleOpenModalForAddNewCustomModel when clicking "Add New Model" in list', () => {
mockCanAddedModels = [{ model: 'gpt-4', model_type: 'llm' }]
render(
<AddCustomModel
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText(/modelProvider.auth.addNewModel/))
expect(mockHandleOpenModalForAddNewCustomModel).toHaveBeenCalled()
})
it('should show tooltip when no models and custom tokens not allowed', () => {
const restrictedProvider = { ...mockProvider, allow_custom_token: false }
mockCanAddedModels = []
render(
<AddCustomModel
provider={restrictedProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
/>,
)
expect(screen.getByTestId('tooltip-mock')).toBeInTheDocument()
expect(screen.getByText('plugin.auth.credentialUnavailable')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(mockHandleOpenModalForAddNewCustomModel).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,164 @@
import type { Credential, CustomModelCredential, ModelProvider } from '../../declarations'
import { render, screen } from '@testing-library/react'
import { ModelTypeEnum } from '../../declarations'
import { AuthorizedItem } from './authorized-item'
vi.mock('../../model-icon', () => ({
default: ({ modelName }: { modelName: string }) => <div data-testid="model-icon">{modelName}</div>,
}))
vi.mock('./credential-item', () => ({
default: ({ credential, onEdit, onDelete, onItemClick }: {
credential: Credential
onEdit?: (credential: Credential) => void
onDelete?: (credential: Credential) => void
onItemClick?: (credential: Credential) => void
}) => (
<div data-testid={`credential-item-${credential.credential_id}`}>
{credential.credential_name}
<button onClick={() => onEdit?.(credential)}>Edit</button>
<button onClick={() => onDelete?.(credential)}>Delete</button>
<button onClick={() => onItemClick?.(credential)}>Click</button>
</div>
),
}))
describe('AuthorizedItem', () => {
const mockProvider: ModelProvider = {
provider: 'openai',
} as ModelProvider
const mockCredentials: Credential[] = [
{ credential_id: 'cred-1', credential_name: 'API Key 1' },
{ credential_id: 'cred-2', credential_name: 'API Key 2' },
]
const mockModel: CustomModelCredential = {
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render credentials list', () => {
render(
<AuthorizedItem
provider={mockProvider}
credentials={mockCredentials}
/>,
)
expect(screen.getByTestId('credential-item-cred-1')).toBeInTheDocument()
expect(screen.getByTestId('credential-item-cred-2')).toBeInTheDocument()
expect(screen.getByText('API Key 1')).toBeInTheDocument()
expect(screen.getByText('API Key 2')).toBeInTheDocument()
})
it('should render model title when showModelTitle is true', () => {
render(
<AuthorizedItem
provider={mockProvider}
credentials={mockCredentials}
model={mockModel}
showModelTitle
/>,
)
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
expect(screen.getAllByText('gpt-4')).toHaveLength(2)
})
it('should not render model title when showModelTitle is false', () => {
render(
<AuthorizedItem
provider={mockProvider}
credentials={mockCredentials}
model={mockModel}
/>,
)
expect(screen.queryByTestId('model-icon')).not.toBeInTheDocument()
})
it('should render custom title instead of model name', () => {
render(
<AuthorizedItem
provider={mockProvider}
credentials={mockCredentials}
model={mockModel}
title="Custom Title"
showModelTitle
/>,
)
expect(screen.getByText('Custom Title')).toBeInTheDocument()
})
it('should handle empty credentials array', () => {
const { container } = render(
<AuthorizedItem
provider={mockProvider}
credentials={[]}
/>,
)
expect(container.querySelector('[data-testid^="credential-item-"]')).not.toBeInTheDocument()
})
})
describe('Callback Propagation', () => {
it('should pass onEdit callback to credential items', () => {
const onEdit = vi.fn()
render(
<AuthorizedItem
provider={mockProvider}
credentials={mockCredentials}
model={mockModel}
onEdit={onEdit}
/>,
)
screen.getAllByText('Edit')[0].click()
expect(onEdit).toHaveBeenCalledWith(mockCredentials[0], mockModel)
})
it('should pass onDelete callback to credential items', () => {
const onDelete = vi.fn()
render(
<AuthorizedItem
provider={mockProvider}
credentials={mockCredentials}
model={mockModel}
onDelete={onDelete}
/>,
)
screen.getAllByText('Delete')[0].click()
expect(onDelete).toHaveBeenCalledWith(mockCredentials[0], mockModel)
})
it('should pass onItemClick callback to credential items', () => {
const onItemClick = vi.fn()
render(
<AuthorizedItem
provider={mockProvider}
credentials={mockCredentials}
model={mockModel}
onItemClick={onItemClick}
/>,
)
screen.getAllByText('Click')[0].click()
expect(onItemClick).toHaveBeenCalledWith(mockCredentials[0], mockModel)
})
})
})

View File

@ -0,0 +1,88 @@
import type { Credential } from '../../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import CredentialItem from './credential-item'
vi.mock('@remixicon/react', () => ({
RiCheckLine: () => <div data-testid="check-icon" />,
RiDeleteBinLine: () => <div data-testid="delete-icon" />,
RiEqualizer2Line: () => <div data-testid="edit-icon" />,
}))
vi.mock('@/app/components/header/indicator', () => ({
default: () => <div data-testid="indicator" />,
}))
describe('CredentialItem', () => {
const credential: Credential = {
credential_id: 'cred-1',
credential_name: 'Test API Key',
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render credential text and indicator', () => {
render(<CredentialItem credential={credential} />)
expect(screen.getByText('Test API Key')).toBeInTheDocument()
expect(screen.getByTestId('indicator')).toBeInTheDocument()
})
it('should render enterprise badge for enterprise credential', () => {
render(<CredentialItem credential={{ ...credential, from_enterprise: true }} />)
expect(screen.getByText('Enterprise')).toBeInTheDocument()
})
it('should call onItemClick when list item is clicked', () => {
const onItemClick = vi.fn()
render(<CredentialItem credential={credential} onItemClick={onItemClick} />)
fireEvent.click(screen.getByText('Test API Key'))
expect(onItemClick).toHaveBeenCalledWith(credential)
})
it('should not call onItemClick when credential is unavailable', () => {
const onItemClick = vi.fn()
render(<CredentialItem credential={{ ...credential, not_allowed_to_use: true }} onItemClick={onItemClick} />)
fireEvent.click(screen.getByText('Test API Key'))
expect(onItemClick).not.toHaveBeenCalled()
})
it('should call onEdit and onDelete from action buttons', () => {
const onEdit = vi.fn()
const onDelete = vi.fn()
render(<CredentialItem credential={credential} onEdit={onEdit} onDelete={onDelete} />)
fireEvent.click(screen.getByTestId('edit-icon').closest('button') as HTMLButtonElement)
fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement)
expect(onEdit).toHaveBeenCalledWith(credential)
expect(onDelete).toHaveBeenCalledWith(credential)
})
it('should block delete action for the currently selected credential when delete is disabled', () => {
const onDelete = vi.fn()
render(
<CredentialItem
credential={credential}
onDelete={onDelete}
disableDeleteButShowAction
selectedCredentialId="cred-1"
disableDeleteTip="Cannot remove selected"
/>,
)
fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement)
expect(onDelete).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,486 @@
import type { Credential, CustomModel, ModelProvider } from '../../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { ConfigurationMethodEnum, ModelTypeEnum } from '../../declarations'
import Authorized from './index'
const mockHandleOpenModal = vi.fn()
const mockHandleActiveCredential = vi.fn()
const mockOpenConfirmDelete = vi.fn()
const mockCloseConfirmDelete = vi.fn()
const mockHandleConfirmDelete = vi.fn()
let mockDeleteCredentialId: string | null = null
let mockDoingAction = false
vi.mock('../hooks', () => ({
useAuth: () => ({
openConfirmDelete: mockOpenConfirmDelete,
closeConfirmDelete: mockCloseConfirmDelete,
doingAction: mockDoingAction,
handleActiveCredential: mockHandleActiveCredential,
handleConfirmDelete: mockHandleConfirmDelete,
deleteCredentialId: mockDeleteCredentialId,
handleOpenModal: mockHandleOpenModal,
}),
}))
let mockPortalOpen = false
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => {
mockPortalOpen = open
return <div data-testid="portal" data-open={open}>{children}</div>
},
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
if (!mockPortalOpen)
return null
return <div data-testid="portal-content">{children}</div>
},
}))
vi.mock('@/app/components/base/confirm', () => ({
default: ({ isShow, onCancel, onConfirm }: { isShow: boolean, onCancel: () => void, onConfirm: () => void }) => {
if (!isShow)
return null
return (
<div data-testid="confirm-dialog">
<button onClick={onCancel}>Cancel</button>
<button onClick={onConfirm}>Confirm</button>
</div>
)
},
}))
vi.mock('./authorized-item', () => ({
default: ({ credentials, model, onEdit, onDelete, onItemClick }: {
credentials: Credential[]
model?: CustomModel
onEdit?: (credential: Credential, model?: CustomModel) => void
onDelete?: (credential: Credential, model?: CustomModel) => void
onItemClick?: (credential: Credential, model?: CustomModel) => void
}) => (
<div data-testid="authorized-item">
{credentials.map((cred: Credential) => (
<div key={cred.credential_id}>
<span>{cred.credential_name}</span>
<button onClick={() => onEdit?.(cred, model)}>Edit</button>
<button onClick={() => onDelete?.(cred, model)}>Delete</button>
<button onClick={() => onItemClick?.(cred, model)}>Select</button>
</div>
))}
</div>
),
}))
describe('Authorized', () => {
const mockProvider: ModelProvider = {
provider: 'openai',
allow_custom_token: true,
} as ModelProvider
const mockCredentials: Credential[] = [
{ credential_id: 'cred-1', credential_name: 'API Key 1' },
{ credential_id: 'cred-2', credential_name: 'API Key 2' },
]
const mockItems = [
{
model: {
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
},
credentials: mockCredentials,
},
]
const mockRenderTrigger = (open?: boolean) => (
<button>
Trigger
{open ? 'Open' : 'Closed'}
</button>
)
beforeEach(() => {
vi.clearAllMocks()
mockPortalOpen = false
mockDeleteCredentialId = null
mockDoingAction = false
})
describe('Rendering', () => {
it('should render trigger button', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
expect(screen.getByText(/Trigger/)).toBeInTheDocument()
})
it('should render portal content when open', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
expect(screen.getByTestId('authorized-item')).toBeInTheDocument()
})
it('should not render portal content when closed', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument()
})
it('should render Add API Key button when not model credential', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
expect(screen.getByText(/addApiKey/)).toBeInTheDocument()
})
it('should render Add Model Credential button when is model credential', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
authParams={{ isModelCredential: true }}
isOpen
/>,
)
expect(screen.getByText(/addModelCredential/)).toBeInTheDocument()
})
it('should not render add action when hideAddAction is true', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
hideAddAction
isOpen
/>,
)
expect(screen.queryByText(/addApiKey/)).not.toBeInTheDocument()
})
it('should render popup title when provided', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
popupTitle="Select Credential"
isOpen
/>,
)
expect(screen.getByText('Select Credential')).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onOpenChange when trigger is clicked in controlled mode', () => {
const onOpenChange = vi.fn()
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen={false}
onOpenChange={onOpenChange}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(onOpenChange).toHaveBeenCalledWith(true)
})
it('should toggle portal on trigger click', () => {
const { rerender } = render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
rerender(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
})
it('should open modal when triggerOnlyOpenModal is true', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
triggerOnlyOpenModal
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(mockHandleOpenModal).toHaveBeenCalled()
})
it('should call handleOpenModal when Add API Key is clicked', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
fireEvent.click(screen.getByText(/addApiKey/))
expect(mockHandleOpenModal).toHaveBeenCalled()
})
it('should call handleOpenModal with credential and model when edit is clicked', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
fireEvent.click(screen.getAllByText('Edit')[0])
expect(mockHandleOpenModal).toHaveBeenCalledWith(
mockCredentials[0],
mockItems[0].model,
)
})
it('should pass current model fields when adding model credential', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.customizableModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
authParams={{ isModelCredential: true }}
currentCustomConfigurationModelFixedFields={{
__model_name: 'gpt-4',
__model_type: ModelTypeEnum.textGeneration,
}}
isOpen
/>,
)
fireEvent.click(screen.getByText(/addModelCredential/))
expect(mockHandleOpenModal).toHaveBeenCalledWith(undefined, {
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
})
})
it('should call onItemClick when credential is selected', () => {
const onItemClick = vi.fn()
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
onItemClick={onItemClick}
isOpen
/>,
)
fireEvent.click(screen.getAllByText('Select')[0])
expect(onItemClick).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model)
})
it('should call handleActiveCredential when onItemClick is not provided', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
fireEvent.click(screen.getAllByText('Select')[0])
expect(mockHandleActiveCredential).toHaveBeenCalledWith(mockCredentials[0], mockItems[0].model)
})
it('should not call onItemClick when disableItemClick is true', () => {
const onItemClick = vi.fn()
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
onItemClick={onItemClick}
disableItemClick
isOpen
/>,
)
fireEvent.click(screen.getAllByText('Select')[0])
expect(onItemClick).not.toHaveBeenCalled()
})
})
describe('Delete Confirmation', () => {
it('should show confirm dialog when deleteCredentialId is set', () => {
mockDeleteCredentialId = 'cred-1'
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
expect(screen.getByTestId('confirm-dialog')).toBeInTheDocument()
})
it('should not show confirm dialog when deleteCredentialId is null', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
expect(screen.queryByTestId('confirm-dialog')).not.toBeInTheDocument()
})
it('should call closeConfirmDelete when cancel is clicked', () => {
mockDeleteCredentialId = 'cred-1'
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
fireEvent.click(screen.getByText('Cancel'))
expect(mockCloseConfirmDelete).toHaveBeenCalled()
})
it('should call handleConfirmDelete when confirm is clicked', () => {
mockDeleteCredentialId = 'cred-1'
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
/>,
)
fireEvent.click(screen.getByText('Confirm'))
expect(mockHandleConfirmDelete).toHaveBeenCalled()
})
})
describe('Edge Cases', () => {
it('should handle empty items array', () => {
render(
<Authorized
provider={mockProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={[]}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
expect(screen.queryByTestId('authorized-item')).not.toBeInTheDocument()
})
it('should not render add action when provider does not allow custom token', () => {
const restrictedProvider = { ...mockProvider, allow_custom_token: false }
render(
<Authorized
provider={restrictedProvider}
configurationMethod={ConfigurationMethodEnum.predefinedModel}
items={mockItems}
renderTrigger={mockRenderTrigger}
isOpen
/>,
)
expect(screen.queryByText(/addApiKey/)).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,48 @@
import { fireEvent, render, screen } from '@testing-library/react'
import ConfigModel from './config-model'
// Mock icons
vi.mock('@remixicon/react', () => ({
RiEqualizer2Line: () => <div data-testid="config-icon" />,
RiScales3Line: () => <div data-testid="scales-icon" />,
}))
// Mock Indicator
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <div data-testid={`indicator-${color}`} />,
}))
describe('ConfigModel', () => {
it('should render authorization error when loadBalancingInvalid is true', () => {
const onClick = vi.fn()
render(<ConfigModel loadBalancingInvalid onClick={onClick} />)
expect(screen.getByText(/modelProvider.auth.authorizationError/)).toBeInTheDocument()
expect(screen.getByTestId('scales-icon')).toBeInTheDocument()
expect(screen.getByTestId('indicator-orange')).toBeInTheDocument()
fireEvent.click(screen.getByText(/modelProvider.auth.authorizationError/))
expect(onClick).toHaveBeenCalled()
})
it('should render credential removed message when credentialRemoved is true', () => {
render(<ConfigModel credentialRemoved />)
expect(screen.getByText(/modelProvider.auth.credentialRemoved/)).toBeInTheDocument()
expect(screen.getByTestId('indicator-red')).toBeInTheDocument()
})
it('should render standard config message when no flags enabled', () => {
render(<ConfigModel />)
expect(screen.getByText(/operation.config/)).toBeInTheDocument()
expect(screen.getByTestId('config-icon')).toBeInTheDocument()
})
it('should render config load balancing when loadBalancingEnabled is true', () => {
render(<ConfigModel loadBalancingEnabled />)
expect(screen.getByText(/modelProvider.auth.configLoadBalancing/)).toBeInTheDocument()
expect(screen.getByTestId('scales-icon')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,70 @@
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { render, screen } from '@testing-library/react'
import ConfigProvider from './config-provider'
const mockUseCredentialStatus = vi.fn()
vi.mock('./hooks', () => ({
useCredentialStatus: () => mockUseCredentialStatus(),
}))
vi.mock('./authorized', () => ({
default: ({ renderTrigger }: { renderTrigger: () => React.ReactNode }) => (
<div>
{renderTrigger()}
</div>
),
}))
describe('ConfigProvider', () => {
const baseProvider = {
provider: 'openai',
allow_custom_token: true,
} as ModelProvider
beforeEach(() => {
vi.clearAllMocks()
})
it('should show setup label when no credential exists', () => {
mockUseCredentialStatus.mockReturnValue({
hasCredential: false,
authorized: true,
current_credential_id: '',
current_credential_name: '',
available_credentials: [],
})
render(<ConfigProvider provider={baseProvider} />)
expect(screen.getByText(/operation.setup/i)).toBeInTheDocument()
})
it('should show config label when credential exists', () => {
mockUseCredentialStatus.mockReturnValue({
hasCredential: true,
authorized: true,
current_credential_id: 'cred-1',
current_credential_name: 'Key 1',
available_credentials: [],
})
render(<ConfigProvider provider={baseProvider} />)
expect(screen.getByText(/operation.config/i)).toBeInTheDocument()
})
it('should still render setup label when custom credentials are not allowed', () => {
mockUseCredentialStatus.mockReturnValue({
hasCredential: false,
authorized: false,
current_credential_id: '',
current_credential_name: '',
available_credentials: [],
})
render(<ConfigProvider provider={{ ...baseProvider, allow_custom_token: false }} />)
expect(screen.getByText(/operation.setup/i)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,130 @@
import { fireEvent, render, screen } from '@testing-library/react'
import CredentialSelector from './credential-selector'
// Mock components
vi.mock('./authorized/credential-item', () => ({
default: ({ credential, onItemClick }: { credential: { credential_name: string }, onItemClick: (c: unknown) => void }) => (
<div data-testid="credential-item" onClick={() => onItemClick(credential)}>
{credential.credential_name}
</div>
),
}))
vi.mock('@/app/components/header/indicator', () => ({
default: () => <div data-testid="indicator" />,
}))
vi.mock('@remixicon/react', () => ({
RiAddLine: () => <div data-testid="add-icon" />,
RiArrowDownSLine: () => <div data-testid="arrow-icon" />,
}))
// Mock portal components
vi.mock('@/app/components/base/portal-to-follow-elem', () => ({
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<div data-testid="portal" data-open={open}>{children}</div>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode, open?: boolean }) => {
// We should only render children if open or if we want to test they are hidden
// The real component might handle this with CSS or conditional rendering.
// Let's use conditional rendering in the mock to avoid "multiple elements" errors.
return <div data-testid="portal-content">{children}</div>
},
}))
describe('CredentialSelector', () => {
const mockCredentials = [
{ credential_id: 'cred-1', credential_name: 'Key 1' },
{ credential_id: 'cred-2', credential_name: 'Key 2' },
]
const mockOnSelect = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render selected credential name', () => {
render(
<CredentialSelector
selectedCredential={mockCredentials[0]}
credentials={mockCredentials}
onSelect={mockOnSelect}
/>,
)
// Use getAllByText and take the first one (the one in the trigger)
expect(screen.getAllByText('Key 1')[0]).toBeInTheDocument()
expect(screen.getByTestId('indicator')).toBeInTheDocument()
})
it('should render placeholder when no credential selected', () => {
render(
<CredentialSelector
credentials={mockCredentials}
onSelect={mockOnSelect}
/>,
)
expect(screen.getByText(/modelProvider.auth.selectModelCredential/)).toBeInTheDocument()
})
it('should open portal on click', () => {
render(
<CredentialSelector
credentials={mockCredentials}
onSelect={mockOnSelect}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'true')
expect(screen.getAllByTestId('credential-item')).toHaveLength(2)
})
it('should call onSelect when a credential is clicked', () => {
render(
<CredentialSelector
credentials={mockCredentials}
onSelect={mockOnSelect}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText('Key 2'))
expect(mockOnSelect).toHaveBeenCalledWith(mockCredentials[1])
})
it('should call onSelect with add new credential data when clicking add button', () => {
render(
<CredentialSelector
credentials={mockCredentials}
onSelect={mockOnSelect}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
fireEvent.click(screen.getByText(/modelProvider.auth.addNewModelCredential/))
expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({
credential_id: '__add_new_credential',
addNewCredential: true,
}))
})
it('should not open portal when disabled', () => {
render(
<CredentialSelector
disabled
credentials={mockCredentials}
onSelect={mockOnSelect}
/>,
)
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(screen.getByTestId('portal')).toHaveAttribute('data-open', 'false')
})
})

View File

@ -0,0 +1,94 @@
import type { CustomModel } from '../../declarations'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook } from '@testing-library/react'
import { ModelTypeEnum } from '../../declarations'
import { useAuthService, useGetCredential } from './use-auth-service'
vi.mock('@/service/use-models', () => ({
useGetProviderCredential: vi.fn(),
useGetModelCredential: vi.fn(),
useAddProviderCredential: vi.fn(),
useEditProviderCredential: vi.fn(),
useDeleteProviderCredential: vi.fn(),
useActiveProviderCredential: vi.fn(),
useAddModelCredential: vi.fn(),
useEditModelCredential: vi.fn(),
useDeleteModelCredential: vi.fn(),
useActiveModelCredential: vi.fn(),
}))
const {
useGetProviderCredential,
useGetModelCredential,
useAddProviderCredential,
useEditProviderCredential,
useDeleteProviderCredential,
useActiveProviderCredential,
useAddModelCredential,
useEditModelCredential,
useDeleteModelCredential,
useActiveModelCredential,
} = await import('@/service/use-models')
describe('useAuthService hooks', () => {
let queryClient: QueryClient
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
beforeEach(() => {
vi.clearAllMocks()
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const mockMutationReturn = { mutateAsync: vi.fn() }
vi.mocked(useAddProviderCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useAddProviderCredential>)
vi.mocked(useEditProviderCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useEditProviderCredential>)
vi.mocked(useDeleteProviderCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useDeleteProviderCredential>)
vi.mocked(useActiveProviderCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useActiveProviderCredential>)
vi.mocked(useAddModelCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useAddModelCredential>)
vi.mocked(useEditModelCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useEditModelCredential>)
vi.mocked(useDeleteModelCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useDeleteModelCredential>)
vi.mocked(useActiveModelCredential).mockReturnValue(mockMutationReturn as unknown as ReturnType<typeof useActiveModelCredential>)
})
it('useGetCredential selects correct source and params', () => {
const mockData = { data: 'test' }
vi.mocked(useGetProviderCredential).mockReturnValue(mockData as unknown as ReturnType<typeof useGetProviderCredential>)
vi.mocked(useGetModelCredential).mockReturnValue(mockData as unknown as ReturnType<typeof useGetModelCredential>)
// Provider case
const { result: providerRes } = renderHook(() => useGetCredential('openai', false, 'cred-123'), { wrapper })
expect(useGetProviderCredential).toHaveBeenCalledWith(true, 'openai', 'cred-123')
expect(providerRes.current).toBe(mockData)
// Model case
const mockModel = { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration } as CustomModel
const { result: modelRes } = renderHook(() => useGetCredential('openai', true, 'cred-123', mockModel, 'src'), { wrapper })
expect(useGetModelCredential).toHaveBeenCalledWith(true, 'openai', 'cred-123', 'gpt-4', ModelTypeEnum.textGeneration, 'src')
expect(modelRes.current).toBe(mockData)
// Early return cases
renderHook(() => useGetCredential('openai', false), { wrapper })
expect(useGetProviderCredential).toHaveBeenCalledWith(false, 'openai', undefined)
// Branch: isModelCredential true but no id/model
renderHook(() => useGetCredential('openai', true), { wrapper })
expect(useGetModelCredential).toHaveBeenCalledWith(false, 'openai', undefined, undefined, undefined, undefined)
})
it('useAuthService provides correct services for provider and model', () => {
const { result } = renderHook(() => useAuthService('openai'), { wrapper })
// Provider services
expect(result.current.getAddCredentialService(false)).toBe(vi.mocked(useAddProviderCredential).mock.results[0].value.mutateAsync)
expect(result.current.getEditCredentialService(false)).toBe(vi.mocked(useEditProviderCredential).mock.results[0].value.mutateAsync)
expect(result.current.getDeleteCredentialService(false)).toBe(vi.mocked(useDeleteProviderCredential).mock.results[0].value.mutateAsync)
expect(result.current.getActiveCredentialService(false)).toBe(vi.mocked(useActiveProviderCredential).mock.results[0].value.mutateAsync)
// Model services
expect(result.current.getAddCredentialService(true)).toBe(vi.mocked(useAddModelCredential).mock.results[0].value.mutateAsync)
expect(result.current.getEditCredentialService(true)).toBe(vi.mocked(useEditModelCredential).mock.results[0].value.mutateAsync)
expect(result.current.getDeleteCredentialService(true)).toBe(vi.mocked(useDeleteModelCredential).mock.results[0].value.mutateAsync)
expect(result.current.getActiveCredentialService(true)).toBe(vi.mocked(useActiveModelCredential).mock.results[0].value.mutateAsync)
})
})

View File

@ -0,0 +1,247 @@
import type {
Credential,
CustomModel,
ModelProvider,
} from '../../declarations'
import { act, renderHook } from '@testing-library/react'
import { ConfigurationMethodEnum, ModelModalModeEnum, ModelTypeEnum } from '../../declarations'
import { useAuth } from './use-auth'
const mockNotify = vi.fn()
const mockHandleRefreshModel = vi.fn()
const mockOpenModelModal = vi.fn()
const mockDeleteModelService = vi.fn()
const mockDeleteProviderCredential = vi.fn()
const mockDeleteModelCredential = vi.fn()
const mockActiveProviderCredential = vi.fn()
const mockActiveModelCredential = vi.fn()
const mockAddProviderCredential = vi.fn()
const mockAddModelCredential = vi.fn()
const mockEditProviderCredential = vi.fn()
const mockEditModelCredential = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({ notify: mockNotify }),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelModalHandler: () => mockOpenModelModal,
useRefreshModel: () => ({ handleRefreshModel: mockHandleRefreshModel }),
}))
vi.mock('@/service/use-models', () => ({
useDeleteModel: () => ({ mutateAsync: mockDeleteModelService }),
}))
vi.mock('./use-auth-service', () => ({
useAuthService: () => ({
getDeleteCredentialService: (isModel: boolean) => (isModel ? mockDeleteModelCredential : mockDeleteProviderCredential),
getActiveCredentialService: (isModel: boolean) => (isModel ? mockActiveModelCredential : mockActiveProviderCredential),
getEditCredentialService: (isModel: boolean) => (isModel ? mockEditModelCredential : mockEditProviderCredential),
getAddCredentialService: (isModel: boolean) => (isModel ? mockAddModelCredential : mockAddProviderCredential),
}),
}))
const createDeferred = <T,>() => {
let resolve!: (value: T) => void
const promise = new Promise<T>((res) => {
resolve = res
})
return { promise, resolve }
}
describe('useAuth', () => {
const provider = {
provider: 'openai',
allow_custom_token: true,
} as ModelProvider
const credential: Credential = {
credential_id: 'cred-1',
credential_name: 'Primary key',
}
const model: CustomModel = {
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
}
beforeEach(() => {
vi.clearAllMocks()
mockDeleteModelService.mockResolvedValue({ result: 'success' })
mockDeleteProviderCredential.mockResolvedValue({ result: 'success' })
mockDeleteModelCredential.mockResolvedValue({ result: 'success' })
mockActiveProviderCredential.mockResolvedValue({ result: 'success' })
mockActiveModelCredential.mockResolvedValue({ result: 'success' })
mockAddProviderCredential.mockResolvedValue({ result: 'success' })
mockAddModelCredential.mockResolvedValue({ result: 'success' })
mockEditProviderCredential.mockResolvedValue({ result: 'success' })
mockEditModelCredential.mockResolvedValue({ result: 'success' })
})
it('should open and close delete confirmation state', () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
act(() => {
result.current.openConfirmDelete(credential, model)
})
expect(result.current.deleteCredentialId).toBe('cred-1')
expect(result.current.deleteModel).toEqual(model)
expect(result.current.pendingOperationCredentialId.current).toBe('cred-1')
expect(result.current.pendingOperationModel.current).toEqual(model)
act(() => {
result.current.closeConfirmDelete()
})
expect(result.current.deleteCredentialId).toBeNull()
expect(result.current.deleteModel).toBeNull()
})
it('should activate credential, notify success, and refresh models', async () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel))
await act(async () => {
await result.current.handleActiveCredential(credential, model)
})
expect(mockActiveModelCredential).toHaveBeenCalledWith({
credential_id: 'cred-1',
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
})
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
type: 'success',
message: 'common.api.actionSuccess',
}))
expect(mockHandleRefreshModel).toHaveBeenCalledWith(provider, undefined, true)
expect(result.current.doingAction).toBe(false)
})
it('should close delete dialog without calling services when nothing is pending', async () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
await act(async () => {
await result.current.handleConfirmDelete()
})
expect(mockDeleteProviderCredential).not.toHaveBeenCalled()
expect(mockDeleteModelService).not.toHaveBeenCalled()
expect(result.current.deleteCredentialId).toBeNull()
expect(result.current.deleteModel).toBeNull()
})
it('should delete credential and call onRemove callback', async () => {
const onRemove = vi.fn()
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel, undefined, {
isModelCredential: false,
onRemove,
}))
act(() => {
result.current.openConfirmDelete(credential, model)
})
await act(async () => {
await result.current.handleConfirmDelete()
})
expect(mockDeleteProviderCredential).toHaveBeenCalledWith({
credential_id: 'cred-1',
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
})
expect(mockDeleteModelService).not.toHaveBeenCalled()
expect(onRemove).toHaveBeenCalledWith('cred-1')
expect(result.current.deleteCredentialId).toBeNull()
})
it('should delete model when pending operation has no credential id', async () => {
const onRemove = vi.fn()
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel, undefined, {
onRemove,
}))
act(() => {
result.current.openConfirmDelete(undefined, model)
})
await act(async () => {
await result.current.handleConfirmDelete()
})
expect(mockDeleteModelService).toHaveBeenCalledWith({
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
})
expect(onRemove).toHaveBeenCalledWith('')
})
it('should add or edit credentials and refresh on successful save', async () => {
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
await act(async () => {
await result.current.handleSaveCredential({ api_key: 'new-key' })
})
expect(mockAddProviderCredential).toHaveBeenCalledWith({ api_key: 'new-key' })
expect(mockHandleRefreshModel).toHaveBeenCalledWith(provider, undefined, true)
await act(async () => {
await result.current.handleSaveCredential({ credential_id: 'cred-1', api_key: 'updated-key' })
})
expect(mockEditProviderCredential).toHaveBeenCalledWith({ credential_id: 'cred-1', api_key: 'updated-key' })
expect(mockHandleRefreshModel).toHaveBeenCalledWith(provider, undefined, false)
})
it('should ignore duplicate save requests while an action is in progress', async () => {
const deferred = createDeferred<{ result: string }>()
mockAddProviderCredential.mockReturnValueOnce(deferred.promise)
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.predefinedModel))
let first!: Promise<void>
let second!: Promise<void>
await act(async () => {
first = result.current.handleSaveCredential({ api_key: 'first' })
second = result.current.handleSaveCredential({ api_key: 'second' })
deferred.resolve({ result: 'success' })
await Promise.all([first, second])
})
expect(mockAddProviderCredential).toHaveBeenCalledTimes(1)
expect(mockAddProviderCredential).toHaveBeenCalledWith({ api_key: 'first' })
})
it('should forward modal open arguments', () => {
const onUpdate = vi.fn()
const fixedFields = {
__model_name: 'gpt-4',
__model_type: ModelTypeEnum.textGeneration,
}
const { result } = renderHook(() => useAuth(provider, ConfigurationMethodEnum.customizableModel, fixedFields, {
isModelCredential: true,
onUpdate,
mode: ModelModalModeEnum.configModelCredential,
}))
act(() => {
result.current.handleOpenModal(credential, model)
})
expect(mockOpenModelModal).toHaveBeenCalledWith(
provider,
ConfigurationMethodEnum.customizableModel,
fixedFields,
expect.objectContaining({
isModelCredential: true,
credential,
model,
onUpdate,
}),
)
})
})

View File

@ -0,0 +1,60 @@
import type { Credential, CustomModelCredential, ModelProvider } from '../../declarations'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook } from '@testing-library/react'
import { useCredentialData } from './use-credential-data'
vi.mock('./use-auth-service', () => ({
useGetCredential: vi.fn(),
}))
const { useGetCredential } = await import('./use-auth-service')
describe('useCredentialData', () => {
let queryClient: QueryClient
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
beforeEach(() => {
vi.clearAllMocks()
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
})
it('determines correct config source and parameters', () => {
vi.mocked(useGetCredential).mockReturnValue({ isLoading: false, data: {} } as unknown as ReturnType<typeof useGetCredential>)
const mockProvider = { provider: 'openai' } as unknown as ModelProvider
// Predefined source
renderHook(() => useCredentialData(mockProvider, true), { wrapper })
expect(useGetCredential).toHaveBeenCalledWith('openai', undefined, undefined, undefined, 'predefined-model')
// Custom source
renderHook(() => useCredentialData(mockProvider, false), { wrapper })
expect(useGetCredential).toHaveBeenCalledWith('openai', undefined, undefined, undefined, 'custom-model')
})
it('returns appropriate loading and data states', () => {
const mockData = { api_key: 'test' }
vi.mocked(useGetCredential).mockReturnValue({ isLoading: true, data: undefined } as unknown as ReturnType<typeof useGetCredential>)
const mockProvider = { provider: 'openai' } as unknown as ModelProvider
const { result: loadingRes } = renderHook(() => useCredentialData(mockProvider, true), { wrapper })
expect(loadingRes.current.isLoading).toBe(true)
expect(loadingRes.current.credentialData).toEqual({})
vi.mocked(useGetCredential).mockReturnValue({ isLoading: false, data: mockData } as unknown as ReturnType<typeof useGetCredential>)
const { result: dataRes } = renderHook(() => useCredentialData(mockProvider, true), { wrapper })
expect(dataRes.current.isLoading).toBe(false)
expect(dataRes.current.credentialData).toBe(mockData)
})
it('passes credential and model identifier correctly', () => {
vi.mocked(useGetCredential).mockReturnValue({ isLoading: false, data: {} } as unknown as ReturnType<typeof useGetCredential>)
const mockProvider = { provider: 'openai' } as unknown as ModelProvider
const mockCredential = { credential_id: 'cred-123' } as unknown as Credential
const mockModel = { model: 'gpt-4' } as unknown as CustomModelCredential
renderHook(() => useCredentialData(mockProvider, true, true, mockCredential, mockModel), { wrapper })
expect(useGetCredential).toHaveBeenCalledWith('openai', true, 'cred-123', mockModel, 'predefined-model')
})
})

View File

@ -0,0 +1,56 @@
import type { ModelProvider } from '../../declarations'
import { renderHook } from '@testing-library/react'
import { useCredentialStatus } from './use-credential-status'
describe('useCredentialStatus', () => {
it('computes authorized and authRemoved status correctly', () => {
// Authorized case
const authProvider = {
custom_configuration: {
current_credential_id: '123',
current_credential_name: 'Key',
available_credentials: [{ credential_id: '123', credential_name: 'Key' }],
},
} as unknown as ModelProvider
const { result: authRes } = renderHook(() => useCredentialStatus(authProvider))
expect(authRes.current.authorized).toBeTruthy()
expect(authRes.current.authRemoved).toBe(false)
// AuthRemoved case (found but not selected)
const removedProvider = {
custom_configuration: {
current_credential_id: '',
current_credential_name: '',
available_credentials: [{ credential_id: '123' }],
},
} as unknown as ModelProvider
const { result: removedRes } = renderHook(() => useCredentialStatus(removedProvider))
expect(removedRes.current.authRemoved).toBe(true)
expect(removedRes.current.authorized).toBeFalsy()
})
it('handles empty or restricted credentials', () => {
// Empty case
const emptyProvider = {
custom_configuration: { available_credentials: [] },
} as unknown as ModelProvider
const { result: emptyRes } = renderHook(() => useCredentialStatus(emptyProvider))
expect(emptyRes.current.hasCredential).toBe(false)
// Restricted case
const restrictedProvider = {
custom_configuration: {
current_credential_id: '123',
available_credentials: [{ credential_id: '123', not_allowed_to_use: true }],
},
} as unknown as ModelProvider
const { result: restrictedRes } = renderHook(() => useCredentialStatus(restrictedProvider))
expect(restrictedRes.current.notAllowedToUse).toBe(true)
})
it('handles undefined custom configuration gracefully', () => {
const { result } = renderHook(() => useCredentialStatus({ custom_configuration: {} } as ModelProvider))
expect(result.current.hasCredential).toBe(false)
expect(result.current.available_credentials).toBeUndefined()
})
})

View File

@ -0,0 +1,38 @@
import type { ModelProvider } from '../../declarations'
import { renderHook } from '@testing-library/react'
import { useCanAddedModels, useCustomModels } from './use-custom-models'
describe('useCustomModels and useCanAddedModels', () => {
it('extracts custom models from provider correctly', () => {
const mockProvider = {
custom_configuration: {
custom_models: [
{ model: 'gpt-4', model_type: 'text-generation' },
{ model: 'gpt-3.5', model_type: 'text-generation' },
],
},
} as unknown as ModelProvider
const { result } = renderHook(() => useCustomModels(mockProvider))
expect(result.current).toHaveLength(2)
expect(result.current[0].model).toBe('gpt-4')
const { result: emptyRes } = renderHook(() => useCustomModels({ custom_configuration: {} } as unknown as ModelProvider))
expect(emptyRes.current).toEqual([])
})
it('extracts can_added_models from provider correctly', () => {
const mockProvider = {
custom_configuration: {
can_added_models: [{ model: 'gpt-4-turbo', model_type: 'text-generation' }],
},
} as unknown as ModelProvider
const { result } = renderHook(() => useCanAddedModels(mockProvider))
expect(result.current).toHaveLength(1)
expect(result.current[0].model).toBe('gpt-4-turbo')
const { result: emptyRes } = renderHook(() => useCanAddedModels({ custom_configuration: {} } as unknown as ModelProvider))
expect(emptyRes.current).toEqual([])
})
})

View File

@ -0,0 +1,78 @@
import type {
Credential,
CustomModelCredential,
ModelProvider,
} from '../../declarations'
import { renderHook } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { FormTypeEnum } from '@/app/components/base/form/types'
import { useModelFormSchemas } from './use-model-form-schemas'
vi.mock('../../utils', () => ({
genModelNameFormSchema: vi.fn(() => ({
type: FormTypeEnum.textInput,
variable: '__model_name',
label: 'Model Name',
required: true,
})),
genModelTypeFormSchema: vi.fn(() => ({
type: FormTypeEnum.select,
variable: '__model_type',
label: 'Model Type',
required: true,
})),
}))
describe('useModelFormSchemas', () => {
const mockProvider = {
provider: 'openai',
provider_credential_schema: {
credential_form_schemas: [
{ type: FormTypeEnum.textInput, variable: 'api_key', label: 'API Key', required: true },
],
},
model_credential_schema: {
credential_form_schemas: [
{ type: FormTypeEnum.textInput, variable: 'model_key', label: 'Model Key', required: true },
],
},
supported_model_types: ['text-generation'],
} as unknown as ModelProvider
it('selects correct form schemas based on providerFormSchemaPredefined', () => {
const { result: providerResult } = renderHook(() => useModelFormSchemas(mockProvider, true))
expect(providerResult.current.formSchemas.some(s => s.variable === 'api_key')).toBe(true)
const { result: modelResult } = renderHook(() => useModelFormSchemas(mockProvider, false))
expect(modelResult.current.formSchemas.some(s => s.variable === 'model_key')).toBe(true)
const { result: emptyResult } = renderHook(() => useModelFormSchemas({} as unknown as ModelProvider, true))
expect(emptyResult.current.formSchemas).toHaveLength(1) // only __authorization_name__
})
it('computes form values correctly for credentials and models', () => {
const mockCredential = { credential_name: 'Test' } as unknown as Credential
const mockModel = { model: 'gpt-4', model_type: 'text-generation' } as unknown as CustomModelCredential
const { result } = renderHook(() => useModelFormSchemas(mockProvider, true, { api_key: 'val' }, mockCredential, mockModel))
expect((result.current.formValues as Record<string, unknown>).api_key).toBe('val')
expect((result.current.formValues as Record<string, unknown>).__authorization_name__).toBe('Test')
expect((result.current.formValues as Record<string, unknown>).__model_name).toBe('gpt-4')
// Branch: credential present but credentials (param) missing
const { result: emptyCredsRes } = renderHook(() => useModelFormSchemas(mockProvider, true, undefined, mockCredential))
expect((emptyCredsRes.current.formValues as Record<string, unknown>).__authorization_name__).toBe('Test')
})
it('handles model name and type schemas for custom models', () => {
const { result: predefined } = renderHook(() => useModelFormSchemas(mockProvider, true))
expect(predefined.current.modelNameAndTypeFormSchemas).toHaveLength(0)
const { result: custom } = renderHook(() => useModelFormSchemas(mockProvider, false))
expect(custom.current.modelNameAndTypeFormSchemas).toHaveLength(2)
expect(custom.current.modelNameAndTypeFormSchemas[0].variable).toBe('__model_name')
const mockModel = { model: 'custom', model_type: 'text' } as unknown as CustomModelCredential
const { result: customWithVal } = renderHook(() => useModelFormSchemas(mockProvider, false, undefined, undefined, mockModel))
expect((customWithVal.current.modelNameAndTypeFormValues as Record<string, unknown>).__model_name).toBe('custom')
})
})

View File

@ -0,0 +1,62 @@
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { render, screen } from '@testing-library/react'
import ManageCustomModelCredentials from './manage-custom-model-credentials'
// Mock hooks
const mockUseCustomModels = vi.fn()
vi.mock('./hooks', () => ({
useCustomModels: () => mockUseCustomModels(),
useAuth: () => ({
handleOpenModal: vi.fn(),
}),
}))
// Mock Authorized
vi.mock('./authorized', () => ({
default: ({ renderTrigger, items, popupTitle }: { renderTrigger: (o?: boolean) => React.ReactNode, items: { length: number }, popupTitle: string }) => (
<div data-testid="authorized-mock">
<div data-testid="trigger-container">{renderTrigger()}</div>
<div data-testid="popup-title">{popupTitle}</div>
<div data-testid="items-count">{items.length}</div>
</div>
),
}))
describe('ManageCustomModelCredentials', () => {
const mockProvider = {
provider: 'openai',
} as unknown as ModelProvider
beforeEach(() => {
vi.clearAllMocks()
})
it('should return null when no custom models exist', () => {
mockUseCustomModels.mockReturnValue([])
const { container } = render(<ManageCustomModelCredentials provider={mockProvider} />)
expect(container.firstChild).toBeNull()
})
it('should render authorized component when custom models exist', () => {
const mockModels = [
{
model: 'gpt-4',
available_model_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }],
current_credential_id: 'c1',
current_credential_name: 'Key 1',
},
{
model: 'gpt-3.5',
// testing undefined credentials branch
},
]
mockUseCustomModels.mockReturnValue(mockModels)
render(<ManageCustomModelCredentials provider={mockProvider} />)
expect(screen.getByTestId('authorized-mock')).toBeInTheDocument()
expect(screen.getByText(/modelProvider.auth.manageCredentials/)).toBeInTheDocument()
expect(screen.getByTestId('items-count')).toHaveTextContent('2')
expect(screen.getByTestId('popup-title')).toHaveTextContent('modelProvider.auth.customModelCredentials')
})
})

View File

@ -0,0 +1,130 @@
import type { CustomModel, ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import SwitchCredentialInLoadBalancing from './switch-credential-in-load-balancing'
// Mock components
vi.mock('./authorized', () => ({
default: ({ renderTrigger, onItemClick, items }: { renderTrigger: () => React.ReactNode, onItemClick: (c: unknown) => void, items: { credentials: unknown[] }[] }) => (
<div data-testid="authorized-mock">
<div data-testid="trigger-container" onClick={() => onItemClick(items[0].credentials[0])}>
{renderTrigger()}
</div>
</div>
),
}))
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <div data-testid={`indicator-${color}`} />,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
<div data-testid="tooltip-mock">
{children}
<div>{popupContent}</div>
</div>
),
}))
vi.mock('@remixicon/react', () => ({
RiArrowDownSLine: () => <div data-testid="arrow-icon" />,
}))
describe('SwitchCredentialInLoadBalancing', () => {
const mockProvider = {
provider: 'openai',
allow_custom_token: true,
} as unknown as ModelProvider
const mockModel = {
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
} as unknown as CustomModel
const mockCredentials = [
{ credential_id: 'cred-1', credential_name: 'Key 1' },
{ credential_id: 'cred-2', credential_name: 'Key 2' },
]
const mockSetCustomModelCredential = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render selected credential name correctly', () => {
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
model={mockModel}
credentials={mockCredentials}
customModelCredential={mockCredentials[0]}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
expect(screen.getByText('Key 1')).toBeInTheDocument()
expect(screen.getByTestId('indicator-green')).toBeInTheDocument()
})
it('should render auth removed status when selected credential is not in list', () => {
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
model={mockModel}
credentials={mockCredentials}
customModelCredential={{ credential_id: 'dead-cred', credential_name: 'Dead Key' }}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
expect(screen.getByText(/modelProvider.auth.authRemoved/)).toBeInTheDocument()
expect(screen.getByTestId('indicator-red')).toBeInTheDocument()
})
it('should render unavailable status when credentials list is empty', () => {
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
model={mockModel}
credentials={[]}
customModelCredential={undefined}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
expect(screen.getByText(/auth.credentialUnavailableInButton/)).toBeInTheDocument()
expect(screen.queryByTestId(/indicator-/)).not.toBeInTheDocument()
})
it('should call setCustomModelCredential when an item is selected in Authorized', () => {
render(
<SwitchCredentialInLoadBalancing
provider={mockProvider}
model={mockModel}
credentials={mockCredentials}
customModelCredential={mockCredentials[0]}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
fireEvent.click(screen.getByTestId('trigger-container'))
expect(mockSetCustomModelCredential).toHaveBeenCalledWith(mockCredentials[0])
})
it('should show tooltip when empty and custom credentials not allowed', () => {
const restrictedProvider = { ...mockProvider, allow_custom_token: false }
render(
<SwitchCredentialInLoadBalancing
provider={restrictedProvider}
model={mockModel}
credentials={[]}
customModelCredential={undefined}
setCustomModelCredential={mockSetCustomModelCredential}
/>,
)
expect(screen.getByText('plugin.auth.credentialUnavailable')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react'
import ModelBadge from './index'
describe('ModelBadge', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering behavior for user-visible content.
describe('Rendering', () => {
it('should render provided text', () => {
render(<ModelBadge>Provider</ModelBadge>)
expect(screen.getByText(/provider/i)).toBeInTheDocument()
})
it('should render without text when children is null', () => {
const { container } = render(<ModelBadge>{null}</ModelBadge>)
expect(container.textContent).toBe('')
})
it('should render nested content', () => {
render(
<ModelBadge>
<span>Badge Label</span>
</ModelBadge>,
)
expect(screen.getByText(/badge label/i)).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,108 @@
import type { Model } from '../declarations'
import { render, screen } from '@testing-library/react'
import { Theme } from '@/types/app'
import {
ConfigurationMethodEnum,
ModelStatusEnum,
ModelTypeEnum,
} from '../declarations'
import ModelIcon from './index'
type I18nText = {
en_US: string
zh_Hans: string
}
let mockTheme: Theme = Theme.light
let mockLanguage = 'en_US'
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: mockTheme }),
}))
vi.mock('../hooks', () => ({
useLanguage: () => mockLanguage,
}))
vi.mock('@/app/components/base/icons/src/public/llm', () => ({
OpenaiYellow: () => <svg data-testid="openai-yellow-icon" />,
}))
const createI18nText = (value: string): I18nText => ({
en_US: value,
zh_Hans: value,
})
const createModel = (overrides?: Partial<Model>): Model => ({
provider: 'test-provider',
icon_small: createI18nText('light.png'),
icon_small_dark: createI18nText('dark.png'),
label: createI18nText('Test Provider'),
models: [
{
model: 'test-model',
label: createI18nText('Test Model'),
model_type: ModelTypeEnum.textGeneration,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
},
],
status: ModelStatusEnum.active,
...overrides,
})
describe('ModelIcon', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTheme = Theme.light
mockLanguage = 'en_US'
})
// Rendering
it('should render the light icon when icon_small is provided', () => {
const provider = createModel({
icon_small: createI18nText('light-only.png'),
icon_small_dark: undefined,
})
render(<ModelIcon provider={provider} />)
expect(screen.getByRole('img', { name: /model-icon/i })).toHaveAttribute('src', 'light-only.png')
})
// Theme selection
it('should render the dark icon when theme is dark and icon_small_dark exists', () => {
mockTheme = Theme.dark
const provider = createModel({
icon_small: createI18nText('light.png'),
icon_small_dark: createI18nText('dark.png'),
})
render(<ModelIcon provider={provider} />)
expect(screen.getByRole('img', { name: /model-icon/i })).toHaveAttribute('src', 'dark.png')
})
// Provider override
it('should ignore icon_small for OpenAI models starting with "o"', () => {
const provider = createModel({
provider: 'openai',
icon_small: createI18nText('openai.png'),
})
render(<ModelIcon provider={provider} modelName="o1" />)
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
expect(screen.getByTestId('openai-yellow-icon')).toBeInTheDocument()
})
// Edge case
it('should render without an icon when provider is undefined', () => {
const { container } = render(<ModelIcon />)
expect(screen.queryByRole('img', { name: /model-icon/i })).not.toBeInTheDocument()
expect(container.firstChild).not.toBeNull()
})
})

View File

@ -0,0 +1,447 @@
import type {
CredentialFormSchema,
CredentialFormSchemaBase,
CredentialFormSchemaNumberInput,
CredentialFormSchemaRadio,
CredentialFormSchemaSelect,
CredentialFormSchemaTextInput,
FormValue,
} from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { FormTypeEnum } from '../declarations'
import Form from './Form'
type CustomSchema = Omit<CredentialFormSchemaBase, 'type'> & { type: 'custom-type' }
type MockVarPayload = { type: string }
type AnyFormSchema = CredentialFormSchema | (CredentialFormSchemaBase & { type: FormTypeEnum })
vi.mock('../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/app-selector', () => ({
default: ({ onSelect }: { onSelect: (item: { id: string }) => void }) => (
<button type="button" onClick={() => onSelect({ id: 'app-1' })}>Select App</button>
),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/model-selector', () => ({
default: ({ setModel }: { setModel: (model: { model: string, model_type: string }) => void }) => (
<button type="button" onClick={() => setModel({ model: 'gpt-1', model_type: 'llm' })}>Select Model</button>
),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/multiple-tool-selector', () => ({
default: ({ onChange }: { onChange: (items: Array<{ id: string }>) => void }) => (
<button type="button" onClick={() => onChange([{ id: 'tool-1' }])}>Select Tools</button>
),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/tool-selector', () => ({
default: ({ onSelect, onDelete }: { onSelect: (item: { id: string }) => void, onDelete: () => void }) => (
<div>
<button type="button" onClick={() => onSelect({ id: 'tool-1' })}>Select Tool</button>
<button type="button" onClick={onDelete}>Remove Tool</button>
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: ({ filterVar, onChange }: { filterVar?: (payload: MockVarPayload) => boolean, onChange: (items: Array<{ name: string }>) => void }) => {
const allowed = filterVar ? filterVar({ type: 'text' }) : true
const blocked = filterVar ? filterVar({ type: 'image' }) : false
return (
<div>
<div>{allowed ? 'allowed' : 'blocked'}</div>
<div>{blocked ? 'allowed' : 'blocked'}</div>
<button type="button" onClick={() => onChange([{ name: 'var-1' }])}>Pick Variable</button>
</div>
)
},
}))
vi.mock('../../key-validator/ValidateStatus', () => ({
ValidatingTip: () => <div>Validating...</div>,
}))
const createI18n = (text: string) => ({ en_US: text, zh_Hans: text })
const createBaseSchema = (
type: FormTypeEnum,
overrides: Partial<CredentialFormSchemaBase> = {},
): CredentialFormSchemaBase => ({
name: overrides.variable ?? 'field',
variable: overrides.variable ?? 'field',
label: createI18n('Field'),
type,
required: false,
show_on: [],
...overrides,
})
const createTextSchema = (overrides: Partial<CredentialFormSchemaTextInput> & { type?: FormTypeEnum }) => ({
...createBaseSchema(overrides.type ?? FormTypeEnum.textInput, { variable: overrides.variable ?? 'text' }),
placeholder: createI18n('Input'),
...overrides,
})
const createNumberSchema = (overrides: Partial<CredentialFormSchemaNumberInput>) => ({
...createBaseSchema(FormTypeEnum.textNumber, { variable: overrides.variable ?? 'number' }),
placeholder: createI18n('Number'),
min: 1,
max: 9,
...overrides,
})
const createRadioSchema = (overrides: Partial<CredentialFormSchemaRadio>) => ({
...createBaseSchema(FormTypeEnum.radio, { variable: overrides.variable ?? 'radio' }),
options: [
{ label: createI18n('Option A'), value: 'a', show_on: [] },
{ label: createI18n('Option B'), value: 'b', show_on: [] },
],
...overrides,
})
const createSelectSchema = (overrides: Partial<CredentialFormSchemaSelect>) => ({
...createBaseSchema(FormTypeEnum.select, { variable: overrides.variable ?? 'select' }),
placeholder: createI18n('Select one'),
options: [
{ label: createI18n('Select A'), value: 'a', show_on: [] },
{ label: createI18n('Select B'), value: 'b', show_on: [] },
],
...overrides,
})
describe('Form', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering basics
describe('Rendering', () => {
it('should render visible fields and apply default values', () => {
const formSchemas: AnyFormSchema[] = [
createTextSchema({
variable: 'api_key',
label: createI18n('API Key'),
placeholder: createI18n('API Key'),
required: true,
default: 'default-key',
}),
createTextSchema({
variable: 'secret',
type: FormTypeEnum.secretInput,
label: createI18n('Secret'),
placeholder: createI18n('Secret'),
}),
createNumberSchema({
variable: 'limit',
label: createI18n('Limit'),
placeholder: createI18n('Limit'),
default: '5',
}),
createTextSchema({
variable: 'hidden',
label: createI18n('Hidden'),
show_on: [{ variable: 'toggle', value: 'on' }],
}),
]
const value: FormValue = {
api_key: '',
secret: 'top-secret',
limit: '',
toggle: 'off',
}
render(
<Form
value={value}
onChange={vi.fn()}
formSchemas={formSchemas}
validating={false}
validatedSuccess={false}
showOnVariableMap={{}}
isEditMode={false}
isShowDefaultValue
/>,
)
expect(screen.getByPlaceholderText('API Key')).toHaveValue('default-key')
expect(screen.getByPlaceholderText('Secret')).toHaveValue('top-secret')
expect(screen.getByPlaceholderText('Limit')).toHaveValue(5)
expect(screen.queryByText('Hidden')).not.toBeInTheDocument()
expect(screen.getAllByText('*')).toHaveLength(1)
})
})
// Interaction updates
describe('Interactions', () => {
it('should update values and clear dependent fields when a field changes', () => {
const formSchemas: AnyFormSchema[] = [
createTextSchema({
variable: 'api_key',
label: createI18n('API Key'),
placeholder: createI18n('API Key'),
}),
createTextSchema({
variable: 'dependent',
label: createI18n('Dependent'),
default: 'reset',
}),
]
const value: FormValue = { api_key: 'old', dependent: 'keep' }
const onChange = vi.fn()
render(
<Form
value={value}
onChange={onChange}
formSchemas={formSchemas}
validating
validatedSuccess={false}
showOnVariableMap={{ api_key: ['dependent'] }}
isEditMode={false}
/>,
)
fireEvent.change(screen.getByPlaceholderText('API Key'), { target: { value: 'new-key' } })
expect(onChange).toHaveBeenCalledWith({ api_key: 'new-key', dependent: 'reset' })
expect(screen.getByText('Validating...')).toBeInTheDocument()
})
it('should render radio options based on show conditions and ignore edit-locked changes', () => {
const formSchemas: AnyFormSchema[] = [
createRadioSchema({
variable: 'region',
label: createI18n('Region'),
options: [
{ label: createI18n('US'), value: 'us', show_on: [] },
{ label: createI18n('EU'), value: 'eu', show_on: [{ variable: 'toggle', value: 'on' }] },
],
}),
createRadioSchema({
variable: 'hidden_region',
label: createI18n('Hidden Region'),
show_on: [{ variable: 'toggle', value: 'hidden' }],
options: [
{ label: createI18n('Hidden A'), value: 'a', show_on: [] },
],
}),
createRadioSchema({
variable: '__model_name',
label: createI18n('Locked'),
options: [
{ label: createI18n('Locked A'), value: 'a', show_on: [] },
],
}),
]
const value: FormValue = { region: 'us', toggle: 'on', __model_name: 'a' }
const onChange = vi.fn()
render(
<Form
value={value}
onChange={onChange}
formSchemas={formSchemas}
validating={false}
validatedSuccess={false}
showOnVariableMap={{}}
isEditMode
/>,
)
expect(screen.getByText('EU')).toBeInTheDocument()
expect(screen.queryByText('Hidden Region')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('EU'))
fireEvent.click(screen.getByText('Locked A'))
expect(onChange).toHaveBeenCalledWith({ region: 'eu', toggle: 'on', __model_name: 'a' })
expect(onChange).toHaveBeenCalledTimes(1)
})
it('should render select and checkbox fields and update checkbox value', () => {
const formSchemas: AnyFormSchema[] = [
createSelectSchema({
variable: 'model',
label: createI18n('Model'),
placeholder: createI18n('Pick model'),
show_on: [{ variable: 'toggle', value: 'on' }],
options: [
{ label: createI18n('Select A'), value: 'a', show_on: [] },
{ label: createI18n('Select B'), value: 'b', show_on: [{ variable: 'toggle', value: 'on' }] },
],
}),
createRadioSchema({
variable: 'agree',
type: FormTypeEnum.checkbox,
label: createI18n('Agree'),
options: [],
show_on: [{ variable: 'toggle', value: 'on' }],
}),
]
const value: FormValue = { model: 'a', agree: false, toggle: 'off' }
const onChange = vi.fn()
const { rerender } = render(
<Form
value={value}
onChange={onChange}
formSchemas={formSchemas}
validating={false}
validatedSuccess={false}
showOnVariableMap={{}}
isEditMode={false}
/>,
)
expect(screen.queryByText('Pick model')).not.toBeInTheDocument()
expect(screen.queryByText('Agree')).not.toBeInTheDocument()
rerender(
<Form
value={{ model: 'a', agree: false, toggle: 'on' }}
onChange={onChange}
formSchemas={formSchemas}
validating={false}
validatedSuccess={false}
showOnVariableMap={{}}
isEditMode={false}
/>,
)
expect(screen.getByText('Select A')).toBeInTheDocument()
fireEvent.click(screen.getByText('Select A'))
fireEvent.click(screen.getByText('Select B'))
fireEvent.click(screen.getByText('True'))
expect(onChange).toHaveBeenCalledWith({ model: 'b', agree: false, toggle: 'on' })
expect(onChange).toHaveBeenCalledWith({ model: 'a', agree: true, toggle: 'on' })
})
it('should pass selected items from model and tool selectors to the form value', () => {
const formSchemas: AnyFormSchema[] = [
createTextSchema({
variable: 'model_selector',
type: FormTypeEnum.modelSelector,
label: createI18n('Model Selector'),
}),
createTextSchema({
variable: 'tool_selector',
type: FormTypeEnum.toolSelector,
label: createI18n('Tool Selector'),
}),
createTextSchema({
variable: 'multi_tool',
type: FormTypeEnum.multiToolSelector,
label: createI18n('Multi Tool'),
tooltip: createI18n('Tips'),
}),
createTextSchema({
variable: 'app_selector',
type: FormTypeEnum.appSelector,
label: createI18n('App Selector'),
}),
]
const value: FormValue = { model_selector: {}, tool_selector: null, multi_tool: [], app_selector: null }
const onChange = vi.fn()
render(
<Form
value={value}
onChange={onChange}
formSchemas={formSchemas}
validating={false}
validatedSuccess={false}
showOnVariableMap={{}}
isEditMode={false}
/>,
)
fireEvent.click(screen.getByText('Select Model'))
fireEvent.click(screen.getByText('Select Tool'))
fireEvent.click(screen.getByText('Remove Tool'))
fireEvent.click(screen.getByText('Select Tools'))
fireEvent.click(screen.getByText('Select App'))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
model_selector: { model: 'gpt-1', model_type: 'llm', type: FormTypeEnum.modelSelector },
}))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
tool_selector: { id: 'tool-1' },
}))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
tool_selector: null,
}))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
multi_tool: [{ id: 'tool-1' }],
}))
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({
app_selector: { id: 'app-1', type: FormTypeEnum.appSelector },
}))
})
it('should render variable picker and custom render overrides', () => {
const formSchemas: Array<AnyFormSchema | CustomSchema> = [
createTextSchema({
variable: 'override',
label: createI18n('Override'),
type: FormTypeEnum.textInput,
}),
createTextSchema({
variable: 'any_var',
type: FormTypeEnum.any,
label: createI18n('Any Var'),
scope: 'text&audio',
}),
createTextSchema({
variable: 'any_without_scope',
type: FormTypeEnum.any,
label: createI18n('Any Without Scope'),
}),
{
...createTextSchema({
variable: 'custom_field',
label: createI18n('Custom Field'),
}),
type: 'custom-type',
},
]
const value: FormValue = { override: '', any_var: [], any_without_scope: [], custom_field: '' }
const onChange = vi.fn()
render(
<Form<CustomSchema>
value={value}
onChange={onChange}
formSchemas={formSchemas}
validating={false}
validatedSuccess={false}
showOnVariableMap={{}}
isEditMode={false}
fieldMoreInfo={() => <div>Extra Info</div>}
override={[[FormTypeEnum.textInput], () => <div>Override Field</div>]}
customRenderField={schema => (
<div>
Custom Render:
{schema.variable}
</div>
)}
/>,
)
expect(screen.getByText('Override Field')).toBeInTheDocument()
expect(screen.getByText(/Custom Render:.*custom_field/)).toBeInTheDocument()
expect(screen.getAllByText('allowed')).toHaveLength(3)
expect(screen.getAllByText('blocked')).toHaveLength(1)
fireEvent.click(screen.getAllByText('Pick Variable')[0])
expect(onChange).toHaveBeenCalledWith({ override: '', any_var: [{ name: 'var-1' }], any_without_scope: [], custom_field: '' })
expect(screen.getAllByText('Extra Info')).toHaveLength(2)
})
})
})

View File

@ -0,0 +1,96 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Input from './Input'
describe('Input', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering basics
it('should render with the provided placeholder and value', () => {
render(
<Input
value="hello"
placeholder="API Key"
onChange={vi.fn()}
/>,
)
expect(screen.getByPlaceholderText('API Key')).toHaveValue('hello')
})
// User interaction
it('should call onChange when the user types', () => {
const onChange = vi.fn()
render(
<Input
placeholder="API Key"
onChange={onChange}
/>,
)
fireEvent.change(screen.getByPlaceholderText('API Key'), { target: { value: 'next' } })
expect(onChange).toHaveBeenCalledWith('next')
})
// Edge cases: min/max enforcement
it('should clamp to the min value when the input is below min on blur', () => {
const onChange = vi.fn()
render(
<Input
placeholder="Limit"
onChange={onChange}
min={2}
max={6}
/>,
)
const input = screen.getByPlaceholderText('Limit')
fireEvent.change(input, { target: { value: '1' } })
fireEvent.blur(input)
expect(onChange).toHaveBeenLastCalledWith('2')
})
it('should clamp to the max value when the input is above max on blur', () => {
const onChange = vi.fn()
render(
<Input
placeholder="Limit"
onChange={onChange}
min={2}
max={6}
/>,
)
const input = screen.getByPlaceholderText('Limit')
fireEvent.change(input, { target: { value: '8' } })
fireEvent.blur(input)
expect(onChange).toHaveBeenLastCalledWith('6')
})
it('should keep the value when it is within the min/max range on blur', () => {
const onChange = vi.fn()
render(
<Input
placeholder="Limit"
onChange={onChange}
min={2}
max={6}
/>,
)
const input = screen.getByPlaceholderText('Limit')
fireEvent.change(input, { target: { value: '4' } })
fireEvent.blur(input)
expect(onChange).not.toHaveBeenCalledWith('2')
expect(onChange).not.toHaveBeenCalledWith('6')
})
})

View File

@ -0,0 +1,353 @@
import type { Credential, CredentialFormSchema, ModelProvider } from '../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
ModelModalModeEnum,
ModelTypeEnum,
PreferredProviderTypeEnum,
QuotaUnitEnum,
} from '../declarations'
import ModelModal from './index'
type CredentialData = {
credentials: Record<string, unknown>
available_credentials: Credential[]
}
type ModelFormSchemas = {
formSchemas: CredentialFormSchema[]
formValues: Record<string, unknown>
modelNameAndTypeFormSchemas: CredentialFormSchema[]
modelNameAndTypeFormValues: Record<string, unknown>
}
const mockState = vi.hoisted(() => ({
isLoading: false,
credentialData: { credentials: {}, available_credentials: [] } as CredentialData,
doingAction: false,
deleteCredentialId: null as string | null,
isCurrentWorkspaceManager: true,
formSchemas: [] as CredentialFormSchema[],
formValues: {} as Record<string, unknown>,
modelNameAndTypeFormSchemas: [] as CredentialFormSchema[],
modelNameAndTypeFormValues: {} as Record<string, unknown>,
}))
const mockHandlers = vi.hoisted(() => ({
handleSaveCredential: vi.fn(),
handleConfirmDelete: vi.fn(),
closeConfirmDelete: vi.fn(),
openConfirmDelete: vi.fn(),
handleActiveCredential: vi.fn(),
}))
type FormResponse = {
isCheckValidated: boolean
values: Record<string, unknown>
}
const mockFormState = vi.hoisted(() => ({
responses: [] as FormResponse[],
setFieldValue: vi.fn(),
}))
vi.mock('../model-auth/hooks', () => ({
useCredentialData: () => ({
isLoading: mockState.isLoading,
credentialData: mockState.credentialData,
}),
useAuth: () => ({
handleSaveCredential: mockHandlers.handleSaveCredential,
handleConfirmDelete: mockHandlers.handleConfirmDelete,
deleteCredentialId: mockState.deleteCredentialId,
closeConfirmDelete: mockHandlers.closeConfirmDelete,
openConfirmDelete: mockHandlers.openConfirmDelete,
doingAction: mockState.doingAction,
handleActiveCredential: mockHandlers.handleActiveCredential,
}),
useModelFormSchemas: (): ModelFormSchemas => ({
formSchemas: mockState.formSchemas,
formValues: mockState.formValues,
modelNameAndTypeFormSchemas: mockState.modelNameAndTypeFormSchemas,
modelNameAndTypeFormValues: mockState.modelNameAndTypeFormValues,
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({ isCurrentWorkspaceManager: mockState.isCurrentWorkspaceManager }),
}))
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (value: { en_US: string }) => value.en_US,
}))
vi.mock('../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/base/form/form-scenarios/auth', async () => {
const React = await import('react')
const AuthForm = React.forwardRef(({
onChange,
}: {
onChange?: (field: string, value: string) => void
}, ref: React.ForwardedRef<{ getFormValues: () => FormResponse, getForm: () => { setFieldValue: (field: string, value: string) => void } }>) => {
React.useImperativeHandle(ref, () => ({
getFormValues: () => mockFormState.responses.shift() || { isCheckValidated: false, values: {} },
getForm: () => ({ setFieldValue: mockFormState.setFieldValue }),
}))
return (
<div>
<button type="button" onClick={() => onChange?.('__model_name', 'updated-model')}>Model Name Change</button>
</div>
)
})
return { default: AuthForm }
})
vi.mock('../model-auth', () => ({
CredentialSelector: ({ onSelect }: { onSelect: (credential: Credential & { addNewCredential?: boolean }) => void }) => (
<div>
<button type="button" onClick={() => onSelect({ credential_id: 'existing' })}>Choose Existing</button>
<button type="button" onClick={() => onSelect({ credential_id: 'new', addNewCredential: true })}>Add New</button>
</div>
),
}))
const createI18n = (text: string) => ({ en_US: text, zh_Hans: text })
const createProvider = (overrides?: Partial<ModelProvider>): ModelProvider => ({
provider: 'openai',
label: createI18n('OpenAI'),
help: {
title: createI18n('Help'),
url: createI18n('https://example.com'),
},
icon_small: createI18n('icon'),
supported_model_types: [ModelTypeEnum.textGeneration],
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
provider_credential_schema: { credential_form_schemas: [] },
model_credential_schema: {
model: { label: createI18n('Model'), placeholder: createI18n('Model') },
credential_form_schemas: [],
},
preferred_provider_type: PreferredProviderTypeEnum.system,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
available_credentials: [],
custom_models: [],
can_added_models: [],
},
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.trial,
quota_configurations: [
{
quota_type: CurrentSystemQuotaTypeEnum.trial,
quota_unit: QuotaUnitEnum.times,
quota_limit: 0,
quota_used: 0,
last_used: 0,
is_valid: true,
},
],
},
allow_custom_token: true,
...overrides,
})
const renderModal = (overrides?: Partial<React.ComponentProps<typeof ModelModal>>) => {
const provider = createProvider()
const props = {
provider,
configurateMethod: ConfigurationMethodEnum.predefinedModel,
onCancel: vi.fn(),
onSave: vi.fn(),
onRemove: vi.fn(),
...overrides,
}
const view = render(<ModelModal {...props} />)
return {
...props,
unmount: view.unmount,
}
}
describe('ModelModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockState.isLoading = false
mockState.credentialData = { credentials: {}, available_credentials: [] }
mockState.doingAction = false
mockState.deleteCredentialId = null
mockState.isCurrentWorkspaceManager = true
mockState.formSchemas = []
mockState.formValues = {}
mockState.modelNameAndTypeFormSchemas = []
mockState.modelNameAndTypeFormValues = {}
mockFormState.responses = []
})
it('should show title, description, and loading state for predefined models', () => {
mockState.isLoading = true
const predefined = renderModal()
expect(screen.getByText('common.modelProvider.auth.apiKeyModal.title')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.auth.apiKeyModal.desc')).toBeInTheDocument()
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeDisabled()
predefined.unmount()
const customizable = renderModal({ configurateMethod: ConfigurationMethodEnum.customizableModel })
expect(screen.queryByText('common.modelProvider.auth.apiKeyModal.desc')).not.toBeInTheDocument()
customizable.unmount()
mockState.credentialData = { credentials: {}, available_credentials: [] }
renderModal({ mode: ModelModalModeEnum.configModelCredential, model: { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration } })
expect(screen.getByText('common.modelProvider.auth.addModelCredential')).toBeInTheDocument()
})
it('should reveal the credential label when adding a new credential', () => {
renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList })
expect(screen.queryByText('common.modelProvider.auth.modelCredential')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Add New'))
expect(screen.getByText('common.modelProvider.auth.modelCredential')).toBeInTheDocument()
})
it('should call onCancel when the cancel button is clicked', () => {
const { onCancel } = renderModal()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should call onCancel when the escape key is pressed', () => {
const { onCancel } = renderModal()
fireEvent.keyDown(document, { key: 'Escape' })
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should confirm deletion when a delete dialog is shown', () => {
mockState.credentialData = { credentials: { api_key: 'secret' }, available_credentials: [] }
mockState.deleteCredentialId = 'delete-id'
const credential: Credential = { credential_id: 'cred-1' }
const { onCancel } = renderModal({ credential })
expect(screen.getByText('common.modelProvider.confirmDelete')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
expect(mockHandlers.handleConfirmDelete).toHaveBeenCalledTimes(1)
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should handle save flows for different modal modes', async () => {
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text-input' } as unknown as CredentialFormSchema]
mockState.formSchemas = [{ variable: 'api_key', type: 'secret-input' } as unknown as CredentialFormSchema]
mockFormState.responses = [
{ isCheckValidated: true, values: { __model_name: 'custom-model', __model_type: ModelTypeEnum.textGeneration } },
{ isCheckValidated: true, values: { __authorization_name__: 'Auth Name', api_key: 'secret' } },
]
const configCustomModel = renderModal({ mode: ModelModalModeEnum.configCustomModel })
fireEvent.click(screen.getAllByText('Model Name Change')[0])
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockFormState.setFieldValue).toHaveBeenCalledWith('__model_name', 'updated-model')
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'secret' },
name: 'Auth Name',
model: 'custom-model',
model_type: ModelTypeEnum.textGeneration,
})
})
expect(configCustomModel.onSave).toHaveBeenCalledWith({ __authorization_name__: 'Auth Name', api_key: 'secret' })
configCustomModel.unmount()
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'Model Auth', api_key: 'abc' } }]
const model = { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration }
const configModelCredential = renderModal({
mode: ModelModalModeEnum.configModelCredential,
model,
credential: { credential_id: 'cred-123' },
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: 'cred-123',
credentials: { api_key: 'abc' },
name: 'Model Auth',
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
})
})
expect(configModelCredential.onSave).toHaveBeenCalledWith({ __authorization_name__: 'Model Auth', api_key: 'abc' })
configModelCredential.unmount()
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'Provider Auth', api_key: 'provider-key' } }]
const configProviderCredential = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'provider-key' },
name: 'Provider Auth',
})
})
configProviderCredential.unmount()
const addToModelList = renderModal({
mode: ModelModalModeEnum.addCustomModelToModelList,
model,
})
fireEvent.click(screen.getByText('Choose Existing'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockHandlers.handleActiveCredential).toHaveBeenCalledWith({ credential_id: 'existing' }, model)
expect(addToModelList.onCancel).toHaveBeenCalled()
addToModelList.unmount()
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'New Auth', api_key: 'new-key' } }]
const addToModelListWithNew = renderModal({
mode: ModelModalModeEnum.addCustomModelToModelList,
model,
})
fireEvent.click(screen.getByText('Add New'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'new-key' },
name: 'New Auth',
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
})
})
addToModelListWithNew.unmount()
mockFormState.responses = [{ isCheckValidated: false, values: {} }]
const invalidSave = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledTimes(4)
})
invalidSave.unmount()
mockState.credentialData = { credentials: { api_key: 'value' }, available_credentials: [] }
mockState.formValues = { api_key: 'value' }
const removable = renderModal({ credential: { credential_id: 'remove-1' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' }))
expect(mockHandlers.openConfirmDelete).toHaveBeenCalledWith({ credential_id: 'remove-1' }, undefined)
removable.unmount()
})
})

View File

@ -0,0 +1,116 @@
import type { ModelItem } from '../declarations'
import { render, screen } from '@testing-library/react'
import {
ConfigurationMethodEnum,
ModelFeatureEnum,
ModelStatusEnum,
ModelTypeEnum,
} from '../declarations'
import ModelName from './index'
let mockLocale = 'en-US'
vi.mock('#i18n', () => ({
useTranslation: () => ({
i18n: {
language: mockLocale,
},
}),
}))
const createModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
model: 'gpt-4o',
label: {
en_US: 'English Model',
zh_Hans: 'Chinese Model',
},
model_type: ModelTypeEnum.textGeneration,
features: [],
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
...overrides,
})
describe('ModelName', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLocale = 'en-US'
})
// Rendering scenarios for the model name label.
describe('rendering', () => {
it('should render the localized model label when translation exists', () => {
mockLocale = 'zh-Hans'
const modelItem = createModelItem()
render(<ModelName modelItem={modelItem} />)
expect(screen.getByText('Chinese Model')).toBeInTheDocument()
})
it('should fall back to en_US label when localized label is missing', () => {
mockLocale = 'fr-FR'
const modelItem = createModelItem({
label: {
en_US: 'English Only',
zh_Hans: 'Chinese Model',
},
})
render(<ModelName modelItem={modelItem} />)
expect(screen.getByText('English Only')).toBeInTheDocument()
})
it('should render nothing when modelItem is null', () => {
const { container } = render(<ModelName modelItem={null as unknown as ModelItem} />)
expect(container).toBeEmptyDOMElement()
})
})
// Badges that surface model metadata to the user.
describe('badges', () => {
it('should show model type, mode, and context size when enabled', () => {
const modelItem = createModelItem({
model_type: ModelTypeEnum.textEmbedding,
model_properties: {
mode: 'chat',
context_size: 2000,
},
})
render(
<ModelName
modelItem={modelItem}
showModelType
showMode
showContextSize
/>,
)
expect(screen.getByText('TEXT EMBEDDING')).toBeInTheDocument()
expect(screen.getByText('CHAT')).toBeInTheDocument()
expect(screen.getByText('2K')).toBeInTheDocument()
})
it('should render feature labels when showFeaturesLabel is enabled', () => {
const modelItem = createModelItem({
features: [ModelFeatureEnum.vision, ModelFeatureEnum.audio],
})
render(
<ModelName
modelItem={modelItem}
showFeatures
showFeaturesLabel
/>,
)
expect(screen.getByText('Vision')).toBeInTheDocument()
expect(screen.getByText('Audio')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,154 @@
import type { MouseEvent } from 'react'
import type { ModelProvider } from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import {
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
ModelTypeEnum,
QuotaUnitEnum,
} from '../declarations'
import AgentModelTrigger from './agent-model-trigger'
let modelProviders: ModelProvider[] = []
let pluginInfo: { latest_package_identifier: string } | null = null
let pluginLoading = false
let inModelList = true
const invalidateInstalledPluginList = vi.fn()
const handleOpenModal = vi.fn()
const updateModelProviders = vi.fn()
const updateModelList = vi.fn()
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders,
}),
}))
vi.mock('@/service/use-plugins', () => ({
useInvalidateInstalledPluginList: () => invalidateInstalledPluginList,
useModelInList: () => ({ data: inModelList }),
usePluginInfo: () => ({ data: pluginInfo, isLoading: pluginLoading }),
}))
vi.mock('../hooks', () => ({
useModelModalHandler: () => handleOpenModal,
useUpdateModelList: () => updateModelList,
useUpdateModelProviders: () => updateModelProviders,
}))
vi.mock('../model-icon', () => ({
default: () => <div>Icon</div>,
}))
vi.mock('./model-display', () => ({
default: () => <div>ModelDisplay</div>,
}))
vi.mock('./status-indicators', () => ({
default: () => <div>StatusIndicators</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({
InstallPluginButton: ({ onClick, onSuccess }: { onClick: (event: MouseEvent<HTMLButtonElement>) => void, onSuccess: () => void }) => (
<button
onClick={(event) => {
onClick(event)
onSuccess()
}}
>
Install Plugin
</button>
),
}))
describe('AgentModelTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
modelProviders = []
pluginInfo = null
pluginLoading = false
inModelList = true
})
it('should render loading state when plugin info is still fetching', () => {
pluginLoading = true
render(
<AgentModelTrigger
modelId="gpt-4"
providerName="openai"
/>,
)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should render model actions for configured provider', () => {
modelProviders = [{
provider: 'openai',
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.paid,
quota_configurations: [{
quota_type: CurrentSystemQuotaTypeEnum.paid,
quota_unit: QuotaUnitEnum.times,
quota_limit: 10,
quota_used: 1,
last_used: 1,
is_valid: true,
}],
},
}] as unknown as ModelProvider[]
render(
<AgentModelTrigger
modelId="gpt-4"
providerName="openai"
/>,
)
expect(screen.getByText('ModelDisplay')).toBeInTheDocument()
expect(screen.getByText('StatusIndicators')).toBeInTheDocument()
})
it('should support plugin installation flow when provider is missing', () => {
pluginInfo = { latest_package_identifier: 'plugin/demo@1.0.0' }
render(
<AgentModelTrigger
modelId="gpt-4"
providerName="openai"
scope={`${ModelTypeEnum.textGeneration},${ModelTypeEnum.tts}`}
/>,
)
fireEvent.click(screen.getByText('Install Plugin'))
expect(updateModelList).toHaveBeenCalledWith(ModelTypeEnum.textGeneration)
expect(updateModelList).toHaveBeenCalledWith(ModelTypeEnum.tts)
expect(updateModelProviders).toHaveBeenCalledTimes(1)
expect(invalidateInstalledPluginList).toHaveBeenCalledTimes(1)
})
it('should show configuration action when provider requires setup', () => {
modelProviders = [{
provider: 'openai',
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.paid,
quota_configurations: [],
},
}] as unknown as ModelProvider[]
render(
<AgentModelTrigger
modelId="gpt-4"
providerName="openai"
/>,
)
expect(screen.getByText('workflow.nodes.agent.notAuthorized')).toBeInTheDocument()
})
it('should render unconfigured state when model is not selected', () => {
render(<AgentModelTrigger />)
expect(screen.getByText('workflow.nodes.agent.configureModel')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,28 @@
import type { ComponentProps } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import { ConfigurationMethodEnum } from '../declarations'
import ConfigurationButton from './configuration-button'
describe('ConfigurationButton', () => {
it('should render and handle click', () => {
const handleOpenModal = vi.fn()
const modelProvider = { id: 1 }
render(
<ConfigurationButton
modelProvider={modelProvider as unknown as ComponentProps<typeof ConfigurationButton>['modelProvider']}
handleOpenModal={handleOpenModal}
/>,
)
const button = screen.getByRole('button')
fireEvent.click(button)
expect(handleOpenModal).toHaveBeenCalledWith(
modelProvider,
ConfigurationMethodEnum.predefinedModel,
undefined,
)
})
})

View File

@ -0,0 +1,273 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import ModelParameterModal from './index'
let isAPIKeySet = true
let parameterRules = [
{
name: 'temperature',
label: { en_US: 'Temperature' },
type: 'float',
default: 0.7,
min: 0,
max: 1,
help: { en_US: 'Control randomness' },
},
]
let isRulesLoading = false
let currentProvider: Record<string, unknown> | undefined = { provider: 'openai', label: { en_US: 'OpenAI' } }
let currentModel: Record<string, unknown> | undefined = {
model: 'gpt-3.5-turbo',
status: 'active',
model_properties: { mode: 'chat' },
}
let activeTextGenerationModelList: Array<Record<string, unknown>> = [
{
provider: 'openai',
models: [
{
model: 'gpt-3.5-turbo',
model_properties: { mode: 'chat' },
features: ['vision'],
},
{
model: 'gpt-4.1',
model_properties: { mode: 'chat' },
features: ['vision', 'tool-call'],
},
],
},
]
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
isAPIKeySet,
}),
}))
vi.mock('@/service/use-common', () => ({
useModelParameterRules: () => ({
data: {
data: parameterRules,
},
isPending: isRulesLoading,
}),
}))
vi.mock('../hooks', () => ({
useTextGenerationCurrentProviderAndModelAndModelList: () => ({
currentProvider,
currentModel,
activeTextGenerationModelList,
}),
}))
// Mock PortalToFollowElem components to control visibility and simplify testing
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
return {
PortalToFollowElem: ({ children }: { children: React.ReactNode }) => {
return (
<div>
<div data-testid="portal-wrapper">
{children}
</div>
</div>
)
},
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick: () => void }) => (
<div data-testid="portal-trigger" onClick={onClick}>
{children}
</div>
),
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className: string }) => (
<div data-testid="portal-content" className={className}>
{children}
</div>
),
}
})
vi.mock('./parameter-item', () => ({
default: ({ parameterRule, value, onChange, onSwitch }: { parameterRule: { name: string, label: { en_US: string } }, value: string | number, onChange: (v: number) => void, onSwitch: (checked: boolean, val: unknown) => void }) => (
<div data-testid={`param-${parameterRule.name}`}>
{parameterRule.label.en_US}
<input
aria-label={parameterRule.name}
value={value || ''}
onChange={e => onChange(Number(e.target.value))}
/>
<button onClick={() => onSwitch?.(false, undefined)}>Remove</button>
<button onClick={() => onSwitch?.(true, 'assigned')}>Add</button>
</div>
),
}))
vi.mock('../model-selector', () => ({
default: ({ onSelect }: { onSelect: (value: { provider: string, model: string }) => void }) => (
<div data-testid="model-selector">
Model Selector
<button onClick={() => onSelect({ provider: 'openai', model: 'gpt-4.1' })}>Select GPT-4.1</button>
</div>
),
}))
vi.mock('./presets-parameter', () => ({
default: ({ onSelect }: { onSelect: (id: number) => void }) => (
<button onClick={() => onSelect(1)}>Preset 1</button>
),
}))
vi.mock('./trigger', () => ({
default: () => <button>Open Settings</button>,
}))
vi.mock('@/utils/classnames', () => ({
cn: (...args: (string | undefined | null | false)[]) => args.filter(Boolean).join(' '),
}))
// Mock config
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
PROVIDER_WITH_PRESET_TONE: ['openai'], // ensure presets mock renders
}
})
describe('ModelParameterModal', () => {
const defaultProps = {
isAdvancedMode: false,
modelId: 'gpt-3.5-turbo',
provider: 'openai',
setModel: vi.fn(),
completionParams: { temperature: 0.7 },
onCompletionParamsChange: vi.fn(),
hideDebugWithMultipleModel: false,
debugWithMultipleModel: false,
onDebugWithMultipleModelChange: vi.fn(),
readonly: false,
}
beforeEach(() => {
vi.clearAllMocks()
isAPIKeySet = true
isRulesLoading = false
parameterRules = [
{
name: 'temperature',
label: { en_US: 'Temperature' },
type: 'float',
default: 0.7,
min: 0,
max: 1,
help: { en_US: 'Control randomness' },
},
]
currentProvider = { provider: 'openai', label: { en_US: 'OpenAI' } }
currentModel = {
model: 'gpt-3.5-turbo',
status: 'active',
model_properties: { mode: 'chat' },
}
activeTextGenerationModelList = [
{
provider: 'openai',
models: [
{
model: 'gpt-3.5-turbo',
model_properties: { mode: 'chat' },
features: ['vision'],
},
{
model: 'gpt-4.1',
model_properties: { mode: 'chat' },
features: ['vision', 'tool-call'],
},
],
},
]
})
it('should render trigger and content', () => {
render(<ModelParameterModal {...defaultProps} />)
expect(screen.getByText('Open Settings')).toBeInTheDocument()
expect(screen.getByText('Temperature')).toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('portal-trigger'))
})
it('should update params when changed and handle switch add/remove', () => {
render(<ModelParameterModal {...defaultProps} />)
const input = screen.getByLabelText('temperature')
fireEvent.change(input, { target: { value: '0.9' } })
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({
...defaultProps.completionParams,
temperature: 0.9,
})
fireEvent.click(screen.getByText('Remove'))
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({})
fireEvent.click(screen.getByText('Add'))
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalledWith({
...defaultProps.completionParams,
temperature: 'assigned',
})
})
it('should handle preset selection', () => {
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Preset 1'))
expect(defaultProps.onCompletionParamsChange).toHaveBeenCalled()
})
it('should handle debug mode toggle', () => {
const { rerender } = render(<ModelParameterModal {...defaultProps} />)
const toggle = screen.getByText(/debugAsMultipleModel/i)
fireEvent.click(toggle)
expect(defaultProps.onDebugWithMultipleModelChange).toHaveBeenCalled()
rerender(<ModelParameterModal {...defaultProps} debugWithMultipleModel />)
expect(screen.getByText(/debugAsSingleModel/i)).toBeInTheDocument()
})
it('should handle custom renderTrigger', () => {
const renderTrigger = vi.fn().mockReturnValue(<div>Custom Trigger</div>)
render(<ModelParameterModal {...defaultProps} renderTrigger={renderTrigger} readonly />)
expect(screen.getByText('Custom Trigger')).toBeInTheDocument()
expect(renderTrigger).toHaveBeenCalled()
fireEvent.click(screen.getByTestId('portal-trigger'))
expect(renderTrigger).toHaveBeenCalledTimes(1)
})
it('should handle model selection and advanced mode parameters', () => {
parameterRules = [
{
name: 'temperature',
label: { en_US: 'Temperature' },
type: 'float',
default: 0.7,
min: 0,
max: 1,
help: { en_US: 'Control randomness' },
},
]
const { rerender } = render(<ModelParameterModal {...defaultProps} />)
expect(screen.getByTestId('param-temperature')).toBeInTheDocument()
rerender(<ModelParameterModal {...defaultProps} isAdvancedMode />)
expect(screen.getByTestId('param-stop')).toBeInTheDocument()
fireEvent.click(screen.getByText('Select GPT-4.1'))
expect(defaultProps.setModel).toHaveBeenCalledWith({
modelId: 'gpt-4.1',
provider: 'openai',
mode: 'chat',
features: ['vision', 'tool-call'],
})
})
})

View File

@ -0,0 +1,20 @@
import { render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import ModelDisplay from './model-display'
vi.mock('../model-name', () => ({
default: ({ modelItem }: { modelItem: { model: string } }) => <div>{modelItem.model}</div>,
}))
describe('ModelDisplay', () => {
it('should render model name when model is present', () => {
const currentModel = { model: 'gpt-4' }
render(<ModelDisplay currentModel={currentModel} modelId="gpt-4" />)
expect(screen.getByText('gpt-4')).toBeInTheDocument()
})
it('should render modelID when currentModel is missing', () => {
render(<ModelDisplay currentModel={null} modelId="unknown-model" />)
expect(screen.getByText('unknown-model')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,239 @@
import type { ModelParameterRule } from '../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import ParameterItem from './parameter-item'
vi.mock('../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/base/radio', () => {
const Radio = ({ children, value }: { children: React.ReactNode, value: boolean }) => <button data-testid={`radio-${value}`}>{children}</button>
Radio.Group = ({ children, onChange }: { children: React.ReactNode, onChange: (value: boolean) => void }) => (
<div>
{children}
<button onClick={() => onChange(true)}>Select True</button>
<button onClick={() => onChange(false)}>Select False</button>
</div>
)
return { default: Radio }
})
vi.mock('@/app/components/base/select', () => ({
SimpleSelect: ({ onSelect, items }: { onSelect: (item: { value: string }) => void, items: { value: string, name: string }[] }) => (
<select onChange={e => onSelect({ value: e.target.value })}>
{items.map(item => (
<option key={item.value} value={item.value}>{item.name}</option>
))}
</select>
),
}))
vi.mock('@/app/components/base/slider', () => ({
default: ({ value, onChange }: { value: number, onChange: (val: number) => void }) => (
<input type="range" value={value} onChange={e => onChange(Number(e.target.value))} />
),
}))
vi.mock('@/app/components/base/switch', () => ({
default: ({ onChange, value }: { onChange: (val: boolean) => void, value: boolean }) => (
<button onClick={() => onChange(!value)}>Switch</button>
),
}))
vi.mock('@/app/components/base/tag-input', () => ({
default: ({ onChange }: { onChange: (val: string[]) => void }) => (
<input onChange={e => onChange(e.target.value.split(','))} />
),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent: React.ReactNode }) => <div>{popupContent}</div>,
}))
describe('ParameterItem', () => {
const createRule = (overrides: Partial<ModelParameterRule> = {}): ModelParameterRule => ({
name: 'temp',
label: { en_US: 'Temperature', zh_Hans: 'Temperature' },
type: 'float',
min: 0,
max: 1,
help: { en_US: 'Help text', zh_Hans: 'Help text' },
required: false,
...overrides,
})
const createProps = (overrides: {
parameterRule?: ModelParameterRule
value?: number | string | boolean | string[]
} = {}) => {
const onChange = vi.fn()
const onSwitch = vi.fn()
return {
parameterRule: createRule(),
value: 0.7,
onChange,
onSwitch,
...overrides,
}
}
beforeEach(() => {
vi.clearAllMocks()
})
it('should render float input with slider', () => {
const props = createProps()
const { rerender } = render(<ParameterItem {...props} />)
expect(screen.getByText('Temperature')).toBeInTheDocument()
const input = screen.getByRole('spinbutton')
fireEvent.change(input, { target: { value: '0.8' } })
expect(props.onChange).toHaveBeenCalledWith(0.8)
fireEvent.change(input, { target: { value: '1.4' } })
expect(props.onChange).toHaveBeenCalledWith(1)
fireEvent.change(input, { target: { value: '-0.2' } })
expect(props.onChange).toHaveBeenCalledWith(0)
const slider = screen.getByRole('slider')
fireEvent.change(slider, { target: { value: '2' } })
expect(props.onChange).toHaveBeenCalledWith(1)
fireEvent.change(slider, { target: { value: '-1' } })
expect(props.onChange).toHaveBeenCalledWith(0)
fireEvent.change(slider, { target: { value: '0.4' } })
expect(props.onChange).toHaveBeenCalledWith(0.4)
fireEvent.blur(input)
expect(input).toHaveValue(0.7)
const minBoundedProps = createProps({
parameterRule: createRule({ type: 'float', min: 1, max: 2 }),
value: 1.5,
})
rerender(<ParameterItem {...minBoundedProps} />)
fireEvent.change(screen.getByRole('slider'), { target: { value: '0' } })
expect(minBoundedProps.onChange).toHaveBeenCalledWith(1)
})
it('should render boolean radio', () => {
const props = createProps({ parameterRule: createRule({ type: 'boolean', default: false }), value: true })
render(<ParameterItem {...props} />)
expect(screen.getByText('True')).toBeInTheDocument()
fireEvent.click(screen.getByText('Select False'))
expect(props.onChange).toHaveBeenCalledWith(false)
})
it('should render string input and select options', () => {
const props = createProps({ parameterRule: createRule({ type: 'string' }), value: 'test' })
const { rerender } = render(<ParameterItem {...props} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'new' } })
expect(props.onChange).toHaveBeenCalledWith('new')
const selectProps = createProps({
parameterRule: createRule({ type: 'string', options: ['opt1', 'opt2'] }),
value: 'opt1',
})
rerender(<ParameterItem {...selectProps} />)
const select = screen.getByRole('combobox')
fireEvent.change(select, { target: { value: 'opt2' } })
expect(selectProps.onChange).toHaveBeenCalledWith('opt2')
})
it('should handle switch toggle', () => {
const props = createProps()
let view = render(<ParameterItem {...props} />)
fireEvent.click(screen.getByText('Switch'))
expect(props.onSwitch).toHaveBeenCalledWith(false, 0.7)
const intDefaultProps = createProps({
parameterRule: createRule({ type: 'int', min: 0, default: undefined }),
value: undefined,
})
view.unmount()
view = render(<ParameterItem {...intDefaultProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(intDefaultProps.onSwitch).toHaveBeenCalledWith(true, 0)
const stringDefaultProps = createProps({
parameterRule: createRule({ type: 'string', default: 'preset-value' }),
value: undefined,
})
view.unmount()
view = render(<ParameterItem {...stringDefaultProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(stringDefaultProps.onSwitch).toHaveBeenCalledWith(true, 'preset-value')
const booleanDefaultProps = createProps({
parameterRule: createRule({ type: 'boolean', default: true }),
value: undefined,
})
view.unmount()
view = render(<ParameterItem {...booleanDefaultProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(booleanDefaultProps.onSwitch).toHaveBeenCalledWith(true, true)
const tagDefaultProps = createProps({
parameterRule: createRule({ type: 'tag', default: ['one'] }),
value: undefined,
})
view.unmount()
const tagView = render(<ParameterItem {...tagDefaultProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(tagDefaultProps.onSwitch).toHaveBeenCalledWith(true, ['one'])
const zeroValueProps = createProps({
parameterRule: createRule({ type: 'float', default: 0.5 }),
value: 0,
})
tagView.unmount()
render(<ParameterItem {...zeroValueProps} />)
fireEvent.click(screen.getByText('Switch'))
expect(zeroValueProps.onSwitch).toHaveBeenCalledWith(false, 0)
})
it('should support text and tag parameter interactions', () => {
const textProps = createProps({
parameterRule: createRule({ type: 'text', name: 'prompt' }),
value: 'initial prompt',
})
const { rerender } = render(<ParameterItem {...textProps} />)
const textarea = screen.getByRole('textbox')
fireEvent.change(textarea, { target: { value: 'rewritten prompt' } })
expect(textProps.onChange).toHaveBeenCalledWith('rewritten prompt')
const tagProps = createProps({
parameterRule: createRule({
type: 'tag',
name: 'tags',
tagPlaceholder: { en_US: 'Tag hint', zh_Hans: 'Tag hint' },
}),
value: ['alpha'],
})
rerender(<ParameterItem {...tagProps} />)
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'one,two' } })
expect(tagProps.onChange).toHaveBeenCalledWith(['one', 'two'])
})
it('should support int parameters and unknown type fallback', () => {
const intProps = createProps({
parameterRule: createRule({ type: 'int', min: 0, max: 500, default: 100 }),
value: 100,
})
const { rerender } = render(<ParameterItem {...intProps} />)
fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '350' } })
expect(intProps.onChange).toHaveBeenCalledWith(350)
const unknownTypeProps = createProps({
parameterRule: createRule({ type: 'unsupported' }),
value: 0.7,
})
rerender(<ParameterItem {...unknownTypeProps} />)
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument()
})
})

View File

@ -109,7 +109,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
const handleSwitch = (checked: boolean) => {
if (onSwitch) {
const assignValue: ParameterValue = localValue || getDefaultValue()
const assignValue: ParameterValue = localValue ?? getDefaultValue()
onSwitch(checked, assignValue)
}
@ -118,7 +118,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
useEffect(() => {
if ((parameterRule.type === 'int' || parameterRule.type === 'float') && numberInputRef.current)
numberInputRef.current.value = `${renderValue}`
}, [value])
}, [value, parameterRule.type, renderValue])
const renderInput = () => {
const numberInputWithSlide = (parameterRule.type === 'int' || parameterRule.type === 'float')
@ -257,7 +257,7 @@ const ParameterItem: FC<ParameterItemProps> = ({
!parameterRule.required && parameterRule.name !== 'stop' && (
<div className="mr-2 w-7">
<Switch
defaultValue={!isNullOrUndefined(value)}
value={!isNullOrUndefined(value)}
onChange={handleSwitch}
size="md"
/>

View File

@ -0,0 +1,32 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import PresetsParameter from './presets-parameter'
vi.mock('@/app/components/base/dropdown', () => ({
default: ({ renderTrigger, items, onSelect }: { renderTrigger: (open: boolean) => React.ReactNode, items: { value: number, text: string }[], onSelect: (item: { value: number }) => void }) => (
<div>
{renderTrigger(false)}
{items.map(item => (
<button key={item.value} onClick={() => onSelect(item)}>
{item.text}
</button>
))}
</div>
),
}))
describe('PresetsParameter', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render presets and handle selection', () => {
const onSelect = vi.fn()
render(<PresetsParameter onSelect={onSelect} />)
expect(screen.getByText('common.modelProvider.loadPresets')).toBeInTheDocument()
fireEvent.click(screen.getByText('common.model.tone.Creative'))
expect(onSelect).toHaveBeenCalledWith(1)
})
})

View File

@ -0,0 +1,103 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { vi } from 'vitest'
import StatusIndicators from './status-indicators'
let installedPlugins = [{ name: 'demo-plugin', plugin_unique_identifier: 'demo@1.0.0' }]
vi.mock('@/service/use-plugins', () => ({
useInstalledPluginList: () => ({ data: { plugins: installedPlugins } }),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent: React.ReactNode }) => <div>{popupContent}</div>,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/switch-plugin-version', () => ({
SwitchPluginVersion: ({ uniqueIdentifier }: { uniqueIdentifier: string }) => <div>{`SwitchVersion:${uniqueIdentifier}`}</div>,
}))
const t = (key: string) => key
describe('StatusIndicators', () => {
beforeEach(() => {
vi.clearAllMocks()
installedPlugins = [{ name: 'demo-plugin', plugin_unique_identifier: 'demo@1.0.0' }]
})
it('should render nothing when model is available and enabled', () => {
const { container } = render(
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={true}
disabled={false}
pluginInfo={null}
t={t}
/>,
)
expect(container).toBeEmptyDOMElement()
})
it('should render warning states when provider model is disabled', () => {
const parentClick = vi.fn()
const { rerender } = render(
<div onClick={parentClick}>
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={true}
disabled={true}
pluginInfo={null}
t={t}
/>
</div>,
)
expect(screen.getByText('nodes.agent.modelSelectorTooltips.deprecated')).toBeInTheDocument()
rerender(
<div onClick={parentClick}>
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={false}
disabled={true}
pluginInfo={null}
t={t}
/>
</div>,
)
expect(screen.getByText('nodes.agent.modelNotSupport.title')).toBeInTheDocument()
expect(screen.getByText('nodes.agent.linkToPlugin').closest('a')).toHaveAttribute('href', '/plugins')
fireEvent.click(screen.getByText('nodes.agent.modelNotSupport.title'))
fireEvent.click(screen.getByText('nodes.agent.linkToPlugin'))
expect(parentClick).not.toHaveBeenCalled()
rerender(
<div onClick={parentClick}>
<StatusIndicators
needsConfiguration={false}
modelProvider={true}
inModelList={false}
disabled={true}
pluginInfo={{ name: 'demo-plugin' }}
t={t}
/>
</div>,
)
expect(screen.getByText('SwitchVersion:demo@1.0.0')).toBeInTheDocument()
})
it('should render marketplace warning when provider is unavailable', () => {
render(
<StatusIndicators
needsConfiguration={false}
modelProvider={false}
inModelList={false}
disabled={false}
pluginInfo={null}
t={t}
/>,
)
expect(screen.getByText('nodes.agent.modelNotInMarketplace.title')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,47 @@
import type { ComponentProps } from 'react'
import { render, screen } from '@testing-library/react'
import Trigger from './trigger'
vi.mock('../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders: [{ provider: 'openai', label: { en_US: 'OpenAI' } }],
}),
}))
vi.mock('../model-icon', () => ({
default: () => <div data-testid="model-icon">Icon</div>,
}))
vi.mock('../model-name', () => ({
default: ({ modelItem }: { modelItem: { model: string } }) => <div>{modelItem.model}</div>,
}))
describe('Trigger', () => {
const currentProvider = { provider: 'openai', label: { en_US: 'OpenAI' } } as unknown as ComponentProps<typeof Trigger>['currentProvider']
const currentModel = { model: 'gpt-4' } as unknown as ComponentProps<typeof Trigger>['currentModel']
it('should render initialized state', () => {
render(
<Trigger
currentProvider={currentProvider}
currentModel={currentModel}
/>,
)
expect(screen.getByText('gpt-4')).toBeInTheDocument()
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
})
it('should render fallback model id when current model is missing', () => {
render(
<Trigger
modelId="gpt-4"
providerName="openai"
/>,
)
expect(screen.getByText('gpt-4')).toBeInTheDocument()
})
})

View File

@ -92,13 +92,13 @@ const ModelListItem = ({ model, provider, isConfigurable, onChange, onModifyLoad
}
offset={{ mainAxis: 4 }}
>
<Switch defaultValue={false} disabled size="md" />
<Switch value={false} disabled size="md" />
</Tooltip>
)
: (isCurrentWorkspaceManager && (
<Switch
className="ml-2"
defaultValue={model?.status === ModelStatusEnum.active}
value={model?.status === ModelStatusEnum.active}
disabled={![ModelStatusEnum.active, ModelStatusEnum.disabled].includes(model.status)}
size="md"
onChange={onEnablingStateChange}

View File

@ -167,7 +167,7 @@ const ModelLoadBalancingConfigs = ({
{
withSwitch && (
<Switch
defaultValue={Boolean(draftConfig.enabled)}
value={Boolean(draftConfig.enabled)}
size="l"
className="ml-3 justify-self-end"
disabled={!modelLoadBalancingEnabled && !draftConfig.enabled}
@ -227,7 +227,7 @@ const ModelLoadBalancingConfigs = ({
<>
<span className="mr-2 h-3 border-r border-r-divider-subtle" />
<Switch
defaultValue={credential?.not_allowed_to_use ? false : Boolean(config.enabled)}
value={credential?.not_allowed_to_use ? false : Boolean(config.enabled)}
size="md"
className="justify-self-end"
onChange={value => toggleConfigEntryEnabled(index, value)}

View File

@ -0,0 +1,206 @@
import type { PluginProvider } from '@/models/common'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useToastContext } from '@/app/components/base/toast'
import { useAppContext } from '@/context/app-context'
import SerpapiPlugin from './SerpapiPlugin'
import { updatePluginKey, validatePluginKey } from './utils'
const mockEventEmitter = vi.hoisted(() => {
let subscriber: ((value: string) => void) | undefined
return {
useSubscription: vi.fn((callback: (value: string) => void) => {
subscriber = callback
}),
emit: vi.fn((value: string) => {
subscriber?.(value)
}),
reset: () => {
subscriber = undefined
},
}
})
vi.mock('@/app/components/base/toast', () => ({
useToastContext: vi.fn(),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('./utils', () => ({
updatePluginKey: vi.fn(),
validatePluginKey: vi.fn(),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: vi.fn(() => ({
eventEmitter: mockEventEmitter,
})),
}))
describe('SerpapiPlugin', () => {
const mockOnUpdate = vi.fn()
const mockNotify = vi.fn()
const mockUpdatePluginKey = updatePluginKey as ReturnType<typeof vi.fn>
const mockValidatePluginKey = validatePluginKey as ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
mockEventEmitter.reset()
const mockUseAppContext = useAppContext as ReturnType<typeof vi.fn>
const mockUseToastContext = useToastContext as ReturnType<typeof vi.fn>
mockUseAppContext.mockReturnValue({
isCurrentWorkspaceManager: true,
})
mockUseToastContext.mockReturnValue({
notify: mockNotify,
})
mockValidatePluginKey.mockResolvedValue({ status: 'success' })
mockUpdatePluginKey.mockResolvedValue({ status: 'success' })
})
it('should show key input when manager clicks edit key', () => {
const mockPlugin: PluginProvider = {
tool_name: 'serpapi',
credentials: {
api_key: 'existing-key',
},
} as PluginProvider
render(<SerpapiPlugin plugin={mockPlugin} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.provider.editKey'))
expect(screen.getByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder')).toBeInTheDocument()
})
it('should clear existing key on focus and show validation error for invalid key', async () => {
vi.useFakeTimers()
try {
mockValidatePluginKey.mockResolvedValue({ status: 'error', message: 'Invalid API key' })
const mockPlugin: PluginProvider = {
tool_name: 'serpapi',
credentials: {
api_key: 'existing-key',
},
} as PluginProvider
render(<SerpapiPlugin plugin={mockPlugin} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.provider.editKey'))
const input = screen.getByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder')
expect(input).toHaveValue('existing-key')
fireEvent.focus(input)
expect(input).toHaveValue('')
fireEvent.change(input, {
target: { value: 'invalid-key' },
})
await act(async () => {
await vi.advanceTimersByTimeAsync(1000)
})
expect(screen.getByText(/Invalid API key/)).toBeInTheDocument()
fireEvent.focus(input)
expect(input).toHaveValue('invalid-key')
fireEvent.change(input, {
target: { value: '' },
})
await act(async () => {
await vi.advanceTimersByTimeAsync(1000)
})
expect(screen.queryByText(/Invalid API key/)).toBeNull()
}
finally {
vi.useRealTimers()
}
})
it('should not open key input when user is not workspace manager', () => {
const mockUseAppContext = useAppContext as ReturnType<typeof vi.fn>
mockUseAppContext.mockReturnValue({
isCurrentWorkspaceManager: false,
})
const mockPlugin = {
tool_name: 'serpapi',
is_enabled: true,
credentials: null,
} satisfies PluginProvider
render(<SerpapiPlugin plugin={mockPlugin} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.provider.addKey'))
expect(screen.queryByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder')).toBeNull()
})
it('should save changed key and trigger success feedback', async () => {
const mockPlugin: PluginProvider = {
tool_name: 'serpapi',
credentials: {
api_key: 'existing-key',
},
} as PluginProvider
render(<SerpapiPlugin plugin={mockPlugin} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.provider.editKey'))
fireEvent.change(screen.getByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder'), {
target: { value: 'new-key' },
})
fireEvent.click(screen.getByText('common.operation.save'))
await waitFor(() => {
expect(screen.queryByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder')).toBeNull()
})
})
it('should keep editor open when save request fails', async () => {
mockUpdatePluginKey.mockResolvedValue({ status: 'error', message: 'update failed' })
const mockPlugin: PluginProvider = {
tool_name: 'serpapi',
credentials: {
api_key: 'existing-key',
},
} as PluginProvider
render(<SerpapiPlugin plugin={mockPlugin} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.provider.editKey'))
fireEvent.change(screen.getByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder'), {
target: { value: 'new-key' },
})
fireEvent.click(screen.getByText('common.operation.save'))
await waitFor(() => {
expect(screen.getByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder')).toBeInTheDocument()
})
})
it('should keep editor open when key value is unchanged', async () => {
const mockPlugin: PluginProvider = {
tool_name: 'serpapi',
credentials: {
api_key: 'existing-key',
},
} as PluginProvider
render(<SerpapiPlugin plugin={mockPlugin} onUpdate={mockOnUpdate} />)
fireEvent.click(screen.getByText('common.provider.editKey'))
fireEvent.click(screen.getByText('common.operation.save'))
await waitFor(() => {
expect(screen.getByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,118 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { useState } from 'react'
import { useAppContext } from '@/context/app-context'
import PluginPage from './index'
import { updatePluginKey, validatePluginKey } from './utils'
const mockUsePluginProviders = vi.hoisted(() => vi.fn())
vi.mock('@/service/use-common', () => ({
usePluginProviders: mockUsePluginProviders,
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
notify: vi.fn(),
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
emit: vi.fn(),
useSubscription: vi.fn(),
},
}),
}))
vi.mock('./utils', () => ({
updatePluginKey: vi.fn(),
validatePluginKey: vi.fn(),
}))
describe('PluginPage', () => {
const mockUpdatePluginKey = updatePluginKey as ReturnType<typeof vi.fn>
const mockValidatePluginKey = validatePluginKey as ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
const mockUseAppContext = useAppContext as ReturnType<typeof vi.fn>
mockUseAppContext.mockReturnValue({
isCurrentWorkspaceManager: true,
})
mockValidatePluginKey.mockResolvedValue({ status: 'success' })
mockUpdatePluginKey.mockResolvedValue({ status: 'success' })
})
it('should render plugin settings with edit action when serpapi key exists', () => {
mockUsePluginProviders.mockReturnValue({
data: [
{ tool_name: 'serpapi', credentials: { api_key: 'test-key' } },
],
refetch: vi.fn(),
})
render(<PluginPage />)
expect(screen.getByText('common.provider.editKey')).toBeInTheDocument()
})
it('should render plugin settings with add action when serpapi key is missing', () => {
mockUsePluginProviders.mockReturnValue({
data: [
{ tool_name: 'serpapi', credentials: null },
],
refetch: vi.fn(),
})
render(<PluginPage />)
expect(screen.getByText('common.provider.addKey')).toBeInTheDocument()
})
it('should display encryption notice with PKCS1_OAEP link', () => {
mockUsePluginProviders.mockReturnValue({
data: [],
refetch: vi.fn(),
})
render(<PluginPage />)
expect(screen.getByText(/common\.provider\.encrypted\.front/)).toBeInTheDocument()
expect(screen.getByText(/common\.provider\.encrypted\.back/)).toBeInTheDocument()
const link = screen.getByRole('link', { name: 'PKCS1_OAEP' })
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('href', 'https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html')
})
it('should show reload state after saving key', async () => {
let showReloadedState = () => {}
const Wrapper = () => {
const [reloaded, setReloaded] = useState(false)
showReloadedState = () => setReloaded(true)
return (
<>
<PluginPage />
{reloaded && <div>providers-reloaded</div>}
</>
)
}
mockUsePluginProviders.mockImplementation(() => ({
data: [{ tool_name: 'serpapi', credentials: { api_key: 'existing-key' } }],
refetch: () => showReloadedState(),
}))
render(<Wrapper />)
fireEvent.click(screen.getByText('common.provider.editKey'))
fireEvent.change(screen.getByPlaceholderText('common.plugin.serpapi.apiKeyPlaceholder'), {
target: { value: 'new-key' },
})
fireEvent.click(screen.getByText('common.operation.save'))
await waitFor(() => {
expect(screen.getByText('providers-reloaded')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,73 @@
import { updatePluginProviderAIKey, validatePluginProviderKey } from '@/service/common'
import { ValidatedStatus } from '../key-validator/declarations'
import { updatePluginKey, validatePluginKey } from './utils'
vi.mock('@/service/common', () => ({
validatePluginProviderKey: vi.fn(),
updatePluginProviderAIKey: vi.fn(),
}))
const mockValidatePluginProviderKey = validatePluginProviderKey as ReturnType<typeof vi.fn>
const mockUpdatePluginProviderAIKey = updatePluginProviderAIKey as ReturnType<typeof vi.fn>
describe('Plugin Utils', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe.each([
{
name: 'validatePluginKey',
utilFn: validatePluginKey,
serviceMock: mockValidatePluginProviderKey,
successBody: { credentials: { api_key: 'test-key' } },
failureBody: { credentials: { api_key: 'invalid' } },
exceptionBody: { credentials: { api_key: 'test' } },
serviceErrorMessage: 'Invalid API key',
thrownErrorMessage: 'Network error',
},
{
name: 'updatePluginKey',
utilFn: updatePluginKey,
serviceMock: mockUpdatePluginProviderAIKey,
successBody: { credentials: { api_key: 'new-key' } },
failureBody: { credentials: { api_key: 'test' } },
exceptionBody: { credentials: { api_key: 'test' } },
serviceErrorMessage: 'Update failed',
thrownErrorMessage: 'Request failed',
},
])('$name', ({ utilFn, serviceMock, successBody, failureBody, exceptionBody, serviceErrorMessage, thrownErrorMessage }) => {
it('should return success status when service succeeds', async () => {
serviceMock.mockResolvedValue({ result: 'success' })
const result = await utilFn('serpapi', successBody)
expect(result.status).toBe(ValidatedStatus.Success)
})
it('should return error status with message when service returns an error', async () => {
serviceMock.mockResolvedValue({
result: 'error',
error: serviceErrorMessage,
})
const result = await utilFn('serpapi', failureBody)
expect(result).toMatchObject({
status: ValidatedStatus.Error,
message: serviceErrorMessage,
})
})
it('should return error status when service throws exception', async () => {
serviceMock.mockRejectedValue(new Error(thrownErrorMessage))
const result = await utilFn('serpapi', exceptionBody)
expect(result).toMatchObject({
status: ValidatedStatus.Error,
message: thrownErrorMessage,
})
})
})
})