Files
dify/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.spec.tsx
2025-12-19 17:49:51 +08:00

1358 lines
43 KiB
TypeScript

import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import React from 'react'
import OnlineDocuments from './index'
import type { DataSourceNotionWorkspace, NotionPage } from '@/models/common'
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
import { VarKindType } from '@/app/components/workflow/nodes/_base/types'
// ==========================================
// Mock Modules
// ==========================================
// Note: react-i18next uses global mock from web/__mocks__/react-i18next.ts
// Mock useDocLink - context hook requires mocking
const mockDocLink = jest.fn((path?: string) => `https://docs.example.com${path || ''}`)
jest.mock('@/context/i18n', () => ({
useDocLink: () => mockDocLink,
}))
// Mock dataset-detail context - context provider requires mocking
let mockPipelineId = 'pipeline-123'
jest.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (s: any) => any) => selector({ dataset: { pipeline_id: mockPipelineId } }),
}))
// Mock modal context - context provider requires mocking
const mockSetShowAccountSettingModal = jest.fn()
jest.mock('@/context/modal-context', () => ({
useModalContextSelector: (selector: (s: any) => any) => selector({ setShowAccountSettingModal: mockSetShowAccountSettingModal }),
}))
// Mock ssePost - API service requires mocking
const mockSsePost = jest.fn()
jest.mock('@/service/base', () => ({
ssePost: (...args: any[]) => mockSsePost(...args),
}))
// Mock Toast.notify - static method that manipulates DOM, needs mocking to verify calls
const mockToastNotify = jest.fn()
jest.mock('@/app/components/base/toast', () => ({
__esModule: true,
default: {
notify: (options: any) => mockToastNotify(options),
},
}))
// Mock useGetDataSourceAuth - API service hook requires mocking
const mockUseGetDataSourceAuth = jest.fn()
jest.mock('@/service/use-datasource', () => ({
useGetDataSourceAuth: (params: any) => mockUseGetDataSourceAuth(params),
}))
// Note: zustand/react/shallow useShallow is imported directly (simple utility function)
// Mock store
const mockStoreState = {
documentsData: [] as DataSourceNotionWorkspace[],
searchValue: '',
selectedPagesId: new Set<string>(),
currentCredentialId: '',
setDocumentsData: jest.fn(),
setSearchValue: jest.fn(),
setSelectedPagesId: jest.fn(),
setOnlineDocuments: jest.fn(),
setCurrentDocument: jest.fn(),
}
const mockGetState = jest.fn(() => mockStoreState)
const mockDataSourceStore = { getState: mockGetState }
jest.mock('../store', () => ({
useDataSourceStoreWithSelector: (selector: (s: any) => any) => selector(mockStoreState),
useDataSourceStore: () => mockDataSourceStore,
}))
// Mock Header component
jest.mock('../base/header', () => {
const MockHeader = (props: any) => (
<div data-testid="header">
<span data-testid="header-doc-title">{props.docTitle}</span>
<span data-testid="header-doc-link">{props.docLink}</span>
<span data-testid="header-plugin-name">{props.pluginName}</span>
<span data-testid="header-credential-id">{props.currentCredentialId}</span>
<button data-testid="header-config-btn" onClick={props.onClickConfiguration}>Configure</button>
<button data-testid="header-credential-change" onClick={() => props.onCredentialChange('new-cred-id')}>Change Credential</button>
<span data-testid="header-credentials-count">{props.credentials?.length || 0}</span>
</div>
)
return MockHeader
})
// Mock SearchInput component
jest.mock('@/app/components/base/notion-page-selector/search-input', () => {
const MockSearchInput = ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
<div data-testid="search-input">
<input
data-testid="search-input-field"
value={value}
onChange={e => onChange(e.target.value)}
placeholder="Search"
/>
</div>
)
return MockSearchInput
})
// Mock PageSelector component
jest.mock('./page-selector', () => {
const MockPageSelector = (props: any) => (
<div data-testid="page-selector">
<span data-testid="page-selector-checked-count">{props.checkedIds?.size || 0}</span>
<span data-testid="page-selector-search-value">{props.searchValue}</span>
<span data-testid="page-selector-can-preview">{String(props.canPreview)}</span>
<span data-testid="page-selector-multiple-choice">{String(props.isMultipleChoice)}</span>
<span data-testid="page-selector-credential-id">{props.currentCredentialId}</span>
<button
data-testid="page-selector-select-btn"
onClick={() => props.onSelect(new Set(['page-1', 'page-2']))}
>
Select Pages
</button>
<button
data-testid="page-selector-preview-btn"
onClick={() => props.onPreview?.('page-1')}
>
Preview Page
</button>
</div>
)
return MockPageSelector
})
// Mock Title component
jest.mock('./title', () => {
const MockTitle = ({ name }: { name: string }) => (
<div data-testid="title">
<span data-testid="title-name">{name}</span>
</div>
)
return MockTitle
})
// Mock Loading component
jest.mock('@/app/components/base/loading', () => {
const MockLoading = ({ type }: { type: string }) => (
<div data-testid="loading" data-type={type}>Loading...</div>
)
return MockLoading
})
// ==========================================
// Test Data Builders
// ==========================================
const createMockNodeData = (overrides?: Partial<DataSourceNodeType>): DataSourceNodeType => ({
title: 'Test Node',
plugin_id: 'plugin-123',
provider_type: 'notion',
provider_name: 'notion-provider',
datasource_name: 'notion-ds',
datasource_label: 'Notion',
datasource_parameters: {},
datasource_configurations: {},
...overrides,
} as DataSourceNodeType)
const createMockPage = (overrides?: Partial<NotionPage>): NotionPage => ({
page_id: 'page-1',
page_name: 'Test Page',
page_icon: null,
is_bound: false,
parent_id: 'root',
type: 'page',
workspace_id: 'workspace-1',
...overrides,
})
const createMockWorkspace = (overrides?: Partial<DataSourceNotionWorkspace>): DataSourceNotionWorkspace => ({
workspace_id: 'workspace-1',
workspace_name: 'Test Workspace',
workspace_icon: null,
pages: [createMockPage()],
...overrides,
})
const createMockCredential = (overrides?: Partial<{ id: string; name: string }>) => ({
id: 'cred-1',
name: 'Test Credential',
avatar_url: 'https://example.com/avatar.png',
credential: {},
is_default: false,
type: 'oauth2',
...overrides,
})
type OnlineDocumentsProps = React.ComponentProps<typeof OnlineDocuments>
const createDefaultProps = (overrides?: Partial<OnlineDocumentsProps>): OnlineDocumentsProps => ({
nodeId: 'node-1',
nodeData: createMockNodeData(),
onCredentialChange: jest.fn(),
isInPipeline: false,
supportBatchUpload: true,
...overrides,
})
// ==========================================
// Test Suites
// ==========================================
describe('OnlineDocuments', () => {
beforeEach(() => {
jest.clearAllMocks()
// Reset store state
mockStoreState.documentsData = []
mockStoreState.searchValue = ''
mockStoreState.selectedPagesId = new Set()
mockStoreState.currentCredentialId = ''
mockStoreState.setDocumentsData = jest.fn()
mockStoreState.setSearchValue = jest.fn()
mockStoreState.setSelectedPagesId = jest.fn()
mockStoreState.setOnlineDocuments = jest.fn()
mockStoreState.setCurrentDocument = jest.fn()
// Reset context values
mockPipelineId = 'pipeline-123'
mockSetShowAccountSettingModal.mockClear()
// Default mock return values
mockUseGetDataSourceAuth.mockReturnValue({
data: { result: [createMockCredential()] },
})
mockGetState.mockReturnValue(mockStoreState)
})
// ==========================================
// Rendering Tests
// ==========================================
describe('Rendering', () => {
it('should render without crashing', () => {
// Arrange
const props = createDefaultProps()
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(screen.getByTestId('header')).toBeInTheDocument()
})
it('should render Header with correct props', () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-123'
const props = createDefaultProps({
nodeData: createMockNodeData({ datasource_label: 'My Notion' }),
})
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(screen.getByTestId('header-doc-title')).toHaveTextContent('Docs')
expect(screen.getByTestId('header-plugin-name')).toHaveTextContent('My Notion')
expect(screen.getByTestId('header-credential-id')).toHaveTextContent('cred-123')
})
it('should render Loading when documentsData is empty', () => {
// Arrange
mockStoreState.documentsData = []
const props = createDefaultProps()
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(screen.getByTestId('loading')).toBeInTheDocument()
expect(screen.getByTestId('loading')).toHaveAttribute('data-type', 'app')
})
it('should render PageSelector when documentsData has content', () => {
// Arrange
mockStoreState.documentsData = [createMockWorkspace()]
const props = createDefaultProps()
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(screen.getByTestId('page-selector')).toBeInTheDocument()
expect(screen.queryByTestId('loading')).not.toBeInTheDocument()
})
it('should render Title with datasource_label', () => {
// Arrange
mockStoreState.documentsData = [createMockWorkspace()]
const props = createDefaultProps({
nodeData: createMockNodeData({ datasource_label: 'Notion Integration' }),
})
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(screen.getByTestId('title-name')).toHaveTextContent('Notion Integration')
})
it('should render SearchInput with current searchValue', () => {
// Arrange
mockStoreState.searchValue = 'test search'
mockStoreState.documentsData = [createMockWorkspace()]
const props = createDefaultProps()
// Act
render(<OnlineDocuments {...props} />)
// Assert
const searchInput = screen.getByTestId('search-input-field') as HTMLInputElement
expect(searchInput.value).toBe('test search')
})
})
// ==========================================
// Props Testing
// ==========================================
describe('Props', () => {
describe('nodeId prop', () => {
it('should use nodeId in datasourceNodeRunURL for non-pipeline mode', () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
const props = createDefaultProps({
nodeId: 'custom-node-id',
isInPipeline: false,
})
// Act
render(<OnlineDocuments {...props} />)
// Assert - Effect triggers ssePost with correct URL
expect(mockSsePost).toHaveBeenCalledWith(
expect.stringContaining('/nodes/custom-node-id/run'),
expect.any(Object),
expect.any(Object),
)
})
})
describe('nodeData prop', () => {
it('should pass datasource_parameters to ssePost', () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
const nodeData = createMockNodeData({
datasource_parameters: {
param1: { type: VarKindType.constant, value: 'value1' },
param2: { type: VarKindType.constant, value: 'value2' },
},
})
const props = createDefaultProps({ nodeData })
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(mockSsePost).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: expect.objectContaining({
inputs: { param1: 'value1', param2: 'value2' },
}),
}),
expect.any(Object),
)
})
it('should pass plugin_id and provider_name to useGetDataSourceAuth', () => {
// Arrange
const nodeData = createMockNodeData({
plugin_id: 'my-plugin-id',
provider_name: 'my-provider',
})
const props = createDefaultProps({ nodeData })
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(mockUseGetDataSourceAuth).toHaveBeenCalledWith({
pluginId: 'my-plugin-id',
provider: 'my-provider',
})
})
})
describe('isInPipeline prop', () => {
it('should use draft URL when isInPipeline is true', () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
const props = createDefaultProps({ isInPipeline: true })
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(mockSsePost).toHaveBeenCalledWith(
expect.stringContaining('/workflows/draft/'),
expect.any(Object),
expect.any(Object),
)
})
it('should use published URL when isInPipeline is false', () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
const props = createDefaultProps({ isInPipeline: false })
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(mockSsePost).toHaveBeenCalledWith(
expect.stringContaining('/workflows/published/'),
expect.any(Object),
expect.any(Object),
)
})
it('should pass canPreview as false to PageSelector when isInPipeline is true', () => {
// Arrange
mockStoreState.documentsData = [createMockWorkspace()]
const props = createDefaultProps({ isInPipeline: true })
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(screen.getByTestId('page-selector-can-preview')).toHaveTextContent('false')
})
it('should pass canPreview as true to PageSelector when isInPipeline is false', () => {
// Arrange
mockStoreState.documentsData = [createMockWorkspace()]
const props = createDefaultProps({ isInPipeline: false })
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(screen.getByTestId('page-selector-can-preview')).toHaveTextContent('true')
})
})
describe('supportBatchUpload prop', () => {
it('should pass isMultipleChoice as true to PageSelector when supportBatchUpload is true', () => {
// Arrange
mockStoreState.documentsData = [createMockWorkspace()]
const props = createDefaultProps({ supportBatchUpload: true })
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(screen.getByTestId('page-selector-multiple-choice')).toHaveTextContent('true')
})
it('should pass isMultipleChoice as false to PageSelector when supportBatchUpload is false', () => {
// Arrange
mockStoreState.documentsData = [createMockWorkspace()]
const props = createDefaultProps({ supportBatchUpload: false })
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(screen.getByTestId('page-selector-multiple-choice')).toHaveTextContent('false')
})
it.each([
[true, 'true'],
[false, 'false'],
[undefined, 'true'], // Default value
])('should handle supportBatchUpload=%s correctly', (value, expected) => {
// Arrange
mockStoreState.documentsData = [createMockWorkspace()]
const props = createDefaultProps({ supportBatchUpload: value })
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(screen.getByTestId('page-selector-multiple-choice')).toHaveTextContent(expected)
})
})
describe('onCredentialChange prop', () => {
it('should pass onCredentialChange to Header', () => {
// Arrange
const mockOnCredentialChange = jest.fn()
const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange })
// Act
render(<OnlineDocuments {...props} />)
fireEvent.click(screen.getByTestId('header-credential-change'))
// Assert
expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id')
})
})
})
// ==========================================
// Side Effects and Cleanup
// ==========================================
describe('Side Effects and Cleanup', () => {
it('should call getOnlineDocuments when currentCredentialId changes', () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
const props = createDefaultProps()
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(mockSsePost).toHaveBeenCalledTimes(1)
})
it('should not call getOnlineDocuments when currentCredentialId is empty', () => {
// Arrange
mockStoreState.currentCredentialId = ''
const props = createDefaultProps()
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(mockSsePost).not.toHaveBeenCalled()
})
it('should pass correct body parameters to ssePost', () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-123'
const props = createDefaultProps()
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(mockSsePost).toHaveBeenCalledWith(
expect.any(String),
{
body: {
inputs: {},
credential_id: 'cred-123',
datasource_type: 'online_document',
},
},
expect.any(Object),
)
})
it('should handle onDataSourceNodeCompleted callback correctly', async () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
const mockWorkspaces = [createMockWorkspace()]
mockSsePost.mockImplementation((url, options, callbacks) => {
// Simulate successful response
callbacks.onDataSourceNodeCompleted({
event: 'datasource_completed',
data: mockWorkspaces,
time_consuming: 1000,
})
})
const props = createDefaultProps()
// Act
render(<OnlineDocuments {...props} />)
// Assert
await waitFor(() => {
expect(mockStoreState.setDocumentsData).toHaveBeenCalledWith(mockWorkspaces)
})
})
it('should handle onDataSourceNodeError callback correctly', async () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
mockSsePost.mockImplementation((url, options, callbacks) => {
// Simulate error response
callbacks.onDataSourceNodeError({
event: 'datasource_error',
error: 'Something went wrong',
})
})
const props = createDefaultProps()
// Act
render(<OnlineDocuments {...props} />)
// Assert
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
message: 'Something went wrong',
})
})
})
it('should construct correct URL for draft workflow', () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
mockPipelineId = 'pipeline-456'
const props = createDefaultProps({
nodeId: 'node-789',
isInPipeline: true,
})
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(mockSsePost).toHaveBeenCalledWith(
'/rag/pipelines/pipeline-456/workflows/draft/datasource/nodes/node-789/run',
expect.any(Object),
expect.any(Object),
)
})
it('should construct correct URL for published workflow', () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
mockPipelineId = 'pipeline-456'
const props = createDefaultProps({
nodeId: 'node-789',
isInPipeline: false,
})
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(mockSsePost).toHaveBeenCalledWith(
'/rag/pipelines/pipeline-456/workflows/published/datasource/nodes/node-789/run',
expect.any(Object),
expect.any(Object),
)
})
})
// ==========================================
// Callback Stability and Memoization
// ==========================================
describe('Callback Stability and Memoization', () => {
it('should have stable handleSearchValueChange that updates store', () => {
// Arrange
mockStoreState.documentsData = [createMockWorkspace()]
const props = createDefaultProps()
render(<OnlineDocuments {...props} />)
// Act
const searchInput = screen.getByTestId('search-input-field')
fireEvent.change(searchInput, { target: { value: 'new search value' } })
// Assert
expect(mockStoreState.setSearchValue).toHaveBeenCalledWith('new search value')
})
it('should have stable handleSelectPages that updates store', () => {
// Arrange
mockStoreState.documentsData = [createMockWorkspace()]
const props = createDefaultProps()
render(<OnlineDocuments {...props} />)
// Act
fireEvent.click(screen.getByTestId('page-selector-select-btn'))
// Assert
expect(mockStoreState.setSelectedPagesId).toHaveBeenCalled()
expect(mockStoreState.setOnlineDocuments).toHaveBeenCalled()
})
it('should have stable handlePreviewPage that updates store', () => {
// Arrange
const mockPages = [
createMockPage({ page_id: 'page-1', page_name: 'Page 1' }),
]
mockStoreState.documentsData = [createMockWorkspace({ pages: mockPages })]
const props = createDefaultProps()
render(<OnlineDocuments {...props} />)
// Act
fireEvent.click(screen.getByTestId('page-selector-preview-btn'))
// Assert
expect(mockStoreState.setCurrentDocument).toHaveBeenCalled()
})
it('should have stable handleSetting callback', () => {
// Arrange
const props = createDefaultProps()
render(<OnlineDocuments {...props} />)
// Act
fireEvent.click(screen.getByTestId('header-config-btn'))
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'data-source',
})
})
})
// ==========================================
// Memoization Logic and Dependencies
// ==========================================
describe('Memoization Logic and Dependencies', () => {
it('should compute PagesMapAndSelectedPagesId correctly from documentsData', () => {
// Arrange
const mockPages = [
createMockPage({ page_id: 'page-1', page_name: 'Page 1' }),
createMockPage({ page_id: 'page-2', page_name: 'Page 2' }),
]
mockStoreState.documentsData = [
createMockWorkspace({ workspace_id: 'ws-1', pages: mockPages }),
]
const props = createDefaultProps()
// Act
render(<OnlineDocuments {...props} />)
// Assert - PageSelector receives the pagesMap (verified via mock)
expect(screen.getByTestId('page-selector')).toBeInTheDocument()
})
it('should recompute PagesMapAndSelectedPagesId when documentsData changes', () => {
// Arrange
const initialPages = [createMockPage({ page_id: 'page-1' })]
mockStoreState.documentsData = [createMockWorkspace({ pages: initialPages })]
const props = createDefaultProps()
const { rerender } = render(<OnlineDocuments {...props} />)
// Act - Update documentsData
const newPages = [
createMockPage({ page_id: 'page-1' }),
createMockPage({ page_id: 'page-2' }),
]
mockStoreState.documentsData = [createMockWorkspace({ pages: newPages })]
rerender(<OnlineDocuments {...props} />)
// Assert
expect(screen.getByTestId('page-selector')).toBeInTheDocument()
})
it('should handle empty documentsData in PagesMapAndSelectedPagesId computation', () => {
// Arrange
mockStoreState.documentsData = []
const props = createDefaultProps()
// Act
render(<OnlineDocuments {...props} />)
// Assert - Should show loading instead of PageSelector
expect(screen.getByTestId('loading')).toBeInTheDocument()
})
})
// ==========================================
// User Interactions and Event Handlers
// ==========================================
describe('User Interactions and Event Handlers', () => {
it('should handle search input changes', () => {
// Arrange
mockStoreState.documentsData = [createMockWorkspace()]
const props = createDefaultProps()
render(<OnlineDocuments {...props} />)
// Act
const searchInput = screen.getByTestId('search-input-field')
fireEvent.change(searchInput, { target: { value: 'search query' } })
// Assert
expect(mockStoreState.setSearchValue).toHaveBeenCalledWith('search query')
})
it('should handle page selection', () => {
// Arrange
const mockPages = [
createMockPage({ page_id: 'page-1', page_name: 'Page 1' }),
createMockPage({ page_id: 'page-2', page_name: 'Page 2' }),
]
mockStoreState.documentsData = [createMockWorkspace({ pages: mockPages })]
const props = createDefaultProps()
render(<OnlineDocuments {...props} />)
// Act
fireEvent.click(screen.getByTestId('page-selector-select-btn'))
// Assert
expect(mockStoreState.setSelectedPagesId).toHaveBeenCalled()
expect(mockStoreState.setOnlineDocuments).toHaveBeenCalled()
})
it('should handle page preview', () => {
// Arrange
const mockPages = [createMockPage({ page_id: 'page-1', page_name: 'Page 1' })]
mockStoreState.documentsData = [createMockWorkspace({ pages: mockPages })]
const props = createDefaultProps()
render(<OnlineDocuments {...props} />)
// Act
fireEvent.click(screen.getByTestId('page-selector-preview-btn'))
// Assert
expect(mockStoreState.setCurrentDocument).toHaveBeenCalled()
})
it('should handle configuration button click', () => {
// Arrange
const props = createDefaultProps()
render(<OnlineDocuments {...props} />)
// Act
fireEvent.click(screen.getByTestId('header-config-btn'))
// Assert
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'data-source',
})
})
it('should handle credential change', () => {
// Arrange
const mockOnCredentialChange = jest.fn()
const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange })
render(<OnlineDocuments {...props} />)
// Act
fireEvent.click(screen.getByTestId('header-credential-change'))
// Assert
expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id')
})
})
// ==========================================
// API Calls Mocking
// ==========================================
describe('API Calls', () => {
it('should call ssePost with correct parameters', () => {
// Arrange
mockStoreState.currentCredentialId = 'test-cred'
const props = createDefaultProps({
nodeData: createMockNodeData({
datasource_parameters: {
workspace: { type: VarKindType.constant, value: 'ws-123' },
database: { type: VarKindType.constant, value: 'db-456' },
},
}),
})
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(mockSsePost).toHaveBeenCalledWith(
expect.any(String),
{
body: {
inputs: { workspace: 'ws-123', database: 'db-456' },
credential_id: 'test-cred',
datasource_type: 'online_document',
},
},
expect.objectContaining({
onDataSourceNodeCompleted: expect.any(Function),
onDataSourceNodeError: expect.any(Function),
}),
)
})
it('should handle successful API response', async () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
const mockData = [createMockWorkspace()]
mockSsePost.mockImplementation((url, options, callbacks) => {
callbacks.onDataSourceNodeCompleted({
event: 'datasource_completed',
data: mockData,
time_consuming: 500,
})
})
const props = createDefaultProps()
// Act
render(<OnlineDocuments {...props} />)
// Assert
await waitFor(() => {
expect(mockStoreState.setDocumentsData).toHaveBeenCalledWith(mockData)
})
})
it('should handle API error response', async () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
mockSsePost.mockImplementation((url, options, callbacks) => {
callbacks.onDataSourceNodeError({
event: 'datasource_error',
error: 'API Error Message',
})
})
const props = createDefaultProps()
// Act
render(<OnlineDocuments {...props} />)
// Assert
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
message: 'API Error Message',
})
})
})
it('should use useGetDataSourceAuth with correct parameters', () => {
// Arrange
const nodeData = createMockNodeData({
plugin_id: 'notion-plugin',
provider_name: 'notion-provider',
})
const props = createDefaultProps({ nodeData })
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(mockUseGetDataSourceAuth).toHaveBeenCalledWith({
pluginId: 'notion-plugin',
provider: 'notion-provider',
})
})
it('should pass credentials from useGetDataSourceAuth to Header', () => {
// Arrange
const mockCredentials = [
createMockCredential({ id: 'cred-1', name: 'Credential 1' }),
createMockCredential({ id: 'cred-2', name: 'Credential 2' }),
]
mockUseGetDataSourceAuth.mockReturnValue({
data: { result: mockCredentials },
})
const props = createDefaultProps()
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('2')
})
})
// ==========================================
// Edge Cases and Error Handling
// ==========================================
describe('Edge Cases and Error Handling', () => {
it('should handle empty credentials array', () => {
// Arrange
mockUseGetDataSourceAuth.mockReturnValue({
data: { result: [] },
})
const props = createDefaultProps()
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0')
})
it('should handle undefined dataSourceAuth result', () => {
// Arrange
mockUseGetDataSourceAuth.mockReturnValue({
data: { result: undefined },
})
const props = createDefaultProps()
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0')
})
it('should handle null dataSourceAuth data', () => {
// Arrange
mockUseGetDataSourceAuth.mockReturnValue({
data: null,
})
const props = createDefaultProps()
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0')
})
it('should handle documentsData with empty pages array', () => {
// Arrange
mockStoreState.documentsData = [createMockWorkspace({ pages: [] })]
const props = createDefaultProps()
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(screen.getByTestId('page-selector')).toBeInTheDocument()
})
it('should handle undefined documentsData in useMemo (line 59 branch)', () => {
// Arrange - Set documentsData to undefined to test the || [] fallback
mockStoreState.documentsData = undefined as unknown as DataSourceNotionWorkspace[]
const props = createDefaultProps()
// Act
render(<OnlineDocuments {...props} />)
// Assert - Should show loading when documentsData is undefined
expect(screen.getByTestId('loading')).toBeInTheDocument()
})
it('should handle undefined datasource_parameters (line 79 branch)', () => {
// Arrange - Set datasource_parameters to undefined to test the || {} fallback
mockStoreState.currentCredentialId = 'cred-1'
const nodeData = createMockNodeData()
// @ts-expect-error - Testing undefined case for branch coverage
nodeData.datasource_parameters = undefined
const props = createDefaultProps({ nodeData })
// Act
render(<OnlineDocuments {...props} />)
// Assert - ssePost should be called with empty inputs
expect(mockSsePost).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: expect.objectContaining({
inputs: {},
}),
}),
expect.any(Object),
)
})
it('should handle datasource_parameters value without value property (line 80 else branch)', () => {
// Arrange - Test the else branch where value is not an object with 'value' property
// This tests: typeof value === 'object' && value !== null && 'value' in value ? value.value : value
// The else branch (: value) is executed when value is a primitive or object without 'value' key
mockStoreState.currentCredentialId = 'cred-1'
const nodeData = createMockNodeData({
datasource_parameters: {
// Object without 'value' key - should use the object itself
objWithoutValue: { type: VarKindType.constant, other: 'data' } as any,
},
})
const props = createDefaultProps({ nodeData })
// Act
render(<OnlineDocuments {...props} />)
// Assert - The object without 'value' property should be passed as-is
expect(mockSsePost).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: expect.objectContaining({
inputs: expect.objectContaining({
objWithoutValue: expect.objectContaining({ type: VarKindType.constant, other: 'data' }),
}),
}),
}),
expect.any(Object),
)
})
it('should handle multiple workspaces in documentsData', () => {
// Arrange
mockStoreState.documentsData = [
createMockWorkspace({ workspace_id: 'ws-1', pages: [createMockPage({ page_id: 'page-1' })] }),
createMockWorkspace({ workspace_id: 'ws-2', pages: [createMockPage({ page_id: 'page-2' })] }),
]
const props = createDefaultProps()
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(screen.getByTestId('page-selector')).toBeInTheDocument()
})
it('should handle special characters in searchValue', () => {
// Arrange
mockStoreState.documentsData = [createMockWorkspace()]
const props = createDefaultProps()
render(<OnlineDocuments {...props} />)
// Act
const searchInput = screen.getByTestId('search-input-field')
fireEvent.change(searchInput, { target: { value: 'test<script>alert("xss")</script>' } })
// Assert
expect(mockStoreState.setSearchValue).toHaveBeenCalledWith('test<script>alert("xss")</script>')
})
it('should handle unicode characters in searchValue', () => {
// Arrange
mockStoreState.documentsData = [createMockWorkspace()]
const props = createDefaultProps()
render(<OnlineDocuments {...props} />)
// Act
const searchInput = screen.getByTestId('search-input-field')
fireEvent.change(searchInput, { target: { value: '测试搜索 🔍' } })
// Assert
expect(mockStoreState.setSearchValue).toHaveBeenCalledWith('测试搜索 🔍')
})
it('should handle empty string currentCredentialId', () => {
// Arrange
mockStoreState.currentCredentialId = ''
const props = createDefaultProps()
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(mockSsePost).not.toHaveBeenCalled()
})
it('should handle complex datasource_parameters with nested objects', () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
const nodeData = createMockNodeData({
datasource_parameters: {
simple: { type: VarKindType.constant, value: 'value' },
nested: { type: VarKindType.constant, value: 'nested-value' },
},
})
const props = createDefaultProps({ nodeData })
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(mockSsePost).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: expect.objectContaining({
inputs: expect.objectContaining({
simple: 'value',
nested: 'nested-value',
}),
}),
}),
expect.any(Object),
)
})
it('should handle undefined pipelineId gracefully', () => {
// Arrange
mockPipelineId = undefined as any
mockStoreState.currentCredentialId = 'cred-1'
const props = createDefaultProps()
// Act
render(<OnlineDocuments {...props} />)
// Assert - Should still call ssePost with undefined in URL
expect(mockSsePost).toHaveBeenCalled()
})
})
// ==========================================
// All Prop Variations
// ==========================================
describe('Prop Variations', () => {
it.each([
[{ isInPipeline: true, supportBatchUpload: true }],
[{ isInPipeline: true, supportBatchUpload: false }],
[{ isInPipeline: false, supportBatchUpload: true }],
[{ isInPipeline: false, supportBatchUpload: false }],
])('should render correctly with props %o', (propVariation) => {
// Arrange
mockStoreState.documentsData = [createMockWorkspace()]
const props = createDefaultProps(propVariation)
// Act
render(<OnlineDocuments {...props} />)
// Assert
expect(screen.getByTestId('page-selector')).toBeInTheDocument()
expect(screen.getByTestId('page-selector-can-preview')).toHaveTextContent(
String(!propVariation.isInPipeline),
)
expect(screen.getByTestId('page-selector-multiple-choice')).toHaveTextContent(
String(propVariation.supportBatchUpload),
)
})
it('should use default values for optional props', () => {
// Arrange
mockStoreState.documentsData = [createMockWorkspace()]
const props: OnlineDocumentsProps = {
nodeId: 'node-1',
nodeData: createMockNodeData(),
onCredentialChange: jest.fn(),
// isInPipeline and supportBatchUpload are not provided
}
// Act
render(<OnlineDocuments {...props} />)
// Assert - Default values: isInPipeline = false, supportBatchUpload = true
expect(screen.getByTestId('page-selector-can-preview')).toHaveTextContent('true')
expect(screen.getByTestId('page-selector-multiple-choice')).toHaveTextContent('true')
})
})
// ==========================================
// Integration Tests
// ==========================================
describe('Integration', () => {
it('should complete full workflow: load data -> search -> select -> preview', async () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
const mockPages = [
createMockPage({ page_id: 'page-1', page_name: 'Test Page 1' }),
createMockPage({ page_id: 'page-2', page_name: 'Test Page 2' }),
]
const mockWorkspace = createMockWorkspace({ pages: mockPages })
mockSsePost.mockImplementation((url, options, callbacks) => {
callbacks.onDataSourceNodeCompleted({
event: 'datasource_completed',
data: [mockWorkspace],
time_consuming: 100,
})
})
// Update store state after API call
mockStoreState.documentsData = [mockWorkspace]
const props = createDefaultProps()
render(<OnlineDocuments {...props} />)
// Assert - Data loaded and PageSelector shown
await waitFor(() => {
expect(mockStoreState.setDocumentsData).toHaveBeenCalled()
})
// Act - Search
const searchInput = screen.getByTestId('search-input-field')
fireEvent.change(searchInput, { target: { value: 'Test' } })
expect(mockStoreState.setSearchValue).toHaveBeenCalledWith('Test')
// Act - Select pages
fireEvent.click(screen.getByTestId('page-selector-select-btn'))
expect(mockStoreState.setSelectedPagesId).toHaveBeenCalled()
// Act - Preview page
fireEvent.click(screen.getByTestId('page-selector-preview-btn'))
expect(mockStoreState.setCurrentDocument).toHaveBeenCalled()
})
it('should handle error flow correctly', async () => {
// Arrange
mockStoreState.currentCredentialId = 'cred-1'
mockSsePost.mockImplementation((url, options, callbacks) => {
callbacks.onDataSourceNodeError({
event: 'datasource_error',
error: 'Failed to fetch documents',
})
})
const props = createDefaultProps()
// Act
render(<OnlineDocuments {...props} />)
// Assert
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith({
type: 'error',
message: 'Failed to fetch documents',
})
})
// Should still show loading since documentsData is empty
expect(screen.getByTestId('loading')).toBeInTheDocument()
})
it('should handle credential change and refetch documents', () => {
// Arrange
mockStoreState.currentCredentialId = 'initial-cred'
const mockOnCredentialChange = jest.fn()
const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange })
// Act
render(<OnlineDocuments {...props} />)
// Initial fetch
expect(mockSsePost).toHaveBeenCalledTimes(1)
// Change credential
fireEvent.click(screen.getByTestId('header-credential-change'))
expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id')
})
})
// ==========================================
// Styling
// ==========================================
describe('Styling', () => {
it('should apply correct container classes', () => {
// Arrange
const props = createDefaultProps()
// Act
const { container } = render(<OnlineDocuments {...props} />)
// Assert
const rootDiv = container.firstChild as HTMLElement
expect(rootDiv).toHaveClass('flex', 'flex-col', 'gap-y-2')
})
it('should apply correct classes to main content container', () => {
// Arrange
mockStoreState.documentsData = [createMockWorkspace()]
const props = createDefaultProps()
// Act
const { container } = render(<OnlineDocuments {...props} />)
// Assert
const contentContainer = container.querySelector('.rounded-xl.border')
expect(contentContainer).toBeInTheDocument()
expect(contentContainer).toHaveClass('border-components-panel-border', 'bg-background-default-subtle')
})
})
})