test: Add unit tests for Data Source Integrations (Notion, Website) and Modals (#32313)

Co-authored-by: akashseth-ifp <akash.seth@infocusp.com>
This commit is contained in:
mahammadasim 2026-02-23 10:30:02 +05:30 committed by GitHub
parent d0bb642fc5
commit ce8354a42a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 3364 additions and 0 deletions

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()
})
})
})