mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 18:08:07 +08:00
Merge commit '657eeb65' into sandboxed-agent-rebase
Made-with: Cursor # Conflicts: # api/core/agent/cot_chat_agent_runner.py # api/core/agent/fc_agent_runner.py # api/core/memory/token_buffer_memory.py # api/core/variables/segments.py # api/core/workflow/file/file_manager.py # api/core/workflow/nodes/agent/agent_node.py # api/core/workflow/nodes/llm/llm_utils.py # api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py # api/core/workflow/workflow_entry.py # api/factories/variable_factory.py # api/pyproject.toml # api/services/variable_truncator.py # api/uv.lock # web/app/components/app/app-publisher/index.tsx # web/app/components/app/overview/settings/index.tsx # web/app/components/apps/app-card.tsx # web/app/components/apps/index.tsx # web/app/components/apps/list.tsx # web/app/components/base/chat/chat-with-history/header-in-mobile.tsx # web/app/components/base/features/new-feature-panel/conversation-opener/modal.tsx # web/app/components/base/features/new-feature-panel/file-upload/setting-content.tsx # web/app/components/base/features/new-feature-panel/moderation/moderation-setting-modal.tsx # web/app/components/base/features/new-feature-panel/text-to-speech/param-config-content.tsx # web/app/components/base/message-log-modal/index.tsx # web/app/components/base/switch/index.tsx # web/app/components/base/tab-slider-plain/index.tsx # web/app/components/explore/try-app/app-info/index.tsx # web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.tsx # web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/required-switch.tsx # web/app/components/workflow/nodes/llm/panel.tsx # web/contract/router.ts # web/eslint-suppressions.json # web/i18n/fa-IR/workflow.json
This commit is contained in:
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user