import type { DocumentListResponse } from '@/models/datasets' import { act, fireEvent, render, screen } from '@testing-library/react' import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail' import { useProviderContext } from '@/context/provider-context' import { DataSourceType } from '@/models/datasets' import { useDocumentList } from '@/service/knowledge/use-document' import useDocumentsPageState from './hooks/use-documents-page-state' import Documents from './index' // Type for mock selector function - use `as MockState` to bypass strict type checking in tests type MockSelector = Parameters[0] type MockState = Parameters[0] // Mock Next.js router const mockPush = vi.fn() vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush, replace: vi.fn(), prefetch: vi.fn(), }), usePathname: () => '/datasets/test-dataset-id/documents', useSearchParams: () => new URLSearchParams(), })) // Mock context providers vi.mock('@/context/dataset-detail', () => ({ useDatasetDetailContextWithSelector: vi.fn((selector: (state: unknown) => unknown) => { const mockState = { dataset: { id: 'test-dataset-id', name: 'Test Dataset', embedding_available: true, data_source_type: DataSourceType.FILE, runtime_mode: 'rag', }, } return selector(mockState as MockState) }), })) vi.mock('@/context/provider-context', () => ({ useProviderContext: vi.fn(() => ({ plan: { type: 'professional' }, })), })) // Mock document service hooks const mockInvalidDocumentList = vi.fn() const mockInvalidDocumentDetail = vi.fn() vi.mock('@/service/knowledge/use-document', () => ({ useDocumentList: vi.fn(() => ({ data: { data: [ { id: 'doc-1', name: 'Document 1', indexing_status: 'completed', data_source_type: 'upload_file', position: 1, enabled: true, }, { id: 'doc-2', name: 'Document 2', indexing_status: 'indexing', data_source_type: 'upload_file', position: 2, enabled: true, }, ], total: 2, page: 1, limit: 10, has_more: false, } as DocumentListResponse, isLoading: false, refetch: vi.fn(), })), useInvalidDocumentList: vi.fn(() => mockInvalidDocumentList), useInvalidDocumentDetail: vi.fn(() => mockInvalidDocumentDetail), })) // Mock segment service hooks vi.mock('@/service/knowledge/use-segment', () => ({ useSegmentListKey: 'segment-list-key', useChildSegmentListKey: 'child-segment-list-key', })) // Mock base service hooks vi.mock('@/service/use-base', () => ({ useInvalid: vi.fn(() => vi.fn()), })) // Mock metadata hook vi.mock('../metadata/hooks/use-edit-dataset-metadata', () => ({ default: vi.fn(() => ({ isShowEditModal: false, showEditModal: vi.fn(), hideEditModal: vi.fn(), datasetMetaData: [], handleAddMetaData: vi.fn(), handleRename: vi.fn(), handleDeleteMetaData: vi.fn(), builtInEnabled: false, setBuiltInEnabled: vi.fn(), builtInMetaData: [], })), })) // Mock page state hook const mockSetSelectedIds = vi.fn() const mockHandleInputChange = vi.fn() const mockHandleStatusFilterChange = vi.fn() const mockHandleStatusFilterClear = vi.fn() const mockHandleSortChange = vi.fn() const mockHandlePageChange = vi.fn() const mockHandleLimitChange = vi.fn() const mockUpdatePollingState = vi.fn() const mockAdjustPageForTotal = vi.fn() vi.mock('./hooks/use-documents-page-state', () => ({ default: vi.fn(() => ({ inputValue: '', searchValue: '', debouncedSearchValue: '', handleInputChange: mockHandleInputChange, statusFilterValue: 'all', sortValue: '-created_at' as const, normalizedStatusFilterValue: 'all', handleStatusFilterChange: mockHandleStatusFilterChange, handleStatusFilterClear: mockHandleStatusFilterClear, handleSortChange: mockHandleSortChange, currPage: 0, limit: 10, handlePageChange: mockHandlePageChange, handleLimitChange: mockHandleLimitChange, selectedIds: [] as string[], setSelectedIds: mockSetSelectedIds, timerCanRun: false, updatePollingState: mockUpdatePollingState, adjustPageForTotal: mockAdjustPageForTotal, })), })) // Mock child components - these have deep dependency chains (QueryClient, API hooks, contexts) // Mocking them allows us to test the Documents component logic in isolation vi.mock('./components/documents-header', () => ({ default: ({ datasetId, embeddingAvailable, onInputChange, onAddDocument, onStatusFilterChange, onStatusFilterClear, onSortChange, }: { datasetId: string dataSourceType?: string embeddingAvailable: boolean isFreePlan: boolean statusFilterValue: string sortValue: string inputValue: string onInputChange: (value: string) => void onAddDocument: () => void onStatusFilterChange: (value: string) => void onStatusFilterClear: () => void onSortChange: (value: string) => void isShowEditMetadataModal: boolean showEditMetadataModal: () => void hideEditMetadataModal: () => void datasetMetaData: unknown[] builtInMetaData: unknown[] builtInEnabled: boolean onAddMetaData: () => void onRenameMetaData: () => void onDeleteMetaData: () => void onBuiltInEnabledChange: () => void }) => (
{datasetId} {String(embeddingAvailable)} onInputChange(e.target.value)} placeholder="Search documents" />
), })) vi.mock('./components/empty-element', () => ({ default: ({ canAdd, onClick, type }: { canAdd: boolean onClick: () => void type: 'sync' | 'upload' }) => (
{String(canAdd)} {type}
), })) vi.mock('./components/list', () => ({ default: ({ documents, datasetId, onUpdate, selectedIds, onSelectedIdChange, pagination, }: { embeddingAvailable: boolean documents: unknown[] datasetId: string onUpdate: () => void selectedIds: string[] onSelectedIdChange: (ids: string[]) => void statusFilterValue: string remoteSortValue: string pagination: { total: number limit: number current: number onChange: (page: number) => void onLimitChange: (limit: number) => void } onManageMetadata: () => void }) => (
{datasetId} {documents.length} {selectedIds.length} {pagination.total} {pagination.current}
), })) describe('Documents', () => { const defaultProps = { datasetId: 'test-dataset-id', } beforeEach(() => { vi.clearAllMocks() mockPush.mockClear() // Reset context mocks to default vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector: MockSelector) => { const mockState = { dataset: { id: 'test-dataset-id', name: 'Test Dataset', embedding_available: true, data_source_type: DataSourceType.FILE, runtime_mode: 'rag', }, } return selector(mockState as MockState) }) }) describe('Rendering', () => { it('should render without crashing', () => { render() expect(screen.getByTestId('documents-header')).toBeInTheDocument() }) it('should render DocumentsHeader with correct props', () => { render() expect(screen.getByTestId('header-dataset-id')).toHaveTextContent('test-dataset-id') expect(screen.getByTestId('header-embedding-available')).toHaveTextContent('true') }) it('should render document list when documents exist', () => { render() expect(screen.getByTestId('documents-list')).toBeInTheDocument() expect(screen.getByTestId('list-documents-count')).toHaveTextContent('2') }) it('should render loading state when isLoading is true', () => { vi.mocked(useDocumentList).mockReturnValueOnce({ data: undefined, isLoading: true, refetch: vi.fn(), } as unknown as ReturnType) render() expect(screen.queryByTestId('documents-list')).not.toBeInTheDocument() }) it('should render empty element when no documents exist', () => { vi.mocked(useDocumentList).mockReturnValueOnce({ data: { data: [], total: 0, page: 1, limit: 10, has_more: false }, isLoading: false, refetch: vi.fn(), } as unknown as ReturnType) render() expect(screen.getByTestId('empty-element')).toBeInTheDocument() expect(screen.getByTestId('empty-can-add')).toHaveTextContent('true') expect(screen.getByTestId('empty-type')).toHaveTextContent('upload') }) it('should render sync type empty element for Notion data source', () => { vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector: MockSelector) => { const mockState = { dataset: { id: 'test-dataset-id', name: 'Test Dataset', embedding_available: true, data_source_type: DataSourceType.NOTION, runtime_mode: 'rag', }, } return selector(mockState as MockState) }) vi.mocked(useDocumentList).mockReturnValueOnce({ data: { data: [], total: 0, page: 1, limit: 10, has_more: false }, isLoading: false, refetch: vi.fn(), } as unknown as ReturnType) render() expect(screen.getByTestId('empty-type')).toHaveTextContent('sync') }) }) describe('Props', () => { it('should pass datasetId to child components', () => { render() expect(screen.getByTestId('header-dataset-id')).toHaveTextContent('test-dataset-id') }) it('should handle different datasetId', () => { render() expect(screen.getByTestId('header-dataset-id')).toHaveTextContent('different-dataset-id') }) }) describe('User Interactions', () => { it('should call handleInputChange when search input changes', async () => { render() const searchInput = screen.getByTestId('search-input') fireEvent.change(searchInput, { target: { value: 'test' } }) expect(mockHandleInputChange).toHaveBeenCalledWith('test') }) it('should call handleStatusFilterChange when filter button is clicked', () => { render() screen.getByTestId('status-filter-btn').click() expect(mockHandleStatusFilterChange).toHaveBeenCalledWith('completed') }) it('should call handleStatusFilterClear when clear button is clicked', () => { render() screen.getByTestId('clear-filter-btn').click() expect(mockHandleStatusFilterClear).toHaveBeenCalled() }) it('should call handleSortChange when sort button is clicked', () => { render() screen.getByTestId('sort-btn').click() expect(mockHandleSortChange).toHaveBeenCalledWith('-updated_at') }) it('should call setSelectedIds when document is selected', () => { render() screen.getByTestId('select-btn').click() expect(mockSetSelectedIds).toHaveBeenCalledWith(['doc-1']) }) it('should call handlePageChange when page changes', () => { render() screen.getByTestId('page-change-btn').click() expect(mockHandlePageChange).toHaveBeenCalledWith(1) }) it('should call handleLimitChange when limit changes', () => { render() screen.getByTestId('limit-change-btn').click() expect(mockHandleLimitChange).toHaveBeenCalledWith(20) }) }) describe('Router Navigation', () => { it('should navigate to create page when add document is clicked', () => { render() screen.getByTestId('add-document-btn').click() expect(mockPush).toHaveBeenCalledWith('/datasets/test-dataset-id/documents/create') }) it('should navigate to pipeline create page when dataset is rag_pipeline mode', () => { vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector: MockSelector) => { const mockState = { dataset: { id: 'test-dataset-id', name: 'Test Dataset', embedding_available: true, data_source_type: DataSourceType.FILE, runtime_mode: 'rag_pipeline', }, } return selector(mockState as MockState) }) render() screen.getByTestId('add-document-btn').click() expect(mockPush).toHaveBeenCalledWith('/datasets/test-dataset-id/documents/create-from-pipeline') }) it('should navigate from empty element add button', () => { vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector: MockSelector) => { const mockState = { dataset: { id: 'test-dataset-id', name: 'Test Dataset', embedding_available: true, data_source_type: DataSourceType.FILE, runtime_mode: 'rag', }, } return selector(mockState as MockState) }) vi.mocked(useDocumentList).mockReturnValueOnce({ data: { data: [], total: 0, page: 1, limit: 10, has_more: false }, isLoading: false, refetch: vi.fn(), } as unknown as ReturnType) render() screen.getByTestId('empty-add-btn').click() expect(mockPush).toHaveBeenCalledWith('/datasets/test-dataset-id/documents/create') }) }) describe('Side Effects and Cleanup', () => { it('should call updatePollingState when documents response changes', () => { render() expect(mockUpdatePollingState).toHaveBeenCalled() }) it('should call adjustPageForTotal when documents response changes', () => { render() expect(mockAdjustPageForTotal).toHaveBeenCalled() }) }) describe('Callback Stability and Memoization', () => { it('should call handleUpdate with invalidation functions', async () => { render() screen.getByTestId('update-btn').click() expect(mockInvalidDocumentList).toHaveBeenCalled() expect(mockInvalidDocumentDetail).toHaveBeenCalled() }) it('should handle update with delayed chunk invalidation', async () => { vi.useFakeTimers() render() screen.getByTestId('update-btn').click() expect(mockInvalidDocumentList).toHaveBeenCalled() expect(mockInvalidDocumentDetail).toHaveBeenCalled() await act(async () => { vi.advanceTimersByTime(5000) }) vi.useRealTimers() }) }) describe('Edge Cases and Error Handling', () => { it('should handle undefined dataset gracefully', () => { vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector: MockSelector) => { const mockState = { dataset: undefined } return selector(mockState as MockState) }) render() expect(screen.getByTestId('documents-header')).toBeInTheDocument() }) it('should handle empty documents array', () => { vi.mocked(useDocumentList).mockReturnValueOnce({ data: { data: [], total: 0, page: 1, limit: 10, has_more: false }, isLoading: false, refetch: vi.fn(), } as unknown as ReturnType) render() expect(screen.getByTestId('empty-element')).toBeInTheDocument() }) it('should handle undefined documentsRes', () => { vi.mocked(useDocumentList).mockReturnValueOnce({ data: undefined, isLoading: false, refetch: vi.fn(), } as unknown as ReturnType) render() expect(screen.getByTestId('empty-element')).toBeInTheDocument() }) it('should handle embedding not available', () => { vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector: MockSelector) => { const mockState = { dataset: { id: 'test-dataset-id', name: 'Test Dataset', embedding_available: false, data_source_type: DataSourceType.FILE, runtime_mode: 'rag', }, } return selector(mockState as MockState) }) render() expect(screen.getByTestId('header-embedding-available')).toHaveTextContent('false') }) it('should handle free plan user', () => { vi.mocked(useProviderContext).mockReturnValueOnce({ plan: { type: 'sandbox' }, } as ReturnType) render() expect(screen.getByTestId('documents-header')).toBeInTheDocument() }) }) describe('Polling State', () => { it('should enable polling when documents are indexing', () => { vi.mocked(useDocumentsPageState).mockReturnValueOnce({ inputValue: '', searchValue: '', debouncedSearchValue: '', handleInputChange: mockHandleInputChange, statusFilterValue: 'all', sortValue: '-created_at' as const, normalizedStatusFilterValue: 'all', handleStatusFilterChange: mockHandleStatusFilterChange, handleStatusFilterClear: mockHandleStatusFilterClear, handleSortChange: mockHandleSortChange, currPage: 0, limit: 10, handlePageChange: mockHandlePageChange, handleLimitChange: mockHandleLimitChange, selectedIds: [] as string[], setSelectedIds: mockSetSelectedIds, timerCanRun: true, updatePollingState: mockUpdatePollingState, adjustPageForTotal: mockAdjustPageForTotal, }) render() expect(screen.getByTestId('documents-list')).toBeInTheDocument() }) }) describe('Pagination', () => { it('should display correct total in list', () => { render() expect(screen.getByTestId('list-total')).toHaveTextContent('2') }) it('should display correct current page', () => { render() expect(screen.getByTestId('list-current-page')).toHaveTextContent('0') }) it('should handle page changes', () => { vi.mocked(useDocumentsPageState).mockReturnValueOnce({ inputValue: '', searchValue: '', debouncedSearchValue: '', handleInputChange: mockHandleInputChange, statusFilterValue: 'all', sortValue: '-created_at' as const, normalizedStatusFilterValue: 'all', handleStatusFilterChange: mockHandleStatusFilterChange, handleStatusFilterClear: mockHandleStatusFilterClear, handleSortChange: mockHandleSortChange, currPage: 2, limit: 10, handlePageChange: mockHandlePageChange, handleLimitChange: mockHandleLimitChange, selectedIds: [] as string[], setSelectedIds: mockSetSelectedIds, timerCanRun: false, updatePollingState: mockUpdatePollingState, adjustPageForTotal: mockAdjustPageForTotal, }) render() expect(screen.getByTestId('list-current-page')).toHaveTextContent('2') }) }) describe('Selection State', () => { it('should display selected count', () => { vi.mocked(useDocumentsPageState).mockReturnValueOnce({ inputValue: '', searchValue: '', debouncedSearchValue: '', handleInputChange: mockHandleInputChange, statusFilterValue: 'all', sortValue: '-created_at' as const, normalizedStatusFilterValue: 'all', handleStatusFilterChange: mockHandleStatusFilterChange, handleStatusFilterClear: mockHandleStatusFilterClear, handleSortChange: mockHandleSortChange, currPage: 0, limit: 10, handlePageChange: mockHandlePageChange, handleLimitChange: mockHandleLimitChange, selectedIds: ['doc-1', 'doc-2'], setSelectedIds: mockSetSelectedIds, timerCanRun: false, updatePollingState: mockUpdatePollingState, adjustPageForTotal: mockAdjustPageForTotal, }) render() expect(screen.getByTestId('list-selected-count')).toHaveTextContent('2') }) }) describe('Filter and Sort State', () => { it('should pass filter value to list', () => { vi.mocked(useDocumentsPageState).mockReturnValueOnce({ inputValue: 'test search', searchValue: 'test search', debouncedSearchValue: 'test search', handleInputChange: mockHandleInputChange, statusFilterValue: 'completed', sortValue: '-created_at' as const, normalizedStatusFilterValue: 'completed', handleStatusFilterChange: mockHandleStatusFilterChange, handleStatusFilterClear: mockHandleStatusFilterClear, handleSortChange: mockHandleSortChange, currPage: 0, limit: 10, handlePageChange: mockHandlePageChange, handleLimitChange: mockHandleLimitChange, selectedIds: [] as string[], setSelectedIds: mockSetSelectedIds, timerCanRun: false, updatePollingState: mockUpdatePollingState, adjustPageForTotal: mockAdjustPageForTotal, }) render() expect(screen.getByTestId('documents-list')).toBeInTheDocument() }) }) })