diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source-options/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/index.spec.tsx new file mode 100644 index 0000000000..4ae74be9d1 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source-options/index.spec.tsx @@ -0,0 +1,1662 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' +import { renderHook } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' +import DataSourceOptions from './index' +import OptionCard from './option-card' +import DatasourceIcon from './datasource-icon' +import { useDatasourceIcon } from './hooks' +import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' +import { BlockEnum, type Node } from '@/app/components/workflow/types' +import type { Datasource } from '@/app/components/rag-pipeline/components/panel/test-run/types' + +// ========================================== +// Mock External Dependencies +// ========================================== + +// Mock useDatasourceOptions hook from parent hooks +const mockUseDatasourceOptions = jest.fn() +jest.mock('../hooks', () => ({ + useDatasourceOptions: (nodes: Node[]) => mockUseDatasourceOptions(nodes), +})) + +// Mock useDataSourceList API hook +const mockUseDataSourceList = jest.fn() +jest.mock('@/service/use-pipeline', () => ({ + useDataSourceList: (enabled: boolean) => mockUseDataSourceList(enabled), +})) + +// Mock transformDataSourceToTool utility +const mockTransformDataSourceToTool = jest.fn() +jest.mock('@/app/components/workflow/block-selector/utils', () => ({ + transformDataSourceToTool: (item: unknown) => mockTransformDataSourceToTool(item), +})) + +// Mock basePath +jest.mock('@/utils/var', () => ({ + basePath: '/mock-base-path', +})) + +// ========================================== +// Test Data Builders +// ========================================== + +const createMockDataSourceNodeData = (overrides?: Partial): DataSourceNodeType => ({ + title: 'Test Data Source', + desc: 'Test description', + type: BlockEnum.DataSource, + plugin_id: 'test-plugin-id', + provider_type: 'local_file', + provider_name: 'Test Provider', + datasource_name: 'test-datasource', + datasource_label: 'Test Datasource Label', + datasource_parameters: {}, + datasource_configurations: {}, + ...overrides, +}) + +const createMockPipelineNode = (overrides?: Partial>): Node => { + const nodeData = createMockDataSourceNodeData(overrides?.data) + return { + id: `node-${Math.random().toString(36).slice(2, 9)}`, + type: 'custom', + position: { x: 0, y: 0 }, + data: nodeData, + ...overrides, + } +} + +const createMockPipelineNodes = (count = 3): Node[] => { + return Array.from({ length: count }, (_, i) => + createMockPipelineNode({ + id: `node-${i + 1}`, + data: createMockDataSourceNodeData({ + title: `Data Source ${i + 1}`, + plugin_id: `plugin-${i + 1}`, + datasource_name: `datasource-${i + 1}`, + }), + }), + ) +} + +const createMockDatasourceOption = ( + node: Node, +) => ({ + label: node.data.title, + value: node.id, + data: node.data, +}) + +const createMockDataSourceListItem = (overrides?: Record) => ({ + declaration: { + identity: { + icon: '/icons/test-icon.png', + name: 'test-datasource', + label: { en_US: 'Test Datasource' }, + }, + provider: 'test-provider', + }, + plugin_id: 'test-plugin-id', + ...overrides, +}) + +// ========================================== +// Test Utilities +// ========================================== + +const createQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, +}) + +const renderWithProviders = ( + ui: React.ReactElement, + queryClient?: QueryClient, +) => { + const client = queryClient || createQueryClient() + return render( + + {ui} + , + ) +} + +const createHookWrapper = () => { + const queryClient = createQueryClient() + return ({ children }: { children: React.ReactNode }) => ( + + {children} + + ) +} + +// ========================================== +// DatasourceIcon Tests +// ========================================== +describe('DatasourceIcon', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + const { container } = render() + + // Assert + expect(container.firstChild).toBeInTheDocument() + }) + + it('should render icon with background image', () => { + // Arrange + const iconUrl = 'https://example.com/icon.png' + + // Act + const { container } = render() + + // Assert + const iconDiv = container.querySelector('[style*="background-image"]') + expect(iconDiv).toHaveStyle({ backgroundImage: `url(${iconUrl})` }) + }) + + it('should render with default size (sm)', () => { + // Arrange & Act + const { container } = render() + + // Assert - Default size is 'sm' which maps to 'w-5 h-5' + expect(container.firstChild).toHaveClass('w-5') + expect(container.firstChild).toHaveClass('h-5') + }) + }) + + describe('Props', () => { + describe('size', () => { + it('should render with xs size', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert + expect(container.firstChild).toHaveClass('w-4') + expect(container.firstChild).toHaveClass('h-4') + expect(container.firstChild).toHaveClass('rounded-[5px]') + }) + + it('should render with sm size', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert + expect(container.firstChild).toHaveClass('w-5') + expect(container.firstChild).toHaveClass('h-5') + expect(container.firstChild).toHaveClass('rounded-md') + }) + + it('should render with md size', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert + expect(container.firstChild).toHaveClass('w-6') + expect(container.firstChild).toHaveClass('h-6') + expect(container.firstChild).toHaveClass('rounded-lg') + }) + }) + + describe('className', () => { + it('should apply custom className', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert + expect(container.firstChild).toHaveClass('custom-class') + }) + + it('should merge custom className with default classes', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert + expect(container.firstChild).toHaveClass('custom-class') + expect(container.firstChild).toHaveClass('w-5') + expect(container.firstChild).toHaveClass('h-5') + }) + }) + + describe('iconUrl', () => { + it('should handle empty iconUrl', () => { + // Arrange & Act + const { container } = render() + + // Assert + const iconDiv = container.querySelector('[style*="background-image"]') + expect(iconDiv).toHaveStyle({ backgroundImage: 'url()' }) + }) + + it('should handle special characters in iconUrl', () => { + // Arrange + const iconUrl = 'https://example.com/icon.png?param=value&other=123' + + // Act + const { container } = render() + + // Assert + const iconDiv = container.querySelector('[style*="background-image"]') + expect(iconDiv).toHaveStyle({ backgroundImage: `url(${iconUrl})` }) + }) + + it('should handle data URL as iconUrl', () => { + // Arrange + const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' + + // Act + const { container } = render() + + // Assert + const iconDiv = container.querySelector('[style*="background-image"]') + expect(iconDiv).toBeInTheDocument() + }) + }) + }) + + describe('Styling', () => { + it('should have flex container classes', () => { + // Arrange & Act + const { container } = render() + + // Assert + expect(container.firstChild).toHaveClass('flex') + expect(container.firstChild).toHaveClass('items-center') + expect(container.firstChild).toHaveClass('justify-center') + }) + + it('should have shadow-xs class from size map', () => { + // Arrange & Act + const { container } = render() + + // Assert - Default size 'sm' has shadow-xs + expect(container.firstChild).toHaveClass('shadow-xs') + }) + + it('should have inner div with bg-cover class', () => { + // Arrange & Act + const { container } = render() + + // Assert + const innerDiv = container.querySelector('.bg-cover') + expect(innerDiv).toBeInTheDocument() + expect(innerDiv).toHaveClass('bg-center') + expect(innerDiv).toHaveClass('rounded-md') + }) + }) +}) + +// ========================================== +// useDatasourceIcon Hook Tests +// ========================================== +describe('useDatasourceIcon', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseDataSourceList.mockReturnValue({ + data: [], + isSuccess: false, + }) + mockTransformDataSourceToTool.mockImplementation(item => ({ + plugin_id: item.plugin_id, + icon: item.declaration?.identity?.icon, + })) + }) + + describe('Loading State', () => { + it('should return undefined when data is not loaded', () => { + // Arrange + mockUseDataSourceList.mockReturnValue({ + data: undefined, + isSuccess: false, + }) + const nodeData = createMockDataSourceNodeData() + + // Act + const { result } = renderHook(() => useDatasourceIcon(nodeData), { + wrapper: createHookWrapper(), + }) + + // Assert + expect(result.current).toBeUndefined() + }) + + it('should call useDataSourceList with true', () => { + // Arrange + const nodeData = createMockDataSourceNodeData() + + // Act + renderHook(() => useDatasourceIcon(nodeData), { + wrapper: createHookWrapper(), + }) + + // Assert + expect(mockUseDataSourceList).toHaveBeenCalledWith(true) + }) + }) + + describe('Success State', () => { + it('should return icon when data is loaded and plugin matches', () => { + // Arrange + const mockDataSourceList = [ + createMockDataSourceListItem({ + plugin_id: 'test-plugin-id', + declaration: { + identity: { + icon: '/icons/test-icon.png', + name: 'test', + label: { en_US: 'Test' }, + }, + }, + }), + ] + mockUseDataSourceList.mockReturnValue({ + data: mockDataSourceList, + isSuccess: true, + }) + mockTransformDataSourceToTool.mockImplementation(item => ({ + plugin_id: item.plugin_id, + icon: item.declaration?.identity?.icon, + })) + const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' }) + + // Act + const { result } = renderHook(() => useDatasourceIcon(nodeData), { + wrapper: createHookWrapper(), + }) + + // Assert - Icon should have basePath prepended + expect(result.current).toBe('/mock-base-path/icons/test-icon.png') + }) + + it('should return undefined when plugin does not match', () => { + // Arrange + const mockDataSourceList = [ + createMockDataSourceListItem({ + plugin_id: 'other-plugin-id', + }), + ] + mockUseDataSourceList.mockReturnValue({ + data: mockDataSourceList, + isSuccess: true, + }) + const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' }) + + // Act + const { result } = renderHook(() => useDatasourceIcon(nodeData), { + wrapper: createHookWrapper(), + }) + + // Assert + expect(result.current).toBeUndefined() + }) + + it('should prepend basePath to icon when icon does not include basePath', () => { + // Arrange + const mockDataSourceList = [ + createMockDataSourceListItem({ + plugin_id: 'test-plugin-id', + declaration: { + identity: { + icon: '/icons/test-icon.png', + name: 'test', + label: { en_US: 'Test' }, + }, + }, + }), + ] + mockUseDataSourceList.mockReturnValue({ + data: mockDataSourceList, + isSuccess: true, + }) + mockTransformDataSourceToTool.mockImplementation(item => ({ + plugin_id: item.plugin_id, + icon: item.declaration?.identity?.icon, + })) + const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' }) + + // Act + const { result } = renderHook(() => useDatasourceIcon(nodeData), { + wrapper: createHookWrapper(), + }) + + // Assert - Icon should have basePath prepended + expect(result.current).toBe('/mock-base-path/icons/test-icon.png') + }) + + it('should not prepend basePath when icon already includes basePath', () => { + // Arrange + const mockDataSourceList = [ + createMockDataSourceListItem({ + plugin_id: 'test-plugin-id', + declaration: { + identity: { + icon: '/mock-base-path/icons/test-icon.png', + name: 'test', + label: { en_US: 'Test' }, + }, + }, + }), + ] + mockUseDataSourceList.mockReturnValue({ + data: mockDataSourceList, + isSuccess: true, + }) + mockTransformDataSourceToTool.mockImplementation(item => ({ + plugin_id: item.plugin_id, + icon: item.declaration?.identity?.icon, + })) + const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' }) + + // Act + const { result } = renderHook(() => useDatasourceIcon(nodeData), { + wrapper: createHookWrapper(), + }) + + // Assert - Icon should not be modified + expect(result.current).toBe('/mock-base-path/icons/test-icon.png') + }) + }) + + describe('Edge Cases', () => { + it('should handle empty dataSourceList', () => { + // Arrange + mockUseDataSourceList.mockReturnValue({ + data: [], + isSuccess: true, + }) + const nodeData = createMockDataSourceNodeData() + + // Act + const { result } = renderHook(() => useDatasourceIcon(nodeData), { + wrapper: createHookWrapper(), + }) + + // Assert + expect(result.current).toBeUndefined() + }) + + it('should handle null dataSourceList', () => { + // Arrange + mockUseDataSourceList.mockReturnValue({ + data: null, + isSuccess: true, + }) + const nodeData = createMockDataSourceNodeData() + + // Act + const { result } = renderHook(() => useDatasourceIcon(nodeData), { + wrapper: createHookWrapper(), + }) + + // Assert + expect(result.current).toBeUndefined() + }) + + it('should handle icon as non-string type', () => { + // Arrange + const mockDataSourceList = [ + createMockDataSourceListItem({ + plugin_id: 'test-plugin-id', + declaration: { + identity: { + icon: { url: '/icons/test-icon.png' }, // Object instead of string + name: 'test', + label: { en_US: 'Test' }, + }, + }, + }), + ] + mockUseDataSourceList.mockReturnValue({ + data: mockDataSourceList, + isSuccess: true, + }) + mockTransformDataSourceToTool.mockImplementation(item => ({ + plugin_id: item.plugin_id, + icon: item.declaration?.identity?.icon, + })) + const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' }) + + // Act + const { result } = renderHook(() => useDatasourceIcon(nodeData), { + wrapper: createHookWrapper(), + }) + + // Assert - Should return the icon object as-is since it's not a string + expect(result.current).toEqual({ url: '/icons/test-icon.png' }) + }) + + it('should memoize result based on plugin_id', () => { + // Arrange + const mockDataSourceList = [ + createMockDataSourceListItem({ + plugin_id: 'test-plugin-id', + }), + ] + mockUseDataSourceList.mockReturnValue({ + data: mockDataSourceList, + isSuccess: true, + }) + const nodeData = createMockDataSourceNodeData({ plugin_id: 'test-plugin-id' }) + + // Act + const { result, rerender } = renderHook(() => useDatasourceIcon(nodeData), { + wrapper: createHookWrapper(), + }) + const firstResult = result.current + + // Rerender with same props + rerender() + + // Assert - Should return the same memoized result + expect(result.current).toBe(firstResult) + }) + }) +}) + +// ========================================== +// OptionCard Tests +// ========================================== +describe('OptionCard', () => { + const defaultProps = { + label: 'Test Option', + selected: false, + nodeData: createMockDataSourceNodeData(), + } + + beforeEach(() => { + jest.clearAllMocks() + // Setup default mock for useDatasourceIcon + mockUseDataSourceList.mockReturnValue({ + data: [], + isSuccess: true, + }) + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + renderWithProviders() + + // Assert + expect(screen.getByText('Test Option')).toBeInTheDocument() + }) + + it('should render label text', () => { + // Arrange & Act + renderWithProviders() + + // Assert + expect(screen.getByText('Custom Label')).toBeInTheDocument() + }) + + it('should render DatasourceIcon component', () => { + // Arrange & Act + const { container } = renderWithProviders() + + // Assert - DatasourceIcon container should exist + const iconContainer = container.querySelector('.size-8') + expect(iconContainer).toBeInTheDocument() + }) + + it('should set title attribute for label truncation', () => { + // Arrange + const longLabel = 'This is a very long label that might be truncated' + + // Act + renderWithProviders() + + // Assert + const labelElement = screen.getByText(longLabel) + expect(labelElement).toHaveAttribute('title', longLabel) + }) + }) + + describe('Props', () => { + describe('selected', () => { + it('should apply selected styles when selected is true', () => { + // Arrange & Act + const { container } = renderWithProviders( + , + ) + + // Assert + const card = container.firstChild + expect(card).toHaveClass('border-components-option-card-option-selected-border') + expect(card).toHaveClass('bg-components-option-card-option-selected-bg') + }) + + it('should apply unselected styles when selected is false', () => { + // Arrange & Act + const { container } = renderWithProviders( + , + ) + + // Assert + const card = container.firstChild + expect(card).toHaveClass('border-components-option-card-option-border') + expect(card).toHaveClass('bg-components-option-card-option-bg') + }) + + it('should apply text-text-primary to label when selected', () => { + // Arrange & Act + renderWithProviders() + + // Assert + const label = screen.getByText('Test Option') + expect(label).toHaveClass('text-text-primary') + }) + + it('should apply text-text-secondary to label when not selected', () => { + // Arrange & Act + renderWithProviders() + + // Assert + const label = screen.getByText('Test Option') + expect(label).toHaveClass('text-text-secondary') + }) + }) + + describe('onClick', () => { + it('should call onClick when card is clicked', () => { + // Arrange + const mockOnClick = jest.fn() + renderWithProviders( + , + ) + + // Act - Click on the label text's parent card + const labelElement = screen.getByText('Test Option') + const card = labelElement.closest('[class*="cursor-pointer"]') + expect(card).toBeInTheDocument() + fireEvent.click(card!) + + // Assert + expect(mockOnClick).toHaveBeenCalledTimes(1) + }) + + it('should not crash when onClick is not provided', () => { + // Arrange & Act + renderWithProviders( + , + ) + + // Act - Click on the label text's parent card should not throw + const labelElement = screen.getByText('Test Option') + const card = labelElement.closest('[class*="cursor-pointer"]') + expect(card).toBeInTheDocument() + fireEvent.click(card!) + + // Assert - Component should still be rendered + expect(screen.getByText('Test Option')).toBeInTheDocument() + }) + }) + + describe('nodeData', () => { + it('should pass nodeData to useDatasourceIcon hook', () => { + // Arrange + const customNodeData = createMockDataSourceNodeData({ plugin_id: 'custom-plugin' }) + + // Act + renderWithProviders() + + // Assert - Hook should be called (via useDataSourceList mock) + expect(mockUseDataSourceList).toHaveBeenCalled() + }) + }) + }) + + describe('Styling', () => { + it('should have cursor-pointer class', () => { + // Arrange & Act + const { container } = renderWithProviders() + + // Assert + expect(container.firstChild).toHaveClass('cursor-pointer') + }) + + it('should have flex layout classes', () => { + // Arrange & Act + const { container } = renderWithProviders() + + // Assert + expect(container.firstChild).toHaveClass('flex') + expect(container.firstChild).toHaveClass('items-center') + expect(container.firstChild).toHaveClass('gap-2') + }) + + it('should have rounded-xl border', () => { + // Arrange & Act + const { container } = renderWithProviders() + + // Assert + expect(container.firstChild).toHaveClass('rounded-xl') + expect(container.firstChild).toHaveClass('border') + }) + + it('should have padding p-3', () => { + // Arrange & Act + const { container } = renderWithProviders() + + // Assert + expect(container.firstChild).toHaveClass('p-3') + }) + + it('should have line-clamp-2 for label truncation', () => { + // Arrange & Act + renderWithProviders() + + // Assert + const label = screen.getByText('Test Option') + expect(label).toHaveClass('line-clamp-2') + }) + }) + + describe('Memoization', () => { + it('should be wrapped with React.memo', () => { + // Assert - OptionCard should be a memoized component + expect(OptionCard).toBeDefined() + // React.memo wraps the component, so we check it renders correctly + const { container } = renderWithProviders() + expect(container.firstChild).toBeInTheDocument() + }) + }) +}) + +// ========================================== +// DataSourceOptions Tests +// ========================================== +describe('DataSourceOptions', () => { + const defaultNodes = createMockPipelineNodes(3) + const defaultOptions = defaultNodes.map(createMockDatasourceOption) + + const defaultProps = { + pipelineNodes: defaultNodes, + datasourceNodeId: '', + onSelect: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + mockUseDatasourceOptions.mockReturnValue(defaultOptions) + mockUseDataSourceList.mockReturnValue({ + data: [], + isSuccess: true, + }) + }) + + // ========================================== + // Rendering Tests + // ========================================== + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + renderWithProviders() + + // Assert + expect(screen.getByText('Data Source 1')).toBeInTheDocument() + expect(screen.getByText('Data Source 2')).toBeInTheDocument() + expect(screen.getByText('Data Source 3')).toBeInTheDocument() + }) + + it('should render correct number of option cards', () => { + // Arrange & Act + renderWithProviders() + + // Assert + expect(screen.getByText('Data Source 1')).toBeInTheDocument() + expect(screen.getByText('Data Source 2')).toBeInTheDocument() + expect(screen.getByText('Data Source 3')).toBeInTheDocument() + }) + + it('should render with grid layout', () => { + // Arrange & Act + const { container } = renderWithProviders() + + // Assert + const gridContainer = container.firstChild + expect(gridContainer).toHaveClass('grid') + expect(gridContainer).toHaveClass('w-full') + expect(gridContainer).toHaveClass('grid-cols-4') + expect(gridContainer).toHaveClass('gap-1') + }) + + it('should render no option cards when options is empty', () => { + // Arrange + mockUseDatasourceOptions.mockReturnValue([]) + + // Act + const { container } = renderWithProviders() + + // Assert + expect(screen.queryByText('Data Source')).not.toBeInTheDocument() + // Grid container should still exist + expect(container.firstChild).toHaveClass('grid') + }) + + it('should render single option card when only one option exists', () => { + // Arrange + const singleOption = [createMockDatasourceOption(defaultNodes[0])] + mockUseDatasourceOptions.mockReturnValue(singleOption) + + // Act + renderWithProviders() + + // Assert + expect(screen.getByText('Data Source 1')).toBeInTheDocument() + expect(screen.queryByText('Data Source 2')).not.toBeInTheDocument() + }) + }) + + // ========================================== + // Props Tests + // ========================================== + describe('Props', () => { + describe('pipelineNodes', () => { + it('should pass pipelineNodes to useDatasourceOptions hook', () => { + // Arrange + const customNodes = createMockPipelineNodes(2) + mockUseDatasourceOptions.mockReturnValue(customNodes.map(createMockDatasourceOption)) + + // Act + renderWithProviders( + , + ) + + // Assert + expect(mockUseDatasourceOptions).toHaveBeenCalledWith(customNodes) + }) + + it('should handle empty pipelineNodes array', () => { + // Arrange + mockUseDatasourceOptions.mockReturnValue([]) + + // Act + renderWithProviders( + , + ) + + // Assert + expect(mockUseDatasourceOptions).toHaveBeenCalledWith([]) + }) + }) + + describe('datasourceNodeId', () => { + it('should mark corresponding option as selected', () => { + // Arrange & Act + const { container } = renderWithProviders( + , + ) + + // Assert - Check for selected styling on second card + const cards = container.querySelectorAll('.rounded-xl.border') + expect(cards[1]).toHaveClass('border-components-option-card-option-selected-border') + }) + + it('should show no selection when datasourceNodeId is empty', () => { + // Arrange & Act + const { container } = renderWithProviders( + , + ) + + // Assert - No card should have selected styling + const selectedCards = container.querySelectorAll('.border-components-option-card-option-selected-border') + expect(selectedCards).toHaveLength(0) + }) + + it('should show no selection when datasourceNodeId does not match any option', () => { + // Arrange & Act + const { container } = renderWithProviders( + , + ) + + // Assert + const selectedCards = container.querySelectorAll('.border-components-option-card-option-selected-border') + expect(selectedCards).toHaveLength(0) + }) + + it('should update selection when datasourceNodeId changes', () => { + // Arrange + const { container, rerender } = renderWithProviders( + , + ) + + // Assert initial selection + let cards = container.querySelectorAll('.rounded-xl.border') + expect(cards[0]).toHaveClass('border-components-option-card-option-selected-border') + + // Act - Change selection + rerender( + + + , + ) + + // Assert new selection + cards = container.querySelectorAll('.rounded-xl.border') + expect(cards[0]).not.toHaveClass('border-components-option-card-option-selected-border') + expect(cards[1]).toHaveClass('border-components-option-card-option-selected-border') + }) + }) + + describe('onSelect', () => { + it('should receive onSelect callback', () => { + // Arrange + const mockOnSelect = jest.fn() + + // Act + renderWithProviders( + , + ) + + // Assert - Component renders without error + expect(screen.getByText('Data Source 1')).toBeInTheDocument() + }) + }) + }) + + // ========================================== + // Side Effects and Cleanup Tests + // ========================================== + describe('Side Effects and Cleanup', () => { + describe('useEffect - Auto-select first option', () => { + it('should auto-select first option when options exist and no datasourceNodeId', () => { + // Arrange + const mockOnSelect = jest.fn() + + // Act + renderWithProviders( + , + ) + + // Assert - Should auto-select first option on mount + expect(mockOnSelect).toHaveBeenCalledTimes(1) + expect(mockOnSelect).toHaveBeenCalledWith({ + nodeId: 'node-1', + nodeData: defaultOptions[0].data, + } satisfies Datasource) + }) + + it('should NOT auto-select when datasourceNodeId is provided', () => { + // Arrange + const mockOnSelect = jest.fn() + + // Act + renderWithProviders( + , + ) + + // Assert - Should not auto-select because datasourceNodeId is provided + expect(mockOnSelect).not.toHaveBeenCalled() + }) + + it('should NOT auto-select when options array is empty', () => { + // Arrange + mockUseDatasourceOptions.mockReturnValue([]) + const mockOnSelect = jest.fn() + + // Act + renderWithProviders( + , + ) + + // Assert + expect(mockOnSelect).not.toHaveBeenCalled() + }) + + it('should only run useEffect once on initial mount', () => { + // Arrange + const mockOnSelect = jest.fn() + const { rerender } = renderWithProviders( + , + ) + + // Assert - Called once on mount + expect(mockOnSelect).toHaveBeenCalledTimes(1) + + // Act - Rerender with same props + rerender( + + + , + ) + + // Assert - Still called only once (useEffect has empty dependency array) + expect(mockOnSelect).toHaveBeenCalledTimes(1) + }) + }) + }) + + // ========================================== + // Callback Stability and Memoization Tests + // ========================================== + describe('Callback Stability and Memoization', () => { + it('should maintain callback reference stability across renders with same props', () => { + // Arrange + const mockOnSelect = jest.fn() + + const { rerender } = renderWithProviders( + , + ) + + // Get initial click handlers + expect(screen.getByText('Data Source 1')).toBeInTheDocument() + + // Trigger clicks to test handlers work + fireEvent.click(screen.getByText('Data Source 1')) + expect(mockOnSelect).toHaveBeenCalledTimes(2) // 1 auto-select + 1 click + + // Act - Rerender with same onSelect reference + rerender( + + + , + ) + + // Assert - Component still works after rerender + fireEvent.click(screen.getByText('Data Source 2')) + expect(mockOnSelect).toHaveBeenCalledTimes(3) + }) + + it('should update callback when onSelect changes', () => { + // Arrange + const mockOnSelect1 = jest.fn() + const mockOnSelect2 = jest.fn() + + const { rerender } = renderWithProviders( + , + ) + + // Act - Click with first callback + fireEvent.click(screen.getByText('Data Source 2')) + expect(mockOnSelect1).toHaveBeenCalledTimes(1) + + // Act - Change callback + rerender( + + + , + ) + + // Act - Click with new callback + fireEvent.click(screen.getByText('Data Source 3')) + + // Assert - New callback should be called + expect(mockOnSelect2).toHaveBeenCalledTimes(1) + expect(mockOnSelect2).toHaveBeenCalledWith({ + nodeId: 'node-3', + nodeData: defaultOptions[2].data, + }) + }) + + it('should update callback when options change', () => { + // Arrange + const mockOnSelect = jest.fn() + + const { rerender } = renderWithProviders( + , + ) + + // Act - Click first option + fireEvent.click(screen.getByText('Data Source 1')) + expect(mockOnSelect).toHaveBeenCalledWith({ + nodeId: 'node-1', + nodeData: defaultOptions[0].data, + }) + + // Act - Change options + const newNodes = createMockPipelineNodes(2) + const newOptions = newNodes.map(node => createMockDatasourceOption(node)) + mockUseDatasourceOptions.mockReturnValue(newOptions) + + rerender( + + + , + ) + + // Act - Click updated first option + fireEvent.click(screen.getByText('Data Source 1')) + + // Assert - Callback receives new option data + expect(mockOnSelect).toHaveBeenLastCalledWith({ + nodeId: newOptions[0].value, + nodeData: newOptions[0].data, + }) + }) + }) + + // ========================================== + // User Interactions and Event Handlers Tests + // ========================================== + describe('User Interactions and Event Handlers', () => { + describe('Option Selection', () => { + it('should call onSelect with correct datasource when clicking an option', () => { + // Arrange + const mockOnSelect = jest.fn() + renderWithProviders( + , + ) + + // Act - Click second option + fireEvent.click(screen.getByText('Data Source 2')) + + // Assert + expect(mockOnSelect).toHaveBeenCalledTimes(1) + expect(mockOnSelect).toHaveBeenCalledWith({ + nodeId: 'node-2', + nodeData: defaultOptions[1].data, + } satisfies Datasource) + }) + + it('should allow selecting already selected option', () => { + // Arrange + const mockOnSelect = jest.fn() + renderWithProviders( + , + ) + + // Act - Click already selected option + fireEvent.click(screen.getByText('Data Source 1')) + + // Assert + expect(mockOnSelect).toHaveBeenCalledTimes(1) + expect(mockOnSelect).toHaveBeenCalledWith({ + nodeId: 'node-1', + nodeData: defaultOptions[0].data, + }) + }) + + it('should allow multiple sequential selections', () => { + // Arrange + const mockOnSelect = jest.fn() + renderWithProviders( + , + ) + + // Act - Click options sequentially + fireEvent.click(screen.getByText('Data Source 1')) + fireEvent.click(screen.getByText('Data Source 2')) + fireEvent.click(screen.getByText('Data Source 3')) + + // Assert + expect(mockOnSelect).toHaveBeenCalledTimes(3) + expect(mockOnSelect).toHaveBeenNthCalledWith(1, { + nodeId: 'node-1', + nodeData: defaultOptions[0].data, + }) + expect(mockOnSelect).toHaveBeenNthCalledWith(2, { + nodeId: 'node-2', + nodeData: defaultOptions[1].data, + }) + expect(mockOnSelect).toHaveBeenNthCalledWith(3, { + nodeId: 'node-3', + nodeData: defaultOptions[2].data, + }) + }) + }) + + describe('handelSelect Internal Logic', () => { + it('should handle rapid successive clicks', async () => { + // Arrange + const mockOnSelect = jest.fn() + renderWithProviders( + , + ) + + // Act - Rapid clicks + await act(async () => { + fireEvent.click(screen.getByText('Data Source 1')) + fireEvent.click(screen.getByText('Data Source 2')) + fireEvent.click(screen.getByText('Data Source 3')) + fireEvent.click(screen.getByText('Data Source 1')) + fireEvent.click(screen.getByText('Data Source 2')) + }) + + // Assert - All clicks should be registered + expect(mockOnSelect).toHaveBeenCalledTimes(5) + }) + }) + }) + + // ========================================== + // Edge Cases and Error Handling Tests + // ========================================== + describe('Edge Cases and Error Handling', () => { + describe('Empty States', () => { + it('should handle empty options array gracefully', () => { + // Arrange + mockUseDatasourceOptions.mockReturnValue([]) + + // Act + const { container } = renderWithProviders( + , + ) + + // Assert + expect(container.firstChild).toBeInTheDocument() + }) + + it('should not crash when datasourceNodeId is undefined', () => { + // Arrange & Act + renderWithProviders( + , + ) + + // Assert + expect(screen.getByText('Data Source 1')).toBeInTheDocument() + }) + }) + + describe('Null/Undefined Values', () => { + it('should handle option with missing data properties', () => { + // Arrange + const optionWithMinimalData = [{ + label: 'Minimal Option', + value: 'minimal-1', + data: { + title: 'Minimal', + desc: '', + type: BlockEnum.DataSource, + plugin_id: '', + provider_type: '', + provider_name: '', + datasource_name: '', + datasource_label: '', + datasource_parameters: {}, + datasource_configurations: {}, + } as DataSourceNodeType, + }] + mockUseDatasourceOptions.mockReturnValue(optionWithMinimalData) + + // Act + renderWithProviders() + + // Assert + expect(screen.getByText('Minimal Option')).toBeInTheDocument() + }) + }) + + describe('Large Data Sets', () => { + it('should handle large number of options', () => { + // Arrange + const manyNodes = createMockPipelineNodes(50) + const manyOptions = manyNodes.map(createMockDatasourceOption) + mockUseDatasourceOptions.mockReturnValue(manyOptions) + + // Act + renderWithProviders( + , + ) + + // Assert + expect(screen.getByText('Data Source 1')).toBeInTheDocument() + expect(screen.getByText('Data Source 50')).toBeInTheDocument() + }) + }) + + describe('Special Characters in Data', () => { + it('should handle special characters in option labels', () => { + // Arrange + const specialNode = createMockPipelineNode({ + id: 'special-node', + data: createMockDataSourceNodeData({ + title: 'Data Source ', + }), + }) + const specialOptions = [createMockDatasourceOption(specialNode)] + mockUseDatasourceOptions.mockReturnValue(specialOptions) + + // Act + renderWithProviders( + , + ) + + // Assert - Special characters should be escaped/rendered safely + expect(screen.getByText('Data Source ')).toBeInTheDocument() + }) + + it('should handle unicode characters in option labels', () => { + // Arrange + const unicodeNode = createMockPipelineNode({ + id: 'unicode-node', + data: createMockDataSourceNodeData({ + title: '数据源 📁 Source émoji', + }), + }) + const unicodeOptions = [createMockDatasourceOption(unicodeNode)] + mockUseDatasourceOptions.mockReturnValue(unicodeOptions) + + // Act + renderWithProviders( + , + ) + + // Assert + expect(screen.getByText('数据源 📁 Source émoji')).toBeInTheDocument() + }) + + it('should handle empty string as option value', () => { + // Arrange + const emptyValueOption = [{ + label: 'Empty Value Option', + value: '', + data: createMockDataSourceNodeData(), + }] + mockUseDatasourceOptions.mockReturnValue(emptyValueOption) + + // Act + renderWithProviders() + + // Assert + expect(screen.getByText('Empty Value Option')).toBeInTheDocument() + }) + }) + + describe('Boundary Conditions', () => { + it('should handle single option selection correctly', () => { + // Arrange + const singleOption = [createMockDatasourceOption(defaultNodes[0])] + mockUseDatasourceOptions.mockReturnValue(singleOption) + const mockOnSelect = jest.fn() + + // Act + renderWithProviders( + , + ) + + // Assert - Click should still work + fireEvent.click(screen.getByText('Data Source 1')) + expect(mockOnSelect).toHaveBeenCalledTimes(1) + }) + + it('should handle options with same labels but different values', () => { + // Arrange + const duplicateLabelOptions = [ + { + label: 'Duplicate Label', + value: 'node-a', + data: createMockDataSourceNodeData({ plugin_id: 'plugin-a' }), + }, + { + label: 'Duplicate Label', + value: 'node-b', + data: createMockDataSourceNodeData({ plugin_id: 'plugin-b' }), + }, + ] + mockUseDatasourceOptions.mockReturnValue(duplicateLabelOptions) + const mockOnSelect = jest.fn() + + // Act + renderWithProviders( + , + ) + + // Assert - Both should render + const labels = screen.getAllByText('Duplicate Label') + expect(labels).toHaveLength(2) + + // Click second one + fireEvent.click(labels[1]) + expect(mockOnSelect).toHaveBeenCalledWith({ + nodeId: 'node-b', + nodeData: expect.objectContaining({ plugin_id: 'plugin-b' }), + }) + }) + }) + + describe('Component Unmounting', () => { + it('should handle unmounting without errors', () => { + // Arrange + const mockOnSelect = jest.fn() + const { unmount } = renderWithProviders( + , + ) + + // Act + unmount() + + // Assert - No errors thrown, component cleanly unmounted + expect(screen.queryByText('Data Source 1')).not.toBeInTheDocument() + }) + + it('should handle unmounting during rapid interactions', async () => { + // Arrange + const mockOnSelect = jest.fn() + const { unmount } = renderWithProviders( + , + ) + + // Act - Start interactions then unmount + fireEvent.click(screen.getByText('Data Source 1')) + + // Unmount during/after interaction + unmount() + + // Assert - Should not throw + expect(screen.queryByText('Data Source 1')).not.toBeInTheDocument() + }) + }) + }) + + // ========================================== + // Integration Tests + // ========================================== + describe('Integration', () => { + it('should render OptionCard with correct props', () => { + // Arrange & Act + const { container } = renderWithProviders() + + // Assert - Verify real OptionCard components are rendered + const cards = container.querySelectorAll('.rounded-xl.border') + expect(cards).toHaveLength(3) + }) + + it('should correctly pass selected state to OptionCard', () => { + // Arrange & Act + const { container } = renderWithProviders( + , + ) + + // Assert + const cards = container.querySelectorAll('.rounded-xl.border') + expect(cards[0]).not.toHaveClass('border-components-option-card-option-selected-border') + expect(cards[1]).toHaveClass('border-components-option-card-option-selected-border') + expect(cards[2]).not.toHaveClass('border-components-option-card-option-selected-border') + }) + + it('should use option.value as key for React rendering', () => { + // This test verifies that React doesn't throw duplicate key warnings + // Arrange + const uniqueValueOptions = createMockPipelineNodes(5).map(createMockDatasourceOption) + mockUseDatasourceOptions.mockReturnValue(uniqueValueOptions) + + // Act - Should render without console warnings about duplicate keys + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn()) + renderWithProviders() + + // Assert + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining('key'), + ) + consoleSpy.mockRestore() + }) + }) + + // ========================================== + // All Prop Variations Tests + // ========================================== + describe('All Prop Variations', () => { + it.each([ + { datasourceNodeId: '', description: 'empty string' }, + { datasourceNodeId: 'node-1', description: 'first node' }, + { datasourceNodeId: 'node-2', description: 'middle node' }, + { datasourceNodeId: 'node-3', description: 'last node' }, + { datasourceNodeId: 'non-existent', description: 'non-existent node' }, + ])('should handle datasourceNodeId as $description', ({ datasourceNodeId }) => { + // Arrange & Act + renderWithProviders( + , + ) + + // Assert + expect(screen.getByText('Data Source 1')).toBeInTheDocument() + }) + + it.each([ + { count: 0, description: 'zero options' }, + { count: 1, description: 'single option' }, + { count: 3, description: 'few options' }, + { count: 10, description: 'many options' }, + ])('should render correctly with $description', ({ count }) => { + // Arrange + const nodes = createMockPipelineNodes(count) + const options = nodes.map(createMockDatasourceOption) + mockUseDatasourceOptions.mockReturnValue(options) + + // Act + renderWithProviders( + , + ) + + // Assert + if (count > 0) + expect(screen.getByText('Data Source 1')).toBeInTheDocument() + else + expect(screen.queryByText('Data Source 1')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.spec.tsx new file mode 100644 index 0000000000..2e370c5cbc --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/credential-selector/index.spec.tsx @@ -0,0 +1,1056 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import React from 'react' +import CredentialSelector from './index' +import type { CredentialSelectorProps } from './index' +import type { DataSourceCredential } from '@/types/pipeline' + +// Mock CredentialTypeEnum to avoid deep import chain issues +enum MockCredentialTypeEnum { + OAUTH2 = 'oauth2', + API_KEY = 'api_key', +} + +// Mock plugin-auth module to avoid deep import chain issues +jest.mock('@/app/components/plugins/plugin-auth', () => ({ + CredentialTypeEnum: { + OAUTH2: 'oauth2', + API_KEY: 'api_key', + }, +})) + +// Mock portal-to-follow-elem - use React state to properly handle open/close +jest.mock('@/app/components/base/portal-to-follow-elem', () => { + const MockPortalToFollowElem = ({ children, open }: any) => { + return ( +
+ {React.Children.map(children, (child: any) => { + if (!child) + return null + // Pass open state to children via context-like prop cloning + return React.cloneElement(child, { __portalOpen: open }) + })} +
+ ) + } + + const MockPortalToFollowElemTrigger = ({ children, onClick, className, __portalOpen }: any) => ( +
+ {children} +
+ ) + + const MockPortalToFollowElemContent = ({ children, className, __portalOpen }: any) => { + // Match actual behavior: returns null when not open + if (!__portalOpen) + return null + return ( +
+ {children} +
+ ) + } + + return { + PortalToFollowElem: MockPortalToFollowElem, + PortalToFollowElemTrigger: MockPortalToFollowElemTrigger, + PortalToFollowElemContent: MockPortalToFollowElemContent, + } +}) + +// CredentialIcon - imported directly (not mocked) +// This is a simple UI component with no external dependencies + +// ========================================== +// Test Data Builders +// ========================================== +const createMockCredential = (overrides?: Partial): DataSourceCredential => ({ + id: 'cred-1', + name: 'Test Credential', + avatar_url: 'https://example.com/avatar.png', + credential: { key: 'value' }, + is_default: false, + type: MockCredentialTypeEnum.OAUTH2 as unknown as DataSourceCredential['type'], + ...overrides, +}) + +const createMockCredentials = (count: number = 3): DataSourceCredential[] => + Array.from({ length: count }, (_, i) => + createMockCredential({ + id: `cred-${i + 1}`, + name: `Credential ${i + 1}`, + avatar_url: `https://example.com/avatar-${i + 1}.png`, + is_default: i === 0, + }), + ) + +const createDefaultProps = (overrides?: Partial): CredentialSelectorProps => ({ + currentCredentialId: 'cred-1', + onCredentialChange: jest.fn(), + credentials: createMockCredentials(), + ...overrides, +}) + +describe('CredentialSelector', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // ========================================== + // Rendering Tests - Verify component renders correctly + // ========================================== + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('portal-root')).toBeInTheDocument() + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + }) + + it('should render current credential name in trigger', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByText('Credential 1')).toBeInTheDocument() + }) + + it('should render credential icon with correct props', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert - CredentialIcon renders an img when avatarUrl is provided + const iconImg = container.querySelector('img') + expect(iconImg).toBeInTheDocument() + expect(iconImg).toHaveAttribute('src', 'https://example.com/avatar-1.png') + }) + + it('should render dropdown arrow icon', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert + const svgIcon = container.querySelector('svg') + expect(svgIcon).toBeInTheDocument() + }) + + it('should not render dropdown content initially', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + }) + + it('should render all credentials in dropdown when opened', () => { + // Arrange + const props = createDefaultProps() + render() + + // Act - Click trigger to open dropdown + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + // Assert - All credentials should be visible (current credential appears in both trigger and list) + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + // 3 in dropdown list + 1 in trigger (current) = 4 total + expect(screen.getAllByText(/Credential \d/)).toHaveLength(4) + }) + }) + + // ========================================== + // Props Testing - Verify all prop variations + // ========================================== + describe('Props', () => { + describe('currentCredentialId prop', () => { + it('should display first credential when currentCredentialId matches first', () => { + // Arrange + const props = createDefaultProps({ currentCredentialId: 'cred-1' }) + + // Act + render() + + // Assert + expect(screen.getByText('Credential 1')).toBeInTheDocument() + }) + + it('should display second credential when currentCredentialId matches second', () => { + // Arrange + const props = createDefaultProps({ currentCredentialId: 'cred-2' }) + + // Act + render() + + // Assert + expect(screen.getByText('Credential 2')).toBeInTheDocument() + }) + + it('should display third credential when currentCredentialId matches third', () => { + // Arrange + const props = createDefaultProps({ currentCredentialId: 'cred-3' }) + + // Act + render() + + // Assert + expect(screen.getByText('Credential 3')).toBeInTheDocument() + }) + + it.each([ + ['cred-1', 'Credential 1'], + ['cred-2', 'Credential 2'], + ['cred-3', 'Credential 3'], + ])('should display %s credential name when currentCredentialId is %s', (credId, expectedName) => { + // Arrange + const props = createDefaultProps({ currentCredentialId: credId }) + + // Act + render() + + // Assert + expect(screen.getByText(expectedName)).toBeInTheDocument() + }) + }) + + describe('credentials prop', () => { + it('should render single credential correctly', () => { + // Arrange + const props = createDefaultProps({ + credentials: [createMockCredential()], + currentCredentialId: 'cred-1', + }) + + // Act + render() + + // Assert + expect(screen.getByText('Test Credential')).toBeInTheDocument() + }) + + it('should render multiple credentials in dropdown', () => { + // Arrange + const props = createDefaultProps({ + credentials: createMockCredentials(5), + currentCredentialId: 'cred-1', + }) + render() + + // Act + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + // Assert - 5 in dropdown + 1 in trigger (current credential appears twice) + expect(screen.getAllByText(/Credential \d/).length).toBe(6) + }) + + it('should handle credentials with special characters in name', () => { + // Arrange + const props = createDefaultProps({ + credentials: [createMockCredential({ id: 'cred-special', name: 'Test & Credential ' })], + currentCredentialId: 'cred-special', + }) + + // Act + render() + + // Assert + expect(screen.getByText('Test & Credential ')).toBeInTheDocument() + }) + }) + + describe('onCredentialChange prop', () => { + it('should be called when selecting a credential', () => { + // Arrange + const mockOnChange = jest.fn() + const props = createDefaultProps({ onCredentialChange: mockOnChange }) + render() + + // Act - Open dropdown + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + // Click on second credential + const credential2 = screen.getByText('Credential 2') + fireEvent.click(credential2) + + // Assert + expect(mockOnChange).toHaveBeenCalledWith('cred-2') + }) + + it.each([ + ['cred-2', 'Credential 2'], + ['cred-3', 'Credential 3'], + ])('should call onCredentialChange with %s when selecting %s', (credId, credentialName) => { + // Arrange + const mockOnChange = jest.fn() + const props = createDefaultProps({ onCredentialChange: mockOnChange }) + render() + + // Act - Open dropdown and select credential + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + // Get the dropdown item using within() to scope query to portal content + const portalContent = screen.getByTestId('portal-content') + const credentialOption = within(portalContent).getByText(credentialName) + fireEvent.click(credentialOption) + + // Assert + expect(mockOnChange).toHaveBeenCalledWith(credId) + }) + + it('should call onCredentialChange with cred-1 when selecting Credential 1 in dropdown', () => { + // Arrange - Start with cred-2 selected so cred-1 is only in dropdown + const mockOnChange = jest.fn() + const props = createDefaultProps({ + onCredentialChange: mockOnChange, + currentCredentialId: 'cred-2', + }) + render() + + // Act - Open dropdown and select Credential 1 + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + const credential1 = screen.getByText('Credential 1') + fireEvent.click(credential1) + + // Assert + expect(mockOnChange).toHaveBeenCalledWith('cred-1') + }) + }) + }) + + // ========================================== + // User Interactions - Test event handlers + // ========================================== + describe('User Interactions', () => { + it('should toggle dropdown open when trigger is clicked', () => { + // Arrange + const props = createDefaultProps() + render() + + // Assert - Initially closed + expect(screen.queryByTestId('portal-content')).not.toBeInTheDocument() + + // Act - Click trigger + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + // Assert - Now open + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + + it('should call onCredentialChange when clicking a credential item', () => { + // Arrange + const mockOnChange = jest.fn() + const props = createDefaultProps({ onCredentialChange: mockOnChange }) + render() + + // Act + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + const credential2 = screen.getByText('Credential 2') + fireEvent.click(credential2) + + // Assert + expect(mockOnChange).toHaveBeenCalledTimes(1) + expect(mockOnChange).toHaveBeenCalledWith('cred-2') + }) + + it('should close dropdown after selecting a credential', () => { + // Arrange + const mockOnChange = jest.fn() + const props = createDefaultProps({ onCredentialChange: mockOnChange }) + render() + + // Act - Open and select + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + + const credential2 = screen.getByText('Credential 2') + fireEvent.click(credential2) + + // Assert - The handleCredentialChange calls toggle(), which should change the open state + expect(mockOnChange).toHaveBeenCalled() + }) + + it('should handle rapid consecutive clicks on trigger', () => { + // Arrange + const props = createDefaultProps() + render() + + // Act - Rapid clicks + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + fireEvent.click(trigger) + fireEvent.click(trigger) + + // Assert - Should not crash + expect(trigger).toBeInTheDocument() + }) + + it('should allow selecting credentials multiple times', () => { + // Arrange - Start with cred-2 selected so we can select other credentials + const mockOnChange = jest.fn() + const props = createDefaultProps({ + onCredentialChange: mockOnChange, + currentCredentialId: 'cred-2', + }) + + render() + + // Act & Assert - Select Credential 1 (different from current) + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + const credential1 = screen.getByText('Credential 1') + fireEvent.click(credential1) + + expect(mockOnChange).toHaveBeenCalledWith('cred-1') + }) + }) + + // ========================================== + // Side Effects and Cleanup - Test useEffect behavior + // ========================================== + describe('Side Effects and Cleanup', () => { + it('should auto-select first credential when currentCredential is not found and credentials exist', () => { + // Arrange + const mockOnChange = jest.fn() + const props = createDefaultProps({ + currentCredentialId: 'non-existent-id', + onCredentialChange: mockOnChange, + }) + + // Act + render() + + // Assert - Should auto-select first credential + expect(mockOnChange).toHaveBeenCalledWith('cred-1') + }) + + it('should not call onCredentialChange when currentCredential is found', () => { + // Arrange + const mockOnChange = jest.fn() + const props = createDefaultProps({ + currentCredentialId: 'cred-2', + onCredentialChange: mockOnChange, + }) + + // Act + render() + + // Assert - Should not auto-select + expect(mockOnChange).not.toHaveBeenCalled() + }) + + it('should not call onCredentialChange when credentials array is empty', () => { + // Arrange + const mockOnChange = jest.fn() + const props = createDefaultProps({ + currentCredentialId: 'cred-1', + credentials: [], + onCredentialChange: mockOnChange, + }) + + // Act + render() + + // Assert - Should not call since no credentials to select + expect(mockOnChange).not.toHaveBeenCalled() + }) + + it('should auto-select when credentials change and currentCredential becomes invalid', async () => { + // Arrange + const mockOnChange = jest.fn() + const initialCredentials = createMockCredentials(3) + const props = createDefaultProps({ + currentCredentialId: 'cred-1', + credentials: initialCredentials, + onCredentialChange: mockOnChange, + }) + + const { rerender } = render() + expect(mockOnChange).not.toHaveBeenCalled() + + // Act - Change credentials to not include current + const newCredentials = [ + createMockCredential({ id: 'cred-4', name: 'New Credential 4' }), + createMockCredential({ id: 'cred-5', name: 'New Credential 5' }), + ] + rerender( + , + ) + + // Assert - Should auto-select first of new credentials + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith('cred-4') + }) + }) + + it('should not trigger auto-select effect on every render with same props', () => { + // Arrange + const mockOnChange = jest.fn() + const props = createDefaultProps({ onCredentialChange: mockOnChange }) + + // Act - Render and rerender with same props + const { rerender } = render() + rerender() + rerender() + + // Assert - onCredentialChange should not be called for auto-selection + expect(mockOnChange).not.toHaveBeenCalled() + }) + }) + + // ========================================== + // Callback Stability and Memoization - Test useCallback behavior + // ========================================== + describe('Callback Stability and Memoization', () => { + it('should have stable handleCredentialChange callback', () => { + // Arrange + const mockOnChange = jest.fn() + const props = createDefaultProps({ onCredentialChange: mockOnChange }) + render() + + // Act - Open dropdown and select + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + const credential = screen.getByText('Credential 2') + fireEvent.click(credential) + + // Assert - Callback should work correctly + expect(mockOnChange).toHaveBeenCalledWith('cred-2') + }) + + it('should update handleCredentialChange when onCredentialChange changes', () => { + // Arrange + const mockOnChange1 = jest.fn() + const mockOnChange2 = jest.fn() + const props = createDefaultProps({ onCredentialChange: mockOnChange1 }) + + const { rerender } = render() + + // Act - Update onCredentialChange prop + rerender() + + // Open and select + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + const credential = screen.getByText('Credential 2') + fireEvent.click(credential) + + // Assert - New callback should be used + expect(mockOnChange1).not.toHaveBeenCalled() + expect(mockOnChange2).toHaveBeenCalledWith('cred-2') + }) + }) + + // ========================================== + // Memoization Logic and Dependencies - Test useMemo behavior + // ========================================== + describe('Memoization Logic and Dependencies', () => { + it('should find currentCredential by id', () => { + // Arrange + const props = createDefaultProps({ currentCredentialId: 'cred-2' }) + + // Act + render() + + // Assert - Should display credential 2 + expect(screen.getByText('Credential 2')).toBeInTheDocument() + }) + + it('should update currentCredential when currentCredentialId changes', () => { + // Arrange + const props = createDefaultProps({ currentCredentialId: 'cred-1' }) + const { rerender } = render() + + // Assert initial + expect(screen.getByText('Credential 1')).toBeInTheDocument() + + // Act - Change currentCredentialId + rerender() + + // Assert - Should now display credential 3 + expect(screen.getByText('Credential 3')).toBeInTheDocument() + }) + + it('should update currentCredential when credentials array changes', () => { + // Arrange + const props = createDefaultProps({ currentCredentialId: 'cred-1' }) + const { rerender } = render() + + // Assert initial + expect(screen.getByText('Credential 1')).toBeInTheDocument() + + // Act - Change credentials + const newCredentials = [ + createMockCredential({ id: 'cred-1', name: 'Updated Credential 1' }), + ] + rerender() + + // Assert - Should display updated name + expect(screen.getByText('Updated Credential 1')).toBeInTheDocument() + }) + + it('should return undefined currentCredential when id not found', () => { + // Arrange + const mockOnChange = jest.fn() + const props = createDefaultProps({ + currentCredentialId: 'non-existent', + onCredentialChange: mockOnChange, + }) + + // Act + render() + + // Assert - Should trigger auto-select effect + expect(mockOnChange).toHaveBeenCalledWith('cred-1') + }) + }) + + // ========================================== + // Component Memoization - Test React.memo behavior + // ========================================== + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + // Assert + expect(CredentialSelector.$$typeof).toBe(Symbol.for('react.memo')) + }) + + it('should not re-render when props remain the same', () => { + // Arrange + const mockOnChange = jest.fn() + const props = createDefaultProps({ onCredentialChange: mockOnChange }) + const renderSpy = jest.fn() + + const TrackedCredentialSelector: React.FC = (trackedProps) => { + renderSpy() + return + } + const MemoizedTracked = React.memo(TrackedCredentialSelector) + + // Act + const { rerender } = render() + rerender() + + // Assert - Should only render once due to same props + expect(renderSpy).toHaveBeenCalledTimes(1) + }) + + it('should re-render when currentCredentialId changes', () => { + // Arrange + const props = createDefaultProps({ currentCredentialId: 'cred-1' }) + const { rerender } = render() + + // Assert initial + expect(screen.getByText('Credential 1')).toBeInTheDocument() + + // Act + rerender() + + // Assert + expect(screen.getByText('Credential 2')).toBeInTheDocument() + }) + + it('should re-render when credentials array reference changes', () => { + // Arrange + const props = createDefaultProps() + const { rerender } = render() + + // Act - Create new credentials array with different data + const newCredentials = [ + createMockCredential({ id: 'cred-1', name: 'New Name 1' }), + ] + rerender() + + // Assert + expect(screen.getByText('New Name 1')).toBeInTheDocument() + }) + + it('should re-render when onCredentialChange reference changes', () => { + // Arrange + const mockOnChange1 = jest.fn() + const mockOnChange2 = jest.fn() + const props = createDefaultProps({ onCredentialChange: mockOnChange1 }) + const { rerender } = render() + + // Act - Change callback reference + rerender() + + // Open and select + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + const credential = screen.getByText('Credential 2') + fireEvent.click(credential) + + // Assert - New callback should be used + expect(mockOnChange2).toHaveBeenCalledWith('cred-2') + }) + }) + + // ========================================== + // Edge Cases and Error Handling + // ========================================== + describe('Edge Cases and Error Handling', () => { + it('should handle empty credentials array', () => { + // Arrange + const props = createDefaultProps({ + credentials: [], + currentCredentialId: 'cred-1', + }) + + // Act + render() + + // Assert - Should render without crashing + expect(screen.getByTestId('portal-root')).toBeInTheDocument() + }) + + it('should handle undefined avatar_url in credential', () => { + // Arrange + const credentialWithoutAvatar = createMockCredential({ + id: 'cred-no-avatar', + name: 'No Avatar Credential', + avatar_url: undefined, + }) + const props = createDefaultProps({ + credentials: [credentialWithoutAvatar], + currentCredentialId: 'cred-no-avatar', + }) + + // Act + const { container } = render() + + // Assert - Should render without crashing and show first letter fallback + expect(screen.getByText('No Avatar Credential')).toBeInTheDocument() + // When avatar_url is undefined, CredentialIcon shows first letter instead of img + const iconImg = container.querySelector('img') + expect(iconImg).not.toBeInTheDocument() + // First letter 'N' should be displayed + expect(screen.getByText('N')).toBeInTheDocument() + }) + + it('should handle empty string name in credential', () => { + // Arrange + const credentialWithEmptyName = createMockCredential({ + id: 'cred-empty-name', + name: '', + }) + const props = createDefaultProps({ + credentials: [credentialWithEmptyName], + currentCredentialId: 'cred-empty-name', + }) + + // Act + render() + + // Assert - Should render without crashing + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + }) + + it('should handle very long credential name', () => { + // Arrange + const longName = 'A'.repeat(200) + const credentialWithLongName = createMockCredential({ + id: 'cred-long-name', + name: longName, + }) + const props = createDefaultProps({ + credentials: [credentialWithLongName], + currentCredentialId: 'cred-long-name', + }) + + // Act + render() + + // Assert + expect(screen.getByText(longName)).toBeInTheDocument() + }) + + it('should handle special characters in credential name', () => { + // Arrange + const specialName = '测试 Credential & "quoted"' + const credentialWithSpecialName = createMockCredential({ + id: 'cred-special', + name: specialName, + }) + const props = createDefaultProps({ + credentials: [credentialWithSpecialName], + currentCredentialId: 'cred-special', + }) + + // Act + render() + + // Assert + expect(screen.getByText(specialName)).toBeInTheDocument() + }) + + it('should handle numeric id as string', () => { + // Arrange + const credentialWithNumericId = createMockCredential({ + id: '123456', + name: 'Numeric ID Credential', + }) + const props = createDefaultProps({ + credentials: [credentialWithNumericId], + currentCredentialId: '123456', + }) + + // Act + render() + + // Assert + expect(screen.getByText('Numeric ID Credential')).toBeInTheDocument() + }) + + it('should handle large number of credentials', () => { + // Arrange + const manyCredentials = createMockCredentials(100) + const props = createDefaultProps({ + credentials: manyCredentials, + currentCredentialId: 'cred-50', + }) + + // Act + render() + + // Assert + expect(screen.getByText('Credential 50')).toBeInTheDocument() + }) + + it('should handle credential selection with duplicate names', () => { + // Arrange + const mockOnChange = jest.fn() + const duplicateCredentials = [ + createMockCredential({ id: 'cred-1', name: 'Same Name' }), + createMockCredential({ id: 'cred-2', name: 'Same Name' }), + ] + const props = createDefaultProps({ + credentials: duplicateCredentials, + currentCredentialId: 'cred-1', + onCredentialChange: mockOnChange, + }) + + // Act + render() + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + // Get all "Same Name" elements + // 1 in trigger (current) + 2 in dropdown (both credentials) = 3 total + const sameNameElements = screen.getAllByText('Same Name') + expect(sameNameElements.length).toBe(3) + + // Click the last dropdown item (cred-2 in dropdown) + fireEvent.click(sameNameElements[2]) + + // Assert - Should call with the correct id even with duplicate names + expect(mockOnChange).toHaveBeenCalledWith('cred-2') + }) + + it('should not crash when clicking credential after unmount', () => { + // Arrange + const mockOnChange = jest.fn() + const props = createDefaultProps({ onCredentialChange: mockOnChange }) + const { unmount } = render() + + // Act + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + unmount() + + // Assert - Should not throw + expect(() => { + // Any cleanup should have happened + }).not.toThrow() + }) + + it('should handle whitespace-only credential name', () => { + // Arrange + const credentialWithWhitespace = createMockCredential({ + id: 'cred-whitespace', + name: ' ', + }) + const props = createDefaultProps({ + credentials: [credentialWithWhitespace], + currentCredentialId: 'cred-whitespace', + }) + + // Act + render() + + // Assert - Should render without crashing + expect(screen.getByTestId('portal-trigger')).toBeInTheDocument() + }) + }) + + // ========================================== + // Styling and CSS Classes + // ========================================== + describe('Styling', () => { + it('should apply overflow-hidden class to trigger', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + const trigger = screen.getByTestId('portal-trigger') + expect(trigger).toHaveClass('overflow-hidden') + }) + + it('should apply grow class to trigger', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + const trigger = screen.getByTestId('portal-trigger') + expect(trigger).toHaveClass('grow') + }) + + it('should apply z-10 class to dropdown content', () => { + // Arrange + const props = createDefaultProps() + render() + + // Act + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + // Assert + const content = screen.getByTestId('portal-content') + expect(content).toHaveClass('z-10') + }) + }) + + // ========================================== + // Integration with Child Components + // ========================================== + describe('Integration with Child Components', () => { + it('should pass currentCredential to Trigger component', () => { + // Arrange + const props = createDefaultProps({ currentCredentialId: 'cred-2' }) + + // Act + render() + + // Assert - Trigger should display the correct credential + expect(screen.getByText('Credential 2')).toBeInTheDocument() + }) + + it('should pass isOpen state to Trigger component', () => { + // Arrange + const props = createDefaultProps() + render() + + // Assert - Initially closed + const portalRoot = screen.getByTestId('portal-root') + expect(portalRoot).toHaveAttribute('data-open', 'false') + + // Act - Open + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + // Assert - Now open + expect(portalRoot).toHaveAttribute('data-open', 'true') + }) + + it('should pass credentials to List component', () => { + // Arrange + const props = createDefaultProps() + render() + + // Act + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + // Assert - All credentials should be rendered in list + // 3 in dropdown + 1 in trigger (current credential appears twice) = 4 total + const credentialNames = screen.getAllByText(/Credential \d/) + expect(credentialNames.length).toBe(4) + }) + + it('should pass currentCredentialId to List component', () => { + // Arrange + const props = createDefaultProps({ currentCredentialId: 'cred-2' }) + render() + + // Act + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + + // Assert - Current credential (Credential 2) appears twice: + // once in trigger and once in dropdown list + const credential2Elements = screen.getAllByText('Credential 2') + expect(credential2Elements.length).toBe(2) + }) + + it('should pass handleCredentialChange to List component', () => { + // Arrange + const mockOnChange = jest.fn() + const props = createDefaultProps({ onCredentialChange: mockOnChange }) + render() + + // Act + const trigger = screen.getByTestId('portal-trigger') + fireEvent.click(trigger) + const credential3 = screen.getByText('Credential 3') + fireEvent.click(credential3) + + // Assert - handleCredentialChange should propagate the call + expect(mockOnChange).toHaveBeenCalledWith('cred-3') + }) + }) + + // ========================================== + // Portal Configuration + // ========================================== + describe('Portal Configuration', () => { + it('should configure PortalToFollowElem with placement bottom-start', () => { + // This test verifies the portal is configured correctly + // The actual placement is handled by the mock, but we verify the component renders + const props = createDefaultProps() + render() + + expect(screen.getByTestId('portal-root')).toBeInTheDocument() + }) + + it('should configure PortalToFollowElem with offset mainAxis 4', () => { + // This test verifies the offset configuration doesn't break rendering + const props = createDefaultProps() + render() + + expect(screen.getByTestId('portal-root')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.spec.tsx new file mode 100644 index 0000000000..089f1f2810 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.spec.tsx @@ -0,0 +1,659 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import Header from './header' +import type { DataSourceCredential } from '@/types/pipeline' + +// Mock CredentialTypeEnum to avoid deep import chain issues +enum MockCredentialTypeEnum { + OAUTH2 = 'oauth2', + API_KEY = 'api_key', +} + +// Mock plugin-auth module to avoid deep import chain issues +jest.mock('@/app/components/plugins/plugin-auth', () => ({ + CredentialTypeEnum: { + OAUTH2: 'oauth2', + API_KEY: 'api_key', + }, +})) + +// Mock portal-to-follow-elem - required for CredentialSelector +jest.mock('@/app/components/base/portal-to-follow-elem', () => { + const MockPortalToFollowElem = ({ children, open }: any) => { + return ( +
+ {React.Children.map(children, (child: any) => { + if (!child) + return null + return React.cloneElement(child, { __portalOpen: open }) + })} +
+ ) + } + + const MockPortalToFollowElemTrigger = ({ children, onClick, className, __portalOpen }: any) => ( +
+ {children} +
+ ) + + const MockPortalToFollowElemContent = ({ children, className, __portalOpen }: any) => { + if (!__portalOpen) + return null + return ( +
+ {children} +
+ ) + } + + return { + PortalToFollowElem: MockPortalToFollowElem, + PortalToFollowElemTrigger: MockPortalToFollowElemTrigger, + PortalToFollowElemContent: MockPortalToFollowElemContent, + } +}) + +// ========================================== +// Test Data Builders +// ========================================== +const createMockCredential = (overrides?: Partial): DataSourceCredential => ({ + id: 'cred-1', + name: 'Test Credential', + avatar_url: 'https://example.com/avatar.png', + credential: { key: 'value' }, + is_default: false, + type: MockCredentialTypeEnum.OAUTH2 as unknown as DataSourceCredential['type'], + ...overrides, +}) + +const createMockCredentials = (count: number = 3): DataSourceCredential[] => + Array.from({ length: count }, (_, i) => + createMockCredential({ + id: `cred-${i + 1}`, + name: `Credential ${i + 1}`, + avatar_url: `https://example.com/avatar-${i + 1}.png`, + is_default: i === 0, + }), + ) + +type HeaderProps = React.ComponentProps + +const createDefaultProps = (overrides?: Partial): HeaderProps => ({ + docTitle: 'Documentation', + docLink: 'https://docs.example.com', + pluginName: 'Test Plugin', + currentCredentialId: 'cred-1', + onCredentialChange: jest.fn(), + credentials: createMockCredentials(), + ...overrides, +}) + +describe('Header', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // ========================================== + // Rendering Tests + // ========================================== + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange + const props = createDefaultProps() + + // Act + render(
) + + // Assert + expect(screen.getByText('Documentation')).toBeInTheDocument() + }) + + it('should render documentation link with correct attributes', () => { + // Arrange + const props = createDefaultProps({ + docTitle: 'API Docs', + docLink: 'https://api.example.com/docs', + }) + + // Act + render(
) + + // Assert + const link = screen.getByRole('link', { name: /API Docs/i }) + expect(link).toHaveAttribute('href', 'https://api.example.com/docs') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('should render document title with title attribute', () => { + // Arrange + const props = createDefaultProps({ docTitle: 'My Documentation' }) + + // Act + render(
) + + // Assert + const titleSpan = screen.getByText('My Documentation') + expect(titleSpan).toHaveAttribute('title', 'My Documentation') + }) + + it('should render CredentialSelector with correct props', () => { + // Arrange + const props = createDefaultProps() + + // Act + render(
) + + // Assert - CredentialSelector should render current credential name + expect(screen.getByText('Credential 1')).toBeInTheDocument() + }) + + it('should render configuration button', () => { + // Arrange + const props = createDefaultProps() + + // Act + render(
) + + // Assert + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should render book icon in documentation link', () => { + // Arrange + const props = createDefaultProps() + + // Act + render(
) + + // Assert - RiBookOpenLine renders as SVG + const link = screen.getByRole('link') + const svg = link.querySelector('svg') + expect(svg).toBeInTheDocument() + }) + + it('should render divider between credential selector and configuration button', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render(
) + + // Assert - Divider component should be rendered + // Divider typically renders as a div with specific styling + const divider = container.querySelector('[class*="divider"]') || container.querySelector('.mx-1.h-3\\.5') + expect(divider).toBeInTheDocument() + }) + }) + + // ========================================== + // Props Testing + // ========================================== + describe('Props', () => { + describe('docTitle prop', () => { + it('should display the document title', () => { + // Arrange + const props = createDefaultProps({ docTitle: 'Getting Started Guide' }) + + // Act + render(
) + + // Assert + expect(screen.getByText('Getting Started Guide')).toBeInTheDocument() + }) + + it.each([ + 'Quick Start', + 'API Reference', + 'Configuration Guide', + 'Plugin Documentation', + ])('should display "%s" as document title', (title) => { + // Arrange + const props = createDefaultProps({ docTitle: title }) + + // Act + render(
) + + // Assert + expect(screen.getByText(title)).toBeInTheDocument() + }) + }) + + describe('docLink prop', () => { + it('should set correct href on documentation link', () => { + // Arrange + const props = createDefaultProps({ docLink: 'https://custom.docs.com/guide' }) + + // Act + render(
) + + // Assert + const link = screen.getByRole('link') + expect(link).toHaveAttribute('href', 'https://custom.docs.com/guide') + }) + + it.each([ + 'https://docs.dify.ai', + 'https://example.com/api', + '/local/docs', + ])('should accept "%s" as docLink', (link) => { + // Arrange + const props = createDefaultProps({ docLink: link }) + + // Act + render(
) + + // Assert + expect(screen.getByRole('link')).toHaveAttribute('href', link) + }) + }) + + describe('pluginName prop', () => { + it('should pass pluginName to translation function', () => { + // Arrange + const props = createDefaultProps({ pluginName: 'MyPlugin' }) + + // Act + render(
) + + // Assert - The translation mock returns the key with options + // Tooltip uses the translated content + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) + + describe('onClickConfiguration prop', () => { + it('should call onClickConfiguration when configuration icon is clicked', () => { + // Arrange + const mockOnClick = jest.fn() + const props = createDefaultProps({ onClickConfiguration: mockOnClick }) + render(
) + + // Act - Find the configuration button and click the icon inside + // The button contains the RiEqualizer2Line icon with onClick handler + const configButton = screen.getByRole('button') + const configIcon = configButton.querySelector('svg') + expect(configIcon).toBeInTheDocument() + fireEvent.click(configIcon!) + + // Assert + expect(mockOnClick).toHaveBeenCalledTimes(1) + }) + + it('should not crash when onClickConfiguration is undefined', () => { + // Arrange + const props = createDefaultProps({ onClickConfiguration: undefined }) + render(
) + + // Act - Find the configuration button and click the icon inside + const configButton = screen.getByRole('button') + const configIcon = configButton.querySelector('svg') + expect(configIcon).toBeInTheDocument() + fireEvent.click(configIcon!) + + // Assert - Component should still be rendered (no crash) + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) + + describe('CredentialSelector props passthrough', () => { + it('should pass currentCredentialId to CredentialSelector', () => { + // Arrange + const props = createDefaultProps({ currentCredentialId: 'cred-2' }) + + // Act + render(
) + + // Assert - Should display the second credential + expect(screen.getByText('Credential 2')).toBeInTheDocument() + }) + + it('should pass credentials to CredentialSelector', () => { + // Arrange + const customCredentials = [ + createMockCredential({ id: 'custom-1', name: 'Custom Credential' }), + ] + const props = createDefaultProps({ + credentials: customCredentials, + currentCredentialId: 'custom-1', + }) + + // Act + render(
) + + // Assert + expect(screen.getByText('Custom Credential')).toBeInTheDocument() + }) + + it('should pass onCredentialChange to CredentialSelector', () => { + // Arrange + const mockOnChange = jest.fn() + const props = createDefaultProps({ onCredentialChange: mockOnChange }) + render(
) + + // Act - Open dropdown and select a credential + // Use getAllByTestId and select the first one (CredentialSelector's trigger) + const triggers = screen.getAllByTestId('portal-trigger') + fireEvent.click(triggers[0]) + const credential2 = screen.getByText('Credential 2') + fireEvent.click(credential2) + + // Assert + expect(mockOnChange).toHaveBeenCalledWith('cred-2') + }) + }) + }) + + // ========================================== + // User Interactions + // ========================================== + describe('User Interactions', () => { + it('should open external link in new tab when clicking documentation link', () => { + // Arrange + const props = createDefaultProps() + + // Act + render(
) + + // Assert - Link has target="_blank" for new tab + const link = screen.getByRole('link') + expect(link).toHaveAttribute('target', '_blank') + }) + + it('should allow credential selection through CredentialSelector', () => { + // Arrange + const mockOnChange = jest.fn() + const props = createDefaultProps({ onCredentialChange: mockOnChange }) + render(
) + + // Act - Open dropdown (use first trigger which is CredentialSelector's) + const triggers = screen.getAllByTestId('portal-trigger') + fireEvent.click(triggers[0]) + + // Assert - Dropdown should be open + expect(screen.getByTestId('portal-content')).toBeInTheDocument() + }) + + it('should trigger configuration callback when clicking config icon', () => { + // Arrange + const mockOnConfig = jest.fn() + const props = createDefaultProps({ onClickConfiguration: mockOnConfig }) + const { container } = render(
) + + // Act + const configIcon = container.querySelector('.h-4.w-4') + fireEvent.click(configIcon!) + + // Assert + expect(mockOnConfig).toHaveBeenCalled() + }) + }) + + // ========================================== + // Component Memoization + // ========================================== + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + // Assert + expect(Header.$$typeof).toBe(Symbol.for('react.memo')) + }) + + it('should not re-render when props remain the same', () => { + // Arrange + const props = createDefaultProps() + const renderSpy = jest.fn() + + const TrackedHeader: React.FC = (trackedProps) => { + renderSpy() + return
+ } + const MemoizedTracked = React.memo(TrackedHeader) + + // Act + const { rerender } = render() + rerender() + + // Assert - Should only render once due to same props + expect(renderSpy).toHaveBeenCalledTimes(1) + }) + + it('should re-render when docTitle changes', () => { + // Arrange + const props = createDefaultProps({ docTitle: 'Original Title' }) + const { rerender } = render(
) + + // Assert initial + expect(screen.getByText('Original Title')).toBeInTheDocument() + + // Act + rerender(
) + + // Assert + expect(screen.getByText('Updated Title')).toBeInTheDocument() + }) + + it('should re-render when currentCredentialId changes', () => { + // Arrange + const props = createDefaultProps({ currentCredentialId: 'cred-1' }) + const { rerender } = render(
) + + // Assert initial + expect(screen.getByText('Credential 1')).toBeInTheDocument() + + // Act + rerender(
) + + // Assert + expect(screen.getByText('Credential 2')).toBeInTheDocument() + }) + }) + + // ========================================== + // Edge Cases + // ========================================== + describe('Edge Cases', () => { + it('should handle empty docTitle', () => { + // Arrange + const props = createDefaultProps({ docTitle: '' }) + + // Act + render(
) + + // Assert - Should render without crashing + const link = screen.getByRole('link') + expect(link).toBeInTheDocument() + }) + + it('should handle very long docTitle', () => { + // Arrange + const longTitle = 'A'.repeat(200) + const props = createDefaultProps({ docTitle: longTitle }) + + // Act + render(
) + + // Assert + expect(screen.getByText(longTitle)).toBeInTheDocument() + }) + + it('should handle special characters in docTitle', () => { + // Arrange + const specialTitle = 'Docs & Guide "Special"' + const props = createDefaultProps({ docTitle: specialTitle }) + + // Act + render(
) + + // Assert + expect(screen.getByText(specialTitle)).toBeInTheDocument() + }) + + it('should handle empty credentials array', () => { + // Arrange + const props = createDefaultProps({ + credentials: [], + currentCredentialId: '', + }) + + // Act + render(
) + + // Assert - Should render without crashing + expect(screen.getByRole('link')).toBeInTheDocument() + }) + + it('should handle special characters in pluginName', () => { + // Arrange + const props = createDefaultProps({ pluginName: 'Plugin & Tool ' }) + + // Act + render(
) + + // Assert - Should render without crashing + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should handle unicode characters in docTitle', () => { + // Arrange + const props = createDefaultProps({ docTitle: '文档说明 📚' }) + + // Act + render(
) + + // Assert + expect(screen.getByText('文档说明 📚')).toBeInTheDocument() + }) + }) + + // ========================================== + // Styling + // ========================================== + describe('Styling', () => { + it('should apply correct classes to container', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render(
) + + // Assert + const rootDiv = container.firstChild as HTMLElement + expect(rootDiv).toHaveClass('flex', 'items-center', 'justify-between', 'gap-x-2') + }) + + it('should apply correct classes to documentation link', () => { + // Arrange + const props = createDefaultProps() + + // Act + render(
) + + // Assert + const link = screen.getByRole('link') + expect(link).toHaveClass('system-xs-medium', 'text-text-accent') + }) + + it('should apply shrink-0 to documentation link', () => { + // Arrange + const props = createDefaultProps() + + // Act + render(
) + + // Assert + const link = screen.getByRole('link') + expect(link).toHaveClass('shrink-0') + }) + }) + + // ========================================== + // Integration Tests + // ========================================== + describe('Integration', () => { + it('should work with full credential workflow', () => { + // Arrange + const mockOnCredentialChange = jest.fn() + const props = createDefaultProps({ + onCredentialChange: mockOnCredentialChange, + currentCredentialId: 'cred-1', + }) + render(
) + + // Assert initial state + expect(screen.getByText('Credential 1')).toBeInTheDocument() + + // Act - Open dropdown and select different credential + // Use first trigger which is CredentialSelector's + const triggers = screen.getAllByTestId('portal-trigger') + fireEvent.click(triggers[0]) + + const credential3 = screen.getByText('Credential 3') + fireEvent.click(credential3) + + // Assert + expect(mockOnCredentialChange).toHaveBeenCalledWith('cred-3') + }) + + it('should display all components together correctly', () => { + // Arrange + const mockOnConfig = jest.fn() + const props = createDefaultProps({ + docTitle: 'Integration Test Docs', + docLink: 'https://test.com/docs', + pluginName: 'TestPlugin', + onClickConfiguration: mockOnConfig, + }) + + // Act + render(
) + + // Assert - All main elements present + expect(screen.getByText('Credential 1')).toBeInTheDocument() // CredentialSelector + expect(screen.getByRole('button')).toBeInTheDocument() // Config button + expect(screen.getByText('Integration Test Docs')).toBeInTheDocument() // Doc link + expect(screen.getByRole('link')).toHaveAttribute('href', 'https://test.com/docs') + }) + }) + + // ========================================== + // Accessibility + // ========================================== + describe('Accessibility', () => { + it('should have accessible link', () => { + // Arrange + const props = createDefaultProps({ docTitle: 'Accessible Docs' }) + + // Act + render(
) + + // Assert + const link = screen.getByRole('link', { name: /Accessible Docs/i }) + expect(link).toBeInTheDocument() + }) + + it('should have accessible button for configuration', () => { + // Arrange + const props = createDefaultProps() + + // Act + render(
) + + // Assert + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + }) + + it('should have noopener noreferrer for security on external links', () => { + // Arrange + const props = createDefaultProps() + + // Act + render(
) + + // Assert + const link = screen.getByRole('link') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.spec.tsx new file mode 100644 index 0000000000..467f6d9816 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.spec.tsx @@ -0,0 +1,1357 @@ +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(), + 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) => ( +
+ {props.docTitle} + {props.docLink} + {props.pluginName} + {props.currentCredentialId} + + + {props.credentials?.length || 0} +
+ ) + 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 }) => ( +
+ onChange(e.target.value)} + placeholder="Search" + /> +
+ ) + return MockSearchInput +}) + +// Mock PageSelector component +jest.mock('./page-selector', () => { + const MockPageSelector = (props: any) => ( +
+ {props.checkedIds?.size || 0} + {props.searchValue} + {String(props.canPreview)} + {String(props.isMultipleChoice)} + {props.currentCredentialId} + + +
+ ) + return MockPageSelector +}) + +// Mock Title component +jest.mock('./title', () => { + const MockTitle = ({ name }: { name: string }) => ( +
+ {name} +
+ ) + return MockTitle +}) + +// Mock Loading component +jest.mock('@/app/components/base/loading', () => { + const MockLoading = ({ type }: { type: string }) => ( +
Loading...
+ ) + return MockLoading +}) + +// ========================================== +// Test Data Builders +// ========================================== +const createMockNodeData = (overrides?: Partial): 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 => ({ + 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 => ({ + 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 + +const createDefaultProps = (overrides?: Partial): 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + 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() + + // Assert + expect(mockSsePost).toHaveBeenCalledTimes(1) + }) + + it('should not call getOnlineDocuments when currentCredentialId is empty', () => { + // Arrange + mockStoreState.currentCredentialId = '' + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(mockSsePost).not.toHaveBeenCalled() + }) + + it('should pass correct body parameters to ssePost', () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-123' + const props = createDefaultProps() + + // Act + render() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // Act - Update documentsData + const newPages = [ + createMockPage({ page_id: 'page-1' }), + createMockPage({ page_id: 'page-2' }), + ] + mockStoreState.documentsData = [createMockWorkspace({ pages: newPages })] + rerender() + + // Assert + expect(screen.getByTestId('page-selector')).toBeInTheDocument() + }) + + it('should handle empty documentsData in PagesMapAndSelectedPagesId computation', () => { + // Arrange + mockStoreState.documentsData = [] + const props = createDefaultProps() + + // Act + render() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // Assert + expect(screen.getByTestId('page-selector')).toBeInTheDocument() + }) + + it('should handle special characters in searchValue', () => { + // Arrange + mockStoreState.documentsData = [createMockWorkspace()] + const props = createDefaultProps() + render() + + // Act + const searchInput = screen.getByTestId('search-input-field') + fireEvent.change(searchInput, { target: { value: 'test' } }) + + // Assert + expect(mockStoreState.setSearchValue).toHaveBeenCalledWith('test') + }) + + it('should handle unicode characters in searchValue', () => { + // Arrange + mockStoreState.documentsData = [createMockWorkspace()] + const props = createDefaultProps() + render() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + + // Assert + const contentContainer = container.querySelector('.rounded-xl.border') + expect(contentContainer).toBeInTheDocument() + expect(contentContainer).toHaveClass('border-components-panel-border', 'bg-background-default-subtle') + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.spec.tsx new file mode 100644 index 0000000000..7307ef7a6f --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/page-selector/index.spec.tsx @@ -0,0 +1,1633 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import PageSelector from './index' +import type { NotionPageTreeItem, NotionPageTreeMap } from './index' +import type { DataSourceNotionPage, DataSourceNotionPageMap } from '@/models/common' +import { recursivePushInParentDescendants } from './utils' + +// ========================================== +// Mock Modules +// ========================================== + +// Note: react-i18next uses global mock from web/__mocks__/react-i18next.ts + +// Mock react-window FixedSizeList - renders items directly for testing +jest.mock('react-window', () => ({ + FixedSizeList: ({ children: ItemComponent, itemCount, itemData, itemKey }: any) => ( +
+ {Array.from({ length: itemCount }).map((_, index) => ( + + ))} +
+ ), +})) + +// Note: NotionIcon from @/app/components/base/ is NOT mocked - using real component per testing guidelines + +// ========================================== +// Helper Functions for Base Components +// ========================================== +// Get checkbox element (uses data-testid pattern from base Checkbox component) +const getCheckbox = () => document.querySelector('[data-testid^="checkbox-"]') as HTMLElement +const getAllCheckboxes = () => document.querySelectorAll('[data-testid^="checkbox-"]') + +// Get radio element (uses size-4 rounded-full class pattern from base Radio component) +const getRadio = () => document.querySelector('.size-4.rounded-full') as HTMLElement +const getAllRadios = () => document.querySelectorAll('.size-4.rounded-full') + +// Check if checkbox is checked by looking for check icon +const isCheckboxChecked = (checkbox: Element) => checkbox.querySelector('[data-testid^="check-icon-"]') !== null + +// Check if checkbox is disabled by looking for disabled class +const isCheckboxDisabled = (checkbox: Element) => checkbox.classList.contains('cursor-not-allowed') + +// ========================================== +// Test Data Builders +// ========================================== +const createMockPage = (overrides?: Partial): DataSourceNotionPage => ({ + page_id: 'page-1', + page_name: 'Test Page', + page_icon: null, + is_bound: false, + parent_id: 'root', + type: 'page', + ...overrides, +}) + +const createMockPagesMap = (pages: DataSourceNotionPage[]): DataSourceNotionPageMap => { + return pages.reduce((acc, page) => { + acc[page.page_id] = { ...page, workspace_id: 'workspace-1' } + return acc + }, {} as DataSourceNotionPageMap) +} + +type PageSelectorProps = React.ComponentProps + +const createDefaultProps = (overrides?: Partial): PageSelectorProps => { + const defaultList = [createMockPage()] + return { + checkedIds: new Set(), + disabledValue: new Set(), + searchValue: '', + pagesMap: createMockPagesMap(defaultList), + list: defaultList, + onSelect: jest.fn(), + canPreview: true, + onPreview: jest.fn(), + isMultipleChoice: true, + currentCredentialId: 'cred-1', + ...overrides, + } +} + +// Helper to create hierarchical page structure +const createHierarchicalPages = () => { + const rootPage = createMockPage({ page_id: 'root-page', page_name: 'Root Page', parent_id: 'root' }) + const childPage1 = createMockPage({ page_id: 'child-1', page_name: 'Child 1', parent_id: 'root-page' }) + const childPage2 = createMockPage({ page_id: 'child-2', page_name: 'Child 2', parent_id: 'root-page' }) + const grandChild = createMockPage({ page_id: 'grandchild-1', page_name: 'Grandchild 1', parent_id: 'child-1' }) + + const list = [rootPage, childPage1, childPage2, grandChild] + const pagesMap = createMockPagesMap(list) + + return { list, pagesMap, rootPage, childPage1, childPage2, grandChild } +} + +// ========================================== +// Test Suites +// ========================================== +describe('PageSelector', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // ========================================== + // Rendering Tests + // ========================================== + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('virtual-list')).toBeInTheDocument() + }) + + it('should render empty state when list is empty', () => { + // Arrange + const props = createDefaultProps({ + list: [], + pagesMap: {}, + }) + + // Act + render() + + // Assert + expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument() + expect(screen.queryByTestId('virtual-list')).not.toBeInTheDocument() + }) + + it('should render items using FixedSizeList', () => { + // Arrange + const pages = [ + createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), + createMockPage({ page_id: 'page-2', page_name: 'Page 2' }), + ] + const props = createDefaultProps({ + list: pages, + pagesMap: createMockPagesMap(pages), + }) + + // Act + render() + + // Assert + expect(screen.getByText('Page 1')).toBeInTheDocument() + expect(screen.getByText('Page 2')).toBeInTheDocument() + }) + + it('should render checkboxes when isMultipleChoice is true', () => { + // Arrange + const props = createDefaultProps({ isMultipleChoice: true }) + + // Act + render() + + // Assert + expect(getCheckbox()).toBeInTheDocument() + }) + + it('should render radio buttons when isMultipleChoice is false', () => { + // Arrange + const props = createDefaultProps({ isMultipleChoice: false }) + + // Act + render() + + // Assert + expect(getRadio()).toBeInTheDocument() + }) + + it('should render preview button when canPreview is true', () => { + // Arrange + const props = createDefaultProps({ canPreview: true }) + + // Act + render() + + // Assert + expect(screen.getByText('common.dataSource.notion.selector.preview')).toBeInTheDocument() + }) + + it('should not render preview button when canPreview is false', () => { + // Arrange + const props = createDefaultProps({ canPreview: false }) + + // Act + render() + + // Assert + expect(screen.queryByText('common.dataSource.notion.selector.preview')).not.toBeInTheDocument() + }) + + it('should render NotionIcon for each page', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert - NotionIcon renders svg when page_icon is null + const notionIcon = document.querySelector('.h-5.w-5') + expect(notionIcon).toBeInTheDocument() + }) + + it('should render page name', () => { + // Arrange + const props = createDefaultProps({ + list: [createMockPage({ page_name: 'My Custom Page' })], + pagesMap: createMockPagesMap([createMockPage({ page_name: 'My Custom Page' })]), + }) + + // Act + render() + + // Assert + expect(screen.getByText('My Custom Page')).toBeInTheDocument() + }) + }) + + // ========================================== + // Props Testing + // ========================================== + describe('Props', () => { + describe('checkedIds prop', () => { + it('should mark checkbox as checked when page is in checkedIds', () => { + // Arrange + const page = createMockPage({ page_id: 'page-1' }) + const props = createDefaultProps({ + list: [page], + pagesMap: createMockPagesMap([page]), + checkedIds: new Set(['page-1']), + }) + + // Act + render() + + // Assert + const checkbox = getCheckbox() + expect(checkbox).toBeInTheDocument() + expect(isCheckboxChecked(checkbox)).toBe(true) + }) + + it('should mark checkbox as unchecked when page is not in checkedIds', () => { + // Arrange + const page = createMockPage({ page_id: 'page-1' }) + const props = createDefaultProps({ + list: [page], + pagesMap: createMockPagesMap([page]), + checkedIds: new Set(), + }) + + // Act + render() + + // Assert + const checkbox = getCheckbox() + expect(checkbox).toBeInTheDocument() + expect(isCheckboxChecked(checkbox)).toBe(false) + }) + + it('should handle empty checkedIds', () => { + // Arrange + const props = createDefaultProps({ checkedIds: new Set() }) + + // Act + render() + + // Assert + const checkbox = getCheckbox() + expect(checkbox).toBeInTheDocument() + expect(isCheckboxChecked(checkbox)).toBe(false) + }) + + it('should handle multiple checked items', () => { + // Arrange + const pages = [ + createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), + createMockPage({ page_id: 'page-2', page_name: 'Page 2' }), + createMockPage({ page_id: 'page-3', page_name: 'Page 3' }), + ] + const props = createDefaultProps({ + list: pages, + pagesMap: createMockPagesMap(pages), + checkedIds: new Set(['page-1', 'page-3']), + }) + + // Act + render() + + // Assert + const checkboxes = getAllCheckboxes() + expect(isCheckboxChecked(checkboxes[0])).toBe(true) + expect(isCheckboxChecked(checkboxes[1])).toBe(false) + expect(isCheckboxChecked(checkboxes[2])).toBe(true) + }) + }) + + describe('disabledValue prop', () => { + it('should disable checkbox when page is in disabledValue', () => { + // Arrange + const page = createMockPage({ page_id: 'page-1' }) + const props = createDefaultProps({ + list: [page], + pagesMap: createMockPagesMap([page]), + disabledValue: new Set(['page-1']), + }) + + // Act + render() + + // Assert + const checkbox = getCheckbox() + expect(checkbox).toBeInTheDocument() + expect(isCheckboxDisabled(checkbox)).toBe(true) + }) + + it('should not disable checkbox when page is not in disabledValue', () => { + // Arrange + const page = createMockPage({ page_id: 'page-1' }) + const props = createDefaultProps({ + list: [page], + pagesMap: createMockPagesMap([page]), + disabledValue: new Set(), + }) + + // Act + render() + + // Assert + const checkbox = getCheckbox() + expect(checkbox).toBeInTheDocument() + expect(isCheckboxDisabled(checkbox)).toBe(false) + }) + + it('should handle partial disabled items', () => { + // Arrange + const pages = [ + createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), + createMockPage({ page_id: 'page-2', page_name: 'Page 2' }), + ] + const props = createDefaultProps({ + list: pages, + pagesMap: createMockPagesMap(pages), + disabledValue: new Set(['page-1']), + }) + + // Act + render() + + // Assert + const checkboxes = getAllCheckboxes() + expect(isCheckboxDisabled(checkboxes[0])).toBe(true) + expect(isCheckboxDisabled(checkboxes[1])).toBe(false) + }) + }) + + describe('searchValue prop', () => { + it('should filter pages by search value', () => { + // Arrange + const pages = [ + createMockPage({ page_id: 'page-1', page_name: 'Apple Page' }), + createMockPage({ page_id: 'page-2', page_name: 'Banana Page' }), + createMockPage({ page_id: 'page-3', page_name: 'Apple Pie' }), + ] + const props = createDefaultProps({ + list: pages, + pagesMap: createMockPagesMap(pages), + searchValue: 'Apple', + }) + + // Act + render() + + // Assert - Only pages containing "Apple" should be visible + // Use getAllByText since the page name appears in both title div and breadcrumbs + expect(screen.getAllByText('Apple Page').length).toBeGreaterThan(0) + expect(screen.getAllByText('Apple Pie').length).toBeGreaterThan(0) + // Banana Page is filtered out because it doesn't contain "Apple" + expect(screen.queryByText('Banana Page')).not.toBeInTheDocument() + }) + + it('should show empty state when no pages match search', () => { + // Arrange + const pages = [createMockPage({ page_id: 'page-1', page_name: 'Test Page' })] + const props = createDefaultProps({ + list: pages, + pagesMap: createMockPagesMap(pages), + searchValue: 'NonExistent', + }) + + // Act + render() + + // Assert + expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument() + }) + + it('should show all pages when searchValue is empty', () => { + // Arrange + const pages = [ + createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), + createMockPage({ page_id: 'page-2', page_name: 'Page 2' }), + ] + const props = createDefaultProps({ + list: pages, + pagesMap: createMockPagesMap(pages), + searchValue: '', + }) + + // Act + render() + + // Assert + expect(screen.getByText('Page 1')).toBeInTheDocument() + expect(screen.getByText('Page 2')).toBeInTheDocument() + }) + + it('should show breadcrumbs when searchValue is present', () => { + // Arrange + const { list, pagesMap } = createHierarchicalPages() + const props = createDefaultProps({ + list, + pagesMap, + searchValue: 'Grandchild', + }) + + // Act + render() + + // Assert - page name should be visible + expect(screen.getByText('Grandchild 1')).toBeInTheDocument() + }) + + it('should perform case-sensitive search', () => { + // Arrange + const pages = [ + createMockPage({ page_id: 'page-1', page_name: 'Apple Page' }), + createMockPage({ page_id: 'page-2', page_name: 'apple page' }), + ] + const props = createDefaultProps({ + list: pages, + pagesMap: createMockPagesMap(pages), + searchValue: 'Apple', + }) + + // Act + render() + + // Assert - Only 'Apple Page' should match (case-sensitive) + // Use getAllByText since the page name appears in both title div and breadcrumbs + expect(screen.getAllByText('Apple Page').length).toBeGreaterThan(0) + expect(screen.queryByText('apple page')).not.toBeInTheDocument() + }) + }) + + describe('canPreview prop', () => { + it('should show preview button when canPreview is true', () => { + // Arrange + const props = createDefaultProps({ canPreview: true }) + + // Act + render() + + // Assert + expect(screen.getByText('common.dataSource.notion.selector.preview')).toBeInTheDocument() + }) + + it('should hide preview button when canPreview is false', () => { + // Arrange + const props = createDefaultProps({ canPreview: false }) + + // Act + render() + + // Assert + expect(screen.queryByText('common.dataSource.notion.selector.preview')).not.toBeInTheDocument() + }) + + it('should use default value true when canPreview is not provided', () => { + // Arrange + const props = createDefaultProps() + delete (props as any).canPreview + + // Act + render() + + // Assert + expect(screen.getByText('common.dataSource.notion.selector.preview')).toBeInTheDocument() + }) + }) + + describe('isMultipleChoice prop', () => { + it('should render checkbox when isMultipleChoice is true', () => { + // Arrange + const props = createDefaultProps({ isMultipleChoice: true }) + + // Act + render() + + // Assert + expect(getCheckbox()).toBeInTheDocument() + expect(getRadio()).not.toBeInTheDocument() + }) + + it('should render radio when isMultipleChoice is false', () => { + // Arrange + const props = createDefaultProps({ isMultipleChoice: false }) + + // Act + render() + + // Assert + expect(getRadio()).toBeInTheDocument() + expect(getCheckbox()).not.toBeInTheDocument() + }) + + it('should use default value true when isMultipleChoice is not provided', () => { + // Arrange + const props = createDefaultProps() + delete (props as any).isMultipleChoice + + // Act + render() + + // Assert + expect(getCheckbox()).toBeInTheDocument() + }) + }) + + describe('onSelect prop', () => { + it('should call onSelect when checkbox is clicked', () => { + // Arrange + const mockOnSelect = jest.fn() + const props = createDefaultProps({ onSelect: mockOnSelect }) + + // Act + render() + fireEvent.click(getCheckbox()) + + // Assert + expect(mockOnSelect).toHaveBeenCalledTimes(1) + expect(mockOnSelect).toHaveBeenCalledWith(expect.any(Set)) + }) + + it('should pass updated set to onSelect', () => { + // Arrange + const mockOnSelect = jest.fn() + const page = createMockPage({ page_id: 'page-1' }) + const props = createDefaultProps({ + list: [page], + pagesMap: createMockPagesMap([page]), + checkedIds: new Set(), + onSelect: mockOnSelect, + }) + + // Act + render() + fireEvent.click(getCheckbox()) + + // Assert + const calledSet = mockOnSelect.mock.calls[0][0] as Set + expect(calledSet.has('page-1')).toBe(true) + }) + }) + + describe('onPreview prop', () => { + it('should call onPreview when preview button is clicked', () => { + // Arrange + const mockOnPreview = jest.fn() + const page = createMockPage({ page_id: 'page-1' }) + const props = createDefaultProps({ + list: [page], + pagesMap: createMockPagesMap([page]), + onPreview: mockOnPreview, + canPreview: true, + }) + + // Act + render() + fireEvent.click(screen.getByText('common.dataSource.notion.selector.preview')) + + // Assert + expect(mockOnPreview).toHaveBeenCalledWith('page-1') + }) + + it('should not throw when onPreview is undefined', () => { + // Arrange + const props = createDefaultProps({ + onPreview: undefined, + canPreview: true, + }) + + // Act & Assert + expect(() => { + render() + fireEvent.click(screen.getByText('common.dataSource.notion.selector.preview')) + }).not.toThrow() + }) + }) + + describe('currentCredentialId prop', () => { + it('should reset dataList when currentCredentialId changes', () => { + // Arrange + const pages = [ + createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), + ] + const props = createDefaultProps({ + list: pages, + pagesMap: createMockPagesMap(pages), + currentCredentialId: 'cred-1', + }) + + // Act + const { rerender } = render() + + // Assert - Initial render + expect(screen.getByText('Page 1')).toBeInTheDocument() + + // Rerender with new credential + rerender() + + // Assert - Should still show pages (reset and rebuild) + expect(screen.getByText('Page 1')).toBeInTheDocument() + }) + }) + }) + + // ========================================== + // State Management and Updates + // ========================================== + describe('State Management and Updates', () => { + it('should initialize dataList with root level pages', () => { + // Arrange + const { list, pagesMap, rootPage, childPage1 } = createHierarchicalPages() + const props = createDefaultProps({ + list, + pagesMap, + }) + + // Act + render() + + // Assert - Only root level page should be visible initially + expect(screen.getByText(rootPage.page_name)).toBeInTheDocument() + // Child pages should not be visible until expanded + expect(screen.queryByText(childPage1.page_name)).not.toBeInTheDocument() + }) + + it('should update dataList when expanding a page with children', () => { + // Arrange + const { list, pagesMap, rootPage, childPage1, childPage2 } = createHierarchicalPages() + const props = createDefaultProps({ + list, + pagesMap, + }) + + // Act + render() + + // Find and click the expand arrow (uses hover:bg-components-button-ghost-bg-hover class) + const arrowButton = document.querySelector('[class*="hover:bg-components-button-ghost-bg-hover"]') + if (arrowButton) + fireEvent.click(arrowButton) + + // Assert + expect(screen.getByText(rootPage.page_name)).toBeInTheDocument() + expect(screen.getByText(childPage1.page_name)).toBeInTheDocument() + expect(screen.getByText(childPage2.page_name)).toBeInTheDocument() + }) + + it('should maintain currentPreviewPageId state', () => { + // Arrange + const mockOnPreview = jest.fn() + const pages = [ + createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), + createMockPage({ page_id: 'page-2', page_name: 'Page 2' }), + ] + const props = createDefaultProps({ + list: pages, + pagesMap: createMockPagesMap(pages), + onPreview: mockOnPreview, + canPreview: true, + }) + + // Act + render() + const previewButtons = screen.getAllByText('common.dataSource.notion.selector.preview') + fireEvent.click(previewButtons[0]) + + // Assert + expect(mockOnPreview).toHaveBeenCalledWith('page-1') + }) + + it('should use searchDataList when searchValue is present', () => { + // Arrange + const pages = [ + createMockPage({ page_id: 'page-1', page_name: 'Apple' }), + createMockPage({ page_id: 'page-2', page_name: 'Banana' }), + ] + const props = createDefaultProps({ + list: pages, + pagesMap: createMockPagesMap(pages), + searchValue: 'Apple', + }) + + // Act + render() + + // Assert - Only pages matching search should be visible + // Use getAllByText since the page name appears in both title div and breadcrumbs + expect(screen.getAllByText('Apple').length).toBeGreaterThan(0) + expect(screen.queryByText('Banana')).not.toBeInTheDocument() + }) + }) + + // ========================================== + // Side Effects and Cleanup + // ========================================== + describe('Side Effects and Cleanup', () => { + it('should reinitialize dataList when currentCredentialId changes', () => { + // Arrange + const pages = [createMockPage({ page_id: 'page-1', page_name: 'Page 1' })] + const props = createDefaultProps({ + list: pages, + pagesMap: createMockPagesMap(pages), + currentCredentialId: 'cred-1', + }) + + // Act + const { rerender } = render() + expect(screen.getByText('Page 1')).toBeInTheDocument() + + // Change credential + rerender() + + // Assert - Component should still render correctly + expect(screen.getByText('Page 1')).toBeInTheDocument() + }) + + it('should filter root pages correctly on initialization', () => { + // Arrange + const { list, pagesMap, rootPage, childPage1 } = createHierarchicalPages() + const props = createDefaultProps({ + list, + pagesMap, + }) + + // Act + render() + + // Assert - Only root level pages visible + expect(screen.getByText(rootPage.page_name)).toBeInTheDocument() + expect(screen.queryByText(childPage1.page_name)).not.toBeInTheDocument() + }) + + it('should include pages whose parent is not in pagesMap', () => { + // Arrange + const orphanPage = createMockPage({ + page_id: 'orphan-page', + page_name: 'Orphan Page', + parent_id: 'non-existent-parent', + }) + const props = createDefaultProps({ + list: [orphanPage], + pagesMap: createMockPagesMap([orphanPage]), + }) + + // Act + render() + + // Assert - Orphan page should be visible at root level + expect(screen.getByText('Orphan Page')).toBeInTheDocument() + }) + }) + + // ========================================== + // Callback Stability and Memoization + // ========================================== + describe('Callback Stability and Memoization', () => { + it('should have stable handleToggle that expands children', () => { + // Arrange + const { list, pagesMap, childPage1, childPage2 } = createHierarchicalPages() + const props = createDefaultProps({ + list, + pagesMap, + }) + + // Act + render() + + // Find expand arrow for root page (has RiArrowRightSLine icon) + const expandArrow = document.querySelector('[class*="hover:bg-components-button-ghost-bg-hover"]') + if (expandArrow) + fireEvent.click(expandArrow) + + // Assert - Children should be visible + expect(screen.getByText(childPage1.page_name)).toBeInTheDocument() + expect(screen.getByText(childPage2.page_name)).toBeInTheDocument() + }) + + it('should have stable handleToggle that collapses descendants', () => { + // Arrange + const { list, pagesMap, childPage1, childPage2 } = createHierarchicalPages() + const props = createDefaultProps({ + list, + pagesMap, + }) + + // Act + render() + + // First expand + const expandArrow = document.querySelector('[class*="hover:bg-components-button-ghost-bg-hover"]') + if (expandArrow) { + fireEvent.click(expandArrow) + // Then collapse + fireEvent.click(expandArrow) + } + + // Assert - Children should be hidden again + expect(screen.queryByText(childPage1.page_name)).not.toBeInTheDocument() + expect(screen.queryByText(childPage2.page_name)).not.toBeInTheDocument() + }) + + it('should have stable handleCheck that adds page and descendants to selection', () => { + // Arrange + const mockOnSelect = jest.fn() + const { list, pagesMap } = createHierarchicalPages() + const props = createDefaultProps({ + list, + pagesMap, + onSelect: mockOnSelect, + checkedIds: new Set(), + isMultipleChoice: true, + }) + + // Act + render() + + // Check the root page + fireEvent.click(getCheckbox()) + + // Assert - onSelect should be called with the page and its descendants + expect(mockOnSelect).toHaveBeenCalled() + const selectedSet = mockOnSelect.mock.calls[0][0] as Set + expect(selectedSet.has('root-page')).toBe(true) + }) + + it('should have stable handleCheck that removes page and descendants from selection', () => { + // Arrange + const mockOnSelect = jest.fn() + const { list, pagesMap } = createHierarchicalPages() + const props = createDefaultProps({ + list, + pagesMap, + onSelect: mockOnSelect, + checkedIds: new Set(['root-page', 'child-1', 'child-2', 'grandchild-1']), + isMultipleChoice: true, + }) + + // Act + render() + + // Uncheck the root page + fireEvent.click(getCheckbox()) + + // Assert - onSelect should be called with empty/reduced set + expect(mockOnSelect).toHaveBeenCalled() + }) + + it('should have stable handlePreview that updates currentPreviewPageId', () => { + // Arrange + const mockOnPreview = jest.fn() + const page = createMockPage({ page_id: 'preview-page' }) + const props = createDefaultProps({ + list: [page], + pagesMap: createMockPagesMap([page]), + onPreview: mockOnPreview, + canPreview: true, + }) + + // Act + render() + fireEvent.click(screen.getByText('common.dataSource.notion.selector.preview')) + + // Assert + expect(mockOnPreview).toHaveBeenCalledWith('preview-page') + }) + }) + + // ========================================== + // Memoization Logic and Dependencies + // ========================================== + describe('Memoization Logic and Dependencies', () => { + it('should compute listMapWithChildrenAndDescendants correctly', () => { + // Arrange + const { list, pagesMap } = createHierarchicalPages() + const props = createDefaultProps({ + list, + pagesMap, + }) + + // Act + render() + + // Assert - Tree structure should be built (verified by expand functionality) + const expandArrow = document.querySelector('[class*="hover:bg-components-button-ghost-bg-hover"]') + expect(expandArrow).toBeInTheDocument() // Root page has children + }) + + it('should recompute listMapWithChildrenAndDescendants when list changes', () => { + // Arrange + const initialList = [createMockPage({ page_id: 'page-1', page_name: 'Page 1' })] + const props = createDefaultProps({ + list: initialList, + pagesMap: createMockPagesMap(initialList), + }) + + // Act + const { rerender } = render() + expect(screen.getByText('Page 1')).toBeInTheDocument() + + // Update with new list + const newList = [ + createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), + createMockPage({ page_id: 'page-2', page_name: 'Page 2' }), + ] + rerender() + + // Assert + expect(screen.getByText('Page 1')).toBeInTheDocument() + // Page 2 won't show because dataList state hasn't updated (only resets on credentialId change) + }) + + it('should recompute listMapWithChildrenAndDescendants when pagesMap changes', () => { + // Arrange + const initialList = [createMockPage({ page_id: 'page-1', page_name: 'Page 1' })] + const props = createDefaultProps({ + list: initialList, + pagesMap: createMockPagesMap(initialList), + }) + + // Act + const { rerender } = render() + + // Update pagesMap + const newPagesMap = { + ...createMockPagesMap(initialList), + 'page-2': { ...createMockPage({ page_id: 'page-2' }), workspace_id: 'ws-1' }, + } + rerender() + + // Assert - Should not throw + expect(screen.getByText('Page 1')).toBeInTheDocument() + }) + + it('should handle empty list in memoization', () => { + // Arrange + const props = createDefaultProps({ + list: [], + pagesMap: {}, + }) + + // Act + render() + + // Assert + expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument() + }) + }) + + // ========================================== + // User Interactions and Event Handlers + // ========================================== + describe('User Interactions and Event Handlers', () => { + it('should toggle expansion when clicking arrow button', () => { + // Arrange + const { list, pagesMap, childPage1 } = createHierarchicalPages() + const props = createDefaultProps({ + list, + pagesMap, + }) + + // Act + render() + + // Initially children are hidden + expect(screen.queryByText(childPage1.page_name)).not.toBeInTheDocument() + + // Click to expand + const expandArrow = document.querySelector('[class*="hover:bg-components-button-ghost-bg-hover"]') + if (expandArrow) + fireEvent.click(expandArrow) + + // Children become visible + expect(screen.getByText(childPage1.page_name)).toBeInTheDocument() + }) + + it('should check/uncheck page when clicking checkbox', () => { + // Arrange + const mockOnSelect = jest.fn() + const props = createDefaultProps({ + onSelect: mockOnSelect, + checkedIds: new Set(), + }) + + // Act + render() + fireEvent.click(getCheckbox()) + + // Assert + expect(mockOnSelect).toHaveBeenCalled() + }) + + it('should select radio when clicking in single choice mode', () => { + // Arrange + const mockOnSelect = jest.fn() + const props = createDefaultProps({ + onSelect: mockOnSelect, + isMultipleChoice: false, + checkedIds: new Set(), + }) + + // Act + render() + fireEvent.click(getRadio()) + + // Assert + expect(mockOnSelect).toHaveBeenCalled() + }) + + it('should clear previous selection in single choice mode', () => { + // Arrange + const mockOnSelect = jest.fn() + const pages = [ + createMockPage({ page_id: 'page-1', page_name: 'Page 1' }), + createMockPage({ page_id: 'page-2', page_name: 'Page 2' }), + ] + const props = createDefaultProps({ + list: pages, + pagesMap: createMockPagesMap(pages), + onSelect: mockOnSelect, + isMultipleChoice: false, + checkedIds: new Set(['page-1']), + }) + + // Act + render() + const radios = getAllRadios() + fireEvent.click(radios[1]) // Click on page-2 + + // Assert - Should clear page-1 and select page-2 + expect(mockOnSelect).toHaveBeenCalled() + const selectedSet = mockOnSelect.mock.calls[0][0] as Set + expect(selectedSet.has('page-2')).toBe(true) + expect(selectedSet.has('page-1')).toBe(false) + }) + + it('should trigger preview when clicking preview button', () => { + // Arrange + const mockOnPreview = jest.fn() + const props = createDefaultProps({ + onPreview: mockOnPreview, + canPreview: true, + }) + + // Act + render() + fireEvent.click(screen.getByText('common.dataSource.notion.selector.preview')) + + // Assert + expect(mockOnPreview).toHaveBeenCalledWith('page-1') + }) + + it('should not cascade selection in search mode', () => { + // Arrange + const mockOnSelect = jest.fn() + const { list, pagesMap } = createHierarchicalPages() + const props = createDefaultProps({ + list, + pagesMap, + onSelect: mockOnSelect, + checkedIds: new Set(), + searchValue: 'Root', + isMultipleChoice: true, + }) + + // Act + render() + fireEvent.click(getCheckbox()) + + // Assert - Only the clicked page should be selected (no descendants) + expect(mockOnSelect).toHaveBeenCalled() + const selectedSet = mockOnSelect.mock.calls[0][0] as Set + expect(selectedSet.size).toBe(1) + expect(selectedSet.has('root-page')).toBe(true) + }) + }) + + // ========================================== + // Edge Cases and Error Handling + // ========================================== + describe('Edge Cases and Error Handling', () => { + it('should handle empty list', () => { + // Arrange + const props = createDefaultProps({ + list: [], + pagesMap: {}, + }) + + // Act + render() + + // Assert + expect(screen.getByText('common.dataSource.notion.selector.noSearchResult')).toBeInTheDocument() + }) + + it('should handle null page_icon', () => { + // Arrange + const page = createMockPage({ page_icon: null }) + const props = createDefaultProps({ + list: [page], + pagesMap: createMockPagesMap([page]), + }) + + // Act + render() + + // Assert - NotionIcon renders svg (RiFileTextLine) when page_icon is null + const notionIcon = document.querySelector('.h-5.w-5') + expect(notionIcon).toBeInTheDocument() + }) + + it('should handle page_icon with all properties', () => { + // Arrange + const page = createMockPage({ + page_icon: { type: 'emoji', url: null, emoji: '📄' }, + }) + const props = createDefaultProps({ + list: [page], + pagesMap: createMockPagesMap([page]), + }) + + // Act + render() + + // Assert - NotionIcon renders the emoji + expect(screen.getByText('📄')).toBeInTheDocument() + }) + + it('should handle empty searchValue correctly', () => { + // Arrange + const props = createDefaultProps({ searchValue: '' }) + + // Act + render() + + // Assert + expect(screen.getByTestId('virtual-list')).toBeInTheDocument() + }) + + it('should handle special characters in page name', () => { + // Arrange + const page = createMockPage({ page_name: 'Test ' }) + const props = createDefaultProps({ + list: [page], + pagesMap: createMockPagesMap([page]), + }) + + // Act + render() + + // Assert + expect(screen.getByText('Test ')).toBeInTheDocument() + }) + + it('should handle unicode characters in page name', () => { + // Arrange + const page = createMockPage({ page_name: '测试页面 🔍 привет' }) + const props = createDefaultProps({ + list: [page], + pagesMap: createMockPagesMap([page]), + }) + + // Act + render() + + // Assert + expect(screen.getByText('测试页面 🔍 привет')).toBeInTheDocument() + }) + + it('should handle very long page names', () => { + // Arrange + const longName = 'A'.repeat(500) + const page = createMockPage({ page_name: longName }) + const props = createDefaultProps({ + list: [page], + pagesMap: createMockPagesMap([page]), + }) + + // Act + render() + + // Assert + expect(screen.getByText(longName)).toBeInTheDocument() + }) + + it('should handle deeply nested hierarchy', () => { + // Arrange - Create 5 levels deep + const pages: DataSourceNotionPage[] = [] + let parentId = 'root' + + for (let i = 0; i < 5; i++) { + const page = createMockPage({ + page_id: `level-${i}`, + page_name: `Level ${i}`, + parent_id: parentId, + }) + pages.push(page) + parentId = page.page_id + } + + const props = createDefaultProps({ + list: pages, + pagesMap: createMockPagesMap(pages), + }) + + // Act + render() + + // Assert - Only root level visible + expect(screen.getByText('Level 0')).toBeInTheDocument() + expect(screen.queryByText('Level 1')).not.toBeInTheDocument() + }) + + it('should handle page with missing parent reference gracefully', () => { + // Arrange - Page whose parent doesn't exist in pagesMap (valid edge case) + const orphanPage = createMockPage({ + page_id: 'orphan', + page_name: 'Orphan Page', + parent_id: 'non-existent-parent', + }) + // Create pagesMap without the parent + const pagesMap = createMockPagesMap([orphanPage]) + const props = createDefaultProps({ + list: [orphanPage], + pagesMap, + }) + + // Act + render() + + // Assert - Should render the orphan page at root level + expect(screen.getByText('Orphan Page')).toBeInTheDocument() + }) + + it('should handle empty checkedIds Set', () => { + // Arrange + const props = createDefaultProps({ checkedIds: new Set() }) + + // Act + render() + + // Assert + const checkbox = getCheckbox() + expect(checkbox).toBeInTheDocument() + expect(isCheckboxChecked(checkbox)).toBe(false) + }) + + it('should handle empty disabledValue Set', () => { + // Arrange + const props = createDefaultProps({ disabledValue: new Set() }) + + // Act + render() + + // Assert + const checkbox = getCheckbox() + expect(checkbox).toBeInTheDocument() + expect(isCheckboxDisabled(checkbox)).toBe(false) + }) + + it('should handle undefined onPreview gracefully', () => { + // Arrange + const props = createDefaultProps({ + onPreview: undefined, + canPreview: true, + }) + + // Act + render() + + // Assert - Click should not throw + expect(() => { + fireEvent.click(screen.getByText('common.dataSource.notion.selector.preview')) + }).not.toThrow() + }) + + it('should handle page without descendants correctly', () => { + // Arrange + const leafPage = createMockPage({ page_id: 'leaf', page_name: 'Leaf Page' }) + const props = createDefaultProps({ + list: [leafPage], + pagesMap: createMockPagesMap([leafPage]), + }) + + // Act + render() + + // Assert - No expand arrow for leaf pages + const arrowButton = document.querySelector('[class*="hover:bg-components-button-ghost-bg-hover"]') + expect(arrowButton).not.toBeInTheDocument() + }) + }) + + // ========================================== + // All Prop Variations + // ========================================== + describe('Prop Variations', () => { + it.each([ + [{ canPreview: true, isMultipleChoice: true }], + [{ canPreview: true, isMultipleChoice: false }], + [{ canPreview: false, isMultipleChoice: true }], + [{ canPreview: false, isMultipleChoice: false }], + ])('should render correctly with props %o', (propVariation) => { + // Arrange + const props = createDefaultProps(propVariation) + + // Act + render() + + // Assert + expect(screen.getByTestId('virtual-list')).toBeInTheDocument() + if (propVariation.canPreview) + expect(screen.getByText('common.dataSource.notion.selector.preview')).toBeInTheDocument() + else + expect(screen.queryByText('common.dataSource.notion.selector.preview')).not.toBeInTheDocument() + + if (propVariation.isMultipleChoice) + expect(getCheckbox()).toBeInTheDocument() + else + expect(getRadio()).toBeInTheDocument() + }) + + it('should handle all default prop values', () => { + // Arrange + const minimalProps: PageSelectorProps = { + checkedIds: new Set(), + disabledValue: new Set(), + searchValue: '', + pagesMap: createMockPagesMap([createMockPage()]), + list: [createMockPage()], + onSelect: jest.fn(), + currentCredentialId: 'cred-1', + // canPreview defaults to true + // isMultipleChoice defaults to true + } + + // Act + render() + + // Assert - Defaults should be applied + expect(getCheckbox()).toBeInTheDocument() + expect(screen.getByText('common.dataSource.notion.selector.preview')).toBeInTheDocument() + }) + }) + + // ========================================== + // Utils Function Tests + // ========================================== + describe('Utils - recursivePushInParentDescendants', () => { + it('should build tree structure for simple parent-child relationship', () => { + // Arrange + const parent = createMockPage({ page_id: 'parent', page_name: 'Parent', parent_id: 'root' }) + const child = createMockPage({ page_id: 'child', page_name: 'Child', parent_id: 'parent' }) + const pagesMap = createMockPagesMap([parent, child]) + const listTreeMap: NotionPageTreeMap = {} + + // Create initial entry for child + const childEntry: NotionPageTreeItem = { + ...child, + children: new Set(), + descendants: new Set(), + depth: 0, + ancestors: [], + } + listTreeMap[child.page_id] = childEntry + + // Act + recursivePushInParentDescendants(pagesMap, listTreeMap, childEntry, childEntry) + + // Assert + expect(listTreeMap.parent).toBeDefined() + expect(listTreeMap.parent.children.has('child')).toBe(true) + expect(listTreeMap.parent.descendants.has('child')).toBe(true) + expect(childEntry.depth).toBe(1) + expect(childEntry.ancestors).toContain('Parent') + }) + + it('should handle root level pages', () => { + // Arrange + const rootPage = createMockPage({ page_id: 'root-page', parent_id: 'root' }) + const pagesMap = createMockPagesMap([rootPage]) + const listTreeMap: NotionPageTreeMap = {} + + const rootEntry: NotionPageTreeItem = { + ...rootPage, + children: new Set(), + descendants: new Set(), + depth: 0, + ancestors: [], + } + listTreeMap[rootPage.page_id] = rootEntry + + // Act + recursivePushInParentDescendants(pagesMap, listTreeMap, rootEntry, rootEntry) + + // Assert - No parent should be created for root level + expect(Object.keys(listTreeMap)).toHaveLength(1) + expect(rootEntry.depth).toBe(0) + expect(rootEntry.ancestors).toHaveLength(0) + }) + + it('should handle missing parent in pagesMap', () => { + // Arrange + const orphan = createMockPage({ page_id: 'orphan', parent_id: 'missing-parent' }) + const pagesMap = createMockPagesMap([orphan]) + const listTreeMap: NotionPageTreeMap = {} + + const orphanEntry: NotionPageTreeItem = { + ...orphan, + children: new Set(), + descendants: new Set(), + depth: 0, + ancestors: [], + } + listTreeMap[orphan.page_id] = orphanEntry + + // Act + recursivePushInParentDescendants(pagesMap, listTreeMap, orphanEntry, orphanEntry) + + // Assert - Should not create parent entry for missing parent + expect(listTreeMap['missing-parent']).toBeUndefined() + }) + + it('should handle null parent_id', () => { + // Arrange + const page = createMockPage({ page_id: 'page', parent_id: '' }) + const pagesMap = createMockPagesMap([page]) + const listTreeMap: NotionPageTreeMap = {} + + const pageEntry: NotionPageTreeItem = { + ...page, + children: new Set(), + descendants: new Set(), + depth: 0, + ancestors: [], + } + listTreeMap[page.page_id] = pageEntry + + // Act + recursivePushInParentDescendants(pagesMap, listTreeMap, pageEntry, pageEntry) + + // Assert - Early return, no changes + expect(Object.keys(listTreeMap)).toHaveLength(1) + }) + + it('should accumulate depth for deeply nested pages', () => { + // Arrange - 3 levels deep + const level0 = createMockPage({ page_id: 'l0', page_name: 'Level 0', parent_id: 'root' }) + const level1 = createMockPage({ page_id: 'l1', page_name: 'Level 1', parent_id: 'l0' }) + const level2 = createMockPage({ page_id: 'l2', page_name: 'Level 2', parent_id: 'l1' }) + const pagesMap = createMockPagesMap([level0, level1, level2]) + const listTreeMap: NotionPageTreeMap = {} + + // Add all levels + const l0Entry: NotionPageTreeItem = { + ...level0, + children: new Set(), + descendants: new Set(), + depth: 0, + ancestors: [], + } + const l1Entry: NotionPageTreeItem = { + ...level1, + children: new Set(), + descendants: new Set(), + depth: 0, + ancestors: [], + } + const l2Entry: NotionPageTreeItem = { + ...level2, + children: new Set(), + descendants: new Set(), + depth: 0, + ancestors: [], + } + + listTreeMap[level0.page_id] = l0Entry + listTreeMap[level1.page_id] = l1Entry + listTreeMap[level2.page_id] = l2Entry + + // Act - Process from leaf to root + recursivePushInParentDescendants(pagesMap, listTreeMap, l2Entry, l2Entry) + + // Assert + expect(l2Entry.depth).toBe(2) + expect(l2Entry.ancestors).toEqual(['Level 0', 'Level 1']) + expect(listTreeMap.l1.children.has('l2')).toBe(true) + expect(listTreeMap.l0.descendants.has('l2')).toBe(true) + }) + + it('should update existing parent entry', () => { + // Arrange + const parent = createMockPage({ page_id: 'parent', page_name: 'Parent', parent_id: 'root' }) + const child1 = createMockPage({ page_id: 'child1', parent_id: 'parent' }) + const child2 = createMockPage({ page_id: 'child2', parent_id: 'parent' }) + const pagesMap = createMockPagesMap([parent, child1, child2]) + const listTreeMap: NotionPageTreeMap = {} + + // Pre-create parent entry + listTreeMap.parent = { + ...parent, + children: new Set(['child1']), + descendants: new Set(['child1']), + depth: 0, + ancestors: [], + } + + const child2Entry: NotionPageTreeItem = { + ...child2, + children: new Set(), + descendants: new Set(), + depth: 0, + ancestors: [], + } + listTreeMap[child2.page_id] = child2Entry + + // Act + recursivePushInParentDescendants(pagesMap, listTreeMap, child2Entry, child2Entry) + + // Assert - Should add child2 to existing parent + expect(listTreeMap.parent.children.has('child1')).toBe(true) + expect(listTreeMap.parent.children.has('child2')).toBe(true) + expect(listTreeMap.parent.descendants.has('child1')).toBe(true) + expect(listTreeMap.parent.descendants.has('child2')).toBe(true) + }) + }) + + // ========================================== + // Item Component Integration Tests + // ========================================== + describe('Item Component Integration', () => { + it('should render item with correct styling for preview state', () => { + // Arrange + const page = createMockPage({ page_id: 'page-1', page_name: 'Test Page' }) + const props = createDefaultProps({ + list: [page], + pagesMap: createMockPagesMap([page]), + canPreview: true, + }) + + // Act + render() + + // Click preview to set currentPreviewPageId + fireEvent.click(screen.getByText('common.dataSource.notion.selector.preview')) + + // Assert - Item should have preview styling class + const itemContainer = screen.getByText('Test Page').closest('[class*="group"]') + expect(itemContainer).toHaveClass('bg-state-base-hover') + }) + + it('should show arrow for pages with children', () => { + // Arrange + const { list, pagesMap } = createHierarchicalPages() + const props = createDefaultProps({ + list, + pagesMap, + }) + + // Act + render() + + // Assert - Root page should have expand arrow + const arrowContainer = document.querySelector('[class*="hover:bg-components-button-ghost-bg-hover"]') + expect(arrowContainer).toBeInTheDocument() + }) + + it('should not show arrow for leaf pages', () => { + // Arrange + const leafPage = createMockPage({ page_id: 'leaf', page_name: 'Leaf' }) + const props = createDefaultProps({ + list: [leafPage], + pagesMap: createMockPagesMap([leafPage]), + }) + + // Act + render() + + // Assert - No expand arrow for leaf pages + const arrowContainer = document.querySelector('[class*="hover:bg-components-button-ghost-bg-hover"]') + expect(arrowContainer).not.toBeInTheDocument() + }) + + it('should hide arrows in search mode', () => { + // Arrange + const { list, pagesMap } = createHierarchicalPages() + const props = createDefaultProps({ + list, + pagesMap, + searchValue: 'Root', + }) + + // Act + render() + + // Assert - No expand arrows in search mode (renderArrow returns null when searchValue) + // The arrows are only shown when !searchValue + const arrowContainer = document.querySelector('[class*="hover:bg-components-button-ghost-bg-hover"]') + expect(arrowContainer).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/index.spec.tsx new file mode 100644 index 0000000000..8475a01fa8 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/connect/index.spec.tsx @@ -0,0 +1,622 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import Connect from './index' +import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' + +// ========================================== +// Mock Modules +// ========================================== + +// Note: react-i18next uses global mock from web/__mocks__/react-i18next.ts + +// Mock useToolIcon - hook has complex dependencies (API calls, stores) +const mockUseToolIcon = jest.fn() +jest.mock('@/app/components/workflow/hooks', () => ({ + useToolIcon: (data: any) => mockUseToolIcon(data), +})) + +// ========================================== +// Test Data Builders +// ========================================== +const createMockNodeData = (overrides?: Partial): DataSourceNodeType => ({ + title: 'Test Node', + plugin_id: 'plugin-123', + provider_type: 'online_drive', + provider_name: 'online-drive-provider', + datasource_name: 'online-drive-ds', + datasource_label: 'Online Drive', + datasource_parameters: {}, + datasource_configurations: {}, + ...overrides, +} as DataSourceNodeType) + +type ConnectProps = React.ComponentProps + +const createDefaultProps = (overrides?: Partial): ConnectProps => ({ + nodeData: createMockNodeData(), + onSetting: jest.fn(), + ...overrides, +}) + +// ========================================== +// Test Suites +// ========================================== +describe('Connect', () => { + beforeEach(() => { + jest.clearAllMocks() + + // Default mock return values + mockUseToolIcon.mockReturnValue('https://example.com/icon.png') + }) + + // ========================================== + // Rendering Tests + // ========================================== + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert - Component should render with connect button + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should render the BlockIcon component', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert - BlockIcon container should exist + const iconContainer = container.querySelector('.size-12') + expect(iconContainer).toBeInTheDocument() + }) + + it('should render the not connected message with node title', () => { + // Arrange + const props = createDefaultProps({ + nodeData: createMockNodeData({ title: 'My Google Drive' }), + }) + + // Act + render() + + // Assert - Should show translation key with interpolated name (use getAllBy since both messages contain similar text) + const messages = screen.getAllByText(/datasetPipeline\.onlineDrive\.notConnected/) + expect(messages.length).toBeGreaterThanOrEqual(1) + }) + + it('should render the not connected tip message', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert - Should show tip translation key + expect(screen.getByText(/datasetPipeline\.onlineDrive\.notConnectedTip/)).toBeInTheDocument() + }) + + it('should render the connect button with correct text', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert - Button should have connect text + const button = screen.getByRole('button') + expect(button).toHaveTextContent('datasetCreation.stepOne.connect') + }) + + it('should render with primary button variant', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert - Button should be primary variant + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + }) + + it('should render Icon3Dots component', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert - Icon3Dots should be rendered (it's an SVG element) + const iconElement = container.querySelector('svg') + expect(iconElement).toBeInTheDocument() + }) + + it('should apply correct container styling', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert - Container should have expected classes + const mainContainer = container.firstChild + expect(mainContainer).toHaveClass('flex', 'flex-col', 'items-start', 'gap-y-2', 'rounded-xl', 'p-6') + }) + }) + + // ========================================== + // Props Testing + // ========================================== + describe('Props', () => { + describe('nodeData prop', () => { + it('should pass nodeData to useToolIcon hook', () => { + // Arrange + const nodeData = createMockNodeData({ plugin_id: 'my-plugin' }) + const props = createDefaultProps({ nodeData }) + + // Act + render() + + // Assert + expect(mockUseToolIcon).toHaveBeenCalledWith(nodeData) + }) + + it('should display node title in not connected message', () => { + // Arrange + const props = createDefaultProps({ + nodeData: createMockNodeData({ title: 'Dropbox Storage' }), + }) + + // Act + render() + + // Assert - Translation key should be in document (mock returns key) + const messages = screen.getAllByText(/datasetPipeline\.onlineDrive\.notConnected/) + expect(messages.length).toBeGreaterThanOrEqual(1) + }) + + it('should display node title in tip message', () => { + // Arrange + const props = createDefaultProps({ + nodeData: createMockNodeData({ title: 'OneDrive Connector' }), + }) + + // Act + render() + + // Assert - Translation key should be in document + expect(screen.getByText(/datasetPipeline\.onlineDrive\.notConnectedTip/)).toBeInTheDocument() + }) + + it.each([ + { title: 'Google Drive' }, + { title: 'Dropbox' }, + { title: 'OneDrive' }, + { title: 'Amazon S3' }, + { title: '' }, + ])('should handle nodeData with title=$title', ({ title }) => { + // Arrange + const props = createDefaultProps({ + nodeData: createMockNodeData({ title }), + }) + + // Act + render() + + // Assert - Should render without error + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) + + describe('onSetting prop', () => { + it('should call onSetting when connect button is clicked', () => { + // Arrange + const mockOnSetting = jest.fn() + const props = createDefaultProps({ onSetting: mockOnSetting }) + + // Act + render() + fireEvent.click(screen.getByRole('button')) + + // Assert + expect(mockOnSetting).toHaveBeenCalledTimes(1) + }) + + it('should call onSetting when button clicked', () => { + // Arrange + const mockOnSetting = jest.fn() + const props = createDefaultProps({ onSetting: mockOnSetting }) + + // Act + render() + fireEvent.click(screen.getByRole('button')) + + // Assert - onClick handler receives the click event from React + expect(mockOnSetting).toHaveBeenCalled() + expect(mockOnSetting.mock.calls[0]).toBeDefined() + }) + + it('should call onSetting on each button click', () => { + // Arrange + const mockOnSetting = jest.fn() + const props = createDefaultProps({ onSetting: mockOnSetting }) + + // Act + render() + const button = screen.getByRole('button') + fireEvent.click(button) + fireEvent.click(button) + fireEvent.click(button) + + // Assert + expect(mockOnSetting).toHaveBeenCalledTimes(3) + }) + }) + }) + + // ========================================== + // User Interactions and Event Handlers + // ========================================== + describe('User Interactions', () => { + describe('Connect Button', () => { + it('should trigger onSetting callback on click', () => { + // Arrange + const mockOnSetting = jest.fn() + const props = createDefaultProps({ onSetting: mockOnSetting }) + render() + + // Act + fireEvent.click(screen.getByRole('button')) + + // Assert + expect(mockOnSetting).toHaveBeenCalled() + }) + + it('should be interactive and focusable', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + const button = screen.getByRole('button') + + // Assert + expect(button).not.toBeDisabled() + }) + + it('should handle keyboard interaction (Enter key)', () => { + // Arrange + const mockOnSetting = jest.fn() + const props = createDefaultProps({ onSetting: mockOnSetting }) + render() + + // Act + const button = screen.getByRole('button') + fireEvent.keyDown(button, { key: 'Enter' }) + + // Assert - Button should be present and interactive + expect(button).toBeInTheDocument() + }) + }) + }) + + // ========================================== + // Hook Integration Tests + // ========================================== + describe('Hook Integration', () => { + describe('useToolIcon', () => { + it('should call useToolIcon with nodeData', () => { + // Arrange + const nodeData = createMockNodeData() + const props = createDefaultProps({ nodeData }) + + // Act + render() + + // Assert + expect(mockUseToolIcon).toHaveBeenCalledWith(nodeData) + }) + + it('should use toolIcon result from useToolIcon', () => { + // Arrange + mockUseToolIcon.mockReturnValue('custom-icon-url') + const props = createDefaultProps() + + // Act + render() + + // Assert - The hook should be called and its return value used + expect(mockUseToolIcon).toHaveBeenCalled() + }) + + it('should handle empty string icon', () => { + // Arrange + mockUseToolIcon.mockReturnValue('') + const props = createDefaultProps() + + // Act + render() + + // Assert - Should still render without crashing + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should handle undefined icon', () => { + // Arrange + mockUseToolIcon.mockReturnValue(undefined) + const props = createDefaultProps() + + // Act + render() + + // Assert - Should still render without crashing + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) + + describe('useTranslation', () => { + it('should use correct translation keys for not connected message', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert - Should use the correct translation key (both notConnected and notConnectedTip contain similar pattern) + const messages = screen.getAllByText(/datasetPipeline\.onlineDrive\.notConnected/) + expect(messages.length).toBeGreaterThanOrEqual(1) + }) + + it('should use correct translation key for tip message', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByText(/datasetPipeline\.onlineDrive\.notConnectedTip/)).toBeInTheDocument() + }) + + it('should use correct translation key for connect button', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByRole('button')).toHaveTextContent('datasetCreation.stepOne.connect') + }) + }) + }) + + // ========================================== + // Edge Cases and Error Handling + // ========================================== + describe('Edge Cases and Error Handling', () => { + describe('Empty/Null Values', () => { + it('should handle empty title in nodeData', () => { + // Arrange + const props = createDefaultProps({ + nodeData: createMockNodeData({ title: '' }), + }) + + // Act + render() + + // Assert + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should handle undefined optional fields in nodeData', () => { + // Arrange + const minimalNodeData = { + title: 'Test', + plugin_id: 'test', + provider_type: 'online_drive', + provider_name: 'provider', + datasource_name: 'ds', + datasource_label: 'Label', + datasource_parameters: {}, + datasource_configurations: {}, + } as DataSourceNodeType + const props = createDefaultProps({ nodeData: minimalNodeData }) + + // Act + render() + + // Assert + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should handle empty plugin_id', () => { + // Arrange + const props = createDefaultProps({ + nodeData: createMockNodeData({ plugin_id: '' }), + }) + + // Act + render() + + // Assert + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) + + describe('Special Characters', () => { + it('should handle special characters in title', () => { + // Arrange + const props = createDefaultProps({ + nodeData: createMockNodeData({ title: 'Drive ' }), + }) + + // Act + render() + + // Assert - Should render safely without executing script + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should handle unicode characters in title', () => { + // Arrange + const props = createDefaultProps({ + nodeData: createMockNodeData({ title: '云盘存储 🌐' }), + }) + + // Act + render() + + // Assert + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should handle very long title', () => { + // Arrange + const longTitle = 'A'.repeat(500) + const props = createDefaultProps({ + nodeData: createMockNodeData({ title: longTitle }), + }) + + // Act + render() + + // Assert + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) + + describe('Icon Variations', () => { + it('should handle string icon URL', () => { + // Arrange + mockUseToolIcon.mockReturnValue('https://cdn.example.com/icon.png') + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should handle object icon with url property', () => { + // Arrange + mockUseToolIcon.mockReturnValue({ url: 'https://cdn.example.com/icon.png' }) + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should handle null icon', () => { + // Arrange + mockUseToolIcon.mockReturnValue(null) + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) + }) + + // ========================================== + // All Prop Variations Tests + // ========================================== + describe('Prop Variations', () => { + it.each([ + { title: 'Google Drive', plugin_id: 'google-drive' }, + { title: 'Dropbox', plugin_id: 'dropbox' }, + { title: 'OneDrive', plugin_id: 'onedrive' }, + { title: 'Amazon S3', plugin_id: 's3' }, + { title: 'Box', plugin_id: 'box' }, + ])('should render correctly with title=$title and plugin_id=$plugin_id', ({ title, plugin_id }) => { + // Arrange + const props = createDefaultProps({ + nodeData: createMockNodeData({ title, plugin_id }), + }) + + // Act + render() + + // Assert + expect(screen.getByRole('button')).toBeInTheDocument() + expect(mockUseToolIcon).toHaveBeenCalledWith( + expect.objectContaining({ title, plugin_id }), + ) + }) + + it.each([ + { provider_type: 'online_drive' }, + { provider_type: 'cloud_storage' }, + { provider_type: 'file_system' }, + ])('should render correctly with provider_type=$provider_type', ({ provider_type }) => { + // Arrange + const props = createDefaultProps({ + nodeData: createMockNodeData({ provider_type }), + }) + + // Act + render() + + // Assert + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it.each([ + { datasource_label: 'Google Drive Storage' }, + { datasource_label: 'Dropbox Files' }, + { datasource_label: '' }, + { datasource_label: 'S3 Bucket' }, + ])('should render correctly with datasource_label=$datasource_label', ({ datasource_label }) => { + // Arrange + const props = createDefaultProps({ + nodeData: createMockNodeData({ datasource_label }), + }) + + // Act + render() + + // Assert + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) + + // ========================================== + // Accessibility Tests + // ========================================== + describe('Accessibility', () => { + it('should have an accessible button', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert - Button should be accessible by role + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should have proper text content for screen readers', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert - Text content should be present + const messages = screen.getAllByText(/datasetPipeline\.onlineDrive\.notConnected/) + expect(messages.length).toBe(2) // Both notConnected and notConnectedTip + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.spec.tsx new file mode 100644 index 0000000000..887ca856cc --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/dropdown/index.spec.tsx @@ -0,0 +1,865 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import Dropdown from './index' + +// ========================================== +// Note: react-i18next uses global mock from web/__mocks__/react-i18next.ts +// ========================================== + +// ========================================== +// Test Data Builders +// ========================================== +type DropdownProps = React.ComponentProps + +const createDefaultProps = (overrides?: Partial): DropdownProps => ({ + startIndex: 0, + breadcrumbs: ['folder1', 'folder2'], + onBreadcrumbClick: jest.fn(), + ...overrides, +}) + +// ========================================== +// Test Suites +// ========================================== +describe('Dropdown', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + // ========================================== + // Rendering Tests + // ========================================== + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert - Trigger button should be visible + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should render trigger button with more icon', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert - Button should have RiMoreFill icon (rendered as svg) + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + expect(container.querySelector('svg')).toBeInTheDocument() + }) + + it('should render separator after dropdown', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert - Separator "/" should be visible + expect(screen.getByText('/')).toBeInTheDocument() + }) + + it('should render trigger button with correct default styles', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + const button = screen.getByRole('button') + expect(button).toHaveClass('flex') + expect(button).toHaveClass('size-6') + expect(button).toHaveClass('items-center') + expect(button).toHaveClass('justify-center') + expect(button).toHaveClass('rounded-md') + }) + + it('should not render menu content when closed', () => { + // Arrange + const props = createDefaultProps({ breadcrumbs: ['visible-folder'] }) + + // Act + render() + + // Assert - Menu content should not be visible when dropdown is closed + expect(screen.queryByText('visible-folder')).not.toBeInTheDocument() + }) + + it('should render menu content when opened', async () => { + // Arrange + const props = createDefaultProps({ breadcrumbs: ['test-folder1', 'test-folder2'] }) + render() + + // Act - Open dropdown + fireEvent.click(screen.getByRole('button')) + + // Assert - Menu items should be visible + await waitFor(() => { + expect(screen.getByText('test-folder1')).toBeInTheDocument() + expect(screen.getByText('test-folder2')).toBeInTheDocument() + }) + }) + }) + + // ========================================== + // Props Testing + // ========================================== + describe('Props', () => { + describe('startIndex prop', () => { + it('should pass startIndex to Menu component', async () => { + // Arrange + const mockOnBreadcrumbClick = jest.fn() + const props = createDefaultProps({ + startIndex: 5, + breadcrumbs: ['folder1'], + onBreadcrumbClick: mockOnBreadcrumbClick, + }) + render() + + // Act - Open dropdown and click on item + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => { + expect(screen.getByText('folder1')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('folder1')) + + // Assert - Should be called with startIndex (5) + item index (0) = 5 + expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(5) + }) + + it('should calculate correct index for second item', async () => { + // Arrange + const mockOnBreadcrumbClick = jest.fn() + const props = createDefaultProps({ + startIndex: 3, + breadcrumbs: ['folder1', 'folder2'], + onBreadcrumbClick: mockOnBreadcrumbClick, + }) + render() + + // Act - Open dropdown and click on second item + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => { + expect(screen.getByText('folder2')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('folder2')) + + // Assert - Should be called with startIndex (3) + item index (1) = 4 + expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(4) + }) + }) + + describe('breadcrumbs prop', () => { + it('should render all breadcrumbs in menu', async () => { + // Arrange + const props = createDefaultProps({ + breadcrumbs: ['folder-a', 'folder-b', 'folder-c'], + }) + render() + + // Act + fireEvent.click(screen.getByRole('button')) + + // Assert + await waitFor(() => { + expect(screen.getByText('folder-a')).toBeInTheDocument() + expect(screen.getByText('folder-b')).toBeInTheDocument() + expect(screen.getByText('folder-c')).toBeInTheDocument() + }) + }) + + it('should handle single breadcrumb', async () => { + // Arrange + const props = createDefaultProps({ + breadcrumbs: ['single-folder'], + }) + render() + + // Act + fireEvent.click(screen.getByRole('button')) + + // Assert + await waitFor(() => { + expect(screen.getByText('single-folder')).toBeInTheDocument() + }) + }) + + it('should handle empty breadcrumbs array', async () => { + // Arrange + const props = createDefaultProps({ + breadcrumbs: [], + }) + render() + + // Act + fireEvent.click(screen.getByRole('button')) + + // Assert - Menu should be rendered but with no items + await waitFor(() => { + // The menu container should exist but be empty + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) + + it('should handle breadcrumbs with special characters', async () => { + // Arrange + const props = createDefaultProps({ + breadcrumbs: ['folder [1]', 'folder (copy)', 'folder-v2.0'], + }) + render() + + // Act + fireEvent.click(screen.getByRole('button')) + + // Assert + await waitFor(() => { + expect(screen.getByText('folder [1]')).toBeInTheDocument() + expect(screen.getByText('folder (copy)')).toBeInTheDocument() + expect(screen.getByText('folder-v2.0')).toBeInTheDocument() + }) + }) + + it('should handle breadcrumbs with unicode characters', async () => { + // Arrange + const props = createDefaultProps({ + breadcrumbs: ['文件夹', 'フォルダ', 'Папка'], + }) + render() + + // Act + fireEvent.click(screen.getByRole('button')) + + // Assert + await waitFor(() => { + expect(screen.getByText('文件夹')).toBeInTheDocument() + expect(screen.getByText('フォルダ')).toBeInTheDocument() + expect(screen.getByText('Папка')).toBeInTheDocument() + }) + }) + }) + + describe('onBreadcrumbClick prop', () => { + it('should call onBreadcrumbClick with correct index when item clicked', async () => { + // Arrange + const mockOnBreadcrumbClick = jest.fn() + const props = createDefaultProps({ + startIndex: 0, + breadcrumbs: ['folder1'], + onBreadcrumbClick: mockOnBreadcrumbClick, + }) + render() + + // Act + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => { + expect(screen.getByText('folder1')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('folder1')) + + // Assert + expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(0) + expect(mockOnBreadcrumbClick).toHaveBeenCalledTimes(1) + }) + }) + }) + + // ========================================== + // State Management Tests + // ========================================== + describe('State Management', () => { + describe('open state', () => { + it('should initialize with closed state', () => { + // Arrange + const props = createDefaultProps({ breadcrumbs: ['test-folder'] }) + + // Act + render() + + // Assert - Menu content should not be visible + expect(screen.queryByText('test-folder')).not.toBeInTheDocument() + }) + + it('should toggle to open state when trigger is clicked', async () => { + // Arrange + const props = createDefaultProps({ breadcrumbs: ['test-folder'] }) + render() + + // Act + fireEvent.click(screen.getByRole('button')) + + // Assert + await waitFor(() => { + expect(screen.getByText('test-folder')).toBeInTheDocument() + }) + }) + + it('should toggle to closed state when trigger is clicked again', async () => { + // Arrange + const props = createDefaultProps({ breadcrumbs: ['test-folder'] }) + render() + + // Act - Open and then close + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect(screen.getByText('test-folder')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button')) + + // Assert + await waitFor(() => { + expect(screen.queryByText('test-folder')).not.toBeInTheDocument() + }) + }) + + it('should close when breadcrumb item is clicked', async () => { + // Arrange + const mockOnBreadcrumbClick = jest.fn() + const props = createDefaultProps({ + breadcrumbs: ['test-folder'], + onBreadcrumbClick: mockOnBreadcrumbClick, + }) + render() + + // Act - Open dropdown + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => { + expect(screen.getByText('test-folder')).toBeInTheDocument() + }) + + // Click on breadcrumb item + fireEvent.click(screen.getByText('test-folder')) + + // Assert - Menu should close + await waitFor(() => { + expect(screen.queryByText('test-folder')).not.toBeInTheDocument() + }) + }) + + it('should apply correct button styles based on open state', async () => { + // Arrange + const props = createDefaultProps({ breadcrumbs: ['test-folder'] }) + render() + const button = screen.getByRole('button') + + // Assert - Initial state (closed): should have hover:bg-state-base-hover + expect(button).toHaveClass('hover:bg-state-base-hover') + + // Act - Open dropdown + fireEvent.click(button) + + // Assert - Open state: should have bg-state-base-hover + await waitFor(() => { + expect(button).toHaveClass('bg-state-base-hover') + }) + }) + }) + }) + + // ========================================== + // Event Handlers Tests + // ========================================== + describe('Event Handlers', () => { + describe('handleTrigger', () => { + it('should toggle open state when trigger is clicked', async () => { + // Arrange + const props = createDefaultProps({ breadcrumbs: ['folder'] }) + render() + + // Act & Assert - Initially closed + expect(screen.queryByText('folder')).not.toBeInTheDocument() + + // Act - Click to open + fireEvent.click(screen.getByRole('button')) + + // Assert - Now open + await waitFor(() => { + expect(screen.getByText('folder')).toBeInTheDocument() + }) + }) + + it('should toggle multiple times correctly', async () => { + // Arrange + const props = createDefaultProps({ breadcrumbs: ['folder'] }) + render() + const button = screen.getByRole('button') + + // Act & Assert - Toggle multiple times + // 1st click - open + fireEvent.click(button) + await waitFor(() => { + expect(screen.getByText('folder')).toBeInTheDocument() + }) + + // 2nd click - close + fireEvent.click(button) + await waitFor(() => { + expect(screen.queryByText('folder')).not.toBeInTheDocument() + }) + + // 3rd click - open again + fireEvent.click(button) + await waitFor(() => { + expect(screen.getByText('folder')).toBeInTheDocument() + }) + }) + }) + + describe('handleBreadCrumbClick', () => { + it('should call onBreadcrumbClick and close menu', async () => { + // Arrange + const mockOnBreadcrumbClick = jest.fn() + const props = createDefaultProps({ + breadcrumbs: ['folder1'], + onBreadcrumbClick: mockOnBreadcrumbClick, + }) + render() + + // Act - Open dropdown + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => { + expect(screen.getByText('folder1')).toBeInTheDocument() + }) + + // Click on breadcrumb + fireEvent.click(screen.getByText('folder1')) + + // Assert + expect(mockOnBreadcrumbClick).toHaveBeenCalledTimes(1) + + // Menu should close + await waitFor(() => { + expect(screen.queryByText('folder1')).not.toBeInTheDocument() + }) + }) + + it('should pass correct index to onBreadcrumbClick for each item', async () => { + // Arrange + const mockOnBreadcrumbClick = jest.fn() + const props = createDefaultProps({ + startIndex: 2, + breadcrumbs: ['folder1', 'folder2', 'folder3'], + onBreadcrumbClick: mockOnBreadcrumbClick, + }) + render() + + // Act - Open dropdown and click first item + fireEvent.click(screen.getByRole('button')) + + await waitFor(() => { + expect(screen.getByText('folder1')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('folder1')) + + // Assert - Index should be startIndex (2) + item index (0) = 2 + expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(2) + }) + }) + }) + + // ========================================== + // Callback Stability and Memoization Tests + // ========================================== + describe('Callback Stability and Memoization', () => { + it('should be wrapped with React.memo', () => { + // Assert - Dropdown component should be memoized + expect(Dropdown).toHaveProperty('$$typeof', Symbol.for('react.memo')) + }) + + it('should maintain stable callback after rerender with same props', async () => { + // Arrange + const mockOnBreadcrumbClick = jest.fn() + const props = createDefaultProps({ + breadcrumbs: ['folder'], + onBreadcrumbClick: mockOnBreadcrumbClick, + }) + const { rerender } = render() + + // Act - Open and click + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect(screen.getByText('folder')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('folder')) + + // Rerender with same props and click again + rerender() + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect(screen.getByText('folder')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('folder')) + + // Assert + expect(mockOnBreadcrumbClick).toHaveBeenCalledTimes(2) + }) + + it('should update callback when onBreadcrumbClick prop changes', async () => { + // Arrange + const mockOnBreadcrumbClick1 = jest.fn() + const mockOnBreadcrumbClick2 = jest.fn() + const props = createDefaultProps({ + breadcrumbs: ['folder'], + onBreadcrumbClick: mockOnBreadcrumbClick1, + }) + const { rerender } = render() + + // Act - Open and click with first callback + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect(screen.getByText('folder')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('folder')) + + // Rerender with different callback + rerender() + + // Open and click with second callback + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect(screen.getByText('folder')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('folder')) + + // Assert + expect(mockOnBreadcrumbClick1).toHaveBeenCalledTimes(1) + expect(mockOnBreadcrumbClick2).toHaveBeenCalledTimes(1) + }) + + it('should not re-render when props are the same', () => { + // Arrange + const props = createDefaultProps() + const { rerender } = render() + + // Act - Rerender with same props + rerender() + + // Assert - Component should render without errors + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) + + // ========================================== + // Edge Cases and Error Handling + // ========================================== + describe('Edge Cases and Error Handling', () => { + it('should handle rapid toggle clicks', async () => { + // Arrange + const props = createDefaultProps({ breadcrumbs: ['folder'] }) + render() + const button = screen.getByRole('button') + + // Act - Rapid clicks + fireEvent.click(button) + fireEvent.click(button) + fireEvent.click(button) + + // Assert - Should handle gracefully (open after odd number of clicks) + await waitFor(() => { + expect(screen.getByText('folder')).toBeInTheDocument() + }) + }) + + it('should handle very long folder names', async () => { + // Arrange + const longName = 'a'.repeat(100) + const props = createDefaultProps({ + breadcrumbs: [longName], + }) + render() + + // Act + fireEvent.click(screen.getByRole('button')) + + // Assert + await waitFor(() => { + expect(screen.getByText(longName)).toBeInTheDocument() + }) + }) + + it('should handle many breadcrumbs', async () => { + // Arrange + const manyBreadcrumbs = Array.from({ length: 20 }, (_, i) => `folder-${i}`) + const props = createDefaultProps({ + breadcrumbs: manyBreadcrumbs, + }) + render() + + // Act + fireEvent.click(screen.getByRole('button')) + + // Assert - First and last items should be visible + await waitFor(() => { + expect(screen.getByText('folder-0')).toBeInTheDocument() + expect(screen.getByText('folder-19')).toBeInTheDocument() + }) + }) + + it('should handle startIndex of 0', async () => { + // Arrange + const mockOnBreadcrumbClick = jest.fn() + const props = createDefaultProps({ + startIndex: 0, + breadcrumbs: ['folder'], + onBreadcrumbClick: mockOnBreadcrumbClick, + }) + render() + + // Act + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect(screen.getByText('folder')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('folder')) + + // Assert + expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(0) + }) + + it('should handle large startIndex values', async () => { + // Arrange + const mockOnBreadcrumbClick = jest.fn() + const props = createDefaultProps({ + startIndex: 999, + breadcrumbs: ['folder'], + onBreadcrumbClick: mockOnBreadcrumbClick, + }) + render() + + // Act + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect(screen.getByText('folder')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('folder')) + + // Assert + expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(999) + }) + + it('should handle breadcrumbs with whitespace-only names', async () => { + // Arrange + const props = createDefaultProps({ + breadcrumbs: [' ', 'normal-folder'], + }) + render() + + // Act + fireEvent.click(screen.getByRole('button')) + + // Assert + await waitFor(() => { + expect(screen.getByText('normal-folder')).toBeInTheDocument() + }) + }) + + it('should handle breadcrumbs with empty string', async () => { + // Arrange + const props = createDefaultProps({ + breadcrumbs: ['', 'folder'], + }) + render() + + // Act + fireEvent.click(screen.getByRole('button')) + + // Assert + await waitFor(() => { + expect(screen.getByText('folder')).toBeInTheDocument() + }) + }) + }) + + // ========================================== + // All Prop Variations Tests + // ========================================== + describe('Prop Variations', () => { + it.each([ + { startIndex: 0, breadcrumbs: ['a'], expectedIndex: 0 }, + { startIndex: 1, breadcrumbs: ['a'], expectedIndex: 1 }, + { startIndex: 5, breadcrumbs: ['a'], expectedIndex: 5 }, + { startIndex: 10, breadcrumbs: ['a', 'b'], expectedIndex: 10 }, + ])('should handle startIndex=$startIndex correctly', async ({ startIndex, breadcrumbs, expectedIndex }) => { + // Arrange + const mockOnBreadcrumbClick = jest.fn() + const props = createDefaultProps({ + startIndex, + breadcrumbs, + onBreadcrumbClick: mockOnBreadcrumbClick, + }) + render() + + // Act + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect(screen.getByText(breadcrumbs[0])).toBeInTheDocument() + }) + fireEvent.click(screen.getByText(breadcrumbs[0])) + + // Assert + expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(expectedIndex) + }) + + it.each([ + { breadcrumbs: [], description: 'empty array' }, + { breadcrumbs: ['single'], description: 'single item' }, + { breadcrumbs: ['a', 'b'], description: 'two items' }, + { breadcrumbs: ['a', 'b', 'c', 'd', 'e'], description: 'five items' }, + ])('should render correctly with $description breadcrumbs', async ({ breadcrumbs }) => { + // Arrange + const props = createDefaultProps({ breadcrumbs }) + + // Act + render() + fireEvent.click(screen.getByRole('button')) + + // Assert - Should render without errors + await waitFor(() => { + if (breadcrumbs.length > 0) + expect(screen.getByText(breadcrumbs[0])).toBeInTheDocument() + }) + }) + }) + + // ========================================== + // Integration Tests (Menu and Item) + // ========================================== + describe('Integration with Menu and Item', () => { + it('should render all menu items with correct content', async () => { + // Arrange + const props = createDefaultProps({ + breadcrumbs: ['Documents', 'Projects', 'Archive'], + }) + render() + + // Act + fireEvent.click(screen.getByRole('button')) + + // Assert + await waitFor(() => { + expect(screen.getByText('Documents')).toBeInTheDocument() + expect(screen.getByText('Projects')).toBeInTheDocument() + expect(screen.getByText('Archive')).toBeInTheDocument() + }) + }) + + it('should handle click on any menu item', async () => { + // Arrange + const mockOnBreadcrumbClick = jest.fn() + const props = createDefaultProps({ + startIndex: 0, + breadcrumbs: ['first', 'second', 'third'], + onBreadcrumbClick: mockOnBreadcrumbClick, + }) + render() + + // Act - Open and click on second item + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect(screen.getByText('second')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('second')) + + // Assert - Index should be 1 (second item) + expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(1) + }) + + it('should close menu after any item click', async () => { + // Arrange + const mockOnBreadcrumbClick = jest.fn() + const props = createDefaultProps({ + breadcrumbs: ['item1', 'item2', 'item3'], + onBreadcrumbClick: mockOnBreadcrumbClick, + }) + render() + + // Act - Open and click on middle item + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect(screen.getByText('item2')).toBeInTheDocument() + }) + fireEvent.click(screen.getByText('item2')) + + // Assert - Menu should close + await waitFor(() => { + expect(screen.queryByText('item1')).not.toBeInTheDocument() + expect(screen.queryByText('item2')).not.toBeInTheDocument() + expect(screen.queryByText('item3')).not.toBeInTheDocument() + }) + }) + + it('should correctly calculate index for each item based on startIndex', async () => { + // Arrange + const mockOnBreadcrumbClick = jest.fn() + const props = createDefaultProps({ + startIndex: 3, + breadcrumbs: ['folder-a', 'folder-b', 'folder-c'], + onBreadcrumbClick: mockOnBreadcrumbClick, + }) + + // Test clicking each item + for (let i = 0; i < 3; i++) { + mockOnBreadcrumbClick.mockClear() + const { unmount } = render() + + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect(screen.getByText(`folder-${String.fromCharCode(97 + i)}`)).toBeInTheDocument() + }) + fireEvent.click(screen.getByText(`folder-${String.fromCharCode(97 + i)}`)) + + expect(mockOnBreadcrumbClick).toHaveBeenCalledWith(3 + i) + unmount() + } + }) + }) + + // ========================================== + // Accessibility Tests + // ========================================== + describe('Accessibility', () => { + it('should render trigger as button element', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + expect(button.tagName).toBe('BUTTON') + }) + + it('should have type="button" attribute', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + const button = screen.getByRole('button') + expect(button).toHaveAttribute('type', 'button') + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/index.spec.tsx new file mode 100644 index 0000000000..2ccb460a06 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/index.spec.tsx @@ -0,0 +1,1079 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import Breadcrumbs from './index' + +// ========================================== +// Mock Modules +// ========================================== + +// Note: react-i18next uses global mock from web/__mocks__/react-i18next.ts + +// Mock store - context provider requires mocking +const mockStoreState = { + hasBucket: false, + breadcrumbs: [] as string[], + prefix: [] as string[], + setOnlineDriveFileList: jest.fn(), + setSelectedFileIds: jest.fn(), + setBreadcrumbs: jest.fn(), + setPrefix: jest.fn(), + setBucket: jest.fn(), +} + +const mockGetState = jest.fn(() => mockStoreState) +const mockDataSourceStore = { getState: mockGetState } + +jest.mock('../../../../store', () => ({ + useDataSourceStore: () => mockDataSourceStore, + useDataSourceStoreWithSelector: (selector: (s: typeof mockStoreState) => unknown) => selector(mockStoreState), +})) + +// ========================================== +// Test Data Builders +// ========================================== +type BreadcrumbsProps = React.ComponentProps + +const createDefaultProps = (overrides?: Partial): BreadcrumbsProps => ({ + breadcrumbs: [], + keywords: '', + bucket: '', + searchResultsLength: 0, + isInPipeline: false, + ...overrides, +}) + +// ========================================== +// Helper Functions +// ========================================== +const resetMockStoreState = () => { + mockStoreState.hasBucket = false + mockStoreState.breadcrumbs = [] + mockStoreState.prefix = [] + mockStoreState.setOnlineDriveFileList = jest.fn() + mockStoreState.setSelectedFileIds = jest.fn() + mockStoreState.setBreadcrumbs = jest.fn() + mockStoreState.setPrefix = jest.fn() + mockStoreState.setBucket = jest.fn() +} + +// ========================================== +// Test Suites +// ========================================== +describe('Breadcrumbs', () => { + beforeEach(() => { + jest.clearAllMocks() + resetMockStoreState() + }) + + // ========================================== + // Rendering Tests + // ========================================== + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert - Container should be in the document + const container = document.querySelector('.flex.grow') + expect(container).toBeInTheDocument() + }) + + it('should render with correct container styles', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('flex') + expect(wrapper).toHaveClass('grow') + expect(wrapper).toHaveClass('items-center') + expect(wrapper).toHaveClass('overflow-hidden') + }) + + describe('Search Results Display', () => { + it('should show search results when keywords and searchResultsLength > 0', () => { + // Arrange + const props = createDefaultProps({ + keywords: 'test', + searchResultsLength: 5, + breadcrumbs: ['folder1'], + }) + + // Act + render() + + // Assert - Search result text should be displayed + expect(screen.getByText(/datasetPipeline\.onlineDrive\.breadcrumbs\.searchResult/)).toBeInTheDocument() + }) + + it('should not show search results when keywords is empty', () => { + // Arrange + const props = createDefaultProps({ + keywords: '', + searchResultsLength: 5, + breadcrumbs: ['folder1'], + }) + + // Act + render() + + // Assert + expect(screen.queryByText(/searchResult/)).not.toBeInTheDocument() + }) + + it('should not show search results when searchResultsLength is 0', () => { + // Arrange + const props = createDefaultProps({ + keywords: 'test', + searchResultsLength: 0, + }) + + // Act + render() + + // Assert + expect(screen.queryByText(/searchResult/)).not.toBeInTheDocument() + }) + + it('should use bucket as folderName when breadcrumbs is empty', () => { + // Arrange + const props = createDefaultProps({ + keywords: 'test', + searchResultsLength: 5, + breadcrumbs: [], + bucket: 'my-bucket', + }) + + // Act + render() + + // Assert - Should use bucket name in search result + expect(screen.getByText(/searchResult.*my-bucket/i)).toBeInTheDocument() + }) + + it('should use last breadcrumb as folderName when breadcrumbs exist', () => { + // Arrange + const props = createDefaultProps({ + keywords: 'test', + searchResultsLength: 5, + breadcrumbs: ['folder1', 'folder2'], + bucket: 'my-bucket', + }) + + // Act + render() + + // Assert - Should use last breadcrumb in search result + expect(screen.getByText(/searchResult.*folder2/i)).toBeInTheDocument() + }) + }) + + describe('All Buckets Title Display', () => { + it('should show all buckets title when hasBucket=true, bucket is empty, and no breadcrumbs', () => { + // Arrange + mockStoreState.hasBucket = true + const props = createDefaultProps({ + breadcrumbs: [], + bucket: '', + keywords: '', + }) + + // Act + render() + + // Assert + expect(screen.getByText('datasetPipeline.onlineDrive.breadcrumbs.allBuckets')).toBeInTheDocument() + }) + + it('should not show all buckets title when breadcrumbs exist', () => { + // Arrange + mockStoreState.hasBucket = true + const props = createDefaultProps({ + breadcrumbs: ['folder1'], + bucket: '', + }) + + // Act + render() + + // Assert + expect(screen.queryByText('datasetPipeline.onlineDrive.breadcrumbs.allBuckets')).not.toBeInTheDocument() + }) + + it('should not show all buckets title when bucket is set', () => { + // Arrange + mockStoreState.hasBucket = true + const props = createDefaultProps({ + breadcrumbs: [], + bucket: 'my-bucket', + }) + + // Act + render() + + // Assert - Should show bucket name instead + expect(screen.queryByText('datasetPipeline.onlineDrive.breadcrumbs.allBuckets')).not.toBeInTheDocument() + }) + }) + + describe('Bucket Component Display', () => { + it('should render Bucket component when hasBucket and bucket are set', () => { + // Arrange + mockStoreState.hasBucket = true + const props = createDefaultProps({ + bucket: 'test-bucket', + breadcrumbs: [], + }) + + // Act + render() + + // Assert - Bucket name should be displayed + expect(screen.getByText('test-bucket')).toBeInTheDocument() + }) + + it('should not render Bucket when hasBucket is false', () => { + // Arrange + mockStoreState.hasBucket = false + const props = createDefaultProps({ + bucket: 'test-bucket', + breadcrumbs: [], + }) + + // Act + render() + + // Assert - Bucket should not be displayed, Drive should be shown instead + expect(screen.queryByText('test-bucket')).not.toBeInTheDocument() + }) + }) + + describe('Drive Component Display', () => { + it('should render Drive component when hasBucket is false', () => { + // Arrange + mockStoreState.hasBucket = false + const props = createDefaultProps({ + breadcrumbs: [], + }) + + // Act + render() + + // Assert - "All Files" should be displayed + expect(screen.getByText('datasetPipeline.onlineDrive.breadcrumbs.allFiles')).toBeInTheDocument() + }) + + it('should not render Drive component when hasBucket is true', () => { + // Arrange + mockStoreState.hasBucket = true + const props = createDefaultProps({ + bucket: 'test-bucket', + breadcrumbs: [], + }) + + // Act + render() + + // Assert + expect(screen.queryByText('datasetPipeline.onlineDrive.breadcrumbs.allFiles')).not.toBeInTheDocument() + }) + }) + + describe('BreadcrumbItem Display', () => { + it('should render all breadcrumbs when not collapsed', () => { + // Arrange + mockStoreState.hasBucket = false + const props = createDefaultProps({ + breadcrumbs: ['folder1', 'folder2'], + isInPipeline: false, + }) + + // Act + render() + + // Assert + expect(screen.getByText('folder1')).toBeInTheDocument() + expect(screen.getByText('folder2')).toBeInTheDocument() + }) + + it('should render last breadcrumb as active', () => { + // Arrange + mockStoreState.hasBucket = false + const props = createDefaultProps({ + breadcrumbs: ['folder1', 'folder2'], + }) + + // Act + render() + + // Assert - Last breadcrumb should have active styles + const lastBreadcrumb = screen.getByText('folder2') + expect(lastBreadcrumb).toHaveClass('system-sm-medium') + expect(lastBreadcrumb).toHaveClass('text-text-secondary') + }) + + it('should render non-last breadcrumbs with tertiary styles', () => { + // Arrange + mockStoreState.hasBucket = false + const props = createDefaultProps({ + breadcrumbs: ['folder1', 'folder2'], + }) + + // Act + render() + + // Assert - First breadcrumb should have tertiary styles + const firstBreadcrumb = screen.getByText('folder1') + expect(firstBreadcrumb).toHaveClass('system-sm-regular') + expect(firstBreadcrumb).toHaveClass('text-text-tertiary') + }) + }) + + describe('Collapsed Breadcrumbs (Dropdown)', () => { + it('should show dropdown when breadcrumbs exceed displayBreadcrumbNum', () => { + // Arrange + mockStoreState.hasBucket = false + const props = createDefaultProps({ + breadcrumbs: ['folder1', 'folder2', 'folder3', 'folder4'], + isInPipeline: false, // displayBreadcrumbNum = 3 + }) + + // Act + render() + + // Assert - Dropdown trigger (more button) should be present + expect(screen.getByRole('button', { name: '' })).toBeInTheDocument() + }) + + it('should not show dropdown when breadcrumbs do not exceed displayBreadcrumbNum', () => { + // Arrange + mockStoreState.hasBucket = false + const props = createDefaultProps({ + breadcrumbs: ['folder1', 'folder2'], + isInPipeline: false, // displayBreadcrumbNum = 3 + }) + + // Act + const { container } = render() + + // Assert - Should not have dropdown, just regular breadcrumbs + // All breadcrumbs should be directly visible + expect(screen.getByText('folder1')).toBeInTheDocument() + expect(screen.getByText('folder2')).toBeInTheDocument() + // Count buttons - should be 3 (allFiles + folder1 + folder2) + const buttons = container.querySelectorAll('button') + expect(buttons.length).toBe(3) + }) + + it('should show prefix breadcrumbs and last breadcrumb when collapsed', async () => { + // Arrange + mockStoreState.hasBucket = false + const props = createDefaultProps({ + breadcrumbs: ['folder1', 'folder2', 'folder3', 'folder4', 'folder5'], + isInPipeline: false, // displayBreadcrumbNum = 3 + }) + + // Act + render() + + // Assert - First breadcrumb and last breadcrumb should be visible + expect(screen.getByText('folder1')).toBeInTheDocument() + expect(screen.getByText('folder2')).toBeInTheDocument() + expect(screen.getByText('folder5')).toBeInTheDocument() + // Middle breadcrumbs should be in dropdown + expect(screen.queryByText('folder3')).not.toBeInTheDocument() + expect(screen.queryByText('folder4')).not.toBeInTheDocument() + }) + + it('should show collapsed breadcrumbs in dropdown when clicked', async () => { + // Arrange + mockStoreState.hasBucket = false + const props = createDefaultProps({ + breadcrumbs: ['folder1', 'folder2', 'folder3', 'folder4', 'folder5'], + isInPipeline: false, + }) + render() + + // Act - Click on dropdown trigger (the ... button) + const dropdownTrigger = screen.getAllByRole('button').find(btn => btn.querySelector('svg')) + if (dropdownTrigger) + fireEvent.click(dropdownTrigger) + + // Assert - Collapsed breadcrumbs should be visible + await waitFor(() => { + expect(screen.getByText('folder3')).toBeInTheDocument() + expect(screen.getByText('folder4')).toBeInTheDocument() + }) + }) + }) + }) + + // ========================================== + // Props Testing + // ========================================== + describe('Props', () => { + describe('breadcrumbs prop', () => { + it('should handle empty breadcrumbs array', () => { + // Arrange + mockStoreState.hasBucket = false + const props = createDefaultProps({ breadcrumbs: [] }) + + // Act + render() + + // Assert - Only Drive should be visible + expect(screen.getByText('datasetPipeline.onlineDrive.breadcrumbs.allFiles')).toBeInTheDocument() + }) + + it('should handle single breadcrumb', () => { + // Arrange + mockStoreState.hasBucket = false + const props = createDefaultProps({ breadcrumbs: ['single-folder'] }) + + // Act + render() + + // Assert + expect(screen.getByText('single-folder')).toBeInTheDocument() + }) + + it('should handle breadcrumbs with special characters', () => { + // Arrange + mockStoreState.hasBucket = false + const props = createDefaultProps({ + breadcrumbs: ['folder [1]', 'folder (copy)'], + }) + + // Act + render() + + // Assert + expect(screen.getByText('folder [1]')).toBeInTheDocument() + expect(screen.getByText('folder (copy)')).toBeInTheDocument() + }) + + it('should handle breadcrumbs with unicode characters', () => { + // Arrange + mockStoreState.hasBucket = false + const props = createDefaultProps({ + breadcrumbs: ['文件夹', 'フォルダ'], + }) + + // Act + render() + + // Assert + expect(screen.getByText('文件夹')).toBeInTheDocument() + expect(screen.getByText('フォルダ')).toBeInTheDocument() + }) + }) + + describe('keywords prop', () => { + it('should show search results when keywords is non-empty with results', () => { + // Arrange + const props = createDefaultProps({ + keywords: 'search-term', + searchResultsLength: 10, + }) + + // Act + render() + + // Assert + expect(screen.getByText(/searchResult/)).toBeInTheDocument() + }) + + it('should handle whitespace keywords', () => { + // Arrange + const props = createDefaultProps({ + keywords: ' ', + searchResultsLength: 5, + }) + + // Act + render() + + // Assert - Whitespace is truthy, so should show search results + expect(screen.getByText(/searchResult/)).toBeInTheDocument() + }) + }) + + describe('bucket prop', () => { + it('should display bucket name when hasBucket and bucket are set', () => { + // Arrange + mockStoreState.hasBucket = true + const props = createDefaultProps({ + bucket: 'production-bucket', + }) + + // Act + render() + + // Assert + expect(screen.getByText('production-bucket')).toBeInTheDocument() + }) + + it('should handle bucket with special characters', () => { + // Arrange + mockStoreState.hasBucket = true + const props = createDefaultProps({ + bucket: 'bucket-v2.0_backup', + }) + + // Act + render() + + // Assert + expect(screen.getByText('bucket-v2.0_backup')).toBeInTheDocument() + }) + }) + + describe('searchResultsLength prop', () => { + it('should handle zero searchResultsLength', () => { + // Arrange + const props = createDefaultProps({ + keywords: 'test', + searchResultsLength: 0, + }) + + // Act + render() + + // Assert - Should not show search results + expect(screen.queryByText(/searchResult/)).not.toBeInTheDocument() + }) + + it('should handle large searchResultsLength', () => { + // Arrange + const props = createDefaultProps({ + keywords: 'test', + searchResultsLength: 10000, + }) + + // Act + render() + + // Assert + expect(screen.getByText(/searchResult.*10000/)).toBeInTheDocument() + }) + }) + + describe('isInPipeline prop', () => { + it('should use displayBreadcrumbNum=2 when isInPipeline is true', () => { + // Arrange + mockStoreState.hasBucket = false + const props = createDefaultProps({ + breadcrumbs: ['folder1', 'folder2', 'folder3'], + isInPipeline: true, // displayBreadcrumbNum = 2 + }) + + // Act + render() + + // Assert - Should collapse because 3 > 2 + // Dropdown should be present + const buttons = screen.getAllByRole('button') + const hasDropdownTrigger = buttons.some(btn => btn.querySelector('svg')) + expect(hasDropdownTrigger).toBe(true) + }) + + it('should use displayBreadcrumbNum=3 when isInPipeline is false', () => { + // Arrange + mockStoreState.hasBucket = false + const props = createDefaultProps({ + breadcrumbs: ['folder1', 'folder2', 'folder3'], + isInPipeline: false, // displayBreadcrumbNum = 3 + }) + + // Act + render() + + // Assert - Should NOT collapse because 3 <= 3 + expect(screen.getByText('folder1')).toBeInTheDocument() + expect(screen.getByText('folder2')).toBeInTheDocument() + expect(screen.getByText('folder3')).toBeInTheDocument() + }) + + it('should reduce displayBreadcrumbNum by 1 when bucket is set', () => { + // Arrange + mockStoreState.hasBucket = true + const props = createDefaultProps({ + breadcrumbs: ['folder1', 'folder2', 'folder3'], + bucket: 'my-bucket', + isInPipeline: false, // displayBreadcrumbNum = 3 - 1 = 2 + }) + + // Act + render() + + // Assert - Should collapse because 3 > 2 + const buttons = screen.getAllByRole('button') + const hasDropdownTrigger = buttons.some(btn => btn.querySelector('svg')) + expect(hasDropdownTrigger).toBe(true) + }) + }) + }) + + // ========================================== + // Memoization Logic and Dependencies Tests + // ========================================== + describe('Memoization Logic and Dependencies', () => { + describe('displayBreadcrumbNum useMemo', () => { + it('should calculate correct value when isInPipeline=false and no bucket', () => { + // Arrange + mockStoreState.hasBucket = false + const props = createDefaultProps({ + breadcrumbs: ['a', 'b', 'c', 'd'], + isInPipeline: false, + bucket: '', + }) + + // Act + render() + + // Assert - displayBreadcrumbNum = 3, so 4 breadcrumbs should collapse + // First 2 visible, dropdown, last 1 visible + expect(screen.getByText('a')).toBeInTheDocument() + expect(screen.getByText('b')).toBeInTheDocument() + expect(screen.getByText('d')).toBeInTheDocument() + expect(screen.queryByText('c')).not.toBeInTheDocument() + }) + + it('should calculate correct value when isInPipeline=true and no bucket', () => { + // Arrange + mockStoreState.hasBucket = false + const props = createDefaultProps({ + breadcrumbs: ['a', 'b', 'c'], + isInPipeline: true, + bucket: '', + }) + + // Act + render() + + // Assert - displayBreadcrumbNum = 2, so 3 breadcrumbs should collapse + expect(screen.getByText('a')).toBeInTheDocument() + expect(screen.getByText('c')).toBeInTheDocument() + expect(screen.queryByText('b')).not.toBeInTheDocument() + }) + + it('should calculate correct value when isInPipeline=false and bucket exists', () => { + // Arrange + mockStoreState.hasBucket = true + const props = createDefaultProps({ + breadcrumbs: ['a', 'b', 'c'], + isInPipeline: false, + bucket: 'my-bucket', + }) + + // Act + render() + + // Assert - displayBreadcrumbNum = 3 - 1 = 2, so 3 breadcrumbs should collapse + expect(screen.getByText('a')).toBeInTheDocument() + expect(screen.getByText('c')).toBeInTheDocument() + expect(screen.queryByText('b')).not.toBeInTheDocument() + }) + }) + + describe('breadcrumbsConfig useMemo', () => { + it('should correctly split breadcrumbs when collapsed', async () => { + // Arrange + mockStoreState.hasBucket = false + const props = createDefaultProps({ + breadcrumbs: ['f1', 'f2', 'f3', 'f4', 'f5'], + isInPipeline: false, // displayBreadcrumbNum = 3 + }) + render() + + // Act - Click dropdown to see collapsed items + const dropdownTrigger = screen.getAllByRole('button').find(btn => btn.querySelector('svg')) + if (dropdownTrigger) + fireEvent.click(dropdownTrigger) + + // Assert + // prefixBreadcrumbs = ['f1', 'f2'] + // collapsedBreadcrumbs = ['f3', 'f4'] + // lastBreadcrumb = 'f5' + expect(screen.getByText('f1')).toBeInTheDocument() + expect(screen.getByText('f2')).toBeInTheDocument() + expect(screen.getByText('f5')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByText('f3')).toBeInTheDocument() + expect(screen.getByText('f4')).toBeInTheDocument() + }) + }) + + it('should not collapse when breadcrumbs.length <= displayBreadcrumbNum', () => { + // Arrange + mockStoreState.hasBucket = false + const props = createDefaultProps({ + breadcrumbs: ['f1', 'f2'], + isInPipeline: false, // displayBreadcrumbNum = 3 + }) + + // Act + render() + + // Assert - All breadcrumbs should be visible + expect(screen.getByText('f1')).toBeInTheDocument() + expect(screen.getByText('f2')).toBeInTheDocument() + }) + }) + }) + + // ========================================== + // Callback Stability and Event Handlers Tests + // ========================================== + describe('Callback Stability and Event Handlers', () => { + describe('handleBackToBucketList', () => { + it('should reset store state when called', () => { + // Arrange + mockStoreState.hasBucket = true + const props = createDefaultProps({ + bucket: 'my-bucket', + breadcrumbs: [], + }) + render() + + // Act - Click bucket icon button (first button in Bucket component) + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[0]) // Bucket icon button + + // Assert + expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalledWith([]) + expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith([]) + expect(mockStoreState.setBucket).toHaveBeenCalledWith('') + expect(mockStoreState.setBreadcrumbs).toHaveBeenCalledWith([]) + expect(mockStoreState.setPrefix).toHaveBeenCalledWith([]) + }) + }) + + describe('handleClickBucketName', () => { + it('should reset breadcrumbs and prefix when bucket name is clicked', () => { + // Arrange + mockStoreState.hasBucket = true + const props = createDefaultProps({ + bucket: 'my-bucket', + breadcrumbs: ['folder1'], + }) + render() + + // Act - Click bucket name button + const bucketButton = screen.getByText('my-bucket') + fireEvent.click(bucketButton) + + // Assert + expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalledWith([]) + expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith([]) + expect(mockStoreState.setBreadcrumbs).toHaveBeenCalledWith([]) + expect(mockStoreState.setPrefix).toHaveBeenCalledWith([]) + }) + + it('should not call handler when bucket is disabled (no breadcrumbs)', () => { + // Arrange + mockStoreState.hasBucket = true + const props = createDefaultProps({ + bucket: 'my-bucket', + breadcrumbs: [], // disabled when no breadcrumbs + }) + render() + + // Act - Click bucket name button (should be disabled) + const bucketButton = screen.getByText('my-bucket') + fireEvent.click(bucketButton) + + // Assert - Store methods should NOT be called because button is disabled + expect(mockStoreState.setOnlineDriveFileList).not.toHaveBeenCalled() + }) + }) + + describe('handleBackToRoot', () => { + it('should reset state when Drive button is clicked', () => { + // Arrange + mockStoreState.hasBucket = false + const props = createDefaultProps({ + breadcrumbs: ['folder1'], + }) + render() + + // Act - Click "All Files" button + const driveButton = screen.getByText('datasetPipeline.onlineDrive.breadcrumbs.allFiles') + fireEvent.click(driveButton) + + // Assert + expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalledWith([]) + expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith([]) + expect(mockStoreState.setBreadcrumbs).toHaveBeenCalledWith([]) + expect(mockStoreState.setPrefix).toHaveBeenCalledWith([]) + }) + }) + + describe('handleClickBreadcrumb', () => { + it('should slice breadcrumbs and prefix when breadcrumb is clicked', () => { + // Arrange + mockStoreState.hasBucket = false + mockStoreState.breadcrumbs = ['folder1', 'folder2', 'folder3'] + mockStoreState.prefix = ['prefix1', 'prefix2', 'prefix3'] + const props = createDefaultProps({ + breadcrumbs: ['folder1', 'folder2', 'folder3'], + }) + render() + + // Act - Click on first breadcrumb (index 0) + const firstBreadcrumb = screen.getByText('folder1') + fireEvent.click(firstBreadcrumb) + + // Assert - Should slice to index 0 + 1 = 1 + expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalledWith([]) + expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith([]) + expect(mockStoreState.setBreadcrumbs).toHaveBeenCalledWith(['folder1']) + expect(mockStoreState.setPrefix).toHaveBeenCalledWith(['prefix1']) + }) + + it('should not call handler when last breadcrumb is clicked (disabled)', () => { + // Arrange + mockStoreState.hasBucket = false + const props = createDefaultProps({ + breadcrumbs: ['folder1', 'folder2'], + }) + render() + + // Act - Click on last breadcrumb (should be disabled) + const lastBreadcrumb = screen.getByText('folder2') + fireEvent.click(lastBreadcrumb) + + // Assert - Store methods should NOT be called + expect(mockStoreState.setBreadcrumbs).not.toHaveBeenCalled() + }) + + it('should handle click on collapsed breadcrumb from dropdown', async () => { + // Arrange + mockStoreState.hasBucket = false + mockStoreState.breadcrumbs = ['f1', 'f2', 'f3', 'f4', 'f5'] + mockStoreState.prefix = ['p1', 'p2', 'p3', 'p4', 'p5'] + const props = createDefaultProps({ + breadcrumbs: ['f1', 'f2', 'f3', 'f4', 'f5'], + isInPipeline: false, + }) + render() + + // Act - Open dropdown and click on collapsed breadcrumb (f3, index=2) + const dropdownTrigger = screen.getAllByRole('button').find(btn => btn.querySelector('svg')) + if (dropdownTrigger) + fireEvent.click(dropdownTrigger) + + await waitFor(() => { + expect(screen.getByText('f3')).toBeInTheDocument() + }) + + fireEvent.click(screen.getByText('f3')) + + // Assert - Should slice to index 2 + 1 = 3 + expect(mockStoreState.setBreadcrumbs).toHaveBeenCalledWith(['f1', 'f2', 'f3']) + expect(mockStoreState.setPrefix).toHaveBeenCalledWith(['p1', 'p2', 'p3']) + }) + }) + }) + + // ========================================== + // Component Memoization Tests + // ========================================== + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + // Assert + expect(Breadcrumbs).toHaveProperty('$$typeof', Symbol.for('react.memo')) + }) + + it('should not re-render when props are the same', () => { + // Arrange + const props = createDefaultProps() + const { rerender } = render() + + // Act - Rerender with same props + rerender() + + // Assert - Component should render without errors + const container = document.querySelector('.flex.grow') + expect(container).toBeInTheDocument() + }) + + it('should re-render when breadcrumbs change', () => { + // Arrange + mockStoreState.hasBucket = false + const props = createDefaultProps({ breadcrumbs: ['folder1'] }) + const { rerender } = render() + expect(screen.getByText('folder1')).toBeInTheDocument() + + // Act - Rerender with different breadcrumbs + rerender() + + // Assert + expect(screen.getByText('folder2')).toBeInTheDocument() + }) + }) + + // ========================================== + // Edge Cases and Error Handling Tests + // ========================================== + describe('Edge Cases and Error Handling', () => { + it('should handle very long breadcrumb names', () => { + // Arrange + mockStoreState.hasBucket = false + const longName = 'a'.repeat(100) + const props = createDefaultProps({ + breadcrumbs: [longName], + }) + + // Act + render() + + // Assert + expect(screen.getByText(longName)).toBeInTheDocument() + }) + + it('should handle many breadcrumbs', async () => { + // Arrange + mockStoreState.hasBucket = false + const manyBreadcrumbs = Array.from({ length: 20 }, (_, i) => `folder-${i}`) + const props = createDefaultProps({ + breadcrumbs: manyBreadcrumbs, + }) + render() + + // Act - Open dropdown + const dropdownTrigger = screen.getAllByRole('button').find(btn => btn.querySelector('svg')) + if (dropdownTrigger) + fireEvent.click(dropdownTrigger) + + // Assert - First, last, and collapsed should be accessible + expect(screen.getByText('folder-0')).toBeInTheDocument() + expect(screen.getByText('folder-1')).toBeInTheDocument() + expect(screen.getByText('folder-19')).toBeInTheDocument() + await waitFor(() => { + expect(screen.getByText('folder-2')).toBeInTheDocument() + }) + }) + + it('should handle empty bucket string', () => { + // Arrange + mockStoreState.hasBucket = true + const props = createDefaultProps({ + bucket: '', + breadcrumbs: [], + }) + + // Act + render() + + // Assert - Should show all buckets title + expect(screen.getByText('datasetPipeline.onlineDrive.breadcrumbs.allBuckets')).toBeInTheDocument() + }) + + it('should handle breadcrumb with only whitespace', () => { + // Arrange + mockStoreState.hasBucket = false + const props = createDefaultProps({ + breadcrumbs: [' ', 'normal-folder'], + }) + + // Act + render() + + // Assert - Both should be rendered + expect(screen.getByText('normal-folder')).toBeInTheDocument() + }) + }) + + // ========================================== + // All Prop Variations Tests + // ========================================== + describe('Prop Variations', () => { + it.each([ + { hasBucket: true, bucket: 'b1', breadcrumbs: [], expected: 'bucket visible' }, + { hasBucket: true, bucket: '', breadcrumbs: [], expected: 'all buckets title' }, + { hasBucket: false, bucket: '', breadcrumbs: [], expected: 'all files' }, + { hasBucket: false, bucket: '', breadcrumbs: ['f1'], expected: 'drive with breadcrumb' }, + ])('should render correctly for $expected', ({ hasBucket, bucket, breadcrumbs }) => { + // Arrange + mockStoreState.hasBucket = hasBucket + const props = createDefaultProps({ bucket, breadcrumbs }) + + // Act + render() + + // Assert - Component should render without errors + const container = document.querySelector('.flex.grow') + expect(container).toBeInTheDocument() + }) + + it.each([ + { isInPipeline: true, bucket: '', expectedNum: 2 }, + { isInPipeline: false, bucket: '', expectedNum: 3 }, + { isInPipeline: true, bucket: 'b', expectedNum: 1 }, + { isInPipeline: false, bucket: 'b', expectedNum: 2 }, + ])('should calculate displayBreadcrumbNum=$expectedNum when isInPipeline=$isInPipeline and bucket=$bucket', ({ isInPipeline, bucket, expectedNum }) => { + // Arrange + mockStoreState.hasBucket = !!bucket + const breadcrumbs = Array.from({ length: expectedNum + 2 }, (_, i) => `f${i}`) + const props = createDefaultProps({ isInPipeline, bucket, breadcrumbs }) + + // Act + render() + + // Assert - Should collapse because breadcrumbs.length > expectedNum + const buttons = screen.getAllByRole('button') + const hasDropdownTrigger = buttons.some(btn => btn.querySelector('svg')) + expect(hasDropdownTrigger).toBe(true) + }) + }) + + // ========================================== + // Integration Tests + // ========================================== + describe('Integration', () => { + it('should handle full navigation flow: bucket -> folders -> navigation back', () => { + // Arrange + mockStoreState.hasBucket = true + mockStoreState.breadcrumbs = ['folder1', 'folder2'] + mockStoreState.prefix = ['prefix1', 'prefix2'] + const props = createDefaultProps({ + bucket: 'my-bucket', + breadcrumbs: ['folder1', 'folder2'], + }) + render() + + // Act - Click on first folder to navigate back + const firstFolder = screen.getByText('folder1') + fireEvent.click(firstFolder) + + // Assert + expect(mockStoreState.setBreadcrumbs).toHaveBeenCalledWith(['folder1']) + expect(mockStoreState.setPrefix).toHaveBeenCalledWith(['prefix1']) + }) + + it('should handle search result display with navigation elements hidden', () => { + // Arrange + mockStoreState.hasBucket = true + const props = createDefaultProps({ + keywords: 'test', + searchResultsLength: 5, + bucket: 'my-bucket', + breadcrumbs: ['folder1'], + }) + + // Act + render() + + // Assert - Search result should be shown, navigation elements should be hidden + expect(screen.getByText(/searchResult/)).toBeInTheDocument() + expect(screen.queryByText('my-bucket')).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/index.spec.tsx new file mode 100644 index 0000000000..3982fd4243 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/index.spec.tsx @@ -0,0 +1,727 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import Header from './index' + +// ========================================== +// Mock Modules +// ========================================== + +// Note: react-i18next uses global mock from web/__mocks__/react-i18next.ts + +// Mock store - required by Breadcrumbs component +const mockStoreState = { + hasBucket: false, + setOnlineDriveFileList: jest.fn(), + setSelectedFileIds: jest.fn(), + setBreadcrumbs: jest.fn(), + setPrefix: jest.fn(), + setBucket: jest.fn(), + breadcrumbs: [], + prefix: [], +} + +const mockGetState = jest.fn(() => mockStoreState) +const mockDataSourceStore = { getState: mockGetState } + +jest.mock('../../../store', () => ({ + useDataSourceStore: () => mockDataSourceStore, + useDataSourceStoreWithSelector: (selector: (s: typeof mockStoreState) => unknown) => selector(mockStoreState), +})) + +// ========================================== +// Test Data Builders +// ========================================== +type HeaderProps = React.ComponentProps + +const createDefaultProps = (overrides?: Partial): HeaderProps => ({ + breadcrumbs: [], + inputValue: '', + keywords: '', + bucket: '', + searchResultsLength: 0, + handleInputChange: jest.fn(), + handleResetKeywords: jest.fn(), + isInPipeline: false, + ...overrides, +}) + +// ========================================== +// Helper Functions +// ========================================== +const resetMockStoreState = () => { + mockStoreState.hasBucket = false + mockStoreState.setOnlineDriveFileList = jest.fn() + mockStoreState.setSelectedFileIds = jest.fn() + mockStoreState.setBreadcrumbs = jest.fn() + mockStoreState.setPrefix = jest.fn() + mockStoreState.setBucket = jest.fn() + mockStoreState.breadcrumbs = [] + mockStoreState.prefix = [] +} + +// ========================================== +// Test Suites +// ========================================== +describe('Header', () => { + beforeEach(() => { + jest.clearAllMocks() + resetMockStoreState() + }) + + // ========================================== + // Rendering Tests + // ========================================== + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange + const props = createDefaultProps() + + // Act + render(
) + + // Assert - search input should be visible + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + + it('should render with correct container styles', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render(
) + + // Assert - container should have correct class names + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('flex') + expect(wrapper).toHaveClass('items-center') + expect(wrapper).toHaveClass('gap-x-2') + expect(wrapper).toHaveClass('bg-components-panel-bg') + expect(wrapper).toHaveClass('p-1') + expect(wrapper).toHaveClass('pl-3') + }) + + it('should render Input component with correct props', () => { + // Arrange + const props = createDefaultProps({ inputValue: 'test-value' }) + + // Act + render(
) + + // Assert + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + expect(input).toBeInTheDocument() + expect(input).toHaveValue('test-value') + }) + + it('should render Input with search icon', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render(
) + + // Assert - Input should have search icon (RiSearchLine is rendered as svg) + const searchIcon = container.querySelector('svg.h-4.w-4') + expect(searchIcon).toBeInTheDocument() + }) + + it('should render Input with correct wrapper width', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render(
) + + // Assert - Input wrapper should have w-[200px] class + const inputWrapper = container.querySelector('.w-\\[200px\\]') + expect(inputWrapper).toBeInTheDocument() + }) + }) + + // ========================================== + // Props Testing + // ========================================== + describe('Props', () => { + describe('inputValue prop', () => { + it('should display empty input when inputValue is empty string', () => { + // Arrange + const props = createDefaultProps({ inputValue: '' }) + + // Act + render(
) + + // Assert + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + expect(input).toHaveValue('') + }) + + it('should display input value correctly', () => { + // Arrange + const props = createDefaultProps({ inputValue: 'search-query' }) + + // Act + render(
) + + // Assert + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + expect(input).toHaveValue('search-query') + }) + + it('should handle special characters in inputValue', () => { + // Arrange + const specialChars = 'test[file].txt (copy)' + const props = createDefaultProps({ inputValue: specialChars }) + + // Act + render(
) + + // Assert + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + expect(input).toHaveValue(specialChars) + }) + + it('should handle unicode characters in inputValue', () => { + // Arrange + const unicodeValue = '文件搜索 日本語' + const props = createDefaultProps({ inputValue: unicodeValue }) + + // Act + render(
) + + // Assert + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + expect(input).toHaveValue(unicodeValue) + }) + }) + + describe('breadcrumbs prop', () => { + it('should render with empty breadcrumbs', () => { + // Arrange + const props = createDefaultProps({ breadcrumbs: [] }) + + // Act + render(
) + + // Assert - Component should render without errors + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + + it('should render with single breadcrumb', () => { + // Arrange + const props = createDefaultProps({ breadcrumbs: ['folder1'] }) + + // Act + render(
) + + // Assert + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + + it('should render with multiple breadcrumbs', () => { + // Arrange + const props = createDefaultProps({ breadcrumbs: ['folder1', 'folder2', 'folder3'] }) + + // Act + render(
) + + // Assert + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + }) + + describe('keywords prop', () => { + it('should pass keywords to Breadcrumbs', () => { + // Arrange + const props = createDefaultProps({ keywords: 'search-keyword' }) + + // Act + render(
) + + // Assert - keywords are passed through, component renders + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + }) + + describe('bucket prop', () => { + it('should render with empty bucket', () => { + // Arrange + const props = createDefaultProps({ bucket: '' }) + + // Act + render(
) + + // Assert + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + + it('should render with bucket value', () => { + // Arrange + const props = createDefaultProps({ bucket: 'my-bucket' }) + + // Act + render(
) + + // Assert + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + }) + + describe('searchResultsLength prop', () => { + it('should handle zero search results', () => { + // Arrange + const props = createDefaultProps({ searchResultsLength: 0 }) + + // Act + render(
) + + // Assert + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + + it('should handle positive search results', () => { + // Arrange + const props = createDefaultProps({ searchResultsLength: 10, keywords: 'test' }) + + // Act + render(
) + + // Assert - Breadcrumbs will show search results text when keywords exist and results > 0 + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + + it('should handle large search results count', () => { + // Arrange + const props = createDefaultProps({ searchResultsLength: 1000, keywords: 'test' }) + + // Act + render(
) + + // Assert + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + }) + + describe('isInPipeline prop', () => { + it('should render correctly when isInPipeline is false', () => { + // Arrange + const props = createDefaultProps({ isInPipeline: false }) + + // Act + render(
) + + // Assert + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + + it('should render correctly when isInPipeline is true', () => { + // Arrange + const props = createDefaultProps({ isInPipeline: true }) + + // Act + render(
) + + // Assert + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + }) + }) + + // ========================================== + // Event Handlers Tests + // ========================================== + describe('Event Handlers', () => { + describe('handleInputChange', () => { + it('should call handleInputChange when input value changes', () => { + // Arrange + const mockHandleInputChange = jest.fn() + const props = createDefaultProps({ handleInputChange: mockHandleInputChange }) + render(
) + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + + // Act + fireEvent.change(input, { target: { value: 'new-value' } }) + + // Assert + expect(mockHandleInputChange).toHaveBeenCalledTimes(1) + // Verify that onChange event was triggered (React's synthetic event structure) + expect(mockHandleInputChange.mock.calls[0][0]).toHaveProperty('type', 'change') + }) + + it('should call handleInputChange on each keystroke', () => { + // Arrange + const mockHandleInputChange = jest.fn() + const props = createDefaultProps({ handleInputChange: mockHandleInputChange }) + render(
) + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + + // Act + fireEvent.change(input, { target: { value: 'a' } }) + fireEvent.change(input, { target: { value: 'ab' } }) + fireEvent.change(input, { target: { value: 'abc' } }) + + // Assert + expect(mockHandleInputChange).toHaveBeenCalledTimes(3) + }) + + it('should handle empty string input', () => { + // Arrange + const mockHandleInputChange = jest.fn() + const props = createDefaultProps({ inputValue: 'existing', handleInputChange: mockHandleInputChange }) + render(
) + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + + // Act + fireEvent.change(input, { target: { value: '' } }) + + // Assert + expect(mockHandleInputChange).toHaveBeenCalledTimes(1) + expect(mockHandleInputChange.mock.calls[0][0]).toHaveProperty('type', 'change') + }) + + it('should handle whitespace-only input', () => { + // Arrange + const mockHandleInputChange = jest.fn() + const props = createDefaultProps({ handleInputChange: mockHandleInputChange }) + render(
) + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + + // Act + fireEvent.change(input, { target: { value: ' ' } }) + + // Assert + expect(mockHandleInputChange).toHaveBeenCalledTimes(1) + expect(mockHandleInputChange.mock.calls[0][0]).toHaveProperty('type', 'change') + }) + }) + + describe('handleResetKeywords', () => { + it('should call handleResetKeywords when clear icon is clicked', () => { + // Arrange + const mockHandleResetKeywords = jest.fn() + const props = createDefaultProps({ + inputValue: 'to-clear', + handleResetKeywords: mockHandleResetKeywords, + }) + const { container } = render(
) + + // Act - Find and click the clear icon container + const clearButton = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]')?.parentElement + expect(clearButton).toBeInTheDocument() + fireEvent.click(clearButton!) + + // Assert + expect(mockHandleResetKeywords).toHaveBeenCalledTimes(1) + }) + + it('should not show clear icon when inputValue is empty', () => { + // Arrange + const props = createDefaultProps({ inputValue: '' }) + const { container } = render(
) + + // Act & Assert - Clear icon should not be visible + const clearIcon = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]') + expect(clearIcon).not.toBeInTheDocument() + }) + + it('should show clear icon when inputValue is not empty', () => { + // Arrange + const props = createDefaultProps({ inputValue: 'some-value' }) + const { container } = render(
) + + // Act & Assert - Clear icon should be visible + const clearIcon = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]') + expect(clearIcon).toBeInTheDocument() + }) + }) + }) + + // ========================================== + // Component Memoization Tests + // ========================================== + describe('Memoization', () => { + it('should be wrapped with React.memo', () => { + // Assert - Header component should be memoized + expect(Header).toHaveProperty('$$typeof', Symbol.for('react.memo')) + }) + + it('should not re-render when props are the same', () => { + // Arrange + const mockHandleInputChange = jest.fn() + const mockHandleResetKeywords = jest.fn() + const props = createDefaultProps({ + handleInputChange: mockHandleInputChange, + handleResetKeywords: mockHandleResetKeywords, + }) + + // Act - Initial render + const { rerender } = render(
) + + // Rerender with same props + rerender(
) + + // Assert - Component renders without errors + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + + it('should re-render when inputValue changes', () => { + // Arrange + const props = createDefaultProps({ inputValue: 'initial' }) + const { rerender } = render(
) + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + expect(input).toHaveValue('initial') + + // Act - Rerender with different inputValue + const newProps = createDefaultProps({ inputValue: 'changed' }) + rerender(
) + + // Assert - Input value should be updated + expect(input).toHaveValue('changed') + }) + + it('should re-render when breadcrumbs change', () => { + // Arrange + const props = createDefaultProps({ breadcrumbs: [] }) + const { rerender } = render(
) + + // Act - Rerender with different breadcrumbs + const newProps = createDefaultProps({ breadcrumbs: ['folder1', 'folder2'] }) + rerender(
) + + // Assert - Component renders without errors + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + + it('should re-render when keywords change', () => { + // Arrange + const props = createDefaultProps({ keywords: '' }) + const { rerender } = render(
) + + // Act - Rerender with different keywords + const newProps = createDefaultProps({ keywords: 'search-term' }) + rerender(
) + + // Assert - Component renders without errors + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + }) + + // ========================================== + // Edge Cases and Error Handling + // ========================================== + describe('Edge Cases and Error Handling', () => { + it('should handle very long inputValue', () => { + // Arrange + const longValue = 'a'.repeat(500) + const props = createDefaultProps({ inputValue: longValue }) + + // Act + render(
) + + // Assert + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + expect(input).toHaveValue(longValue) + }) + + it('should handle very long breadcrumb paths', () => { + // Arrange + const longBreadcrumbs = Array.from({ length: 20 }, (_, i) => `folder-${i}`) + const props = createDefaultProps({ breadcrumbs: longBreadcrumbs }) + + // Act + render(
) + + // Assert + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + + it('should handle breadcrumbs with special characters', () => { + // Arrange + const specialBreadcrumbs = ['folder [1]', 'folder (2)', 'folder-3.backup'] + const props = createDefaultProps({ breadcrumbs: specialBreadcrumbs }) + + // Act + render(
) + + // Assert + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + + it('should handle breadcrumbs with unicode names', () => { + // Arrange + const unicodeBreadcrumbs = ['文件夹', 'フォルダ', 'Папка'] + const props = createDefaultProps({ breadcrumbs: unicodeBreadcrumbs }) + + // Act + render(
) + + // Assert + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + + it('should handle bucket with special characters', () => { + // Arrange + const props = createDefaultProps({ bucket: 'my-bucket_2024.backup' }) + + // Act + render(
) + + // Assert + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + + it('should pass the event object to handleInputChange callback', () => { + // Arrange + const mockHandleInputChange = jest.fn() + const props = createDefaultProps({ handleInputChange: mockHandleInputChange }) + render(
) + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + + // Act + fireEvent.change(input, { target: { value: 'test-value' } }) + + // Assert - Verify the event object is passed correctly + expect(mockHandleInputChange).toHaveBeenCalledTimes(1) + const eventArg = mockHandleInputChange.mock.calls[0][0] + expect(eventArg).toHaveProperty('type', 'change') + expect(eventArg).toHaveProperty('target') + }) + }) + + // ========================================== + // All Prop Variations Tests + // ========================================== + describe('Prop Variations', () => { + it.each([ + { isInPipeline: true, bucket: '' }, + { isInPipeline: true, bucket: 'my-bucket' }, + { isInPipeline: false, bucket: '' }, + { isInPipeline: false, bucket: 'my-bucket' }, + ])('should render correctly with isInPipeline=$isInPipeline and bucket=$bucket', (propVariation) => { + // Arrange + const props = createDefaultProps(propVariation) + + // Act + render(
) + + // Assert + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + + it.each([ + { keywords: '', searchResultsLength: 0, description: 'no search' }, + { keywords: 'test', searchResultsLength: 0, description: 'search with no results' }, + { keywords: 'test', searchResultsLength: 5, description: 'search with results' }, + { keywords: '', searchResultsLength: 5, description: 'no keywords but has results count' }, + ])('should render correctly with $description', ({ keywords, searchResultsLength }) => { + // Arrange + const props = createDefaultProps({ keywords, searchResultsLength }) + + // Act + render(
) + + // Assert + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + + it.each([ + { breadcrumbs: [], inputValue: '', expected: 'empty state' }, + { breadcrumbs: ['root'], inputValue: 'search', expected: 'single breadcrumb with search' }, + { breadcrumbs: ['a', 'b', 'c'], inputValue: '', expected: 'multiple breadcrumbs no search' }, + { breadcrumbs: ['a', 'b', 'c', 'd', 'e'], inputValue: 'query', expected: 'many breadcrumbs with search' }, + ])('should handle $expected correctly', ({ breadcrumbs, inputValue }) => { + // Arrange + const props = createDefaultProps({ breadcrumbs, inputValue }) + + // Act + render(
) + + // Assert + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + expect(input).toHaveValue(inputValue) + }) + }) + + // ========================================== + // Integration with Child Components + // ========================================== + describe('Integration with Child Components', () => { + it('should pass all required props to Breadcrumbs', () => { + // Arrange + const props = createDefaultProps({ + breadcrumbs: ['folder1', 'folder2'], + keywords: 'test-keyword', + bucket: 'test-bucket', + searchResultsLength: 10, + isInPipeline: true, + }) + + // Act + render(
) + + // Assert - Component should render successfully, meaning props are passed correctly + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + + it('should pass correct props to Input component', () => { + // Arrange + const mockHandleInputChange = jest.fn() + const mockHandleResetKeywords = jest.fn() + const props = createDefaultProps({ + inputValue: 'test-input', + handleInputChange: mockHandleInputChange, + handleResetKeywords: mockHandleResetKeywords, + }) + + // Act + render(
) + + // Assert + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + expect(input).toHaveValue('test-input') + + // Test onChange handler + fireEvent.change(input, { target: { value: 'new-value' } }) + expect(mockHandleInputChange).toHaveBeenCalled() + }) + }) + + // ========================================== + // Callback Stability Tests + // ========================================== + describe('Callback Stability', () => { + it('should maintain stable handleInputChange callback after rerender', () => { + // Arrange + const mockHandleInputChange = jest.fn() + const props = createDefaultProps({ handleInputChange: mockHandleInputChange }) + const { rerender } = render(
) + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + + // Act - Fire change event, rerender, fire again + fireEvent.change(input, { target: { value: 'first' } }) + rerender(
) + fireEvent.change(input, { target: { value: 'second' } }) + + // Assert + expect(mockHandleInputChange).toHaveBeenCalledTimes(2) + }) + + it('should maintain stable handleResetKeywords callback after rerender', () => { + // Arrange + const mockHandleResetKeywords = jest.fn() + const props = createDefaultProps({ + inputValue: 'to-clear', + handleResetKeywords: mockHandleResetKeywords, + }) + const { container, rerender } = render(
) + + // Act - Click clear, rerender, click again + const clearButton = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]')?.parentElement + fireEvent.click(clearButton!) + rerender(
) + fireEvent.click(clearButton!) + + // Assert + expect(mockHandleResetKeywords).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/index.spec.tsx new file mode 100644 index 0000000000..e8e0930e44 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/index.spec.tsx @@ -0,0 +1,757 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import FileList from './index' +import type { OnlineDriveFile } from '@/models/pipeline' +import { OnlineDriveFileType } from '@/models/pipeline' + +// ========================================== +// Mock Modules +// ========================================== + +// Note: react-i18next uses global mock from web/__mocks__/react-i18next.ts + +// Mock ahooks useDebounceFn - third-party library requires mocking +const mockDebounceFnRun = jest.fn() +jest.mock('ahooks', () => ({ + useDebounceFn: (fn: (...args: any[]) => void) => { + mockDebounceFnRun.mockImplementation(fn) + return { run: mockDebounceFnRun } + }, +})) + +// Mock store - context provider requires mocking +const mockStoreState = { + setNextPageParameters: jest.fn(), + currentNextPageParametersRef: { current: {} }, + isTruncated: { current: false }, + hasBucket: false, + setOnlineDriveFileList: jest.fn(), + setSelectedFileIds: jest.fn(), + setBreadcrumbs: jest.fn(), + setPrefix: jest.fn(), + setBucket: jest.fn(), +} + +const mockGetState = jest.fn(() => mockStoreState) +const mockDataSourceStore = { getState: mockGetState } + +jest.mock('../../store', () => ({ + useDataSourceStore: () => mockDataSourceStore, + useDataSourceStoreWithSelector: (selector: (s: any) => any) => selector(mockStoreState), +})) + +// ========================================== +// Test Data Builders +// ========================================== +const createMockOnlineDriveFile = (overrides?: Partial): OnlineDriveFile => ({ + id: 'file-1', + name: 'test-file.txt', + size: 1024, + type: OnlineDriveFileType.file, + ...overrides, +}) + +type FileListProps = React.ComponentProps + +const createDefaultProps = (overrides?: Partial): FileListProps => ({ + fileList: [], + selectedFileIds: [], + breadcrumbs: [], + keywords: '', + bucket: '', + isInPipeline: false, + resetKeywords: jest.fn(), + updateKeywords: jest.fn(), + searchResultsLength: 0, + handleSelectFile: jest.fn(), + handleOpenFolder: jest.fn(), + isLoading: false, + supportBatchUpload: true, + ...overrides, +}) + +// ========================================== +// Helper Functions +// ========================================== +const resetMockStoreState = () => { + mockStoreState.setNextPageParameters = jest.fn() + mockStoreState.currentNextPageParametersRef = { current: {} } + mockStoreState.isTruncated = { current: false } + mockStoreState.hasBucket = false + mockStoreState.setOnlineDriveFileList = jest.fn() + mockStoreState.setSelectedFileIds = jest.fn() + mockStoreState.setBreadcrumbs = jest.fn() + mockStoreState.setPrefix = jest.fn() + mockStoreState.setBucket = jest.fn() +} + +// ========================================== +// Test Suites +// ========================================== +describe('FileList', () => { + beforeEach(() => { + jest.clearAllMocks() + resetMockStoreState() + mockDebounceFnRun.mockClear() + }) + + // ========================================== + // Rendering Tests + // ========================================== + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert - search input should be visible + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + + it('should render with correct container styles', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('flex') + expect(wrapper).toHaveClass('h-[400px]') + expect(wrapper).toHaveClass('flex-col') + expect(wrapper).toHaveClass('overflow-hidden') + expect(wrapper).toHaveClass('rounded-xl') + }) + + it('should render Header component with search input', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + expect(input).toBeInTheDocument() + }) + + it('should render files when fileList has items', () => { + // Arrange + const fileList = [ + createMockOnlineDriveFile({ id: 'file-1', name: 'file1.txt' }), + createMockOnlineDriveFile({ id: 'file-2', name: 'file2.txt' }), + ] + const props = createDefaultProps({ fileList }) + + // Act + render() + + // Assert + expect(screen.getByText('file1.txt')).toBeInTheDocument() + expect(screen.getByText('file2.txt')).toBeInTheDocument() + }) + + it('should show loading state when isLoading is true and fileList is empty', () => { + // Arrange + const props = createDefaultProps({ isLoading: true, fileList: [] }) + + // Act + const { container } = render() + + // Assert - Loading component should be rendered with spin-animation class + expect(container.querySelector('.spin-animation')).toBeInTheDocument() + }) + + it('should show empty folder state when not loading and fileList is empty', () => { + // Arrange + const props = createDefaultProps({ isLoading: false, fileList: [], keywords: '' }) + + // Act + render() + + // Assert + expect(screen.getByText('datasetPipeline.onlineDrive.emptyFolder')).toBeInTheDocument() + }) + + it('should show empty search result when not loading, fileList is empty, and keywords exist', () => { + // Arrange + const props = createDefaultProps({ isLoading: false, fileList: [], keywords: 'search-term' }) + + // Act + render() + + // Assert + expect(screen.getByText('datasetPipeline.onlineDrive.emptySearchResult')).toBeInTheDocument() + }) + }) + + // ========================================== + // Props Testing + // ========================================== + describe('Props', () => { + describe('fileList prop', () => { + it('should render all files from fileList', () => { + // Arrange + const fileList = [ + createMockOnlineDriveFile({ id: '1', name: 'a.txt' }), + createMockOnlineDriveFile({ id: '2', name: 'b.txt' }), + createMockOnlineDriveFile({ id: '3', name: 'c.txt' }), + ] + const props = createDefaultProps({ fileList }) + + // Act + render() + + // Assert + expect(screen.getByText('a.txt')).toBeInTheDocument() + expect(screen.getByText('b.txt')).toBeInTheDocument() + expect(screen.getByText('c.txt')).toBeInTheDocument() + }) + + it('should handle empty fileList', () => { + // Arrange + const props = createDefaultProps({ fileList: [] }) + + // Act + render() + + // Assert - Should show empty folder state + expect(screen.getByText('datasetPipeline.onlineDrive.emptyFolder')).toBeInTheDocument() + }) + }) + + describe('selectedFileIds prop', () => { + it('should mark files as selected based on selectedFileIds', () => { + // Arrange + const fileList = [ + createMockOnlineDriveFile({ id: 'file-1', name: 'file1.txt' }), + createMockOnlineDriveFile({ id: 'file-2', name: 'file2.txt' }), + ] + const props = createDefaultProps({ fileList, selectedFileIds: ['file-1'] }) + + // Act + render() + + // Assert - The checkbox for file-1 should be checked (check icon present) + expect(screen.getByTestId('checkbox-file-1')).toBeInTheDocument() + expect(screen.getByTestId('check-icon-file-1')).toBeInTheDocument() + expect(screen.getByTestId('checkbox-file-2')).toBeInTheDocument() + expect(screen.queryByTestId('check-icon-file-2')).not.toBeInTheDocument() + }) + }) + + describe('keywords prop', () => { + it('should initialize input with keywords value', () => { + // Arrange + const props = createDefaultProps({ keywords: 'my-search' }) + + // Act + render() + + // Assert + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + expect(input).toHaveValue('my-search') + }) + }) + + describe('isLoading prop', () => { + it('should show loading when isLoading is true with empty list', () => { + // Arrange + const props = createDefaultProps({ isLoading: true, fileList: [] }) + + // Act + const { container } = render() + + // Assert - Loading component with spin-animation class + expect(container.querySelector('.spin-animation')).toBeInTheDocument() + }) + + it('should show loading indicator at bottom when isLoading is true with files', () => { + // Arrange + const fileList = [createMockOnlineDriveFile()] + const props = createDefaultProps({ isLoading: true, fileList }) + + // Act + const { container } = render() + + // Assert - Should show spinner icon at the bottom + expect(container.querySelector('.animation-spin')).toBeInTheDocument() + }) + }) + + describe('supportBatchUpload prop', () => { + it('should render checkboxes when supportBatchUpload is true', () => { + // Arrange + const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'file1.txt' })] + const props = createDefaultProps({ fileList, supportBatchUpload: true }) + + // Act + render() + + // Assert - Checkbox component has data-testid="checkbox-{id}" + expect(screen.getByTestId('checkbox-file-1')).toBeInTheDocument() + }) + + it('should render radio buttons when supportBatchUpload is false', () => { + // Arrange + const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'file1.txt' })] + const props = createDefaultProps({ fileList, supportBatchUpload: false }) + + // Act + const { container } = render() + + // Assert - Radio is rendered as a div with rounded-full class + expect(container.querySelector('.rounded-full')).toBeInTheDocument() + // And checkbox should not be present + expect(screen.queryByTestId('checkbox-file-1')).not.toBeInTheDocument() + }) + }) + }) + + // ========================================== + // State Management Tests + // ========================================== + describe('State Management', () => { + describe('inputValue state', () => { + it('should initialize inputValue with keywords prop', () => { + // Arrange + const props = createDefaultProps({ keywords: 'initial-keyword' }) + + // Act + render() + + // Assert + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + expect(input).toHaveValue('initial-keyword') + }) + + it('should update inputValue when input changes', () => { + // Arrange + const props = createDefaultProps({ keywords: '' }) + render() + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + + // Act + fireEvent.change(input, { target: { value: 'new-value' } }) + + // Assert + expect(input).toHaveValue('new-value') + }) + }) + + describe('debounced keywords update', () => { + it('should call updateKeywords with debounce when input changes', () => { + // Arrange + const mockUpdateKeywords = jest.fn() + const props = createDefaultProps({ updateKeywords: mockUpdateKeywords }) + render() + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + + // Act + fireEvent.change(input, { target: { value: 'debounced-value' } }) + + // Assert + expect(mockDebounceFnRun).toHaveBeenCalledWith('debounced-value') + }) + }) + }) + + // ========================================== + // Event Handlers Tests + // ========================================== + describe('Event Handlers', () => { + describe('handleInputChange', () => { + it('should update inputValue on input change', () => { + // Arrange + const props = createDefaultProps() + render() + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + + // Act + fireEvent.change(input, { target: { value: 'typed-text' } }) + + // Assert + expect(input).toHaveValue('typed-text') + }) + + it('should trigger debounced updateKeywords on input change', () => { + // Arrange + const mockUpdateKeywords = jest.fn() + const props = createDefaultProps({ updateKeywords: mockUpdateKeywords }) + render() + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + + // Act + fireEvent.change(input, { target: { value: 'search-term' } }) + + // Assert + expect(mockDebounceFnRun).toHaveBeenCalledWith('search-term') + }) + + it('should handle multiple sequential input changes', () => { + // Arrange + const mockUpdateKeywords = jest.fn() + const props = createDefaultProps({ updateKeywords: mockUpdateKeywords }) + render() + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + + // Act + fireEvent.change(input, { target: { value: 'a' } }) + fireEvent.change(input, { target: { value: 'ab' } }) + fireEvent.change(input, { target: { value: 'abc' } }) + + // Assert + expect(mockDebounceFnRun).toHaveBeenCalledTimes(3) + expect(mockDebounceFnRun).toHaveBeenLastCalledWith('abc') + expect(input).toHaveValue('abc') + }) + }) + + describe('handleResetKeywords', () => { + it('should call resetKeywords prop when clear button is clicked', () => { + // Arrange + const mockResetKeywords = jest.fn() + const props = createDefaultProps({ resetKeywords: mockResetKeywords, keywords: 'to-reset' }) + const { container } = render() + + // Act - Click the clear icon div (it contains RiCloseCircleFill icon) + const clearButton = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]')?.parentElement + expect(clearButton).toBeInTheDocument() + fireEvent.click(clearButton!) + + // Assert + expect(mockResetKeywords).toHaveBeenCalledTimes(1) + }) + + it('should reset inputValue to empty string when clear is clicked', () => { + // Arrange + const props = createDefaultProps({ keywords: 'to-be-reset' }) + const { container } = render() + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + fireEvent.change(input, { target: { value: 'some-search' } }) + + // Act - Find and click the clear icon + const clearButton = container.querySelector('[class*="cursor-pointer"] svg[class*="h-3.5"]')?.parentElement + expect(clearButton).toBeInTheDocument() + fireEvent.click(clearButton!) + + // Assert + expect(input).toHaveValue('') + }) + }) + + describe('handleSelectFile', () => { + it('should call handleSelectFile when file item is clicked', () => { + // Arrange + const mockHandleSelectFile = jest.fn() + const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'test.txt' })] + const props = createDefaultProps({ handleSelectFile: mockHandleSelectFile, fileList }) + render() + + // Act - Click on the file item + const fileItem = screen.getByText('test.txt') + fireEvent.click(fileItem.closest('[class*="cursor-pointer"]')!) + + // Assert + expect(mockHandleSelectFile).toHaveBeenCalledWith(expect.objectContaining({ + id: 'file-1', + name: 'test.txt', + type: OnlineDriveFileType.file, + })) + }) + }) + + describe('handleOpenFolder', () => { + it('should call handleOpenFolder when folder item is clicked', () => { + // Arrange + const mockHandleOpenFolder = jest.fn() + const fileList = [createMockOnlineDriveFile({ id: 'folder-1', name: 'my-folder', type: OnlineDriveFileType.folder })] + const props = createDefaultProps({ handleOpenFolder: mockHandleOpenFolder, fileList }) + render() + + // Act - Click on the folder item + const folderItem = screen.getByText('my-folder') + fireEvent.click(folderItem.closest('[class*="cursor-pointer"]')!) + + // Assert + expect(mockHandleOpenFolder).toHaveBeenCalledWith(expect.objectContaining({ + id: 'folder-1', + name: 'my-folder', + type: OnlineDriveFileType.folder, + })) + }) + }) + }) + + // ========================================== + // Edge Cases and Error Handling + // ========================================== + describe('Edge Cases and Error Handling', () => { + it('should handle empty string keywords', () => { + // Arrange + const props = createDefaultProps({ keywords: '' }) + + // Act + render() + + // Assert + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + expect(input).toHaveValue('') + }) + + it('should handle special characters in keywords', () => { + // Arrange + const specialChars = 'test[file].txt (copy)' + const props = createDefaultProps({ keywords: specialChars }) + + // Act + render() + + // Assert + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + expect(input).toHaveValue(specialChars) + }) + + it('should handle unicode characters in keywords', () => { + // Arrange + const unicodeKeywords = '文件搜索 日本語' + const props = createDefaultProps({ keywords: unicodeKeywords }) + + // Act + render() + + // Assert + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + expect(input).toHaveValue(unicodeKeywords) + }) + + it('should handle very long file names in fileList', () => { + // Arrange + const longName = `${'a'.repeat(100)}.txt` + const fileList = [createMockOnlineDriveFile({ id: '1', name: longName })] + const props = createDefaultProps({ fileList }) + + // Act + render() + + // Assert + expect(screen.getByText(longName)).toBeInTheDocument() + }) + + it('should handle large number of files', () => { + // Arrange + const fileList = Array.from({ length: 50 }, (_, i) => + createMockOnlineDriveFile({ id: `file-${i}`, name: `file-${i}.txt` }), + ) + const props = createDefaultProps({ fileList }) + + // Act + render() + + // Assert - Check a few files exist + expect(screen.getByText('file-0.txt')).toBeInTheDocument() + expect(screen.getByText('file-49.txt')).toBeInTheDocument() + }) + + it('should handle whitespace-only keywords input', () => { + // Arrange + const props = createDefaultProps() + render() + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + + // Act + fireEvent.change(input, { target: { value: ' ' } }) + + // Assert + expect(input).toHaveValue(' ') + expect(mockDebounceFnRun).toHaveBeenCalledWith(' ') + }) + }) + + // ========================================== + // All Prop Variations Tests + // ========================================== + 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 isInPipeline=$isInPipeline and supportBatchUpload=$supportBatchUpload', (propVariation) => { + // Arrange + const props = createDefaultProps(propVariation) + + // Act + render() + + // Assert - Component should render without crashing + expect(screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder')).toBeInTheDocument() + }) + + it.each([ + { isLoading: true, fileCount: 0, description: 'loading state with no files' }, + { isLoading: false, fileCount: 0, description: 'not loading with no files' }, + { isLoading: false, fileCount: 3, description: 'not loading with files' }, + ])('should handle $description correctly', ({ isLoading, fileCount }) => { + // Arrange + const fileList = Array.from({ length: fileCount }, (_, i) => + createMockOnlineDriveFile({ id: `file-${i}`, name: `file-${i}.txt` }), + ) + const props = createDefaultProps({ isLoading, fileList }) + + // Act + const { container } = render() + + // Assert + if (isLoading && fileCount === 0) + expect(container.querySelector('.spin-animation')).toBeInTheDocument() + + else if (!isLoading && fileCount === 0) + expect(screen.getByText('datasetPipeline.onlineDrive.emptyFolder')).toBeInTheDocument() + + else + expect(screen.getByText('file-0.txt')).toBeInTheDocument() + }) + + it.each([ + { keywords: '', searchResultsLength: 0 }, + { keywords: 'test', searchResultsLength: 5 }, + { keywords: 'not-found', searchResultsLength: 0 }, + ])('should render correctly with keywords="$keywords" and searchResultsLength=$searchResultsLength', ({ keywords, searchResultsLength }) => { + // Arrange + const props = createDefaultProps({ keywords, searchResultsLength }) + + // Act + render() + + // Assert + const input = screen.getByPlaceholderText('datasetPipeline.onlineDrive.breadcrumbs.searchPlaceholder') + expect(input).toHaveValue(keywords) + }) + }) + + // ========================================== + // File Type Variations + // ========================================== + describe('File Type Variations', () => { + it('should render folder type correctly', () => { + // Arrange + const fileList = [createMockOnlineDriveFile({ id: 'folder-1', name: 'my-folder', type: OnlineDriveFileType.folder })] + const props = createDefaultProps({ fileList }) + + // Act + render() + + // Assert + expect(screen.getByText('my-folder')).toBeInTheDocument() + }) + + it('should render bucket type correctly', () => { + // Arrange + const fileList = [createMockOnlineDriveFile({ id: 'bucket-1', name: 'my-bucket', type: OnlineDriveFileType.bucket })] + const props = createDefaultProps({ fileList }) + + // Act + render() + + // Assert + expect(screen.getByText('my-bucket')).toBeInTheDocument() + }) + + it('should render file with size', () => { + // Arrange + const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'test.txt', size: 1024 })] + const props = createDefaultProps({ fileList }) + + // Act + render() + + // Assert + expect(screen.getByText('test.txt')).toBeInTheDocument() + // formatFileSize returns '1.00 KB' for 1024 bytes + expect(screen.getByText('1.00 KB')).toBeInTheDocument() + }) + + it('should not show checkbox for bucket type', () => { + // Arrange + const fileList = [createMockOnlineDriveFile({ id: 'bucket-1', name: 'my-bucket', type: OnlineDriveFileType.bucket })] + const props = createDefaultProps({ fileList, supportBatchUpload: true }) + + // Act + render() + + // Assert - No checkbox should be rendered for bucket + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument() + }) + }) + + // ========================================== + // Search Results Display + // ========================================== + describe('Search Results Display', () => { + it('should show search results count when keywords and results exist', () => { + // Arrange + const props = createDefaultProps({ + keywords: 'test', + searchResultsLength: 5, + breadcrumbs: ['folder1'], + }) + + // Act + render() + + // Assert + expect(screen.getByText(/datasetPipeline\.onlineDrive\.breadcrumbs\.searchResult/)).toBeInTheDocument() + }) + }) + + // ========================================== + // Callback Stability + // ========================================== + describe('Callback Stability', () => { + it('should maintain stable handleSelectFile callback', () => { + // Arrange + const mockHandleSelectFile = jest.fn() + const fileList = [createMockOnlineDriveFile({ id: 'file-1', name: 'test.txt' })] + const props = createDefaultProps({ handleSelectFile: mockHandleSelectFile, fileList }) + const { rerender } = render() + + // Act - Click once + const fileItem = screen.getByText('test.txt') + fireEvent.click(fileItem.closest('[class*="cursor-pointer"]')!) + + // Rerender with same props + rerender() + + // Click again + fireEvent.click(fileItem.closest('[class*="cursor-pointer"]')!) + + // Assert + expect(mockHandleSelectFile).toHaveBeenCalledTimes(2) + }) + + it('should maintain stable handleOpenFolder callback', () => { + // Arrange + const mockHandleOpenFolder = jest.fn() + const fileList = [createMockOnlineDriveFile({ id: 'folder-1', name: 'my-folder', type: OnlineDriveFileType.folder })] + const props = createDefaultProps({ handleOpenFolder: mockHandleOpenFolder, fileList }) + const { rerender } = render() + + // Act - Click once + const folderItem = screen.getByText('my-folder') + fireEvent.click(folderItem.closest('[class*="cursor-pointer"]')!) + + // Rerender with same props + rerender() + + // Click again + fireEvent.click(folderItem.closest('[class*="cursor-pointer"]')!) + + // Assert + expect(mockHandleOpenFolder).toHaveBeenCalledTimes(2) + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/index.spec.tsx new file mode 100644 index 0000000000..9d27cff4cf --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/index.spec.tsx @@ -0,0 +1,2071 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import List from './index' +import type { OnlineDriveFile } from '@/models/pipeline' +import { OnlineDriveFileType } from '@/models/pipeline' + +// ========================================== +// Mock Modules +// ========================================== + +// Note: react-i18next uses global mock from web/__mocks__/react-i18next.ts + +// Mock Loading component - base component with simple render +jest.mock('@/app/components/base/loading', () => { + const MockLoading = ({ type }: { type?: string }) => ( +
Loading...
+ ) + return MockLoading +}) + +// Mock Item component for List tests - child component with complex behavior +jest.mock('./item', () => { + const MockItem = ({ file, isSelected, onSelect, onOpen, isMultipleChoice }: { + file: OnlineDriveFile + isSelected: boolean + onSelect: (file: OnlineDriveFile) => void + onOpen: (file: OnlineDriveFile) => void + isMultipleChoice: boolean + }) => { + return ( +
+ {file.name} + + +
+ ) + } + return MockItem +}) + +// Mock EmptyFolder component for List tests +jest.mock('./empty-folder', () => { + const MockEmptyFolder = () => ( +
Empty Folder
+ ) + return MockEmptyFolder +}) + +// Mock EmptySearchResult component for List tests +jest.mock('./empty-search-result', () => { + const MockEmptySearchResult = ({ onResetKeywords }: { onResetKeywords: () => void }) => ( +
+ No results + +
+ ) + return MockEmptySearchResult +}) + +// Mock store state and refs +const mockIsTruncated = { current: false } +const mockCurrentNextPageParametersRef = { current: {} as Record } +const mockSetNextPageParameters = jest.fn() + +const mockStoreState = { + isTruncated: mockIsTruncated, + currentNextPageParametersRef: mockCurrentNextPageParametersRef, + setNextPageParameters: mockSetNextPageParameters, +} + +const mockGetState = jest.fn(() => mockStoreState) +const mockDataSourceStore = { getState: mockGetState } + +jest.mock('../../../store', () => ({ + useDataSourceStore: () => mockDataSourceStore, +})) + +// ========================================== +// Test Data Builders +// ========================================== +const createMockOnlineDriveFile = (overrides?: Partial): OnlineDriveFile => ({ + id: 'file-1', + name: 'test-file.txt', + size: 1024, + type: OnlineDriveFileType.file, + ...overrides, +}) + +const createMockFileList = (count: number): OnlineDriveFile[] => { + return Array.from({ length: count }, (_, index) => createMockOnlineDriveFile({ + id: `file-${index + 1}`, + name: `file-${index + 1}.txt`, + size: (index + 1) * 1024, + })) +} + +type ListProps = React.ComponentProps + +const createDefaultProps = (overrides?: Partial): ListProps => ({ + fileList: [], + selectedFileIds: [], + keywords: '', + isLoading: false, + supportBatchUpload: true, + handleResetKeywords: jest.fn(), + handleSelectFile: jest.fn(), + handleOpenFolder: jest.fn(), + ...overrides, +}) + +// ========================================== +// Mock IntersectionObserver +// ========================================== +let mockIntersectionObserverCallback: IntersectionObserverCallback | null = null +let mockIntersectionObserverInstance: { + observe: jest.Mock + disconnect: jest.Mock + unobserve: jest.Mock +} | null = null + +const createMockIntersectionObserver = () => { + const instance = { + observe: jest.fn(), + disconnect: jest.fn(), + unobserve: jest.fn(), + } + mockIntersectionObserverInstance = instance + + return class MockIntersectionObserver { + callback: IntersectionObserverCallback + options: IntersectionObserverInit + + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + this.callback = callback + this.options = options || {} + mockIntersectionObserverCallback = callback + } + + observe = instance.observe + disconnect = instance.disconnect + unobserve = instance.unobserve + } +} + +// ========================================== +// Helper Functions +// ========================================== +const triggerIntersection = (isIntersecting: boolean) => { + if (mockIntersectionObserverCallback) { + const entries = [{ + isIntersecting, + boundingClientRect: {} as DOMRectReadOnly, + intersectionRatio: isIntersecting ? 1 : 0, + intersectionRect: {} as DOMRectReadOnly, + rootBounds: null, + target: document.createElement('div'), + time: Date.now(), + }] as IntersectionObserverEntry[] + mockIntersectionObserverCallback(entries, {} as IntersectionObserver) + } +} + +const resetMockStoreState = () => { + mockIsTruncated.current = false + mockCurrentNextPageParametersRef.current = {} + mockSetNextPageParameters.mockClear() + mockGetState.mockClear() +} + +// ========================================== +// Test Suites +// ========================================== +describe('List', () => { + const originalIntersectionObserver = window.IntersectionObserver + + beforeEach(() => { + jest.clearAllMocks() + resetMockStoreState() + mockIntersectionObserverCallback = null + mockIntersectionObserverInstance = null + + // Setup IntersectionObserver mock + window.IntersectionObserver = createMockIntersectionObserver() as unknown as typeof IntersectionObserver + }) + + afterEach(() => { + window.IntersectionObserver = originalIntersectionObserver + }) + + // ========================================== + // Rendering Tests + // ========================================== + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(document.body).toBeInTheDocument() + }) + + it('should render Loading component when isAllLoading is true', () => { + // Arrange + const props = createDefaultProps({ + isLoading: true, + fileList: [], + keywords: '', + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('loading')).toBeInTheDocument() + expect(screen.getByTestId('loading')).toHaveAttribute('data-type', 'app') + }) + + it('should render EmptyFolder when folder is empty and not loading', () => { + // Arrange + const props = createDefaultProps({ + isLoading: false, + fileList: [], + keywords: '', + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('empty-folder')).toBeInTheDocument() + }) + + it('should render EmptySearchResult when search has no results', () => { + // Arrange + const props = createDefaultProps({ + isLoading: false, + fileList: [], + keywords: 'non-existent-file', + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('empty-search-result')).toBeInTheDocument() + }) + + it('should render file list when files exist', () => { + // Arrange + const fileList = createMockFileList(3) + const props = createDefaultProps({ fileList }) + + // Act + render() + + // Assert + expect(screen.getByTestId('item-file-1')).toBeInTheDocument() + expect(screen.getByTestId('item-file-2')).toBeInTheDocument() + expect(screen.getByTestId('item-file-3')).toBeInTheDocument() + }) + + it('should render partial loading spinner when loading more files', () => { + // Arrange + const fileList = createMockFileList(2) + const props = createDefaultProps({ + fileList, + isLoading: true, + }) + + // Act + const { container } = render() + + // Assert - Should show files AND loading spinner (animation-spin class) + expect(screen.getByTestId('item-file-1')).toBeInTheDocument() + expect(container.querySelector('.animation-spin')).toBeInTheDocument() + }) + + it('should not render Loading component when partial loading', () => { + // Arrange + const fileList = createMockFileList(2) + const props = createDefaultProps({ + fileList, + isLoading: true, + }) + + // Act + render() + + // Assert - Full page loading should not appear + expect(screen.queryByTestId('loading')).not.toBeInTheDocument() + }) + + it('should render anchor div for infinite scroll', () => { + // Arrange + const fileList = createMockFileList(2) + const props = createDefaultProps({ fileList }) + + // Act + const { container } = render() + + // Assert - Anchor div should exist with h-0 class + const anchorDiv = container.querySelector('.h-0') + expect(anchorDiv).toBeInTheDocument() + }) + }) + + // ========================================== + // Props Testing + // ========================================== + describe('Props', () => { + describe('fileList prop', () => { + it('should render all files from fileList', () => { + // Arrange + const fileList = createMockFileList(5) + const props = createDefaultProps({ fileList }) + + // Act + render() + + // Assert + fileList.forEach((file) => { + expect(screen.getByTestId(`item-${file.id}`)).toBeInTheDocument() + expect(screen.getByTestId(`item-name-${file.id}`)).toHaveTextContent(file.name) + }) + }) + + it('should handle empty fileList', () => { + // Arrange + const props = createDefaultProps({ fileList: [] }) + + // Act + render() + + // Assert + expect(screen.getByTestId('empty-folder')).toBeInTheDocument() + }) + + it('should handle single file in fileList', () => { + // Arrange + const fileList = [createMockOnlineDriveFile()] + const props = createDefaultProps({ fileList }) + + // Act + render() + + // Assert + expect(screen.getByTestId('item-file-1')).toBeInTheDocument() + }) + + it('should handle large fileList', () => { + // Arrange + const fileList = createMockFileList(100) + const props = createDefaultProps({ fileList }) + + // Act + render() + + // Assert + expect(screen.getByTestId('item-file-1')).toBeInTheDocument() + expect(screen.getByTestId('item-file-100')).toBeInTheDocument() + }) + }) + + describe('selectedFileIds prop', () => { + it('should mark selected files as selected', () => { + // Arrange + const fileList = createMockFileList(3) + const props = createDefaultProps({ + fileList, + selectedFileIds: ['file-1', 'file-3'], + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-selected', 'true') + expect(screen.getByTestId('item-file-2')).toHaveAttribute('data-selected', 'false') + expect(screen.getByTestId('item-file-3')).toHaveAttribute('data-selected', 'true') + }) + + it('should handle empty selectedFileIds', () => { + // Arrange + const fileList = createMockFileList(3) + const props = createDefaultProps({ + fileList, + selectedFileIds: [], + }) + + // Act + render() + + // Assert + fileList.forEach((file) => { + expect(screen.getByTestId(`item-${file.id}`)).toHaveAttribute('data-selected', 'false') + }) + }) + + it('should handle all files selected', () => { + // Arrange + const fileList = createMockFileList(3) + const props = createDefaultProps({ + fileList, + selectedFileIds: ['file-1', 'file-2', 'file-3'], + }) + + // Act + render() + + // Assert + fileList.forEach((file) => { + expect(screen.getByTestId(`item-${file.id}`)).toHaveAttribute('data-selected', 'true') + }) + }) + }) + + describe('keywords prop', () => { + it('should show EmptySearchResult when keywords exist but no results', () => { + // Arrange + const props = createDefaultProps({ + fileList: [], + keywords: 'search-term', + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('empty-search-result')).toBeInTheDocument() + }) + + it('should show EmptyFolder when keywords is empty and no files', () => { + // Arrange + const props = createDefaultProps({ + fileList: [], + keywords: '', + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('empty-folder')).toBeInTheDocument() + }) + }) + + describe('isLoading prop', () => { + it.each([ + { isLoading: true, fileList: [], keywords: '', expected: 'isAllLoading' }, + { isLoading: true, fileList: createMockFileList(2), keywords: '', expected: 'isPartialLoading' }, + { isLoading: false, fileList: [], keywords: '', expected: 'isEmpty' }, + { isLoading: false, fileList: createMockFileList(2), keywords: '', expected: 'hasFiles' }, + ])('should render correctly when isLoading=$isLoading with fileList.length=$fileList.length', ({ isLoading, fileList, expected }) => { + // Arrange + const props = createDefaultProps({ isLoading, fileList }) + + // Act + const { container } = render() + + // Assert + switch (expected) { + case 'isAllLoading': + expect(screen.getByTestId('loading')).toBeInTheDocument() + break + case 'isPartialLoading': + expect(container.querySelector('.animation-spin')).toBeInTheDocument() + break + case 'isEmpty': + expect(screen.getByTestId('empty-folder')).toBeInTheDocument() + break + case 'hasFiles': + expect(screen.getByTestId('item-file-1')).toBeInTheDocument() + break + } + }) + }) + + describe('supportBatchUpload prop', () => { + it('should pass supportBatchUpload true to Item components', () => { + // Arrange + const fileList = createMockFileList(2) + const props = createDefaultProps({ + fileList, + supportBatchUpload: true, + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-multiple-choice', 'true') + }) + + it('should pass supportBatchUpload false to Item components', () => { + // Arrange + const fileList = createMockFileList(2) + const props = createDefaultProps({ + fileList, + supportBatchUpload: false, + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-multiple-choice', 'false') + }) + }) + }) + + // ========================================== + // User Interactions and Event Handlers + // ========================================== + describe('User Interactions', () => { + describe('File Selection', () => { + it('should call handleSelectFile when selecting a file', () => { + // Arrange + const handleSelectFile = jest.fn() + const fileList = createMockFileList(2) + const props = createDefaultProps({ + fileList, + handleSelectFile, + }) + render() + + // Act + fireEvent.click(screen.getByTestId('item-select-file-1')) + + // Assert + expect(handleSelectFile).toHaveBeenCalledWith(fileList[0]) + }) + + it('should call handleSelectFile with correct file data', () => { + // Arrange + const handleSelectFile = jest.fn() + const fileList = [ + createMockOnlineDriveFile({ id: 'unique-id', name: 'special-file.pdf', size: 5000 }), + ] + const props = createDefaultProps({ + fileList, + handleSelectFile, + }) + render() + + // Act + fireEvent.click(screen.getByTestId('item-select-unique-id')) + + // Assert + expect(handleSelectFile).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'unique-id', + name: 'special-file.pdf', + size: 5000, + }), + ) + }) + }) + + describe('Folder Navigation', () => { + it('should call handleOpenFolder when opening a folder', () => { + // Arrange + const handleOpenFolder = jest.fn() + const fileList = [ + createMockOnlineDriveFile({ id: 'folder-1', name: 'Documents', type: OnlineDriveFileType.folder }), + ] + const props = createDefaultProps({ + fileList, + handleOpenFolder, + }) + render() + + // Act + fireEvent.click(screen.getByTestId('item-open-folder-1')) + + // Assert + expect(handleOpenFolder).toHaveBeenCalledWith(fileList[0]) + }) + }) + + describe('Reset Keywords', () => { + it('should call handleResetKeywords when reset button is clicked', () => { + // Arrange + const handleResetKeywords = jest.fn() + const props = createDefaultProps({ + fileList: [], + keywords: 'search-term', + handleResetKeywords, + }) + render() + + // Act + fireEvent.click(screen.getByTestId('reset-keywords-btn')) + + // Assert + expect(handleResetKeywords).toHaveBeenCalledTimes(1) + }) + }) + }) + + // ========================================== + // Side Effects and Cleanup Tests (IntersectionObserver) + // ========================================== + describe('Side Effects and Cleanup', () => { + describe('IntersectionObserver Setup', () => { + it('should create IntersectionObserver on mount', () => { + // Arrange + const fileList = createMockFileList(2) + const props = createDefaultProps({ fileList }) + + // Act + render() + + // Assert + expect(mockIntersectionObserverInstance?.observe).toHaveBeenCalled() + }) + + it('should create IntersectionObserver with correct rootMargin', () => { + // Arrange + const fileList = createMockFileList(2) + const props = createDefaultProps({ fileList }) + + // Act + render() + + // Assert - Callback should be set + expect(mockIntersectionObserverCallback).toBeDefined() + }) + + it('should observe the anchor element', () => { + // Arrange + const fileList = createMockFileList(2) + const props = createDefaultProps({ fileList }) + + // Act + const { container } = render() + + // Assert + expect(mockIntersectionObserverInstance?.observe).toHaveBeenCalled() + const anchorDiv = container.querySelector('.h-0') + expect(anchorDiv).toBeInTheDocument() + }) + }) + + describe('IntersectionObserver Callback', () => { + it('should call setNextPageParameters when intersecting and truncated', async () => { + // Arrange + mockIsTruncated.current = true + mockCurrentNextPageParametersRef.current = { cursor: 'next-cursor' } + const fileList = createMockFileList(2) + const props = createDefaultProps({ + fileList, + isLoading: false, + }) + render() + + // Act + triggerIntersection(true) + + // Assert + await waitFor(() => { + expect(mockSetNextPageParameters).toHaveBeenCalledWith({ cursor: 'next-cursor' }) + }) + }) + + it('should not call setNextPageParameters when not intersecting', () => { + // Arrange + mockIsTruncated.current = true + mockCurrentNextPageParametersRef.current = { cursor: 'next-cursor' } + const fileList = createMockFileList(2) + const props = createDefaultProps({ + fileList, + isLoading: false, + }) + render() + + // Act + triggerIntersection(false) + + // Assert + expect(mockSetNextPageParameters).not.toHaveBeenCalled() + }) + + it('should not call setNextPageParameters when not truncated', () => { + // Arrange + mockIsTruncated.current = false + const fileList = createMockFileList(2) + const props = createDefaultProps({ + fileList, + isLoading: false, + }) + render() + + // Act + triggerIntersection(true) + + // Assert + expect(mockSetNextPageParameters).not.toHaveBeenCalled() + }) + + it('should not call setNextPageParameters when loading', () => { + // Arrange + mockIsTruncated.current = true + mockCurrentNextPageParametersRef.current = { cursor: 'next-cursor' } + const fileList = createMockFileList(2) + const props = createDefaultProps({ + fileList, + isLoading: true, + }) + render() + + // Act + triggerIntersection(true) + + // Assert + expect(mockSetNextPageParameters).not.toHaveBeenCalled() + }) + }) + + describe('IntersectionObserver Cleanup', () => { + it('should disconnect IntersectionObserver on unmount', () => { + // Arrange + const fileList = createMockFileList(2) + const props = createDefaultProps({ fileList }) + const { unmount } = render() + + // Act + unmount() + + // Assert + expect(mockIntersectionObserverInstance?.disconnect).toHaveBeenCalled() + }) + + it('should cleanup previous observer when dependencies change', () => { + // Arrange + const fileList = createMockFileList(2) + const props = createDefaultProps({ + fileList, + isLoading: false, + }) + const { rerender } = render() + + // Act - Trigger re-render with changed isLoading + rerender() + + // Assert - Previous observer should be disconnected + expect(mockIntersectionObserverInstance?.disconnect).toHaveBeenCalled() + }) + }) + }) + + // ========================================== + // Component Memoization Tests + // ========================================== + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + // Arrange & Assert + // List component should have $$typeof symbol indicating memo wrapper + expect(List).toHaveProperty('$$typeof', Symbol.for('react.memo')) + }) + + it('should not re-render when props are equal', () => { + // Arrange + const fileList = createMockFileList(2) + const props = createDefaultProps({ fileList }) + const renderSpy = jest.fn() + + // Create a wrapper component to track renders + const TestWrapper = ({ testProps }: { testProps: ListProps }) => { + renderSpy() + return + } + + const { rerender } = render() + const initialRenderCount = renderSpy.mock.calls.length + + // Act - Rerender with same props + rerender() + + // Assert - Should have rendered again (wrapper re-renders, but memo prevents List re-render) + expect(renderSpy.mock.calls.length).toBe(initialRenderCount + 1) + }) + + it('should re-render when fileList changes', () => { + // Arrange + const fileList1 = createMockFileList(2) + const fileList2 = createMockFileList(3) + const props1 = createDefaultProps({ fileList: fileList1 }) + const props2 = createDefaultProps({ fileList: fileList2 }) + + const { rerender } = render() + + // Assert initial state + expect(screen.getByTestId('item-file-1')).toBeInTheDocument() + expect(screen.getByTestId('item-file-2')).toBeInTheDocument() + expect(screen.queryByTestId('item-file-3')).not.toBeInTheDocument() + + // Act - Rerender with new fileList + rerender() + + // Assert - Should show new file + expect(screen.getByTestId('item-file-3')).toBeInTheDocument() + }) + + it('should re-render when selectedFileIds changes', () => { + // Arrange + const fileList = createMockFileList(2) + const props1 = createDefaultProps({ fileList, selectedFileIds: [] }) + const props2 = createDefaultProps({ fileList, selectedFileIds: ['file-1'] }) + + const { rerender } = render() + + // Assert initial state + expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-selected', 'false') + + // Act + rerender() + + // Assert + expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-selected', 'true') + }) + + it('should re-render when isLoading changes', () => { + // Arrange + const fileList = createMockFileList(2) + const props1 = createDefaultProps({ fileList, isLoading: false }) + const props2 = createDefaultProps({ fileList, isLoading: true }) + + const { rerender, container } = render() + + // Assert initial state - no loading spinner + expect(container.querySelector('.animation-spin')).not.toBeInTheDocument() + + // Act + rerender() + + // Assert - loading spinner should appear + expect(container.querySelector('.animation-spin')).toBeInTheDocument() + }) + }) + + // ========================================== + // Edge Cases and Error Handling + // ========================================== + describe('Edge Cases and Error Handling', () => { + describe('Empty/Null Values', () => { + it('should handle empty fileList array', () => { + // Arrange + const props = createDefaultProps({ fileList: [] }) + + // Act + render() + + // Assert + expect(screen.getByTestId('empty-folder')).toBeInTheDocument() + }) + + it('should handle empty selectedFileIds array', () => { + // Arrange + const fileList = createMockFileList(2) + const props = createDefaultProps({ + fileList, + selectedFileIds: [], + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('item-file-1')).toHaveAttribute('data-selected', 'false') + }) + + it('should handle empty keywords string', () => { + // Arrange + const props = createDefaultProps({ + fileList: [], + keywords: '', + }) + + // Act + render() + + // Assert - Shows empty folder, not empty search result + expect(screen.getByTestId('empty-folder')).toBeInTheDocument() + expect(screen.queryByTestId('empty-search-result')).not.toBeInTheDocument() + }) + }) + + describe('Boundary Conditions', () => { + it('should handle very long file names', () => { + // Arrange + const longName = `${'a'.repeat(500)}.txt` + const fileList = [createMockOnlineDriveFile({ name: longName })] + const props = createDefaultProps({ fileList }) + + // Act + render() + + // Assert + expect(screen.getByTestId('item-name-file-1')).toHaveTextContent(longName) + }) + + it('should handle special characters in file names', () => { + // Arrange + const specialName = 'test.txt' + const fileList = [createMockOnlineDriveFile({ name: specialName })] + const props = createDefaultProps({ fileList }) + + // Act + render() + + // Assert + expect(screen.getByTestId('item-name-file-1')).toHaveTextContent(specialName) + }) + + it('should handle unicode characters in file names', () => { + // Arrange + const unicodeName = '文件_📁_ファイル.txt' + const fileList = [createMockOnlineDriveFile({ name: unicodeName })] + const props = createDefaultProps({ fileList }) + + // Act + render() + + // Assert + expect(screen.getByTestId('item-name-file-1')).toHaveTextContent(unicodeName) + }) + + it('should handle file with zero size', () => { + // Arrange + const fileList = [createMockOnlineDriveFile({ size: 0 })] + const props = createDefaultProps({ fileList }) + + // Act + render() + + // Assert + expect(screen.getByTestId('item-file-1')).toBeInTheDocument() + }) + + it('should handle file with undefined size', () => { + // Arrange + const fileList = [createMockOnlineDriveFile({ size: undefined })] + const props = createDefaultProps({ fileList }) + + // Act + render() + + // Assert + expect(screen.getByTestId('item-file-1')).toBeInTheDocument() + }) + }) + + describe('Different File Types', () => { + it.each([ + { type: OnlineDriveFileType.file, name: 'document.pdf' }, + { type: OnlineDriveFileType.folder, name: 'Documents' }, + { type: OnlineDriveFileType.bucket, name: 'my-bucket' }, + ])('should render $type type correctly', ({ type, name }) => { + // Arrange + const fileList = [createMockOnlineDriveFile({ id: `item-${type}`, type, name })] + const props = createDefaultProps({ fileList }) + + // Act + render() + + // Assert + expect(screen.getByTestId(`item-item-${type}`)).toBeInTheDocument() + expect(screen.getByTestId(`item-name-item-${type}`)).toHaveTextContent(name) + }) + + it('should handle mixed file types in list', () => { + // Arrange + const fileList = [ + createMockOnlineDriveFile({ id: 'file-1', type: OnlineDriveFileType.file, name: 'doc.pdf' }), + createMockOnlineDriveFile({ id: 'folder-1', type: OnlineDriveFileType.folder, name: 'Documents' }), + createMockOnlineDriveFile({ id: 'bucket-1', type: OnlineDriveFileType.bucket, name: 'my-bucket' }), + ] + const props = createDefaultProps({ fileList }) + + // Act + render() + + // Assert + expect(screen.getByTestId('item-file-1')).toBeInTheDocument() + expect(screen.getByTestId('item-folder-1')).toBeInTheDocument() + expect(screen.getByTestId('item-bucket-1')).toBeInTheDocument() + }) + }) + + describe('Loading States Transitions', () => { + it('should transition from loading to empty folder', () => { + // Arrange + const props1 = createDefaultProps({ isLoading: true, fileList: [] }) + const props2 = createDefaultProps({ isLoading: false, fileList: [] }) + + const { rerender } = render() + + // Assert initial loading state + expect(screen.getByTestId('loading')).toBeInTheDocument() + + // Act + rerender() + + // Assert + expect(screen.queryByTestId('loading')).not.toBeInTheDocument() + expect(screen.getByTestId('empty-folder')).toBeInTheDocument() + }) + + it('should transition from loading to file list', () => { + // Arrange + const fileList = createMockFileList(2) + const props1 = createDefaultProps({ isLoading: true, fileList: [] }) + const props2 = createDefaultProps({ isLoading: false, fileList }) + + const { rerender } = render() + + // Assert initial loading state + expect(screen.getByTestId('loading')).toBeInTheDocument() + + // Act + rerender() + + // Assert + expect(screen.queryByTestId('loading')).not.toBeInTheDocument() + expect(screen.getByTestId('item-file-1')).toBeInTheDocument() + }) + + it('should transition from partial loading to loaded', () => { + // Arrange + const fileList = createMockFileList(2) + const props1 = createDefaultProps({ isLoading: true, fileList }) + const props2 = createDefaultProps({ isLoading: false, fileList }) + + const { rerender, container } = render() + + // Assert initial partial loading state + expect(container.querySelector('.animation-spin')).toBeInTheDocument() + + // Act + rerender() + + // Assert + expect(container.querySelector('.animation-spin')).not.toBeInTheDocument() + }) + }) + + describe('Store State Edge Cases', () => { + it('should handle store state with empty next page parameters', () => { + // Arrange + mockIsTruncated.current = true + mockCurrentNextPageParametersRef.current = {} + const fileList = createMockFileList(2) + const props = createDefaultProps({ + fileList, + isLoading: false, + }) + render() + + // Act + triggerIntersection(true) + + // Assert + expect(mockSetNextPageParameters).toHaveBeenCalledWith({}) + }) + + it('should handle store state with complex next page parameters', () => { + // Arrange + const complexParams = { + cursor: 'abc123', + page: 2, + metadata: { nested: { value: true } }, + } + mockIsTruncated.current = true + mockCurrentNextPageParametersRef.current = complexParams + const fileList = createMockFileList(2) + const props = createDefaultProps({ + fileList, + isLoading: false, + }) + render() + + // Act + triggerIntersection(true) + + // Assert + expect(mockSetNextPageParameters).toHaveBeenCalledWith(complexParams) + }) + }) + }) + + // ========================================== + // All Prop Variations Tests + // ========================================== + describe('Prop Variations', () => { + it.each([ + { supportBatchUpload: true }, + { supportBatchUpload: false }, + ])('should render correctly with supportBatchUpload=$supportBatchUpload', ({ supportBatchUpload }) => { + // Arrange + const fileList = createMockFileList(2) + const props = createDefaultProps({ fileList, supportBatchUpload }) + + // Act + render() + + // Assert + expect(screen.getByTestId('item-file-1')).toHaveAttribute( + 'data-multiple-choice', + String(supportBatchUpload), + ) + }) + + it.each([ + { isLoading: true, fileCount: 0, keywords: '', expectedState: 'all-loading' }, + { isLoading: true, fileCount: 5, keywords: '', expectedState: 'partial-loading' }, + { isLoading: false, fileCount: 0, keywords: '', expectedState: 'empty-folder' }, + { isLoading: false, fileCount: 0, keywords: 'search', expectedState: 'empty-search' }, + { isLoading: false, fileCount: 5, keywords: '', expectedState: 'file-list' }, + ])('should render $expectedState when isLoading=$isLoading, fileCount=$fileCount, keywords=$keywords', + ({ isLoading, fileCount, keywords, expectedState }) => { + // Arrange + const fileList = createMockFileList(fileCount) + const props = createDefaultProps({ fileList, isLoading, keywords }) + + // Act + const { container } = render() + + // Assert + switch (expectedState) { + case 'all-loading': + expect(screen.getByTestId('loading')).toBeInTheDocument() + break + case 'partial-loading': + expect(container.querySelector('.animation-spin')).toBeInTheDocument() + break + case 'empty-folder': + expect(screen.getByTestId('empty-folder')).toBeInTheDocument() + break + case 'empty-search': + expect(screen.getByTestId('empty-search-result')).toBeInTheDocument() + break + case 'file-list': + expect(screen.getByTestId('item-file-1')).toBeInTheDocument() + break + } + }) + + it.each([ + { selectedCount: 0, expectedSelected: [] }, + { selectedCount: 1, expectedSelected: ['file-1'] }, + { selectedCount: 3, expectedSelected: ['file-1', 'file-2', 'file-3'] }, + ])('should handle $selectedCount selected files', ({ expectedSelected }) => { + // Arrange + const fileList = createMockFileList(3) + const props = createDefaultProps({ + fileList, + selectedFileIds: expectedSelected, + }) + + // Act + render() + + // Assert + fileList.forEach((file) => { + const isSelected = expectedSelected.includes(file.id) + expect(screen.getByTestId(`item-${file.id}`)).toHaveAttribute('data-selected', String(isSelected)) + }) + }) + }) + + // ========================================== + // Accessibility Tests + // ========================================== + describe('Accessibility', () => { + it('should have proper container structure', () => { + // Arrange + const fileList = createMockFileList(2) + const props = createDefaultProps({ fileList }) + + // Act + const { container } = render() + + // Assert - Container should be scrollable + const scrollContainer = container.querySelector('.overflow-y-auto') + expect(scrollContainer).toBeInTheDocument() + }) + + it('should allow interaction with reset keywords button in empty search state', () => { + // Arrange + const handleResetKeywords = jest.fn() + const props = createDefaultProps({ + fileList: [], + keywords: 'search-term', + handleResetKeywords, + }) + + // Act + render() + const resetButton = screen.getByTestId('reset-keywords-btn') + + // Assert + expect(resetButton).toBeInTheDocument() + fireEvent.click(resetButton) + expect(handleResetKeywords).toHaveBeenCalled() + }) + }) +}) + +// ========================================== +// EmptyFolder Component Tests (using actual component) +// ========================================== +describe('EmptyFolder', () => { + // Get real component for testing + const ActualEmptyFolder = jest.requireActual('./empty-folder').default + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(document.body).toBeInTheDocument() + }) + + it('should render empty folder message', () => { + render() + expect(screen.getByText(/datasetPipeline\.onlineDrive\.emptyFolder/)).toBeInTheDocument() + }) + + it('should render with correct container classes', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('flex', 'size-full', 'items-center', 'justify-center') + }) + + it('should render text with correct styling classes', () => { + render() + const textElement = screen.getByText(/datasetPipeline\.onlineDrive\.emptyFolder/) + expect(textElement).toHaveClass('system-xs-regular', 'text-text-tertiary') + }) + }) + + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + expect(ActualEmptyFolder).toHaveProperty('$$typeof', Symbol.for('react.memo')) + }) + }) + + describe('Accessibility', () => { + it('should have readable text content', () => { + render() + const textElement = screen.getByText(/datasetPipeline\.onlineDrive\.emptyFolder/) + expect(textElement.tagName).toBe('SPAN') + }) + }) +}) + +// ========================================== +// EmptySearchResult Component Tests (using actual component) +// ========================================== +describe('EmptySearchResult', () => { + // Get real component for testing + const ActualEmptySearchResult = jest.requireActual('./empty-search-result').default + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + const onResetKeywords = jest.fn() + render() + expect(document.body).toBeInTheDocument() + }) + + it('should render empty search result message', () => { + const onResetKeywords = jest.fn() + render() + expect(screen.getByText(/datasetPipeline\.onlineDrive\.emptySearchResult/)).toBeInTheDocument() + }) + + it('should render reset keywords button', () => { + const onResetKeywords = jest.fn() + render() + expect(screen.getByRole('button')).toBeInTheDocument() + expect(screen.getByText(/datasetPipeline\.onlineDrive\.resetKeywords/)).toBeInTheDocument() + }) + + it('should render search icon', () => { + const onResetKeywords = jest.fn() + const { container } = render() + const svgElement = container.querySelector('svg') + expect(svgElement).toBeInTheDocument() + }) + + it('should render with correct container classes', () => { + const onResetKeywords = jest.fn() + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('flex', 'size-full', 'flex-col', 'items-center', 'justify-center', 'gap-y-2') + }) + }) + + describe('Props', () => { + describe('onResetKeywords prop', () => { + it('should call onResetKeywords when button is clicked', () => { + const onResetKeywords = jest.fn() + render() + fireEvent.click(screen.getByRole('button')) + expect(onResetKeywords).toHaveBeenCalledTimes(1) + }) + + it('should call onResetKeywords on each click', () => { + const onResetKeywords = jest.fn() + render() + const button = screen.getByRole('button') + fireEvent.click(button) + fireEvent.click(button) + fireEvent.click(button) + expect(onResetKeywords).toHaveBeenCalledTimes(3) + }) + }) + }) + + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + expect(ActualEmptySearchResult).toHaveProperty('$$typeof', Symbol.for('react.memo')) + }) + }) + + describe('Accessibility', () => { + it('should have accessible button', () => { + const onResetKeywords = jest.fn() + render() + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should have readable text content', () => { + const onResetKeywords = jest.fn() + render() + expect(screen.getByText(/datasetPipeline\.onlineDrive\.emptySearchResult/)).toBeInTheDocument() + }) + }) +}) + +// ========================================== +// FileIcon Component Tests (using actual component) +// ========================================== +describe('FileIcon', () => { + // Get real component for testing + const ActualFileIcon = jest.requireActual('./file-icon').default + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render( + , + ) + expect(container).toBeInTheDocument() + }) + + it('should render bucket icon for bucket type', () => { + const { container } = render( + , + ) + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + }) + + it('should render folder icon for folder type', () => { + const { container } = render( + , + ) + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + }) + + it('should render file type icon for file type', () => { + const { container } = render( + , + ) + expect(container.firstChild).toBeInTheDocument() + }) + }) + + describe('Props', () => { + describe('type prop', () => { + it.each([ + { type: OnlineDriveFileType.bucket, fileName: 'bucket-name' }, + { type: OnlineDriveFileType.folder, fileName: 'folder-name' }, + { type: OnlineDriveFileType.file, fileName: 'file.txt' }, + ])('should render correctly for type=$type', ({ type, fileName }) => { + const { container } = render( + , + ) + expect(container.firstChild).toBeInTheDocument() + }) + }) + + describe('fileName prop', () => { + it.each([ + { fileName: 'document.pdf' }, + { fileName: 'image.png' }, + { fileName: 'video.mp4' }, + { fileName: 'audio.mp3' }, + { fileName: 'code.json' }, + { fileName: 'readme.md' }, + { fileName: 'data.xlsx' }, + { fileName: 'doc.docx' }, + { fileName: 'slides.pptx' }, + { fileName: 'unknown.xyz' }, + ])('should render icon for $fileName', ({ fileName }) => { + const { container } = render( + , + ) + expect(container.firstChild).toBeInTheDocument() + }) + }) + + describe('size prop', () => { + it.each(['sm', 'md', 'lg', 'xl'] as const)('should accept size=%s', (size) => { + const { container } = render( + , + ) + expect(container.firstChild).toBeInTheDocument() + }) + + it('should default to md size', () => { + const { container } = render( + , + ) + expect(container.firstChild).toBeInTheDocument() + }) + }) + + describe('className prop', () => { + it('should apply custom className to bucket icon', () => { + const { container } = render( + , + ) + const svg = container.querySelector('svg') + expect(svg).toHaveClass('custom-class') + }) + + it('should apply className to folder icon', () => { + const { container } = render( + , + ) + const svg = container.querySelector('svg') + expect(svg).toHaveClass('folder-custom') + }) + }) + }) + + describe('Icon Type Determination', () => { + it('should render bucket icon regardless of fileName', () => { + const { container } = render( + , + ) + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + }) + + it('should render folder icon regardless of fileName', () => { + const { container } = render( + , + ) + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + }) + + it('should determine file type based on fileName extension', () => { + const { container } = render( + , + ) + expect(container.firstChild).toBeInTheDocument() + }) + }) + + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + expect(ActualFileIcon).toHaveProperty('$$typeof', Symbol.for('react.memo')) + }) + }) + + describe('Edge Cases', () => { + it('should handle empty fileName', () => { + const { container } = render( + , + ) + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle fileName without extension', () => { + const { container } = render( + , + ) + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle special characters in fileName', () => { + const { container } = render( + , + ) + expect(container.firstChild).toBeInTheDocument() + }) + + it('should handle very long fileName', () => { + const longFileName = `${'a'.repeat(500)}.pdf` + const { container } = render( + , + ) + expect(container.firstChild).toBeInTheDocument() + }) + }) + + describe('Styling', () => { + it('should apply default size class to bucket icon', () => { + const { container } = render( + , + ) + const svg = container.querySelector('svg') + expect(svg).toHaveClass('size-[18px]') + }) + + it('should apply default size class to folder icon', () => { + const { container } = render( + , + ) + const svg = container.querySelector('svg') + expect(svg).toHaveClass('size-[18px]') + }) + }) +}) + +// ========================================== +// Item Component Tests (using actual component) +// ========================================== +describe('Item', () => { + // Get real component for testing + const ActualItem = jest.requireActual('./item').default + + type ItemProps = { + file: OnlineDriveFile + isSelected: boolean + disabled?: boolean + isMultipleChoice?: boolean + onSelect: (file: OnlineDriveFile) => void + onOpen: (file: OnlineDriveFile) => void + } + + // Reuse createMockOnlineDriveFile from outer scope + const createItemProps = (overrides?: Partial): ItemProps => ({ + file: createMockOnlineDriveFile(), + isSelected: false, + onSelect: jest.fn(), + onOpen: jest.fn(), + ...overrides, + }) + + // Helper to find custom checkbox element (div-based implementation) + const findCheckbox = (container: HTMLElement) => container.querySelector('[data-testid^="checkbox-"]') + // Helper to find custom radio element (div-based implementation) + const findRadio = (container: HTMLElement) => container.querySelector('.rounded-full.size-4') + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + const props = createItemProps() + render() + expect(screen.getByText('test-file.txt')).toBeInTheDocument() + }) + + it('should render file name', () => { + const props = createItemProps({ + file: createMockOnlineDriveFile({ name: 'document.pdf' }), + }) + render() + expect(screen.getByText('document.pdf')).toBeInTheDocument() + }) + + it('should render file size for file type', () => { + const props = createItemProps({ + file: createMockOnlineDriveFile({ size: 1024, type: OnlineDriveFileType.file }), + }) + render() + expect(screen.getByText('1.00 KB')).toBeInTheDocument() + }) + + it('should not render file size for folder type', () => { + const props = createItemProps({ + file: createMockOnlineDriveFile({ size: 1024, type: OnlineDriveFileType.folder, name: 'Documents' }), + }) + render() + expect(screen.queryByText('1 KB')).not.toBeInTheDocument() + }) + + it('should render checkbox in multiple choice mode for file', () => { + const props = createItemProps({ + isMultipleChoice: true, + file: createMockOnlineDriveFile({ type: OnlineDriveFileType.file }), + }) + const { container } = render() + expect(findCheckbox(container)).toBeInTheDocument() + }) + + it('should render radio in single choice mode for file', () => { + const props = createItemProps({ + isMultipleChoice: false, + file: createMockOnlineDriveFile({ type: OnlineDriveFileType.file }), + }) + const { container } = render() + expect(findRadio(container)).toBeInTheDocument() + }) + + it('should not render checkbox or radio for bucket type', () => { + const props = createItemProps({ + file: createMockOnlineDriveFile({ type: OnlineDriveFileType.bucket, name: 'my-bucket' }), + isMultipleChoice: true, + }) + const { container } = render() + expect(findCheckbox(container)).not.toBeInTheDocument() + expect(findRadio(container)).not.toBeInTheDocument() + }) + + it('should render with title attribute for file name', () => { + const props = createItemProps({ + file: createMockOnlineDriveFile({ name: 'very-long-file-name.txt' }), + }) + render() + expect(screen.getByTitle('very-long-file-name.txt')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + describe('isSelected prop', () => { + it('should show checkbox as checked when isSelected is true', () => { + const props = createItemProps({ isSelected: true, isMultipleChoice: true }) + const { container } = render() + const checkbox = findCheckbox(container) + // Checked checkbox shows check icon + expect(checkbox?.querySelector('[data-testid^="check-icon-"]')).toBeInTheDocument() + }) + + it('should show checkbox as unchecked when isSelected is false', () => { + const props = createItemProps({ isSelected: false, isMultipleChoice: true }) + const { container } = render() + const checkbox = findCheckbox(container) + // Unchecked checkbox has no check icon + expect(checkbox?.querySelector('[data-testid^="check-icon-"]')).not.toBeInTheDocument() + }) + + it('should show radio as checked when isSelected is true', () => { + const props = createItemProps({ isSelected: true, isMultipleChoice: false }) + const { container } = render() + const radio = findRadio(container) + // Checked radio has border-[5px] class + expect(radio).toHaveClass('border-[5px]') + }) + }) + + describe('disabled prop', () => { + it('should apply opacity class when disabled', () => { + const props = createItemProps({ disabled: true }) + const { container } = render() + expect(container.querySelector('.opacity-30')).toBeInTheDocument() + }) + + it('should apply disabled styles to checkbox when disabled', () => { + const props = createItemProps({ disabled: true, isMultipleChoice: true }) + const { container } = render() + const checkbox = findCheckbox(container) + expect(checkbox).toHaveClass('cursor-not-allowed') + }) + + it('should apply disabled styles to radio when disabled', () => { + const props = createItemProps({ disabled: true, isMultipleChoice: false }) + const { container } = render() + const radio = findRadio(container) + expect(radio).toHaveClass('border-components-radio-border-disabled') + }) + }) + + describe('isMultipleChoice prop', () => { + it('should default to true', () => { + const props = createItemProps() + delete (props as Partial).isMultipleChoice + const { container } = render() + expect(findCheckbox(container)).toBeInTheDocument() + }) + + it('should render checkbox when true', () => { + const props = createItemProps({ isMultipleChoice: true }) + const { container } = render() + expect(findCheckbox(container)).toBeInTheDocument() + expect(findRadio(container)).not.toBeInTheDocument() + }) + + it('should render radio when false', () => { + const props = createItemProps({ isMultipleChoice: false }) + const { container } = render() + expect(findRadio(container)).toBeInTheDocument() + expect(findCheckbox(container)).not.toBeInTheDocument() + }) + }) + }) + + describe('User Interactions', () => { + describe('Click on Item', () => { + it('should call onSelect when clicking on file item', () => { + const onSelect = jest.fn() + const file = createMockOnlineDriveFile({ type: OnlineDriveFileType.file }) + const props = createItemProps({ file, onSelect }) + render() + fireEvent.click(screen.getByText('test-file.txt')) + expect(onSelect).toHaveBeenCalledWith(file) + }) + + it('should call onOpen when clicking on folder item', () => { + const onOpen = jest.fn() + const file = createMockOnlineDriveFile({ type: OnlineDriveFileType.folder, name: 'Documents' }) + const props = createItemProps({ file, onOpen }) + render() + fireEvent.click(screen.getByText('Documents')) + expect(onOpen).toHaveBeenCalledWith(file) + }) + + it('should call onOpen when clicking on bucket item', () => { + const onOpen = jest.fn() + const file = createMockOnlineDriveFile({ type: OnlineDriveFileType.bucket, name: 'my-bucket' }) + const props = createItemProps({ file, onOpen }) + render() + fireEvent.click(screen.getByText('my-bucket')) + expect(onOpen).toHaveBeenCalledWith(file) + }) + + it('should not call any handler when clicking disabled item', () => { + const onSelect = jest.fn() + const onOpen = jest.fn() + const props = createItemProps({ disabled: true, onSelect, onOpen }) + render() + fireEvent.click(screen.getByText('test-file.txt')) + expect(onSelect).not.toHaveBeenCalled() + expect(onOpen).not.toHaveBeenCalled() + }) + }) + + describe('Click on Checkbox/Radio', () => { + it('should call onSelect when clicking checkbox', () => { + const onSelect = jest.fn() + const file = createMockOnlineDriveFile() + const props = createItemProps({ file, onSelect, isMultipleChoice: true }) + const { container } = render() + const checkbox = findCheckbox(container) + fireEvent.click(checkbox!) + expect(onSelect).toHaveBeenCalledWith(file) + }) + + it('should call onSelect when clicking radio', () => { + const onSelect = jest.fn() + const file = createMockOnlineDriveFile() + const props = createItemProps({ file, onSelect, isMultipleChoice: false }) + const { container } = render() + const radio = findRadio(container) + fireEvent.click(radio!) + expect(onSelect).toHaveBeenCalledWith(file) + }) + + it('should stop event propagation when clicking checkbox', () => { + const onSelect = jest.fn() + const file = createMockOnlineDriveFile() + const props = createItemProps({ file, onSelect, isMultipleChoice: true }) + const { container } = render() + const checkbox = findCheckbox(container) + fireEvent.click(checkbox!) + expect(onSelect).toHaveBeenCalledTimes(1) + }) + }) + }) + + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + expect(ActualItem).toHaveProperty('$$typeof', Symbol.for('react.memo')) + }) + }) + + describe('Edge Cases', () => { + it('should handle empty file name', () => { + const props = createItemProps({ file: createMockOnlineDriveFile({ name: '' }) }) + render() + expect(document.body).toBeInTheDocument() + }) + + it('should handle very long file name', () => { + const longName = `${'a'.repeat(500)}.txt` + const props = createItemProps({ file: createMockOnlineDriveFile({ name: longName }) }) + render() + expect(screen.getByText(longName)).toBeInTheDocument() + }) + + it('should handle special characters in file name', () => { + const specialName = '文件 (1).pdf' + const props = createItemProps({ file: createMockOnlineDriveFile({ name: specialName }) }) + render() + expect(screen.getByText(specialName)).toBeInTheDocument() + }) + + it('should handle zero file size', () => { + const props = createItemProps({ file: createMockOnlineDriveFile({ size: 0 }) }) + render() + // formatFileSize returns 0 for size 0 + expect(screen.getByText('0')).toBeInTheDocument() + }) + + it('should handle very large file size', () => { + const props = createItemProps({ file: createMockOnlineDriveFile({ size: 1024 * 1024 * 1024 * 5 }) }) + render() + expect(screen.getByText('5.00 GB')).toBeInTheDocument() + }) + }) + + describe('Styling', () => { + it('should have cursor-pointer class', () => { + const props = createItemProps() + const { container } = render() + expect(container.firstChild).toHaveClass('cursor-pointer') + }) + + it('should have hover class', () => { + const props = createItemProps() + const { container } = render() + expect(container.firstChild).toHaveClass('hover:bg-state-base-hover') + }) + + it('should truncate file name', () => { + const props = createItemProps() + render() + const nameElement = screen.getByText('test-file.txt') + expect(nameElement).toHaveClass('truncate') + }) + }) + + describe('Prop Variations', () => { + it.each([ + { isSelected: true, isMultipleChoice: true, disabled: false }, + { isSelected: true, isMultipleChoice: false, disabled: false }, + { isSelected: false, isMultipleChoice: true, disabled: false }, + { isSelected: false, isMultipleChoice: false, disabled: false }, + { isSelected: true, isMultipleChoice: true, disabled: true }, + { isSelected: false, isMultipleChoice: false, disabled: true }, + ])('should render with isSelected=$isSelected, isMultipleChoice=$isMultipleChoice, disabled=$disabled', + ({ isSelected, isMultipleChoice, disabled }) => { + const props = createItemProps({ isSelected, isMultipleChoice, disabled }) + const { container } = render() + if (isMultipleChoice) { + const checkbox = findCheckbox(container) + expect(checkbox).toBeInTheDocument() + if (isSelected) + expect(checkbox?.querySelector('[data-testid^="check-icon-"]')).toBeInTheDocument() + if (disabled) + expect(checkbox).toHaveClass('cursor-not-allowed') + } + else { + const radio = findRadio(container) + expect(radio).toBeInTheDocument() + if (isSelected) + expect(radio).toHaveClass('border-[5px]') + if (disabled) + expect(radio).toHaveClass('border-components-radio-border-disabled') + } + }) + }) +}) + +// ========================================== +// Utils Tests +// ========================================== +describe('utils', () => { + // Import actual utils functions + const { getFileExtension, getFileType } = jest.requireActual('./utils') + const { FileAppearanceTypeEnum } = jest.requireActual('@/app/components/base/file-uploader/types') + + describe('getFileExtension', () => { + describe('Basic Functionality', () => { + it('should return file extension for normal file names', () => { + expect(getFileExtension('document.pdf')).toBe('pdf') + expect(getFileExtension('image.PNG')).toBe('png') + expect(getFileExtension('data.JSON')).toBe('json') + }) + + it('should return lowercase extension', () => { + expect(getFileExtension('FILE.PDF')).toBe('pdf') + expect(getFileExtension('IMAGE.JPEG')).toBe('jpeg') + expect(getFileExtension('Doc.TXT')).toBe('txt') + }) + + it('should handle multiple dots in filename', () => { + expect(getFileExtension('file.backup.tar.gz')).toBe('gz') + expect(getFileExtension('my.document.v2.pdf')).toBe('pdf') + expect(getFileExtension('test.spec.ts')).toBe('ts') + }) + }) + + describe('Edge Cases', () => { + it('should return empty string for empty filename', () => { + expect(getFileExtension('')).toBe('') + }) + + it('should return empty string for filename without extension', () => { + expect(getFileExtension('README')).toBe('') + expect(getFileExtension('Makefile')).toBe('') + }) + + it('should return empty string for hidden files without extension', () => { + expect(getFileExtension('.gitignore')).toBe('') + expect(getFileExtension('.env')).toBe('') + }) + + it('should handle hidden files with extension', () => { + expect(getFileExtension('.eslintrc.json')).toBe('json') + expect(getFileExtension('.config.yaml')).toBe('yaml') + }) + + it('should handle files ending with dot', () => { + expect(getFileExtension('file.')).toBe('') + }) + + it('should handle special characters in filename', () => { + expect(getFileExtension('file-name_v1.0.pdf')).toBe('pdf') + expect(getFileExtension('data (1).xlsx')).toBe('xlsx') + }) + }) + + describe('Boundary Conditions', () => { + it('should handle very long file extensions', () => { + expect(getFileExtension('file.verylongextension')).toBe('verylongextension') + }) + + it('should handle single character extensions', () => { + expect(getFileExtension('file.a')).toBe('a') + expect(getFileExtension('data.c')).toBe('c') + }) + + it('should handle numeric extensions', () => { + expect(getFileExtension('file.001')).toBe('001') + expect(getFileExtension('backup.123')).toBe('123') + }) + }) + }) + + describe('getFileType', () => { + describe('Image Files', () => { + it('should return gif type for gif files', () => { + expect(getFileType('animation.gif')).toBe(FileAppearanceTypeEnum.gif) + expect(getFileType('image.GIF')).toBe(FileAppearanceTypeEnum.gif) + }) + + it('should return image type for common image formats', () => { + expect(getFileType('photo.jpg')).toBe(FileAppearanceTypeEnum.image) + expect(getFileType('photo.jpeg')).toBe(FileAppearanceTypeEnum.image) + expect(getFileType('photo.png')).toBe(FileAppearanceTypeEnum.image) + expect(getFileType('photo.webp')).toBe(FileAppearanceTypeEnum.image) + expect(getFileType('photo.svg')).toBe(FileAppearanceTypeEnum.image) + }) + }) + + describe('Video Files', () => { + it('should return video type for video formats', () => { + expect(getFileType('movie.mp4')).toBe(FileAppearanceTypeEnum.video) + expect(getFileType('clip.mov')).toBe(FileAppearanceTypeEnum.video) + expect(getFileType('video.webm')).toBe(FileAppearanceTypeEnum.video) + expect(getFileType('recording.mpeg')).toBe(FileAppearanceTypeEnum.video) + }) + }) + + describe('Audio Files', () => { + it('should return audio type for audio formats', () => { + expect(getFileType('song.mp3')).toBe(FileAppearanceTypeEnum.audio) + expect(getFileType('podcast.wav')).toBe(FileAppearanceTypeEnum.audio) + expect(getFileType('audio.m4a')).toBe(FileAppearanceTypeEnum.audio) + expect(getFileType('music.mpga')).toBe(FileAppearanceTypeEnum.audio) + }) + }) + + describe('Code Files', () => { + it('should return code type for code-related formats', () => { + expect(getFileType('page.html')).toBe(FileAppearanceTypeEnum.code) + expect(getFileType('page.htm')).toBe(FileAppearanceTypeEnum.code) + expect(getFileType('config.xml')).toBe(FileAppearanceTypeEnum.code) + expect(getFileType('data.json')).toBe(FileAppearanceTypeEnum.code) + }) + }) + + describe('Document Files', () => { + it('should return pdf type for PDF files', () => { + expect(getFileType('document.pdf')).toBe(FileAppearanceTypeEnum.pdf) + expect(getFileType('report.PDF')).toBe(FileAppearanceTypeEnum.pdf) + }) + + it('should return markdown type for markdown files', () => { + expect(getFileType('README.md')).toBe(FileAppearanceTypeEnum.markdown) + expect(getFileType('doc.markdown')).toBe(FileAppearanceTypeEnum.markdown) + expect(getFileType('guide.mdx')).toBe(FileAppearanceTypeEnum.markdown) + }) + + it('should return excel type for spreadsheet files', () => { + expect(getFileType('data.xlsx')).toBe(FileAppearanceTypeEnum.excel) + expect(getFileType('data.xls')).toBe(FileAppearanceTypeEnum.excel) + expect(getFileType('data.csv')).toBe(FileAppearanceTypeEnum.excel) + }) + + it('should return word type for Word documents', () => { + expect(getFileType('document.docx')).toBe(FileAppearanceTypeEnum.word) + expect(getFileType('document.doc')).toBe(FileAppearanceTypeEnum.word) + }) + + it('should return ppt type for PowerPoint files', () => { + expect(getFileType('presentation.pptx')).toBe(FileAppearanceTypeEnum.ppt) + expect(getFileType('slides.ppt')).toBe(FileAppearanceTypeEnum.ppt) + }) + + it('should return document type for text files', () => { + expect(getFileType('notes.txt')).toBe(FileAppearanceTypeEnum.document) + }) + }) + + describe('Unknown Files', () => { + it('should return custom type for unknown extensions', () => { + expect(getFileType('file.xyz')).toBe(FileAppearanceTypeEnum.custom) + expect(getFileType('data.unknown')).toBe(FileAppearanceTypeEnum.custom) + expect(getFileType('binary.bin')).toBe(FileAppearanceTypeEnum.custom) + }) + + it('should return custom type for files without extension', () => { + expect(getFileType('README')).toBe(FileAppearanceTypeEnum.custom) + expect(getFileType('Makefile')).toBe(FileAppearanceTypeEnum.custom) + }) + + it('should return custom type for empty filename', () => { + expect(getFileType('')).toBe(FileAppearanceTypeEnum.custom) + }) + }) + + describe('Case Insensitivity', () => { + it('should handle uppercase extensions', () => { + expect(getFileType('file.PDF')).toBe(FileAppearanceTypeEnum.pdf) + expect(getFileType('file.DOCX')).toBe(FileAppearanceTypeEnum.word) + expect(getFileType('file.XLSX')).toBe(FileAppearanceTypeEnum.excel) + }) + + it('should handle mixed case extensions', () => { + expect(getFileType('file.Pdf')).toBe(FileAppearanceTypeEnum.pdf) + expect(getFileType('file.DocX')).toBe(FileAppearanceTypeEnum.word) + }) + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.spec.tsx new file mode 100644 index 0000000000..125a2192aa --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/index.spec.tsx @@ -0,0 +1,1895 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import OnlineDrive from './index' +import Header from './header' +import { convertOnlineDriveData, isBucketListInitiation, isFile } from './utils' +import type { OnlineDriveFile } from '@/models/pipeline' +import { DatasourceType, OnlineDriveFileType } from '@/models/pipeline' +import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' +import type { OnlineDriveData } from '@/types/pipeline' + +// ========================================== +// 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: string | undefined = '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 useGetDataSourceAuth - API service hook requires mocking +const mockUseGetDataSourceAuth = jest.fn() +jest.mock('@/service/use-datasource', () => ({ + useGetDataSourceAuth: (params: any) => mockUseGetDataSourceAuth(params), +})) + +// Mock Toast +const mockToastNotify = jest.fn() +jest.mock('@/app/components/base/toast', () => ({ + __esModule: true, + default: { + notify: (...args: any[]) => mockToastNotify(...args), + }, +})) + +// Note: zustand/react/shallow useShallow is imported directly (simple utility function) + +// Mock store state +const mockStoreState = { + nextPageParameters: {} as Record, + breadcrumbs: [] as string[], + prefix: [] as string[], + keywords: '', + bucket: '', + selectedFileIds: [] as string[], + onlineDriveFileList: [] as OnlineDriveFile[], + currentCredentialId: '', + isTruncated: { current: false }, + currentNextPageParametersRef: { current: {} }, + setOnlineDriveFileList: jest.fn(), + setKeywords: jest.fn(), + setSelectedFileIds: jest.fn(), + setBreadcrumbs: jest.fn(), + setPrefix: jest.fn(), + setBucket: jest.fn(), + setHasBucket: 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) => ( +
+ {props.docTitle} + {props.docLink} + {props.pluginName} + {props.currentCredentialId} + + + {props.credentials?.length || 0} +
+ ) + return MockHeader +}) + +// Mock FileList component +jest.mock('./file-list', () => { + const MockFileList = (props: any) => ( +
+ {props.fileList?.length || 0} + {props.selectedFileIds?.length || 0} + {props.breadcrumbs?.join('/') || ''} + {props.keywords} + {props.bucket} + {String(props.isLoading)} + {String(props.isInPipeline)} + {String(props.supportBatchUpload)} + props.updateKeywords(e.target.value)} + /> + + + + + + +
+ ) + return MockFileList +}) + +// ========================================== +// Test Data Builders +// ========================================== +const createMockNodeData = (overrides?: Partial): DataSourceNodeType => ({ + title: 'Test Node', + plugin_id: 'plugin-123', + provider_type: 'online_drive', + provider_name: 'online-drive-provider', + datasource_name: 'online-drive-ds', + datasource_label: 'Online Drive', + datasource_parameters: {}, + datasource_configurations: {}, + ...overrides, +} as DataSourceNodeType) + +const createMockOnlineDriveFile = (overrides?: Partial): OnlineDriveFile => ({ + id: 'file-1', + name: 'test-file.txt', + size: 1024, + type: OnlineDriveFileType.file, + ...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 OnlineDriveProps = React.ComponentProps + +const createDefaultProps = (overrides?: Partial): OnlineDriveProps => ({ + nodeId: 'node-1', + nodeData: createMockNodeData(), + onCredentialChange: jest.fn(), + isInPipeline: false, + supportBatchUpload: true, + ...overrides, +}) + +// ========================================== +// Helper Functions +// ========================================== +const resetMockStoreState = () => { + mockStoreState.nextPageParameters = {} + mockStoreState.breadcrumbs = [] + mockStoreState.prefix = [] + mockStoreState.keywords = '' + mockStoreState.bucket = '' + mockStoreState.selectedFileIds = [] + mockStoreState.onlineDriveFileList = [] + mockStoreState.currentCredentialId = '' + mockStoreState.isTruncated = { current: false } + mockStoreState.currentNextPageParametersRef = { current: {} } + mockStoreState.setOnlineDriveFileList = jest.fn() + mockStoreState.setKeywords = jest.fn() + mockStoreState.setSelectedFileIds = jest.fn() + mockStoreState.setBreadcrumbs = jest.fn() + mockStoreState.setPrefix = jest.fn() + mockStoreState.setBucket = jest.fn() + mockStoreState.setHasBucket = jest.fn() +} + +// ========================================== +// Test Suites +// ========================================== +describe('OnlineDrive', () => { + beforeEach(() => { + jest.clearAllMocks() + + // Reset store state + resetMockStoreState() + + // 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() + + // Assert + expect(screen.getByTestId('header')).toBeInTheDocument() + expect(screen.getByTestId('file-list')).toBeInTheDocument() + }) + + it('should render Header with correct props', () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-123' + const props = createDefaultProps({ + nodeData: createMockNodeData({ datasource_label: 'My Online Drive' }), + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('header-doc-title')).toHaveTextContent('Docs') + expect(screen.getByTestId('header-plugin-name')).toHaveTextContent('My Online Drive') + expect(screen.getByTestId('header-credential-id')).toHaveTextContent('cred-123') + }) + + it('should render FileList with correct props', () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + mockStoreState.keywords = 'search-term' + mockStoreState.breadcrumbs = ['folder1', 'folder2'] + mockStoreState.bucket = 'my-bucket' + mockStoreState.selectedFileIds = ['file-1', 'file-2'] + mockStoreState.onlineDriveFileList = [ + createMockOnlineDriveFile({ id: 'file-1', name: 'file1.txt' }), + createMockOnlineDriveFile({ id: 'file-2', name: 'file2.txt' }), + ] + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('file-list')).toBeInTheDocument() + expect(screen.getByTestId('file-list-keywords')).toHaveTextContent('search-term') + expect(screen.getByTestId('file-list-breadcrumbs')).toHaveTextContent('folder1/folder2') + expect(screen.getByTestId('file-list-bucket')).toHaveTextContent('my-bucket') + expect(screen.getByTestId('file-list-selected-count')).toHaveTextContent('2') + }) + + it('should pass docLink with correct path to Header', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(mockDocLink).toHaveBeenCalledWith('/guides/knowledge-base/knowledge-pipeline/authorize-data-source') + }) + }) + + // ========================================== + // Props Testing + // ========================================== + describe('Props', () => { + describe('nodeId prop', () => { + it('should use nodeId in datasourceNodeRunURL for non-pipeline mode', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + const props = createDefaultProps({ + nodeId: 'custom-node-id', + isInPipeline: false, + }) + + // Act + render() + + // Assert - ssePost should be called with correct URL + await waitFor(() => { + expect(mockSsePost).toHaveBeenCalledWith( + expect.stringContaining('/rag/pipelines/pipeline-123/workflows/published/datasource/nodes/custom-node-id/run'), + expect.any(Object), + expect.any(Object), + ) + }) + }) + + it('should use nodeId in datasourceNodeRunURL for pipeline mode', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + const props = createDefaultProps({ + nodeId: 'custom-node-id', + isInPipeline: true, + }) + + // Act + render() + + // Assert - ssePost should be called with correct URL for draft + await waitFor(() => { + expect(mockSsePost).toHaveBeenCalledWith( + expect.stringContaining('/rag/pipelines/pipeline-123/workflows/draft/datasource/nodes/custom-node-id/run'), + expect.any(Object), + expect.any(Object), + ) + }) + }) + }) + + describe('nodeData prop', () => { + 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() + + // Assert + expect(mockUseGetDataSourceAuth).toHaveBeenCalledWith({ + pluginId: 'my-plugin-id', + provider: 'my-provider', + }) + }) + + it('should pass datasource_label to Header as pluginName', () => { + // Arrange + const nodeData = createMockNodeData({ + datasource_label: 'Custom Online Drive', + }) + const props = createDefaultProps({ nodeData }) + + // Act + render() + + // Assert + expect(screen.getByTestId('header-plugin-name')).toHaveTextContent('Custom Online Drive') + }) + }) + + describe('isInPipeline prop', () => { + it('should use draft URL when isInPipeline is true', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + const props = createDefaultProps({ isInPipeline: true }) + + // Act + render() + + // Assert + await waitFor(() => { + expect(mockSsePost).toHaveBeenCalledWith( + expect.stringContaining('/workflows/draft/'), + expect.any(Object), + expect.any(Object), + ) + }) + }) + + it('should use published URL when isInPipeline is false', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + const props = createDefaultProps({ isInPipeline: false }) + + // Act + render() + + // Assert + await waitFor(() => { + expect(mockSsePost).toHaveBeenCalledWith( + expect.stringContaining('/workflows/published/'), + expect.any(Object), + expect.any(Object), + ) + }) + }) + + it('should pass isInPipeline to FileList', () => { + // Arrange + const props = createDefaultProps({ isInPipeline: true }) + + // Act + render() + + // Assert + expect(screen.getByTestId('file-list-is-in-pipeline')).toHaveTextContent('true') + }) + }) + + describe('supportBatchUpload prop', () => { + it('should pass supportBatchUpload true to FileList when supportBatchUpload is true', () => { + // Arrange + const props = createDefaultProps({ supportBatchUpload: true }) + + // Act + render() + + // Assert + expect(screen.getByTestId('file-list-support-batch')).toHaveTextContent('true') + }) + + it('should pass supportBatchUpload false to FileList when supportBatchUpload is false', () => { + // Arrange + const props = createDefaultProps({ supportBatchUpload: false }) + + // Act + render() + + // Assert + expect(screen.getByTestId('file-list-support-batch')).toHaveTextContent('false') + }) + + it.each([ + [true, 'true'], + [false, 'false'], + [undefined, 'true'], // Default value + ])('should handle supportBatchUpload=%s correctly', (value, expected) => { + // Arrange + const props = createDefaultProps({ supportBatchUpload: value }) + + // Act + render() + + // Assert + expect(screen.getByTestId('file-list-support-batch')).toHaveTextContent(expected) + }) + }) + + describe('onCredentialChange prop', () => { + it('should call onCredentialChange with credential id', () => { + // Arrange + const mockOnCredentialChange = jest.fn() + const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) + + // Act + render() + fireEvent.click(screen.getByTestId('header-credential-change')) + + // Assert + expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') + }) + }) + }) + + // ========================================== + // State Management Tests + // ========================================== + describe('State Management', () => { + it('should fetch files on initial mount when fileList is empty', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + mockStoreState.onlineDriveFileList = [] + const props = createDefaultProps() + + // Act + render() + + // Assert + await waitFor(() => { + expect(mockSsePost).toHaveBeenCalled() + }) + }) + + it('should not fetch files on initial mount when fileList is not empty', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + mockStoreState.onlineDriveFileList = [createMockOnlineDriveFile()] + const props = createDefaultProps() + + // Act + render() + + // Assert - Wait a bit to ensure no call is made + await new Promise(resolve => setTimeout(resolve, 100)) + expect(mockSsePost).not.toHaveBeenCalled() + }) + + it('should not fetch files when currentCredentialId is empty', async () => { + // Arrange + mockStoreState.currentCredentialId = '' + const props = createDefaultProps() + + // Act + render() + + // Assert - Wait a bit to ensure no call is made + await new Promise(resolve => setTimeout(resolve, 100)) + expect(mockSsePost).not.toHaveBeenCalled() + }) + + it('should show loading state during fetch', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + mockSsePost.mockImplementation(() => { + // Never resolves to keep loading state + }) + const props = createDefaultProps() + + // Act + render() + + // Assert + await waitFor(() => { + expect(screen.getByTestId('file-list-loading')).toHaveTextContent('true') + }) + }) + + it('should update file list on successful fetch', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + const mockFiles = [ + { id: 'file-1', name: 'file1.txt', type: 'file' as const }, + { id: 'file-2', name: 'file2.txt', type: 'file' as const }, + ] + mockSsePost.mockImplementation((url, options, callbacks) => { + callbacks.onDataSourceNodeCompleted({ + data: [{ + bucket: '', + files: mockFiles, + is_truncated: false, + next_page_parameters: {}, + }], + time_consuming: 1.0, + }) + }) + const props = createDefaultProps() + + // Act + render() + + // Assert + await waitFor(() => { + expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalled() + }) + }) + + it('should show error toast on fetch error', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + const errorMessage = 'Failed to fetch files' + mockSsePost.mockImplementation((url, options, callbacks) => { + callbacks.onDataSourceNodeError({ + error: errorMessage, + }) + }) + const props = createDefaultProps() + + // Act + render() + + // Assert + await waitFor(() => { + expect(mockToastNotify).toHaveBeenCalledWith({ + type: 'error', + message: errorMessage, + }) + }) + }) + }) + + // ========================================== + // Memoization Logic and Dependencies Tests + // ========================================== + describe('Memoization Logic', () => { + it('should filter files by keywords', () => { + // Arrange + mockStoreState.keywords = 'test' + mockStoreState.onlineDriveFileList = [ + createMockOnlineDriveFile({ id: '1', name: 'test-file.txt' }), + createMockOnlineDriveFile({ id: '2', name: 'other-file.txt' }), + createMockOnlineDriveFile({ id: '3', name: 'another-test.pdf' }), + ] + const props = createDefaultProps() + + // Act + render() + + // Assert - filteredOnlineDriveFileList should have 2 items matching 'test' + expect(screen.getByTestId('file-list-count')).toHaveTextContent('2') + }) + + it('should return all files when keywords is empty', () => { + // Arrange + mockStoreState.keywords = '' + mockStoreState.onlineDriveFileList = [ + createMockOnlineDriveFile({ id: '1', name: 'file1.txt' }), + createMockOnlineDriveFile({ id: '2', name: 'file2.txt' }), + createMockOnlineDriveFile({ id: '3', name: 'file3.pdf' }), + ] + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('file-list-count')).toHaveTextContent('3') + }) + + it('should filter files case-insensitively', () => { + // Arrange + mockStoreState.keywords = 'TEST' + mockStoreState.onlineDriveFileList = [ + createMockOnlineDriveFile({ id: '1', name: 'test-file.txt' }), + createMockOnlineDriveFile({ id: '2', name: 'Test-Document.pdf' }), + createMockOnlineDriveFile({ id: '3', name: 'other.txt' }), + ] + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('file-list-count')).toHaveTextContent('2') + }) + }) + + // ========================================== + // Callback Stability and Memoization + // ========================================== + describe('Callback Stability and Memoization', () => { + it('should have stable handleSetting callback', () => { + // Arrange + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByTestId('header-config-btn')) + + // Assert + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ + payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, + }) + }) + + it('should have stable updateKeywords that updates store', () => { + // Arrange + const props = createDefaultProps() + render() + + // Act + fireEvent.change(screen.getByTestId('file-list-search-input'), { target: { value: 'new-keyword' } }) + + // Assert + expect(mockStoreState.setKeywords).toHaveBeenCalledWith('new-keyword') + }) + + it('should have stable resetKeywords that clears keywords', () => { + // Arrange + mockStoreState.keywords = 'old-keyword' + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByTestId('file-list-reset-keywords')) + + // Assert + expect(mockStoreState.setKeywords).toHaveBeenCalledWith('') + }) + }) + + // ========================================== + // User Interactions and Event Handlers + // ========================================== + describe('User Interactions', () => { + describe('File Selection', () => { + it('should toggle file selection on file click', () => { + // Arrange + mockStoreState.selectedFileIds = [] + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByTestId('file-list-select-file')) + + // Assert + expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith(['file-1']) + }) + + it('should deselect file if already selected', () => { + // Arrange + mockStoreState.selectedFileIds = ['file-1'] + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByTestId('file-list-select-file')) + + // Assert + expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith([]) + }) + + it('should not select bucket type items', () => { + // Arrange + mockStoreState.selectedFileIds = [] + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByTestId('file-list-select-bucket')) + + // Assert + expect(mockStoreState.setSelectedFileIds).not.toHaveBeenCalled() + }) + + it('should limit selection to one file when supportBatchUpload is false', () => { + // Arrange + mockStoreState.selectedFileIds = ['existing-file'] + const props = createDefaultProps({ supportBatchUpload: false }) + render() + + // Act + fireEvent.click(screen.getByTestId('file-list-select-file')) + + // Assert - Should not add new file because there's already one selected + expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith(['existing-file']) + }) + + it('should allow multiple selections when supportBatchUpload is true', () => { + // Arrange + mockStoreState.selectedFileIds = ['existing-file'] + const props = createDefaultProps({ supportBatchUpload: true }) + render() + + // Act + fireEvent.click(screen.getByTestId('file-list-select-file')) + + // Assert + expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith(['existing-file', 'file-1']) + }) + }) + + describe('Folder Navigation', () => { + it('should open folder and update breadcrumbs/prefix', () => { + // Arrange + mockStoreState.breadcrumbs = [] + mockStoreState.prefix = [] + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByTestId('file-list-open-folder')) + + // Assert + expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalledWith([]) + expect(mockStoreState.setSelectedFileIds).toHaveBeenCalledWith([]) + expect(mockStoreState.setBreadcrumbs).toHaveBeenCalledWith(['my-folder']) + expect(mockStoreState.setPrefix).toHaveBeenCalledWith(['folder-1']) + }) + + it('should open bucket and set bucket name', () => { + // Arrange + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByTestId('file-list-open-bucket')) + + // Assert + expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalledWith([]) + expect(mockStoreState.setBucket).toHaveBeenCalledWith('my-bucket') + }) + + it('should not navigate when opening a file', () => { + // Arrange + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByTestId('file-list-open-file')) + + // Assert - No navigation functions should be called + expect(mockStoreState.setBreadcrumbs).not.toHaveBeenCalled() + expect(mockStoreState.setPrefix).not.toHaveBeenCalled() + expect(mockStoreState.setBucket).not.toHaveBeenCalled() + }) + }) + + describe('Credential Change', () => { + it('should call onCredentialChange prop', () => { + // Arrange + const mockOnCredentialChange = jest.fn() + const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) + render() + + // Act + fireEvent.click(screen.getByTestId('header-credential-change')) + + // Assert + expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') + }) + }) + + describe('Configuration', () => { + it('should open account setting modal on configuration click', () => { + // Arrange + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByTestId('header-config-btn')) + + // Assert + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ + payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, + }) + }) + }) + }) + + // ========================================== + // Side Effects and Cleanup Tests + // ========================================== + describe('Side Effects and Cleanup', () => { + it('should fetch files when nextPageParameters changes after initial mount', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + mockStoreState.onlineDriveFileList = [createMockOnlineDriveFile()] + const props = createDefaultProps() + const { rerender } = render() + + // Act - Simulate nextPageParameters change by re-rendering with updated state + mockStoreState.nextPageParameters = { page: 2 } + rerender() + + // Assert + await waitFor(() => { + expect(mockSsePost).toHaveBeenCalled() + }) + }) + + it('should fetch files when prefix changes after initial mount', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + mockStoreState.onlineDriveFileList = [createMockOnlineDriveFile()] + const props = createDefaultProps() + const { rerender } = render() + + // Act - Simulate prefix change by re-rendering with updated state + mockStoreState.prefix = ['folder1'] + rerender() + + // Assert + await waitFor(() => { + expect(mockSsePost).toHaveBeenCalled() + }) + }) + + it('should fetch files when bucket changes after initial mount', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + mockStoreState.onlineDriveFileList = [createMockOnlineDriveFile()] + const props = createDefaultProps() + const { rerender } = render() + + // Act - Simulate bucket change by re-rendering with updated state + mockStoreState.bucket = 'new-bucket' + rerender() + + // Assert + await waitFor(() => { + expect(mockSsePost).toHaveBeenCalled() + }) + }) + + it('should fetch files when currentCredentialId changes after initial mount', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + mockStoreState.onlineDriveFileList = [createMockOnlineDriveFile()] + const props = createDefaultProps() + const { rerender } = render() + + // Act - Simulate credential change by re-rendering with updated state + mockStoreState.currentCredentialId = 'cred-2' + rerender() + + // Assert + await waitFor(() => { + expect(mockSsePost).toHaveBeenCalled() + }) + }) + + it('should not fetch files concurrently (debounce)', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + let resolveFirst: () => void + const firstPromise = new Promise((resolve) => { + resolveFirst = resolve + }) + mockSsePost.mockImplementationOnce((url, options, callbacks) => { + firstPromise.then(() => { + callbacks.onDataSourceNodeCompleted({ + data: [{ bucket: '', files: [], is_truncated: false, next_page_parameters: {} }], + time_consuming: 1.0, + }) + }) + }) + const props = createDefaultProps() + + // Act + render() + + // Try to trigger another fetch while first is loading + mockStoreState.prefix = ['folder1'] + + // Assert - Only one call should be made initially due to isLoadingRef guard + expect(mockSsePost).toHaveBeenCalledTimes(1) + + // Cleanup + resolveFirst!() + }) + }) + + // ========================================== + // API Calls Mocking Tests + // ========================================== + describe('API Calls', () => { + it('should call ssePost with correct parameters', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + mockStoreState.prefix = ['folder1'] + mockStoreState.bucket = 'my-bucket' + mockStoreState.nextPageParameters = { cursor: 'abc' } + const props = createDefaultProps() + + // Act + render() + + // Assert + await waitFor(() => { + expect(mockSsePost).toHaveBeenCalledWith( + expect.any(String), + { + body: { + inputs: { + prefix: 'folder1', + bucket: 'my-bucket', + next_page_parameters: { cursor: 'abc' }, + max_keys: 30, + }, + datasource_type: DatasourceType.onlineDrive, + credential_id: 'cred-1', + }, + }, + expect.objectContaining({ + onDataSourceNodeCompleted: expect.any(Function), + onDataSourceNodeError: expect.any(Function), + }), + ) + }) + }) + + it('should handle completed response and update store', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + mockStoreState.breadcrumbs = ['folder1'] + mockStoreState.bucket = 'my-bucket' + const mockResponseData = [{ + bucket: 'my-bucket', + files: [ + { id: 'file-1', name: 'file1.txt', size: 1024, type: 'file' as const }, + { id: 'file-2', name: 'file2.txt', size: 2048, type: 'file' as const }, + ], + is_truncated: true, + next_page_parameters: { cursor: 'next-cursor' }, + }] + mockSsePost.mockImplementation((url, options, callbacks) => { + callbacks.onDataSourceNodeCompleted({ + data: mockResponseData, + time_consuming: 1.5, + }) + }) + const props = createDefaultProps() + + // Act + render() + + // Assert + await waitFor(() => { + expect(mockStoreState.setOnlineDriveFileList).toHaveBeenCalled() + expect(mockStoreState.setHasBucket).toHaveBeenCalledWith(true) + expect(mockStoreState.isTruncated.current).toBe(true) + expect(mockStoreState.currentNextPageParametersRef.current).toEqual({ cursor: 'next-cursor' }) + }) + }) + + it('should handle error response and show toast', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + const errorMessage = 'Access denied' + mockSsePost.mockImplementation((url, options, callbacks) => { + callbacks.onDataSourceNodeError({ + error: errorMessage, + }) + }) + const props = createDefaultProps() + + // Act + render() + + // Assert + await waitFor(() => { + expect(mockToastNotify).toHaveBeenCalledWith({ + type: 'error', + message: errorMessage, + }) + }) + }) + }) + + // ========================================== + // Edge Cases and Error Handling + // ========================================== + describe('Edge Cases and Error Handling', () => { + it('should handle empty credentials list', () => { + // Arrange + mockUseGetDataSourceAuth.mockReturnValue({ + data: { result: [] }, + }) + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0') + }) + + it('should handle undefined credentials data', () => { + // Arrange + mockUseGetDataSourceAuth.mockReturnValue({ + data: undefined, + }) + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0') + }) + + it('should handle undefined pipelineId', async () => { + // Arrange + mockPipelineId = undefined + mockStoreState.currentCredentialId = 'cred-1' + const props = createDefaultProps() + + // Act + render() + + // Assert - Should still attempt to call ssePost with undefined in URL + await waitFor(() => { + expect(mockSsePost).toHaveBeenCalledWith( + expect.stringContaining('/rag/pipelines/undefined/'), + expect.any(Object), + expect.any(Object), + ) + }) + }) + + it('should handle empty file list', () => { + // Arrange + mockStoreState.onlineDriveFileList = [] + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('file-list-count')).toHaveTextContent('0') + }) + + it('should handle empty breadcrumbs', () => { + // Arrange + mockStoreState.breadcrumbs = [] + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('file-list-breadcrumbs')).toHaveTextContent('') + }) + + it('should handle empty bucket', () => { + // Arrange + mockStoreState.bucket = '' + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('file-list-bucket')).toHaveTextContent('') + }) + + it('should handle special characters in keywords', () => { + // Arrange + mockStoreState.keywords = 'test.file[1]' + mockStoreState.onlineDriveFileList = [ + createMockOnlineDriveFile({ id: '1', name: 'test.file[1].txt' }), + createMockOnlineDriveFile({ id: '2', name: 'other.txt' }), + ] + const props = createDefaultProps() + + // Act + render() + + // Assert - Should find file with special characters + expect(screen.getByTestId('file-list-count')).toHaveTextContent('1') + }) + + it('should handle very long file names', () => { + // Arrange + const longName = `${'a'.repeat(500)}.txt` + mockStoreState.onlineDriveFileList = [ + createMockOnlineDriveFile({ id: '1', name: longName }), + ] + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('file-list-count')).toHaveTextContent('1') + }) + + it('should handle bucket list initiation response', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + mockStoreState.bucket = '' + mockStoreState.prefix = [] + const mockBucketResponse = [ + { bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} }, + { bucket: 'bucket-2', files: [], is_truncated: false, next_page_parameters: {} }, + ] + mockSsePost.mockImplementation((url, options, callbacks) => { + callbacks.onDataSourceNodeCompleted({ + data: mockBucketResponse, + time_consuming: 1.0, + }) + }) + const props = createDefaultProps() + + // Act + render() + + // Assert + await waitFor(() => { + expect(mockStoreState.setHasBucket).toHaveBeenCalledWith(true) + }) + }) + }) + + // ========================================== + // All Prop Variations Tests + // ========================================== + 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 isInPipeline=%s and supportBatchUpload=%s', (propVariation) => { + // Arrange + const props = createDefaultProps(propVariation) + + // Act + render() + + // Assert + expect(screen.getByTestId('header')).toBeInTheDocument() + expect(screen.getByTestId('file-list')).toBeInTheDocument() + expect(screen.getByTestId('file-list-is-in-pipeline')).toHaveTextContent(String(propVariation.isInPipeline)) + expect(screen.getByTestId('file-list-support-batch')).toHaveTextContent(String(propVariation.supportBatchUpload)) + }) + + it.each([ + { nodeId: 'node-a', expectedUrlPart: 'nodes/node-a/run' }, + { nodeId: 'node-b', expectedUrlPart: 'nodes/node-b/run' }, + { nodeId: '123-456', expectedUrlPart: 'nodes/123-456/run' }, + ])('should use correct URL for nodeId=%s', async ({ nodeId, expectedUrlPart }) => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + const props = createDefaultProps({ nodeId }) + + // Act + render() + + // Assert + await waitFor(() => { + expect(mockSsePost).toHaveBeenCalledWith( + expect.stringContaining(expectedUrlPart), + expect.any(Object), + expect.any(Object), + ) + }) + }) + + it.each([ + { pluginId: 'plugin-a', providerName: 'provider-a' }, + { pluginId: 'plugin-b', providerName: 'provider-b' }, + { pluginId: '', providerName: '' }, + ])('should call useGetDataSourceAuth with pluginId=%s and providerName=%s', ({ pluginId, providerName }) => { + // Arrange + const props = createDefaultProps({ + nodeData: createMockNodeData({ + plugin_id: pluginId, + provider_name: providerName, + }), + }) + + // Act + render() + + // Assert + expect(mockUseGetDataSourceAuth).toHaveBeenCalledWith({ + pluginId, + provider: providerName, + }) + }) + }) +}) + +// ========================================== +// Header Component Tests +// ========================================== +describe('Header', () => { + const createHeaderProps = (overrides?: Partial>) => ({ + onClickConfiguration: jest.fn(), + docTitle: 'Documentation', + docLink: 'https://docs.example.com/guide', + ...overrides, + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange + const props = createHeaderProps() + + // Act + render(
) + + // Assert + expect(screen.getByText('Documentation')).toBeInTheDocument() + }) + + it('should render doc link with correct href', () => { + // Arrange + const props = createHeaderProps({ + docLink: 'https://custom-docs.com/path', + docTitle: 'Custom Docs', + }) + + // Act + render(
) + + // Assert + const link = screen.getByRole('link') + expect(link).toHaveAttribute('href', 'https://custom-docs.com/path') + expect(link).toHaveAttribute('target', '_blank') + expect(link).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('should render doc title text', () => { + // Arrange + const props = createHeaderProps({ docTitle: 'My Documentation Title' }) + + // Act + render(
) + + // Assert + expect(screen.getByText('My Documentation Title')).toBeInTheDocument() + }) + + it('should render configuration button', () => { + // Arrange + const props = createHeaderProps() + + // Act + render(
) + + // Assert + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + describe('docTitle prop', () => { + it.each([ + 'Getting Started', + 'API Reference', + 'Installation Guide', + '', + ])('should render docTitle="%s"', (docTitle) => { + // Arrange + const props = createHeaderProps({ docTitle }) + + // Act + render(
) + + // Assert + if (docTitle) + expect(screen.getByText(docTitle)).toBeInTheDocument() + }) + }) + + describe('docLink prop', () => { + it.each([ + 'https://docs.example.com', + 'https://docs.example.com/path/to/page', + '/relative/path', + ])('should set href to "%s"', (docLink) => { + // Arrange + const props = createHeaderProps({ docLink }) + + // Act + render(
) + + // Assert + expect(screen.getByRole('link')).toHaveAttribute('href', docLink) + }) + }) + + describe('onClickConfiguration prop', () => { + it('should call onClickConfiguration when configuration icon is clicked', () => { + // Arrange + const mockOnClickConfiguration = jest.fn() + const props = createHeaderProps({ onClickConfiguration: mockOnClickConfiguration }) + + // Act + render(
) + const configIcon = screen.getByRole('button').querySelector('svg') + fireEvent.click(configIcon!) + + // Assert + expect(mockOnClickConfiguration).toHaveBeenCalledTimes(1) + }) + + it('should not throw when onClickConfiguration is undefined', () => { + // Arrange + const props = createHeaderProps({ onClickConfiguration: undefined }) + + // Act & Assert + expect(() => render(
)).not.toThrow() + }) + }) + }) + + describe('Accessibility', () => { + it('should have accessible link with title attribute', () => { + // Arrange + const props = createHeaderProps({ docTitle: 'Accessible Title' }) + + // Act + render(
) + + // Assert + const titleSpan = screen.getByTitle('Accessible Title') + expect(titleSpan).toBeInTheDocument() + }) + }) +}) + +// ========================================== +// Utils Tests +// ========================================== +describe('utils', () => { + // ========================================== + // isFile Tests + // ========================================== + describe('isFile', () => { + it('should return true for file type', () => { + // Act & Assert + expect(isFile('file')).toBe(true) + }) + + it('should return false for folder type', () => { + // Act & Assert + expect(isFile('folder')).toBe(false) + }) + + it.each([ + ['file', true], + ['folder', false], + ] as const)('isFile(%s) should return %s', (type, expected) => { + // Act & Assert + expect(isFile(type)).toBe(expected) + }) + }) + + // ========================================== + // isBucketListInitiation Tests + // ========================================== + describe('isBucketListInitiation', () => { + it('should return false when bucket is not empty', () => { + // Arrange + const data: OnlineDriveData[] = [ + { bucket: 'my-bucket', files: [], is_truncated: false, next_page_parameters: {} }, + ] + + // Act & Assert + expect(isBucketListInitiation(data, [], 'existing-bucket')).toBe(false) + }) + + it('should return false when prefix is not empty', () => { + // Arrange + const data: OnlineDriveData[] = [ + { bucket: 'my-bucket', files: [], is_truncated: false, next_page_parameters: {} }, + ] + + // Act & Assert + expect(isBucketListInitiation(data, ['folder1'], '')).toBe(false) + }) + + it('should return false when data items have no bucket', () => { + // Arrange + const data: OnlineDriveData[] = [ + { bucket: '', files: [{ id: '1', name: 'file.txt', size: 1024, type: 'file' }], is_truncated: false, next_page_parameters: {} }, + ] + + // Act & Assert + expect(isBucketListInitiation(data, [], '')).toBe(false) + }) + + it('should return true for multiple buckets with no prefix and bucket', () => { + // Arrange + const data: OnlineDriveData[] = [ + { bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} }, + { bucket: 'bucket-2', files: [], is_truncated: false, next_page_parameters: {} }, + ] + + // Act & Assert + expect(isBucketListInitiation(data, [], '')).toBe(true) + }) + + it('should return true for single bucket with no files, no prefix, and no bucket', () => { + // Arrange + const data: OnlineDriveData[] = [ + { bucket: 'my-bucket', files: [], is_truncated: false, next_page_parameters: {} }, + ] + + // Act & Assert + expect(isBucketListInitiation(data, [], '')).toBe(true) + }) + + it('should return false for single bucket with files', () => { + // Arrange + const data: OnlineDriveData[] = [ + { bucket: 'my-bucket', files: [{ id: '1', name: 'file.txt', size: 1024, type: 'file' }], is_truncated: false, next_page_parameters: {} }, + ] + + // Act & Assert + expect(isBucketListInitiation(data, [], '')).toBe(false) + }) + + it('should return false for empty data array', () => { + // Arrange + const data: OnlineDriveData[] = [] + + // Act & Assert + expect(isBucketListInitiation(data, [], '')).toBe(false) + }) + }) + + // ========================================== + // convertOnlineDriveData Tests + // ========================================== + describe('convertOnlineDriveData', () => { + describe('Empty data handling', () => { + it('should return empty result for empty data array', () => { + // Arrange + const data: OnlineDriveData[] = [] + + // Act + const result = convertOnlineDriveData(data, [], '') + + // Assert + expect(result).toEqual({ + fileList: [], + isTruncated: false, + nextPageParameters: {}, + hasBucket: false, + }) + }) + }) + + describe('Bucket list initiation', () => { + it('should convert multiple buckets to bucket file list', () => { + // Arrange + const data: OnlineDriveData[] = [ + { bucket: 'bucket-1', files: [], is_truncated: false, next_page_parameters: {} }, + { bucket: 'bucket-2', files: [], is_truncated: false, next_page_parameters: {} }, + { bucket: 'bucket-3', files: [], is_truncated: false, next_page_parameters: {} }, + ] + + // Act + const result = convertOnlineDriveData(data, [], '') + + // Assert + expect(result.fileList).toHaveLength(3) + expect(result.fileList[0]).toEqual({ + id: 'bucket-1', + name: 'bucket-1', + type: OnlineDriveFileType.bucket, + }) + expect(result.fileList[1]).toEqual({ + id: 'bucket-2', + name: 'bucket-2', + type: OnlineDriveFileType.bucket, + }) + expect(result.fileList[2]).toEqual({ + id: 'bucket-3', + name: 'bucket-3', + type: OnlineDriveFileType.bucket, + }) + expect(result.hasBucket).toBe(true) + expect(result.isTruncated).toBe(false) + expect(result.nextPageParameters).toEqual({}) + }) + + it('should convert single bucket with no files to bucket list', () => { + // Arrange + const data: OnlineDriveData[] = [ + { bucket: 'my-bucket', files: [], is_truncated: false, next_page_parameters: {} }, + ] + + // Act + const result = convertOnlineDriveData(data, [], '') + + // Assert + expect(result.fileList).toHaveLength(1) + expect(result.fileList[0]).toEqual({ + id: 'my-bucket', + name: 'my-bucket', + type: OnlineDriveFileType.bucket, + }) + expect(result.hasBucket).toBe(true) + }) + }) + + describe('File list conversion', () => { + it('should convert files correctly', () => { + // Arrange + const data: OnlineDriveData[] = [ + { + bucket: 'my-bucket', + files: [ + { id: 'file-1', name: 'document.pdf', size: 1024, type: 'file' }, + { id: 'file-2', name: 'image.png', size: 2048, type: 'file' }, + ], + is_truncated: false, + next_page_parameters: {}, + }, + ] + + // Act + const result = convertOnlineDriveData(data, ['folder1'], 'my-bucket') + + // Assert + expect(result.fileList).toHaveLength(2) + expect(result.fileList[0]).toEqual({ + id: 'file-1', + name: 'document.pdf', + size: 1024, + type: OnlineDriveFileType.file, + }) + expect(result.fileList[1]).toEqual({ + id: 'file-2', + name: 'image.png', + size: 2048, + type: OnlineDriveFileType.file, + }) + expect(result.hasBucket).toBe(true) + }) + + it('should convert folders correctly without size', () => { + // Arrange + const data: OnlineDriveData[] = [ + { + bucket: 'my-bucket', + files: [ + { id: 'folder-1', name: 'Documents', size: 0, type: 'folder' }, + { id: 'folder-2', name: 'Images', size: 0, type: 'folder' }, + ], + is_truncated: false, + next_page_parameters: {}, + }, + ] + + // Act + const result = convertOnlineDriveData(data, [], 'my-bucket') + + // Assert + expect(result.fileList).toHaveLength(2) + expect(result.fileList[0]).toEqual({ + id: 'folder-1', + name: 'Documents', + size: undefined, + type: OnlineDriveFileType.folder, + }) + expect(result.fileList[1]).toEqual({ + id: 'folder-2', + name: 'Images', + size: undefined, + type: OnlineDriveFileType.folder, + }) + }) + + it('should handle mixed files and folders', () => { + // Arrange + const data: OnlineDriveData[] = [ + { + bucket: 'my-bucket', + files: [ + { id: 'folder-1', name: 'Documents', size: 0, type: 'folder' }, + { id: 'file-1', name: 'readme.txt', size: 256, type: 'file' }, + { id: 'folder-2', name: 'Images', size: 0, type: 'folder' }, + { id: 'file-2', name: 'data.json', size: 512, type: 'file' }, + ], + is_truncated: false, + next_page_parameters: {}, + }, + ] + + // Act + const result = convertOnlineDriveData(data, [], 'my-bucket') + + // Assert + expect(result.fileList).toHaveLength(4) + expect(result.fileList[0].type).toBe(OnlineDriveFileType.folder) + expect(result.fileList[1].type).toBe(OnlineDriveFileType.file) + expect(result.fileList[2].type).toBe(OnlineDriveFileType.folder) + expect(result.fileList[3].type).toBe(OnlineDriveFileType.file) + }) + }) + + describe('Truncation and pagination', () => { + it('should return isTruncated true when data is truncated', () => { + // Arrange + const data: OnlineDriveData[] = [ + { + bucket: 'my-bucket', + files: [{ id: 'file-1', name: 'file.txt', size: 1024, type: 'file' }], + is_truncated: true, + next_page_parameters: { cursor: 'next-cursor' }, + }, + ] + + // Act + const result = convertOnlineDriveData(data, [], 'my-bucket') + + // Assert + expect(result.isTruncated).toBe(true) + expect(result.nextPageParameters).toEqual({ cursor: 'next-cursor' }) + }) + + it('should return isTruncated false when not truncated', () => { + // Arrange + const data: OnlineDriveData[] = [ + { + bucket: 'my-bucket', + files: [{ id: 'file-1', name: 'file.txt', size: 1024, type: 'file' }], + is_truncated: false, + next_page_parameters: {}, + }, + ] + + // Act + const result = convertOnlineDriveData(data, [], 'my-bucket') + + // Assert + expect(result.isTruncated).toBe(false) + expect(result.nextPageParameters).toEqual({}) + }) + + it('should handle undefined is_truncated', () => { + // Arrange + const data: OnlineDriveData[] = [ + { + bucket: 'my-bucket', + files: [{ id: 'file-1', name: 'file.txt', size: 1024, type: 'file' }], + is_truncated: undefined as any, + next_page_parameters: undefined as any, + }, + ] + + // Act + const result = convertOnlineDriveData(data, [], 'my-bucket') + + // Assert + expect(result.isTruncated).toBe(false) + expect(result.nextPageParameters).toEqual({}) + }) + }) + + describe('hasBucket flag', () => { + it('should return hasBucket true when bucket exists in data', () => { + // Arrange + const data: OnlineDriveData[] = [ + { + bucket: 'my-bucket', + files: [{ id: 'file-1', name: 'file.txt', size: 1024, type: 'file' }], + is_truncated: false, + next_page_parameters: {}, + }, + ] + + // Act + const result = convertOnlineDriveData(data, [], 'my-bucket') + + // Assert + expect(result.hasBucket).toBe(true) + }) + + it('should return hasBucket false when bucket is empty in data', () => { + // Arrange + const data: OnlineDriveData[] = [ + { + bucket: '', + files: [{ id: 'file-1', name: 'file.txt', size: 1024, type: 'file' }], + is_truncated: false, + next_page_parameters: {}, + }, + ] + + // Act + const result = convertOnlineDriveData(data, [], '') + + // Assert + expect(result.hasBucket).toBe(false) + }) + }) + + describe('Edge cases', () => { + it('should handle files with zero size', () => { + // Arrange + const data: OnlineDriveData[] = [ + { + bucket: 'my-bucket', + files: [{ id: 'file-1', name: 'empty.txt', size: 0, type: 'file' }], + is_truncated: false, + next_page_parameters: {}, + }, + ] + + // Act + const result = convertOnlineDriveData(data, [], 'my-bucket') + + // Assert + expect(result.fileList[0].size).toBe(0) + }) + + it('should handle files with very large size', () => { + // Arrange + const largeSize = Number.MAX_SAFE_INTEGER + const data: OnlineDriveData[] = [ + { + bucket: 'my-bucket', + files: [{ id: 'file-1', name: 'large.bin', size: largeSize, type: 'file' }], + is_truncated: false, + next_page_parameters: {}, + }, + ] + + // Act + const result = convertOnlineDriveData(data, [], 'my-bucket') + + // Assert + expect(result.fileList[0].size).toBe(largeSize) + }) + + it('should handle files with special characters in name', () => { + // Arrange + const data: OnlineDriveData[] = [ + { + bucket: 'my-bucket', + files: [ + { id: 'file-1', name: 'file[1] (copy).txt', size: 1024, type: 'file' }, + { id: 'file-2', name: 'doc-with-dash_and_underscore.pdf', size: 2048, type: 'file' }, + { id: 'file-3', name: 'file with spaces.txt', size: 512, type: 'file' }, + ], + is_truncated: false, + next_page_parameters: {}, + }, + ] + + // Act + const result = convertOnlineDriveData(data, [], 'my-bucket') + + // Assert + expect(result.fileList[0].name).toBe('file[1] (copy).txt') + expect(result.fileList[1].name).toBe('doc-with-dash_and_underscore.pdf') + expect(result.fileList[2].name).toBe('file with spaces.txt') + }) + + it('should handle complex next_page_parameters', () => { + // Arrange + const complexParams = { + cursor: 'abc123', + page: 2, + limit: 50, + nested: { key: 'value' }, + } + const data: OnlineDriveData[] = [ + { + bucket: 'my-bucket', + files: [{ id: 'file-1', name: 'file.txt', size: 1024, type: 'file' }], + is_truncated: true, + next_page_parameters: complexParams, + }, + ] + + // Act + const result = convertOnlineDriveData(data, [], 'my-bucket') + + // Assert + expect(result.nextPageParameters).toEqual(complexParams) + }) + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/index.spec.tsx new file mode 100644 index 0000000000..f96127f361 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/index.spec.tsx @@ -0,0 +1,947 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import CheckboxWithLabel from './checkbox-with-label' +import CrawledResultItem from './crawled-result-item' +import CrawledResult from './crawled-result' +import Crawling from './crawling' +import ErrorMessage from './error-message' +import type { CrawlResultItem as CrawlResultItemType } from '@/models/datasets' + +// ========================================== +// Test Data Builders +// ========================================== + +const createMockCrawlResultItem = (overrides?: Partial): CrawlResultItemType => ({ + source_url: 'https://example.com/page1', + title: 'Test Page Title', + markdown: '# Test content', + description: 'Test description', + ...overrides, +}) + +const createMockCrawlResultItems = (count = 3): CrawlResultItemType[] => { + return Array.from({ length: count }, (_, i) => + createMockCrawlResultItem({ + source_url: `https://example.com/page${i + 1}`, + title: `Page ${i + 1}`, + }), + ) +} + +// ========================================== +// CheckboxWithLabel Tests +// ========================================== +describe('CheckboxWithLabel', () => { + const defaultProps = { + isChecked: false, + onChange: jest.fn(), + label: 'Test Label', + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('Test Label')).toBeInTheDocument() + }) + + it('should render checkbox in unchecked state', () => { + // Arrange & Act + const { container } = render() + + // Assert - Custom checkbox component uses div with data-testid + const checkbox = container.querySelector('[data-testid^="checkbox"]') + expect(checkbox).toBeInTheDocument() + expect(checkbox).not.toHaveClass('bg-components-checkbox-bg') + }) + + it('should render checkbox in checked state', () => { + // Arrange & Act + const { container } = render() + + // Assert - Checked state has check icon + const checkIcon = container.querySelector('[data-testid^="check-icon"]') + expect(checkIcon).toBeInTheDocument() + }) + + it('should render tooltip when provided', () => { + // Arrange & Act + render() + + // Assert - Tooltip trigger should be present + const tooltipTrigger = document.querySelector('[class*="ml-0.5"]') + expect(tooltipTrigger).toBeInTheDocument() + }) + + it('should not render tooltip when not provided', () => { + // Arrange & Act + render() + + // Assert + const tooltipTrigger = document.querySelector('[class*="ml-0.5"]') + expect(tooltipTrigger).not.toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert + const label = container.querySelector('label') + expect(label).toHaveClass('custom-class') + }) + + it('should apply custom labelClassName', () => { + // Arrange & Act + render() + + // Assert + const labelText = screen.getByText('Test Label') + expect(labelText).toHaveClass('custom-label-class') + }) + }) + + describe('User Interactions', () => { + it('should call onChange with true when clicking unchecked checkbox', () => { + // Arrange + const mockOnChange = jest.fn() + const { container } = render() + + // Act + const checkbox = container.querySelector('[data-testid^="checkbox"]')! + fireEvent.click(checkbox) + + // Assert + expect(mockOnChange).toHaveBeenCalledWith(true) + }) + + it('should call onChange with false when clicking checked checkbox', () => { + // Arrange + const mockOnChange = jest.fn() + const { container } = render() + + // Act + const checkbox = container.querySelector('[data-testid^="checkbox"]')! + fireEvent.click(checkbox) + + // Assert + expect(mockOnChange).toHaveBeenCalledWith(false) + }) + + it('should not trigger onChange when clicking label text due to custom checkbox', () => { + // Arrange + const mockOnChange = jest.fn() + render() + + // Act - Click on the label text element + const labelText = screen.getByText('Test Label') + fireEvent.click(labelText) + + // Assert - Custom checkbox does not support native label-input click forwarding + expect(mockOnChange).not.toHaveBeenCalled() + }) + }) +}) + +// ========================================== +// CrawledResultItem Tests +// ========================================== +describe('CrawledResultItem', () => { + const defaultProps = { + payload: createMockCrawlResultItem(), + isChecked: false, + onCheckChange: jest.fn(), + isPreview: false, + showPreview: true, + onPreview: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('Test Page Title')).toBeInTheDocument() + expect(screen.getByText('https://example.com/page1')).toBeInTheDocument() + }) + + it('should render checkbox when isMultipleChoice is true', () => { + // Arrange & Act + const { container } = render() + + // Assert - Custom checkbox uses data-testid + const checkbox = container.querySelector('[data-testid^="checkbox"]') + expect(checkbox).toBeInTheDocument() + }) + + it('should render radio when isMultipleChoice is false', () => { + // Arrange & Act + const { container } = render() + + // Assert - Radio component has size-4 rounded-full classes + const radio = container.querySelector('.size-4.rounded-full') + expect(radio).toBeInTheDocument() + }) + + it('should render checkbox as checked when isChecked is true', () => { + // Arrange & Act + const { container } = render() + + // Assert - Checked state shows check icon + const checkIcon = container.querySelector('[data-testid^="check-icon"]') + expect(checkIcon).toBeInTheDocument() + }) + + it('should render preview button when showPreview is true', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should not render preview button when showPreview is false', () => { + // Arrange & Act + render() + + // Assert + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('should apply active background when isPreview is true', () => { + // Arrange & Act + const { container } = render() + + // Assert + const item = container.firstChild + expect(item).toHaveClass('bg-state-base-active') + }) + + it('should apply hover styles when isPreview is false', () => { + // Arrange & Act + const { container } = render() + + // Assert + const item = container.firstChild + expect(item).toHaveClass('group') + expect(item).toHaveClass('hover:bg-state-base-hover') + }) + }) + + describe('Props', () => { + it('should display payload title', () => { + // Arrange + const payload = createMockCrawlResultItem({ title: 'Custom Title' }) + + // Act + render() + + // Assert + expect(screen.getByText('Custom Title')).toBeInTheDocument() + }) + + it('should display payload source_url', () => { + // Arrange + const payload = createMockCrawlResultItem({ source_url: 'https://custom.url/path' }) + + // Act + render() + + // Assert + expect(screen.getByText('https://custom.url/path')).toBeInTheDocument() + }) + + it('should set title attribute for truncation tooltip', () => { + // Arrange + const payload = createMockCrawlResultItem({ title: 'Very Long Title' }) + + // Act + render() + + // Assert + const titleElement = screen.getByText('Very Long Title') + expect(titleElement).toHaveAttribute('title', 'Very Long Title') + }) + }) + + describe('User Interactions', () => { + it('should call onCheckChange with true when clicking unchecked checkbox', () => { + // Arrange + const mockOnCheckChange = jest.fn() + const { container } = render( + , + ) + + // Act + const checkbox = container.querySelector('[data-testid^="checkbox"]')! + fireEvent.click(checkbox) + + // Assert + expect(mockOnCheckChange).toHaveBeenCalledWith(true) + }) + + it('should call onCheckChange with false when clicking checked checkbox', () => { + // Arrange + const mockOnCheckChange = jest.fn() + const { container } = render( + , + ) + + // Act + const checkbox = container.querySelector('[data-testid^="checkbox"]')! + fireEvent.click(checkbox) + + // Assert + expect(mockOnCheckChange).toHaveBeenCalledWith(false) + }) + + it('should call onPreview when clicking preview button', () => { + // Arrange + const mockOnPreview = jest.fn() + render() + + // Act + fireEvent.click(screen.getByRole('button')) + + // Assert + expect(mockOnPreview).toHaveBeenCalled() + }) + + it('should toggle radio state when isMultipleChoice is false', () => { + // Arrange + const mockOnCheckChange = jest.fn() + const { container } = render( + , + ) + + // Act - Radio uses size-4 rounded-full classes + const radio = container.querySelector('.size-4.rounded-full')! + fireEvent.click(radio) + + // Assert + expect(mockOnCheckChange).toHaveBeenCalledWith(true) + }) + }) +}) + +// ========================================== +// CrawledResult Tests +// ========================================== +describe('CrawledResult', () => { + const defaultProps = { + list: createMockCrawlResultItems(3), + checkedList: [] as CrawlResultItemType[], + onSelectedChange: jest.fn(), + usedTime: 1.5, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + render() + + // Assert - Check for time info which contains total count + expect(screen.getByText(/1.5/)).toBeInTheDocument() + }) + + it('should render all list items', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('Page 1')).toBeInTheDocument() + expect(screen.getByText('Page 2')).toBeInTheDocument() + expect(screen.getByText('Page 3')).toBeInTheDocument() + }) + + it('should display scrape time info', () => { + // Arrange & Act + render() + + // Assert - Check for the time display + expect(screen.getByText(/2.5/)).toBeInTheDocument() + }) + + it('should render select all checkbox when isMultipleChoice is true', () => { + // Arrange & Act + const { container } = render() + + // Assert - Multiple custom checkboxes (select all + items) + const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]') + expect(checkboxes.length).toBe(4) // 1 select all + 3 items + }) + + it('should not render select all checkbox when isMultipleChoice is false', () => { + // Arrange & Act + const { container } = render() + + // Assert - No select all checkbox, only radio buttons for items + const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]') + expect(checkboxes.length).toBe(0) + // Radio buttons have size-4 and rounded-full classes + const radios = container.querySelectorAll('.size-4.rounded-full') + expect(radios.length).toBe(3) + }) + + it('should show "Select All" when not all items are checked', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText(/selectAll|Select All/i)).toBeInTheDocument() + }) + + it('should show "Reset All" when all items are checked', () => { + // Arrange + const allChecked = createMockCrawlResultItems(3) + + // Act + render() + + // Assert + expect(screen.getByText(/resetAll|Reset All/i)).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert + expect(container.firstChild).toHaveClass('custom-class') + }) + + it('should highlight item at previewIndex', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert - Second item should have active state + const items = container.querySelectorAll('[class*="rounded-lg"][class*="cursor-pointer"]') + expect(items[1]).toHaveClass('bg-state-base-active') + }) + + it('should pass showPreview to items', () => { + // Arrange & Act + render() + + // Assert - Preview buttons should be visible + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBe(3) + }) + + it('should not show preview buttons when showPreview is false', () => { + // Arrange & Act + render() + + // Assert + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onSelectedChange with all items when clicking select all', () => { + // Arrange + const mockOnSelectedChange = jest.fn() + const list = createMockCrawlResultItems(3) + const { container } = render( + , + ) + + // Act - Click select all checkbox (first checkbox) + const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]') + fireEvent.click(checkboxes[0]) + + // Assert + expect(mockOnSelectedChange).toHaveBeenCalledWith(list) + }) + + it('should call onSelectedChange with empty array when clicking reset all', () => { + // Arrange + const mockOnSelectedChange = jest.fn() + const list = createMockCrawlResultItems(3) + const { container } = render( + , + ) + + // Act + const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]') + fireEvent.click(checkboxes[0]) + + // Assert + expect(mockOnSelectedChange).toHaveBeenCalledWith([]) + }) + + it('should add item to checkedList when checking unchecked item', () => { + // Arrange + const mockOnSelectedChange = jest.fn() + const list = createMockCrawlResultItems(3) + const { container } = render( + , + ) + + // Act - Click second item checkbox (index 2, accounting for select all) + const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]') + fireEvent.click(checkboxes[2]) + + // Assert + expect(mockOnSelectedChange).toHaveBeenCalledWith([list[0], list[1]]) + }) + + it('should remove item from checkedList when unchecking checked item', () => { + // Arrange + const mockOnSelectedChange = jest.fn() + const list = createMockCrawlResultItems(3) + const { container } = render( + , + ) + + // Act - Uncheck first item (index 1, after select all) + const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]') + fireEvent.click(checkboxes[1]) + + // Assert + expect(mockOnSelectedChange).toHaveBeenCalledWith([list[1]]) + }) + + it('should replace selection when checking in single choice mode', () => { + // Arrange + const mockOnSelectedChange = jest.fn() + const list = createMockCrawlResultItems(3) + const { container } = render( + , + ) + + // Act - Click second item radio (Radio uses size-4 rounded-full classes) + const radios = container.querySelectorAll('.size-4.rounded-full') + fireEvent.click(radios[1]) + + // Assert - Should only select the clicked item + expect(mockOnSelectedChange).toHaveBeenCalledWith([list[1]]) + }) + + it('should call onPreview with item and index when clicking preview', () => { + // Arrange + const mockOnPreview = jest.fn() + const list = createMockCrawlResultItems(3) + render( + , + ) + + // Act + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[1]) // Second item's preview button + + // Assert + expect(mockOnPreview).toHaveBeenCalledWith(list[1], 1) + }) + + it('should not crash when clicking preview without onPreview callback', () => { + // Arrange - showPreview is true but onPreview is undefined + const list = createMockCrawlResultItems(3) + render( + , + ) + + // Act - Click preview button should trigger early return in handlePreview + const buttons = screen.getAllByRole('button') + fireEvent.click(buttons[0]) + + // Assert - Should not throw error, component still renders + expect(screen.getByText('Page 1')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle empty list', () => { + // Arrange & Act + render() + + // Assert - Should show time info with 0 count + expect(screen.getByText(/0.5/)).toBeInTheDocument() + }) + + it('should handle single item list', () => { + // Arrange + const singleItem = [createMockCrawlResultItem()] + + // Act + render() + + // Assert + expect(screen.getByText('Test Page Title')).toBeInTheDocument() + }) + + it('should format usedTime to one decimal place', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText(/1.6/)).toBeInTheDocument() + }) + }) +}) + +// ========================================== +// Crawling Tests +// ========================================== +describe('Crawling', () => { + const defaultProps = { + crawledNum: 5, + totalNum: 10, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText(/5\/10/)).toBeInTheDocument() + }) + + it('should display crawled count and total', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText(/3\/15/)).toBeInTheDocument() + }) + + it('should render skeleton items', () => { + // Arrange & Act + const { container } = render() + + // Assert - Should have 3 skeleton items + const skeletonItems = container.querySelectorAll('.px-2.py-\\[5px\\]') + expect(skeletonItems.length).toBe(3) + }) + + it('should render header skeleton block', () => { + // Arrange & Act + const { container } = render() + + // Assert + const headerBlocks = container.querySelectorAll('.px-4.py-2 .bg-text-quaternary') + expect(headerBlocks.length).toBeGreaterThan(0) + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert + expect(container.firstChild).toHaveClass('custom-crawling-class') + }) + + it('should handle zero values', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText(/0\/0/)).toBeInTheDocument() + }) + + it('should handle large numbers', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText(/999\/1000/)).toBeInTheDocument() + }) + }) + + describe('Skeleton Structure', () => { + it('should render blocks with correct width classes', () => { + // Arrange & Act + const { container } = render() + + // Assert - Check for various width classes + expect(container.querySelector('.w-\\[35\\%\\]')).toBeInTheDocument() + expect(container.querySelector('.w-\\[50\\%\\]')).toBeInTheDocument() + expect(container.querySelector('.w-\\[40\\%\\]')).toBeInTheDocument() + }) + }) +}) + +// ========================================== +// ErrorMessage Tests +// ========================================== +describe('ErrorMessage', () => { + const defaultProps = { + title: 'Error Title', + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('Error Title')).toBeInTheDocument() + }) + + it('should render error icon', () => { + // Arrange & Act + const { container } = render() + + // Assert + const icon = container.querySelector('svg') + expect(icon).toBeInTheDocument() + expect(icon).toHaveClass('text-text-destructive') + }) + + it('should render title', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('Custom Error Title')).toBeInTheDocument() + }) + + it('should render error message when provided', () => { + // Arrange & Act + render() + + // Assert + expect(screen.getByText('Detailed error description')).toBeInTheDocument() + }) + + it('should not render error message when not provided', () => { + // Arrange & Act + render() + + // Assert - Should only have title, not error message container + const textElements = screen.getAllByText(/Error Title/) + expect(textElements.length).toBe(1) + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + // Arrange & Act + const { container } = render( + , + ) + + // Assert + expect(container.firstChild).toHaveClass('custom-error-class') + }) + + it('should render with empty errorMsg', () => { + // Arrange & Act + render() + + // Assert - Empty string should not render message div + expect(screen.getByText('Error Title')).toBeInTheDocument() + }) + + it('should handle long title text', () => { + // Arrange + const longTitle = 'This is a very long error title that might wrap to multiple lines' + + // Act + render() + + // Assert + expect(screen.getByText(longTitle)).toBeInTheDocument() + }) + + it('should handle long error message', () => { + // Arrange + const longErrorMsg = 'This is a very detailed error message explaining what went wrong and how to fix it. It contains multiple sentences.' + + // Act + render() + + // Assert + expect(screen.getByText(longErrorMsg)).toBeInTheDocument() + }) + }) + + describe('Styling', () => { + it('should have error background styling', () => { + // Arrange & Act + const { container } = render() + + // Assert + expect(container.firstChild).toHaveClass('bg-toast-error-bg') + }) + + it('should have border styling', () => { + // Arrange & Act + const { container } = render() + + // Assert + expect(container.firstChild).toHaveClass('border-components-panel-border') + }) + + it('should have rounded corners', () => { + // Arrange & Act + const { container } = render() + + // Assert + expect(container.firstChild).toHaveClass('rounded-xl') + }) + }) +}) + +// ========================================== +// Integration Tests +// ========================================== +describe('Base Components Integration', () => { + it('should render CrawledResult with CrawledResultItem children', () => { + // Arrange + const list = createMockCrawlResultItems(2) + + // Act + render( + , + ) + + // Assert - Both items should render + expect(screen.getByText('Page 1')).toBeInTheDocument() + expect(screen.getByText('Page 2')).toBeInTheDocument() + }) + + it('should render CrawledResult with CheckboxWithLabel for select all', () => { + // Arrange + const list = createMockCrawlResultItems(2) + + // Act + const { container } = render( + , + ) + + // Assert - Should have select all checkbox + item checkboxes + const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]') + expect(checkboxes.length).toBe(3) // select all + 2 items + }) + + it('should allow selecting and previewing items', () => { + // Arrange + const list = createMockCrawlResultItems(3) + const mockOnSelectedChange = jest.fn() + const mockOnPreview = jest.fn() + + const { container } = render( + , + ) + + // Act - Select first item (index 1, after select all) + const checkboxes = container.querySelectorAll('[data-testid^="checkbox"]') + fireEvent.click(checkboxes[1]) + + // Assert + expect(mockOnSelectedChange).toHaveBeenCalledWith([list[0]]) + + // Act - Preview second item + const previewButtons = screen.getAllByRole('button') + fireEvent.click(previewButtons[1]) + + // Assert + expect(mockOnPreview).toHaveBeenCalledWith(list[1], 1) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.spec.tsx new file mode 100644 index 0000000000..01c487c694 --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/base/options/index.spec.tsx @@ -0,0 +1,1128 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import React from 'react' +import Options from './index' +import { CrawlStep } from '@/models/datasets' +import type { RAGPipelineVariables } from '@/models/pipeline' +import { PipelineInputVarType } from '@/models/pipeline' +import Toast from '@/app/components/base/toast' +import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types' + +// ========================================== +// Mock Modules +// ========================================== + +// Note: react-i18next uses global mock from web/__mocks__/react-i18next.ts + +// Mock useInitialData and useConfigurations hooks +const mockUseInitialData = jest.fn() +const mockUseConfigurations = jest.fn() +jest.mock('@/app/components/rag-pipeline/hooks/use-input-fields', () => ({ + useInitialData: (...args: any[]) => mockUseInitialData(...args), + useConfigurations: (...args: any[]) => mockUseConfigurations(...args), +})) + +// Mock BaseField +const mockBaseField = jest.fn() +jest.mock('@/app/components/base/form/form-scenarios/base/field', () => { + const MockBaseFieldFactory = (props: any) => { + mockBaseField(props) + const MockField = ({ form }: { form: any }) => ( +
+ {props.config?.label} + form.setFieldValue?.(props.config?.variable, e.target.value)} + /> +
+ ) + return MockField + } + return MockBaseFieldFactory +}) + +// Mock useAppForm +const mockHandleSubmit = jest.fn() +const mockFormValues: Record = {} +jest.mock('@/app/components/base/form', () => ({ + useAppForm: (options: any) => { + const formOptions = options + return { + handleSubmit: () => { + const validationResult = formOptions.validators?.onSubmit?.({ value: mockFormValues }) + if (!validationResult) { + mockHandleSubmit() + formOptions.onSubmit?.({ value: mockFormValues }) + } + }, + getFieldValue: (field: string) => mockFormValues[field], + setFieldValue: (field: string, value: any) => { + mockFormValues[field] = value + }, + } + }, +})) + +// ========================================== +// Test Data Builders +// ========================================== + +const createMockVariable = (overrides?: Partial): RAGPipelineVariables[0] => ({ + belong_to_node_id: 'node-1', + type: PipelineInputVarType.textInput, + label: 'Test Label', + variable: 'test_variable', + max_length: 100, + default_value: '', + placeholder: 'Enter value', + required: true, + ...overrides, +}) + +const createMockVariables = (count = 1): RAGPipelineVariables => { + return Array.from({ length: count }, (_, i) => + createMockVariable({ + variable: `variable_${i}`, + label: `Label ${i}`, + }), + ) +} + +const createMockConfiguration = (overrides?: Partial): any => ({ + type: BaseFieldType.textInput, + variable: 'test_variable', + label: 'Test Label', + required: true, + maxLength: 100, + options: [], + showConditions: [], + placeholder: 'Enter value', + ...overrides, +}) + +type OptionsProps = React.ComponentProps + +const createDefaultProps = (overrides?: Partial): OptionsProps => ({ + variables: createMockVariables(), + step: CrawlStep.init, + runDisabled: false, + onSubmit: jest.fn(), + ...overrides, +}) + +// ========================================== +// Test Suites +// ========================================== +describe('Options', () => { + let toastNotifySpy: jest.SpyInstance + + beforeEach(() => { + jest.clearAllMocks() + + // Spy on Toast.notify instead of mocking the entire module + toastNotifySpy = jest.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: jest.fn() })) + + // Reset mock form values + Object.keys(mockFormValues).forEach(key => delete mockFormValues[key]) + + // Default mock return values - using real generateZodSchema + mockUseInitialData.mockReturnValue({}) + mockUseConfigurations.mockReturnValue([createMockConfiguration()]) + }) + + afterEach(() => { + toastNotifySpy.mockRestore() + }) + + // ========================================== + // Rendering Tests + // ========================================== + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert + expect(container.querySelector('form')).toBeInTheDocument() + }) + + it('should render options header with toggle text', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByText(/options/i)).toBeInTheDocument() + }) + + it('should render Run button', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByRole('button')).toBeInTheDocument() + expect(screen.getByText(/run/i)).toBeInTheDocument() + }) + + it('should render form fields when not folded', () => { + // Arrange + const configurations = [ + createMockConfiguration({ variable: 'url', label: 'URL' }), + createMockConfiguration({ variable: 'depth', label: 'Depth' }), + ] + mockUseConfigurations.mockReturnValue(configurations) + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('field-url')).toBeInTheDocument() + expect(screen.getByTestId('field-depth')).toBeInTheDocument() + }) + + it('should render arrow icon in correct orientation when expanded', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert - Arrow should not have -rotate-90 class when expanded + const arrowIcon = container.querySelector('svg') + expect(arrowIcon).toBeInTheDocument() + expect(arrowIcon).not.toHaveClass('-rotate-90') + }) + }) + + // ========================================== + // Props Testing + // ========================================== + describe('Props', () => { + describe('variables prop', () => { + it('should pass variables to useInitialData hook', () => { + // Arrange + const variables = createMockVariables(3) + const props = createDefaultProps({ variables }) + + // Act + render() + + // Assert + expect(mockUseInitialData).toHaveBeenCalledWith(variables) + }) + + it('should pass variables to useConfigurations hook', () => { + // Arrange + const variables = createMockVariables(2) + const props = createDefaultProps({ variables }) + + // Act + render() + + // Assert + expect(mockUseConfigurations).toHaveBeenCalledWith(variables) + }) + + it('should render correct number of fields based on configurations', () => { + // Arrange + const configurations = [ + createMockConfiguration({ variable: 'field_1', label: 'Field 1' }), + createMockConfiguration({ variable: 'field_2', label: 'Field 2' }), + createMockConfiguration({ variable: 'field_3', label: 'Field 3' }), + ] + mockUseConfigurations.mockReturnValue(configurations) + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('field-field_1')).toBeInTheDocument() + expect(screen.getByTestId('field-field_2')).toBeInTheDocument() + expect(screen.getByTestId('field-field_3')).toBeInTheDocument() + }) + + it('should handle empty variables array', () => { + // Arrange + mockUseConfigurations.mockReturnValue([]) + const props = createDefaultProps({ variables: [] }) + + // Act + const { container } = render() + + // Assert + expect(container.querySelector('form')).toBeInTheDocument() + expect(screen.queryByTestId(/field-/)).not.toBeInTheDocument() + }) + }) + + describe('step prop', () => { + it('should show "Run" text when step is init', () => { + // Arrange + const props = createDefaultProps({ step: CrawlStep.init }) + + // Act + render() + + // Assert + expect(screen.getByText(/run/i)).toBeInTheDocument() + }) + + it('should show "Running" text when step is running', () => { + // Arrange + const props = createDefaultProps({ step: CrawlStep.running }) + + // Act + render() + + // Assert + expect(screen.getByText(/running/i)).toBeInTheDocument() + }) + + it('should disable button when step is running', () => { + // Arrange + const props = createDefaultProps({ step: CrawlStep.running }) + + // Act + render() + + // Assert + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('should enable button when step is finished', () => { + // Arrange + const props = createDefaultProps({ step: CrawlStep.finished, runDisabled: false }) + + // Act + render() + + // Assert + expect(screen.getByRole('button')).not.toBeDisabled() + }) + + it('should show loading state on button when step is running', () => { + // Arrange + const props = createDefaultProps({ step: CrawlStep.running }) + + // Act + render() + + // Assert - Button should have loading prop which disables it + const button = screen.getByRole('button') + expect(button).toBeDisabled() + }) + }) + + describe('runDisabled prop', () => { + it('should disable button when runDisabled is true', () => { + // Arrange + const props = createDefaultProps({ runDisabled: true }) + + // Act + render() + + // Assert + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('should enable button when runDisabled is false and step is not running', () => { + // Arrange + const props = createDefaultProps({ runDisabled: false, step: CrawlStep.init }) + + // Act + render() + + // Assert + expect(screen.getByRole('button')).not.toBeDisabled() + }) + + it('should disable button when both runDisabled is true and step is running', () => { + // Arrange + const props = createDefaultProps({ runDisabled: true, step: CrawlStep.running }) + + // Act + render() + + // Assert + expect(screen.getByRole('button')).toBeDisabled() + }) + + it('should default runDisabled to undefined (falsy)', () => { + // Arrange + const props = createDefaultProps() + delete (props as any).runDisabled + + // Act + render() + + // Assert + expect(screen.getByRole('button')).not.toBeDisabled() + }) + }) + + describe('onSubmit prop', () => { + it('should call onSubmit when form is submitted successfully', () => { + // Arrange - Use non-required field so validation passes + const config = createMockConfiguration({ + variable: 'optional_field', + required: false, + type: BaseFieldType.textInput, + }) + mockUseConfigurations.mockReturnValue([config]) + const mockOnSubmit = jest.fn() + const props = createDefaultProps({ onSubmit: mockOnSubmit }) + + // Act + render() + fireEvent.click(screen.getByRole('button')) + + // Assert + expect(mockOnSubmit).toHaveBeenCalled() + }) + + it('should not call onSubmit when validation fails', () => { + // Arrange + const mockOnSubmit = jest.fn() + // Create a required field configuration + const requiredConfig = createMockConfiguration({ + variable: 'url', + label: 'URL', + required: true, + type: BaseFieldType.textInput, + }) + mockUseConfigurations.mockReturnValue([requiredConfig]) + // mockFormValues is empty, so required field validation will fail + const props = createDefaultProps({ onSubmit: mockOnSubmit }) + + // Act + render() + fireEvent.click(screen.getByRole('button')) + + // Assert + expect(mockOnSubmit).not.toHaveBeenCalled() + }) + + it('should pass form values to onSubmit', () => { + // Arrange - Use non-required fields so validation passes + const configs = [ + createMockConfiguration({ variable: 'url', required: false, type: BaseFieldType.textInput }), + createMockConfiguration({ variable: 'depth', required: false, type: BaseFieldType.numberInput }), + ] + mockUseConfigurations.mockReturnValue(configs) + mockFormValues.url = 'https://example.com' + mockFormValues.depth = 2 + const mockOnSubmit = jest.fn() + const props = createDefaultProps({ onSubmit: mockOnSubmit }) + + // Act + render() + fireEvent.click(screen.getByRole('button')) + + // Assert + expect(mockOnSubmit).toHaveBeenCalledWith({ url: 'https://example.com', depth: 2 }) + }) + }) + }) + + // ========================================== + // Side Effects and Cleanup (useEffect) + // ========================================== + describe('Side Effects and Cleanup', () => { + it('should expand options when step changes to init', () => { + // Arrange + const props = createDefaultProps({ step: CrawlStep.finished }) + const { rerender, container } = render() + + // Act - Change step to init + rerender() + + // Assert - Fields should be visible (expanded) + expect(screen.getByTestId('field-test_variable')).toBeInTheDocument() + const arrowIcon = container.querySelector('svg') + expect(arrowIcon).not.toHaveClass('-rotate-90') + }) + + it('should collapse options when step changes to running', () => { + // Arrange + const props = createDefaultProps({ step: CrawlStep.init }) + const { rerender, container } = render() + + // Assert - Initially expanded + expect(screen.getByTestId('field-test_variable')).toBeInTheDocument() + + // Act - Change step to running + rerender() + + // Assert - Should collapse (fields hidden, arrow rotated) + expect(screen.queryByTestId('field-test_variable')).not.toBeInTheDocument() + const arrowIcon = container.querySelector('svg') + expect(arrowIcon).toHaveClass('-rotate-90') + }) + + it('should collapse options when step changes to finished', () => { + // Arrange + const props = createDefaultProps({ step: CrawlStep.init }) + const { rerender, container } = render() + + // Act - Change step to finished + rerender() + + // Assert - Should collapse + expect(screen.queryByTestId('field-test_variable')).not.toBeInTheDocument() + const arrowIcon = container.querySelector('svg') + expect(arrowIcon).toHaveClass('-rotate-90') + }) + + it('should respond to step transitions from init -> running -> finished', () => { + // Arrange + const props = createDefaultProps({ step: CrawlStep.init }) + const { rerender, container } = render() + + // Assert - Initially expanded + expect(screen.getByTestId('field-test_variable')).toBeInTheDocument() + + // Act - Transition to running + rerender() + + // Assert - Collapsed + expect(screen.queryByTestId('field-test_variable')).not.toBeInTheDocument() + let arrowIcon = container.querySelector('svg') + expect(arrowIcon).toHaveClass('-rotate-90') + + // Act - Transition to finished + rerender() + + // Assert - Still collapsed + expect(screen.queryByTestId('field-test_variable')).not.toBeInTheDocument() + arrowIcon = container.querySelector('svg') + expect(arrowIcon).toHaveClass('-rotate-90') + }) + + it('should expand when step transitions from finished to init', () => { + // Arrange + const props = createDefaultProps({ step: CrawlStep.finished }) + const { rerender } = render() + + // Assert - Initially collapsed when finished + expect(screen.queryByTestId('field-test_variable')).not.toBeInTheDocument() + + // Act - Transition back to init + rerender() + + // Assert - Should expand + expect(screen.getByTestId('field-test_variable')).toBeInTheDocument() + }) + }) + + // ========================================== + // Memoization Logic and Dependencies + // ========================================== + describe('Memoization Logic and Dependencies', () => { + it('should regenerate schema when configurations change', () => { + // Arrange + const config1 = [createMockConfiguration({ variable: 'url' })] + const config2 = [createMockConfiguration({ variable: 'depth' })] + mockUseConfigurations.mockReturnValue(config1) + const props = createDefaultProps() + const { rerender } = render() + + // Assert - First render creates schema + expect(screen.getByTestId('field-url')).toBeInTheDocument() + + // Act - Change configurations + mockUseConfigurations.mockReturnValue(config2) + rerender() + + // Assert - New field is rendered with new schema + expect(screen.getByTestId('field-depth')).toBeInTheDocument() + }) + + it('should compute isRunning correctly for init step', () => { + // Arrange + const props = createDefaultProps({ step: CrawlStep.init }) + + // Act + render() + + // Assert - Button should not be in loading state + const button = screen.getByRole('button') + expect(button).not.toBeDisabled() + expect(screen.getByText(/run/i)).toBeInTheDocument() + }) + + it('should compute isRunning correctly for running step', () => { + // Arrange + const props = createDefaultProps({ step: CrawlStep.running }) + + // Act + render() + + // Assert - Button should be in loading state + const button = screen.getByRole('button') + expect(button).toBeDisabled() + expect(screen.getByText(/running/i)).toBeInTheDocument() + }) + + it('should compute isRunning correctly for finished step', () => { + // Arrange + const props = createDefaultProps({ step: CrawlStep.finished }) + + // Act + render() + + // Assert - Button should not be in loading state + expect(screen.getByText(/run/i)).toBeInTheDocument() + }) + + it('should use memoized schema for validation', () => { + // Arrange - Use real generateZodSchema with valid configuration + const config = createMockConfiguration({ + variable: 'test_field', + required: false, // Not required so validation passes with empty value + }) + mockUseConfigurations.mockReturnValue([config]) + const mockOnSubmit = jest.fn() + const props = createDefaultProps({ onSubmit: mockOnSubmit }) + render() + + // Act - Trigger validation via submit + fireEvent.click(screen.getByRole('button')) + + // Assert - onSubmit should be called if validation passes + expect(mockOnSubmit).toHaveBeenCalled() + }) + }) + + // ========================================== + // User Interactions and Event Handlers + // ========================================== + describe('User Interactions and Event Handlers', () => { + it('should toggle fold state when header is clicked', () => { + // Arrange + const props = createDefaultProps() + render() + + // Assert - Initially expanded + expect(screen.getByTestId('field-test_variable')).toBeInTheDocument() + + // Act - Click to fold + fireEvent.click(screen.getByText(/options/i)) + + // Assert - Should be folded + expect(screen.queryByTestId('field-test_variable')).not.toBeInTheDocument() + + // Act - Click to unfold + fireEvent.click(screen.getByText(/options/i)) + + // Assert - Should be expanded again + expect(screen.getByTestId('field-test_variable')).toBeInTheDocument() + }) + + it('should prevent default and stop propagation on form submit', () => { + // Arrange + const props = createDefaultProps() + const { container } = render() + + // Act + const form = container.querySelector('form')! + const mockPreventDefault = jest.fn() + const mockStopPropagation = jest.fn() + + fireEvent.submit(form, { + preventDefault: mockPreventDefault, + stopPropagation: mockStopPropagation, + }) + + // Assert - The form element handles submit event + expect(form).toBeInTheDocument() + }) + + it('should trigger form submit when button is clicked', () => { + // Arrange - Use non-required field so validation passes + const config = createMockConfiguration({ + variable: 'optional_field', + required: false, + type: BaseFieldType.textInput, + }) + mockUseConfigurations.mockReturnValue([config]) + const mockOnSubmit = jest.fn() + const props = createDefaultProps({ onSubmit: mockOnSubmit }) + render() + + // Act + fireEvent.click(screen.getByRole('button')) + + // Assert + expect(mockOnSubmit).toHaveBeenCalled() + }) + + it('should not trigger submit when button is disabled', () => { + // Arrange + const mockOnSubmit = jest.fn() + const props = createDefaultProps({ onSubmit: mockOnSubmit, runDisabled: true }) + render() + + // Act - Try to click disabled button + fireEvent.click(screen.getByRole('button')) + + // Assert + expect(mockOnSubmit).not.toHaveBeenCalled() + }) + + it('should maintain fold state after form submission', () => { + // Arrange + const props = createDefaultProps() + render() + + // Assert - Initially expanded + expect(screen.getByTestId('field-test_variable')).toBeInTheDocument() + + // Act - Submit form + fireEvent.click(screen.getByRole('button')) + + // Assert - Should still be expanded (unless step changes) + expect(screen.getByTestId('field-test_variable')).toBeInTheDocument() + }) + + it('should allow clicking on arrow icon container to toggle', () => { + // Arrange + const props = createDefaultProps() + const { container } = render() + + // Assert - Initially expanded + expect(screen.getByTestId('field-test_variable')).toBeInTheDocument() + + // Act - Click on the toggle container (parent of the options text and arrow) + const toggleContainer = container.querySelector('.cursor-pointer') + fireEvent.click(toggleContainer!) + + // Assert - Should be folded + expect(screen.queryByTestId('field-test_variable')).not.toBeInTheDocument() + }) + }) + + // ========================================== + // Edge Cases and Error Handling + // ========================================== + describe('Edge Cases and Error Handling', () => { + it('should handle validation error and show toast', () => { + // Arrange - Create required field that will fail validation when empty + const requiredConfig = createMockConfiguration({ + variable: 'url', + label: 'URL', + required: true, + type: BaseFieldType.textInput, + }) + mockUseConfigurations.mockReturnValue([requiredConfig]) + // mockFormValues.url is undefined, so validation will fail + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByRole('button')) + + // Assert - Toast should be called with error message + expect(toastNotifySpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ) + }) + + it('should handle validation error and display field name in message', () => { + // Arrange - Create required field that will fail validation + const requiredConfig = createMockConfiguration({ + variable: 'email_address', + label: 'Email Address', + required: true, + type: BaseFieldType.textInput, + }) + mockUseConfigurations.mockReturnValue([requiredConfig]) + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByRole('button')) + + // Assert - Toast message should contain field path + expect(toastNotifySpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: expect.stringContaining('email_address'), + }), + ) + }) + + it('should handle empty variables gracefully', () => { + // Arrange + mockUseConfigurations.mockReturnValue([]) + const props = createDefaultProps({ variables: [] }) + + // Act + const { container } = render() + + // Assert - Should render without errors + expect(container.querySelector('form')).toBeInTheDocument() + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should handle single variable configuration', () => { + // Arrange + const singleConfig = [createMockConfiguration({ variable: 'only_field' })] + mockUseConfigurations.mockReturnValue(singleConfig) + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('field-only_field')).toBeInTheDocument() + }) + + it('should handle many configurations', () => { + // Arrange + const manyConfigs = Array.from({ length: 10 }, (_, i) => + createMockConfiguration({ variable: `field_${i}`, label: `Field ${i}` }), + ) + mockUseConfigurations.mockReturnValue(manyConfigs) + const props = createDefaultProps() + + // Act + render() + + // Assert + for (let i = 0; i < 10; i++) + expect(screen.getByTestId(`field-field_${i}`)).toBeInTheDocument() + }) + + it('should handle validation with multiple required fields (shows first error)', () => { + // Arrange - Multiple required fields + const configs = [ + createMockConfiguration({ variable: 'url', label: 'URL', required: true, type: BaseFieldType.textInput }), + createMockConfiguration({ variable: 'depth', label: 'Depth', required: true, type: BaseFieldType.textInput }), + ] + mockUseConfigurations.mockReturnValue(configs) + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByRole('button')) + + // Assert - Toast should be called once (only first error) + expect(toastNotifySpy).toHaveBeenCalledTimes(1) + expect(toastNotifySpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + }), + ) + }) + + it('should handle validation pass when all required fields have values', () => { + // Arrange + const requiredConfig = createMockConfiguration({ + variable: 'url', + label: 'URL', + required: true, + type: BaseFieldType.textInput, + }) + mockUseConfigurations.mockReturnValue([requiredConfig]) + mockFormValues.url = 'https://example.com' // Provide valid value + const mockOnSubmit = jest.fn() + const props = createDefaultProps({ onSubmit: mockOnSubmit }) + render() + + // Act + fireEvent.click(screen.getByRole('button')) + + // Assert - No toast error, onSubmit called + expect(toastNotifySpy).not.toHaveBeenCalled() + expect(mockOnSubmit).toHaveBeenCalled() + }) + + it('should handle undefined variables gracefully', () => { + // Arrange + mockUseInitialData.mockReturnValue({}) + mockUseConfigurations.mockReturnValue([]) + const props = createDefaultProps({ variables: undefined as any }) + + // Act & Assert - Should not throw + expect(() => render()).not.toThrow() + }) + + it('should handle rapid fold/unfold toggling', () => { + // Arrange + const props = createDefaultProps() + render() + + // Act - Toggle rapidly multiple times + const toggleText = screen.getByText(/options/i) + for (let i = 0; i < 5; i++) + fireEvent.click(toggleText) + + // Assert - Final state should be folded (odd number of clicks) + expect(screen.queryByTestId('field-test_variable')).not.toBeInTheDocument() + }) + }) + + // ========================================== + // All Prop Variations + // ========================================== + describe('Prop Variations', () => { + it.each([ + [{ step: CrawlStep.init, runDisabled: false }, false, 'run'], + [{ step: CrawlStep.init, runDisabled: true }, true, 'run'], + [{ step: CrawlStep.running, runDisabled: false }, true, 'running'], + [{ step: CrawlStep.running, runDisabled: true }, true, 'running'], + [{ step: CrawlStep.finished, runDisabled: false }, false, 'run'], + [{ step: CrawlStep.finished, runDisabled: true }, true, 'run'], + ] as const)('should render correctly with step=%s, runDisabled=%s', (propVariation, expectedDisabled, expectedText) => { + // Arrange + const props = createDefaultProps(propVariation) + + // Act + render() + + // Assert + const button = screen.getByRole('button') + if (expectedDisabled) + expect(button).toBeDisabled() + else + expect(button).not.toBeDisabled() + + expect(screen.getByText(new RegExp(expectedText, 'i'))).toBeInTheDocument() + }) + + it('should handle all CrawlStep values', () => { + // Arrange & Act & Assert + Object.values(CrawlStep).forEach((step) => { + const props = createDefaultProps({ step }) + const { unmount, container } = render() + expect(container.querySelector('form')).toBeInTheDocument() + unmount() + }) + }) + + it('should handle variables with different types', () => { + // Arrange + const variables: RAGPipelineVariables = [ + createMockVariable({ type: PipelineInputVarType.textInput, variable: 'text_field' }), + createMockVariable({ type: PipelineInputVarType.paragraph, variable: 'paragraph_field' }), + createMockVariable({ type: PipelineInputVarType.number, variable: 'number_field' }), + createMockVariable({ type: PipelineInputVarType.checkbox, variable: 'checkbox_field' }), + createMockVariable({ type: PipelineInputVarType.select, variable: 'select_field' }), + ] + const configurations = variables.map(v => createMockConfiguration({ variable: v.variable })) + mockUseConfigurations.mockReturnValue(configurations) + const props = createDefaultProps({ variables }) + + // Act + render() + + // Assert + variables.forEach((v) => { + expect(screen.getByTestId(`field-${v.variable}`)).toBeInTheDocument() + }) + }) + }) + + // ========================================== + // Form Validation + // ========================================== + describe('Form Validation', () => { + it('should pass validation with valid data', () => { + // Arrange - Use non-required field so empty value passes + const config = createMockConfiguration({ + variable: 'optional_field', + required: false, + type: BaseFieldType.textInput, + }) + mockUseConfigurations.mockReturnValue([config]) + const mockOnSubmit = jest.fn() + const props = createDefaultProps({ onSubmit: mockOnSubmit }) + render() + + // Act + fireEvent.click(screen.getByRole('button')) + + // Assert + expect(mockOnSubmit).toHaveBeenCalled() + expect(toastNotifySpy).not.toHaveBeenCalled() + }) + + it('should fail validation with invalid data', () => { + // Arrange - Required field with empty value + const config = createMockConfiguration({ + variable: 'url', + label: 'URL', + required: true, + type: BaseFieldType.textInput, + }) + mockUseConfigurations.mockReturnValue([config]) + const mockOnSubmit = jest.fn() + const props = createDefaultProps({ onSubmit: mockOnSubmit }) + render() + + // Act + fireEvent.click(screen.getByRole('button')) + + // Assert + expect(mockOnSubmit).not.toHaveBeenCalled() + expect(toastNotifySpy).toHaveBeenCalled() + }) + + it('should show error toast message when validation fails', () => { + // Arrange - Required field with empty value + const config = createMockConfiguration({ + variable: 'my_field', + label: 'My Field', + required: true, + type: BaseFieldType.textInput, + }) + mockUseConfigurations.mockReturnValue([config]) + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByRole('button')) + + // Assert + expect(toastNotifySpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'error', + message: expect.any(String), + }), + ) + }) + }) + + // ========================================== + // Styling Tests + // ========================================== + describe('Styling', () => { + it('should apply correct container classes to form', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert + const form = container.querySelector('form') + expect(form).toHaveClass('w-full') + }) + + it('should apply cursor-pointer class to toggle container', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert + const toggleContainer = container.querySelector('.cursor-pointer') + expect(toggleContainer).toBeInTheDocument() + }) + + it('should apply select-none class to prevent text selection on toggle', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert + const toggleContainer = container.querySelector('.select-none') + expect(toggleContainer).toBeInTheDocument() + }) + + it('should apply rotate class to arrow icon when folded', () => { + // Arrange + const props = createDefaultProps() + const { container } = render() + + // Act - Fold the options + fireEvent.click(screen.getByText(/options/i)) + + // Assert + const arrowIcon = container.querySelector('svg') + expect(arrowIcon).toHaveClass('-rotate-90') + }) + + it('should not apply rotate class to arrow icon when expanded', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert + const arrowIcon = container.querySelector('svg') + expect(arrowIcon).not.toHaveClass('-rotate-90') + }) + + it('should apply border class to fields container when expanded', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert + const fieldsContainer = container.querySelector('.border-t') + expect(fieldsContainer).toBeInTheDocument() + }) + }) + + // ========================================== + // BaseField Integration + // ========================================== + describe('BaseField Integration', () => { + it('should pass correct props to BaseField factory', () => { + // Arrange + const config = createMockConfiguration({ variable: 'test_var', label: 'Test Label' }) + mockUseConfigurations.mockReturnValue([config]) + mockUseInitialData.mockReturnValue({ test_var: 'default_value' }) + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(mockBaseField).toHaveBeenCalledWith( + expect.objectContaining({ + initialData: { test_var: 'default_value' }, + config, + }), + ) + }) + + it('should render unique key for each field', () => { + // Arrange + const configurations = [ + createMockConfiguration({ variable: 'field_a' }), + createMockConfiguration({ variable: 'field_b' }), + createMockConfiguration({ variable: 'field_c' }), + ] + mockUseConfigurations.mockReturnValue(configurations) + const props = createDefaultProps() + + // Act + render() + + // Assert - All fields should be rendered (React would warn if keys aren't unique) + expect(screen.getByTestId('field-field_a')).toBeInTheDocument() + expect(screen.getByTestId('field-field_b')).toBeInTheDocument() + expect(screen.getByTestId('field-field_c')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.spec.tsx new file mode 100644 index 0000000000..8e28a43b2e --- /dev/null +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/website-crawl/index.spec.tsx @@ -0,0 +1,1497 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import React from 'react' +import WebsiteCrawl from './index' +import type { CrawlResultItem } from '@/models/datasets' +import { CrawlStep } from '@/models/datasets' +import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types' +import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' + +// ========================================== +// 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: string | undefined = '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 useGetDataSourceAuth - API service hook requires mocking +const mockUseGetDataSourceAuth = jest.fn() +jest.mock('@/service/use-datasource', () => ({ + useGetDataSourceAuth: (params: any) => mockUseGetDataSourceAuth(params), +})) + +// Mock usePipeline hooks - API service hooks require mocking +const mockUseDraftPipelinePreProcessingParams = jest.fn() +const mockUsePublishedPipelinePreProcessingParams = jest.fn() +jest.mock('@/service/use-pipeline', () => ({ + useDraftPipelinePreProcessingParams: (...args: any[]) => mockUseDraftPipelinePreProcessingParams(...args), + usePublishedPipelinePreProcessingParams: (...args: any[]) => mockUsePublishedPipelinePreProcessingParams(...args), +})) + +// Note: zustand/react/shallow useShallow is imported directly (simple utility function) + +// Mock store +const mockStoreState = { + crawlResult: undefined as { data: CrawlResultItem[]; time_consuming: number | string } | undefined, + step: CrawlStep.init, + websitePages: [] as CrawlResultItem[], + previewIndex: -1, + currentCredentialId: '', + setWebsitePages: jest.fn(), + setCurrentWebsite: jest.fn(), + setPreviewIndex: jest.fn(), + setStep: jest.fn(), + setCrawlResult: 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) => ( +
+ {props.docTitle} + {props.docLink} + {props.pluginName} + {props.currentCredentialId} + + + {props.credentials?.length || 0} +
+ ) + return MockHeader +}) + +// Mock Options component +const mockOptionsSubmit = jest.fn() +jest.mock('./base/options', () => { + const MockOptions = (props: any) => ( +
+ {props.step} + {String(props.runDisabled)} + {props.variables?.length || 0} + +
+ ) + return MockOptions +}) + +// Mock Crawling component +jest.mock('./base/crawling', () => { + const MockCrawling = (props: any) => ( +
+ {props.crawledNum} + {props.totalNum} +
+ ) + return MockCrawling +}) + +// Mock ErrorMessage component +jest.mock('./base/error-message', () => { + const MockErrorMessage = (props: any) => ( +
+ {props.title} + {props.errorMsg} +
+ ) + return MockErrorMessage +}) + +// Mock CrawledResult component +jest.mock('./base/crawled-result', () => { + const MockCrawledResult = (props: any) => ( +
+ {props.list?.length || 0} + {props.checkedList?.length || 0} + {props.usedTime} + {props.previewIndex} + {String(props.showPreview)} + {String(props.isMultipleChoice)} + + +
+ ) + return MockCrawledResult +}) + +// ========================================== +// Test Data Builders +// ========================================== +const createMockNodeData = (overrides?: Partial): DataSourceNodeType => ({ + title: 'Test Node', + plugin_id: 'plugin-123', + provider_type: 'website', + provider_name: 'website-provider', + datasource_name: 'website-ds', + datasource_label: 'Website Crawler', + datasource_parameters: {}, + datasource_configurations: {}, + ...overrides, +} as DataSourceNodeType) + +const createMockCrawlResultItem = (overrides?: Partial): CrawlResultItem => ({ + source_url: 'https://example.com/page1', + title: 'Test Page 1', + markdown: '# Test content', + description: 'Test description', + ...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 WebsiteCrawlProps = React.ComponentProps + +const createDefaultProps = (overrides?: Partial): WebsiteCrawlProps => ({ + nodeId: 'node-1', + nodeData: createMockNodeData(), + onCredentialChange: jest.fn(), + isInPipeline: false, + supportBatchUpload: true, + ...overrides, +}) + +// ========================================== +// Test Suites +// ========================================== +describe('WebsiteCrawl', () => { + beforeEach(() => { + jest.clearAllMocks() + + // Reset store state + mockStoreState.crawlResult = undefined + mockStoreState.step = CrawlStep.init + mockStoreState.websitePages = [] + mockStoreState.previewIndex = -1 + mockStoreState.currentCredentialId = '' + mockStoreState.setWebsitePages = jest.fn() + mockStoreState.setCurrentWebsite = jest.fn() + mockStoreState.setPreviewIndex = jest.fn() + mockStoreState.setStep = jest.fn() + mockStoreState.setCrawlResult = jest.fn() + + // Reset context values + mockPipelineId = 'pipeline-123' + mockSetShowAccountSettingModal.mockClear() + + // Default mock return values + mockUseGetDataSourceAuth.mockReturnValue({ + data: { result: [createMockCredential()] }, + }) + + mockUseDraftPipelinePreProcessingParams.mockReturnValue({ + data: { variables: [] }, + isFetching: false, + }) + + mockUsePublishedPipelinePreProcessingParams.mockReturnValue({ + data: { variables: [] }, + isFetching: false, + }) + + mockGetState.mockReturnValue(mockStoreState) + }) + + // ========================================== + // Rendering Tests + // ========================================== + describe('Rendering', () => { + it('should render without crashing', () => { + // Arrange + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('header')).toBeInTheDocument() + expect(screen.getByTestId('options')).toBeInTheDocument() + }) + + it('should render Header with correct props', () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-123' + const props = createDefaultProps({ + nodeData: createMockNodeData({ datasource_label: 'My Website Crawler' }), + }) + + // Act + render() + + // Assert + expect(screen.getByTestId('header-doc-title')).toHaveTextContent('Docs') + expect(screen.getByTestId('header-plugin-name')).toHaveTextContent('My Website Crawler') + expect(screen.getByTestId('header-credential-id')).toHaveTextContent('cred-123') + }) + + it('should render Options with correct props', () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('options')).toBeInTheDocument() + expect(screen.getByTestId('options-step')).toHaveTextContent(CrawlStep.init) + }) + + it('should not render Crawling or CrawledResult when step is init', () => { + // Arrange + mockStoreState.step = CrawlStep.init + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.queryByTestId('crawling')).not.toBeInTheDocument() + expect(screen.queryByTestId('crawled-result')).not.toBeInTheDocument() + expect(screen.queryByTestId('error-message')).not.toBeInTheDocument() + }) + + it('should render Crawling when step is running', () => { + // Arrange + mockStoreState.step = CrawlStep.running + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('crawling')).toBeInTheDocument() + expect(screen.queryByTestId('crawled-result')).not.toBeInTheDocument() + expect(screen.queryByTestId('error-message')).not.toBeInTheDocument() + }) + + it('should render CrawledResult when step is finished with no error', () => { + // Arrange + mockStoreState.step = CrawlStep.finished + mockStoreState.crawlResult = { + data: [createMockCrawlResultItem()], + time_consuming: 1.5, + } + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('crawled-result')).toBeInTheDocument() + expect(screen.queryByTestId('crawling')).not.toBeInTheDocument() + expect(screen.queryByTestId('error-message')).not.toBeInTheDocument() + }) + }) + + // ========================================== + // Props Testing + // ========================================== + describe('Props', () => { + describe('nodeId prop', () => { + it('should use nodeId in datasourceNodeRunURL for non-pipeline mode', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + const props = createDefaultProps({ + nodeId: 'custom-node-id', + isInPipeline: false, + }) + + // Act + render() + + // Assert - Options uses nodeId through usePreProcessingParams + expect(mockUsePublishedPipelinePreProcessingParams).toHaveBeenCalledWith( + { pipeline_id: 'pipeline-123', node_id: 'custom-node-id' }, + true, + ) + }) + }) + + describe('nodeData prop', () => { + 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() + + // Assert + expect(mockUseGetDataSourceAuth).toHaveBeenCalledWith({ + pluginId: 'my-plugin-id', + provider: 'my-provider', + }) + }) + + it('should pass datasource_label to Header as pluginName', () => { + // Arrange + const nodeData = createMockNodeData({ + datasource_label: 'Custom Website Scraper', + }) + const props = createDefaultProps({ nodeData }) + + // Act + render() + + // Assert + expect(screen.getByTestId('header-plugin-name')).toHaveTextContent('Custom Website Scraper') + }) + }) + + describe('isInPipeline prop', () => { + it('should use draft URL when isInPipeline is true', () => { + // Arrange + const props = createDefaultProps({ isInPipeline: true }) + + // Act + render() + + // Assert + expect(mockUseDraftPipelinePreProcessingParams).toHaveBeenCalled() + expect(mockUsePublishedPipelinePreProcessingParams).not.toHaveBeenCalled() + }) + + it('should use published URL when isInPipeline is false', () => { + // Arrange + const props = createDefaultProps({ isInPipeline: false }) + + // Act + render() + + // Assert + expect(mockUsePublishedPipelinePreProcessingParams).toHaveBeenCalled() + expect(mockUseDraftPipelinePreProcessingParams).not.toHaveBeenCalled() + }) + + it('should pass showPreview as false to CrawledResult when isInPipeline is true', () => { + // Arrange + mockStoreState.step = CrawlStep.finished + mockStoreState.crawlResult = { + data: [createMockCrawlResultItem()], + time_consuming: 1.5, + } + const props = createDefaultProps({ isInPipeline: true }) + + // Act + render() + + // Assert + expect(screen.getByTestId('crawled-result-show-preview')).toHaveTextContent('false') + }) + + it('should pass showPreview as true to CrawledResult when isInPipeline is false', () => { + // Arrange + mockStoreState.step = CrawlStep.finished + mockStoreState.crawlResult = { + data: [createMockCrawlResultItem()], + time_consuming: 1.5, + } + const props = createDefaultProps({ isInPipeline: false }) + + // Act + render() + + // Assert + expect(screen.getByTestId('crawled-result-show-preview')).toHaveTextContent('true') + }) + }) + + describe('supportBatchUpload prop', () => { + it('should pass isMultipleChoice as true to CrawledResult when supportBatchUpload is true', () => { + // Arrange + mockStoreState.step = CrawlStep.finished + mockStoreState.crawlResult = { + data: [createMockCrawlResultItem()], + time_consuming: 1.5, + } + const props = createDefaultProps({ supportBatchUpload: true }) + + // Act + render() + + // Assert + expect(screen.getByTestId('crawled-result-multiple-choice')).toHaveTextContent('true') + }) + + it('should pass isMultipleChoice as false to CrawledResult when supportBatchUpload is false', () => { + // Arrange + mockStoreState.step = CrawlStep.finished + mockStoreState.crawlResult = { + data: [createMockCrawlResultItem()], + time_consuming: 1.5, + } + const props = createDefaultProps({ supportBatchUpload: false }) + + // Act + render() + + // Assert + expect(screen.getByTestId('crawled-result-multiple-choice')).toHaveTextContent('false') + }) + + it.each([ + [true, 'true'], + [false, 'false'], + [undefined, 'true'], // Default value + ])('should handle supportBatchUpload=%s correctly', (value, expected) => { + // Arrange + mockStoreState.step = CrawlStep.finished + mockStoreState.crawlResult = { + data: [createMockCrawlResultItem()], + time_consuming: 1.5, + } + const props = createDefaultProps({ supportBatchUpload: value }) + + // Act + render() + + // Assert + expect(screen.getByTestId('crawled-result-multiple-choice')).toHaveTextContent(expected) + }) + }) + + describe('onCredentialChange prop', () => { + it('should call onCredentialChange with credential id and reset state', () => { + // Arrange + const mockOnCredentialChange = jest.fn() + const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) + + // Act + render() + fireEvent.click(screen.getByTestId('header-credential-change')) + + // Assert + expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') + }) + }) + }) + + // ========================================== + // State Management Tests + // ========================================== + describe('State Management', () => { + it('should display correct crawledNum and totalNum when running', () => { + // Arrange + mockStoreState.step = CrawlStep.running + const props = createDefaultProps() + + // Act + render() + + // Assert - Initial state is 0/0 + expect(screen.getByTestId('crawling-crawled-num')).toHaveTextContent('0') + expect(screen.getByTestId('crawling-total-num')).toHaveTextContent('0') + }) + + it('should update step and result via ssePost callbacks', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + const mockCrawlData: CrawlResultItem[] = [ + createMockCrawlResultItem({ source_url: 'https://example.com/1' }), + createMockCrawlResultItem({ source_url: 'https://example.com/2' }), + ] + + mockSsePost.mockImplementation((url, options, callbacks) => { + // Simulate processing + callbacks.onDataSourceNodeProcessing({ + total: 10, + completed: 5, + }) + // Simulate completion + callbacks.onDataSourceNodeCompleted({ + data: mockCrawlData, + time_consuming: 2.5, + }) + }) + + const props = createDefaultProps() + render() + + // Act - Trigger submit + fireEvent.click(screen.getByTestId('options-submit-btn')) + + // Assert + await waitFor(() => { + expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.running) + expect(mockStoreState.setCrawlResult).toHaveBeenCalledWith({ + data: mockCrawlData, + time_consuming: 2.5, + }) + expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.finished) + }) + }) + + it('should pass runDisabled as true when no credential is selected', () => { + // Arrange + mockStoreState.currentCredentialId = '' + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('options-run-disabled')).toHaveTextContent('true') + }) + + it('should pass runDisabled as true when params are being fetched', () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + mockUsePublishedPipelinePreProcessingParams.mockReturnValue({ + data: { variables: [] }, + isFetching: true, + }) + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('options-run-disabled')).toHaveTextContent('true') + }) + + it('should pass runDisabled as false when credential is selected and params are loaded', () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + mockUsePublishedPipelinePreProcessingParams.mockReturnValue({ + data: { variables: [] }, + isFetching: false, + }) + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('options-run-disabled')).toHaveTextContent('false') + }) + }) + + // ========================================== + // Callback Stability and Memoization + // ========================================== + describe('Callback Stability and Memoization', () => { + it('should have stable handleCheckedCrawlResultChange that updates store', () => { + // Arrange + mockStoreState.step = CrawlStep.finished + mockStoreState.crawlResult = { + data: [createMockCrawlResultItem()], + time_consuming: 1.5, + } + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByTestId('crawled-result-select-change')) + + // Assert + expect(mockStoreState.setWebsitePages).toHaveBeenCalledWith([ + { source_url: 'https://example.com', title: 'Test' }, + ]) + }) + + it('should have stable handlePreview that updates store', () => { + // Arrange + mockStoreState.step = CrawlStep.finished + mockStoreState.crawlResult = { + data: [createMockCrawlResultItem()], + time_consuming: 1.5, + } + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByTestId('crawled-result-preview')) + + // Assert + expect(mockStoreState.setCurrentWebsite).toHaveBeenCalledWith({ + source_url: 'https://example.com', + title: 'Test', + }) + expect(mockStoreState.setPreviewIndex).toHaveBeenCalledWith(0) + }) + + it('should have stable handleSetting callback', () => { + // Arrange + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByTestId('header-config-btn')) + + // Assert + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ + payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, + }) + }) + + it('should have stable handleCredentialChange that resets state', () => { + // Arrange + const mockOnCredentialChange = jest.fn() + const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) + render() + + // Act + fireEvent.click(screen.getByTestId('header-credential-change')) + + // Assert + expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') + }) + }) + + // ========================================== + // User Interactions and Event Handlers + // ========================================== + describe('User Interactions and Event Handlers', () => { + it('should handle submit and trigger ssePost', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByTestId('options-submit-btn')) + + // Assert + await waitFor(() => { + expect(mockSsePost).toHaveBeenCalled() + expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.running) + }) + }) + + it('should handle configuration button click', () => { + // Arrange + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByTestId('header-config-btn')) + + // Assert + expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({ + payload: ACCOUNT_SETTING_TAB.DATA_SOURCE, + }) + }) + + it('should handle credential change', () => { + // Arrange + const mockOnCredentialChange = jest.fn() + const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) + render() + + // Act + fireEvent.click(screen.getByTestId('header-credential-change')) + + // Assert + expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') + }) + + it('should handle selection change in CrawledResult', () => { + // Arrange + mockStoreState.step = CrawlStep.finished + mockStoreState.crawlResult = { + data: [createMockCrawlResultItem()], + time_consuming: 1.5, + } + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByTestId('crawled-result-select-change')) + + // Assert + expect(mockStoreState.setWebsitePages).toHaveBeenCalled() + }) + + it('should handle preview in CrawledResult', () => { + // Arrange + mockStoreState.step = CrawlStep.finished + mockStoreState.crawlResult = { + data: [createMockCrawlResultItem()], + time_consuming: 1.5, + } + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByTestId('crawled-result-preview')) + + // Assert + expect(mockStoreState.setCurrentWebsite).toHaveBeenCalled() + expect(mockStoreState.setPreviewIndex).toHaveBeenCalled() + }) + }) + + // ========================================== + // API Calls Mocking + // ========================================== + describe('API Calls', () => { + it('should call ssePost with correct parameters for published workflow', async () => { + // Arrange + mockStoreState.currentCredentialId = 'test-cred' + mockPipelineId = 'pipeline-456' + const props = createDefaultProps({ + nodeId: 'node-789', + isInPipeline: false, + }) + render() + + // Act + fireEvent.click(screen.getByTestId('options-submit-btn')) + + // Assert + await waitFor(() => { + expect(mockSsePost).toHaveBeenCalledWith( + '/rag/pipelines/pipeline-456/workflows/published/datasource/nodes/node-789/run', + expect.objectContaining({ + body: expect.objectContaining({ + inputs: { url: 'https://example.com', depth: 2 }, + datasource_type: 'website_crawl', + credential_id: 'test-cred', + response_mode: 'streaming', + }), + }), + expect.any(Object), + ) + }) + }) + + it('should call ssePost with correct parameters for draft workflow', async () => { + // Arrange + mockStoreState.currentCredentialId = 'test-cred' + mockPipelineId = 'pipeline-456' + const props = createDefaultProps({ + nodeId: 'node-789', + isInPipeline: true, + }) + render() + + // Act + fireEvent.click(screen.getByTestId('options-submit-btn')) + + // Assert + await waitFor(() => { + expect(mockSsePost).toHaveBeenCalledWith( + '/rag/pipelines/pipeline-456/workflows/draft/datasource/nodes/node-789/run', + expect.any(Object), + expect.any(Object), + ) + }) + }) + + it('should handle onDataSourceNodeProcessing callback correctly', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + mockStoreState.step = CrawlStep.running + + mockSsePost.mockImplementation((url, options, callbacks) => { + callbacks.onDataSourceNodeProcessing({ + total: 100, + completed: 50, + }) + }) + + const props = createDefaultProps() + const { rerender } = render() + + // Act + fireEvent.click(screen.getByTestId('options-submit-btn')) + + // Update store state to simulate running step + mockStoreState.step = CrawlStep.running + rerender() + + // Assert + await waitFor(() => { + expect(mockSsePost).toHaveBeenCalled() + }) + }) + + it('should handle onDataSourceNodeCompleted callback correctly', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + const mockCrawlData: CrawlResultItem[] = [ + createMockCrawlResultItem({ source_url: 'https://example.com/1' }), + createMockCrawlResultItem({ source_url: 'https://example.com/2' }), + ] + + mockSsePost.mockImplementation((url, options, callbacks) => { + callbacks.onDataSourceNodeCompleted({ + data: mockCrawlData, + time_consuming: 3.5, + }) + }) + + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByTestId('options-submit-btn')) + + // Assert + await waitFor(() => { + expect(mockStoreState.setCrawlResult).toHaveBeenCalledWith({ + data: mockCrawlData, + time_consuming: 3.5, + }) + expect(mockStoreState.setWebsitePages).toHaveBeenCalledWith(mockCrawlData) + expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.finished) + }) + }) + + it('should handle onDataSourceNodeCompleted with single result when supportBatchUpload is false', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + const mockCrawlData: CrawlResultItem[] = [ + createMockCrawlResultItem({ source_url: 'https://example.com/1' }), + createMockCrawlResultItem({ source_url: 'https://example.com/2' }), + createMockCrawlResultItem({ source_url: 'https://example.com/3' }), + ] + + mockSsePost.mockImplementation((url, options, callbacks) => { + callbacks.onDataSourceNodeCompleted({ + data: mockCrawlData, + time_consuming: 3.5, + }) + }) + + const props = createDefaultProps({ supportBatchUpload: false }) + render() + + // Act + fireEvent.click(screen.getByTestId('options-submit-btn')) + + // Assert + await waitFor(() => { + // Should only select first item when supportBatchUpload is false + expect(mockStoreState.setWebsitePages).toHaveBeenCalledWith([mockCrawlData[0]]) + }) + }) + + it('should handle onDataSourceNodeError callback correctly', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + + mockSsePost.mockImplementation((url, options, callbacks) => { + callbacks.onDataSourceNodeError({ + error: 'Crawl failed: Invalid URL', + }) + }) + + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByTestId('options-submit-btn')) + + // Assert + await waitFor(() => { + expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.finished) + }) + }) + + it('should use useGetDataSourceAuth with correct parameters', () => { + // Arrange + const nodeData = createMockNodeData({ + plugin_id: 'website-plugin', + provider_name: 'website-provider', + }) + const props = createDefaultProps({ nodeData }) + + // Act + render() + + // Assert + expect(mockUseGetDataSourceAuth).toHaveBeenCalledWith({ + pluginId: 'website-plugin', + provider: 'website-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() + + // 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() + + // 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() + + // 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() + + // Assert + expect(screen.getByTestId('header-credentials-count')).toHaveTextContent('0') + }) + + it('should handle empty crawlResult data array', () => { + // Arrange + mockStoreState.step = CrawlStep.finished + mockStoreState.crawlResult = { + data: [], + time_consuming: 0.5, + } + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('crawled-result-count')).toHaveTextContent('0') + }) + + it('should handle undefined crawlResult', () => { + // Arrange + mockStoreState.step = CrawlStep.finished + mockStoreState.crawlResult = undefined + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('crawled-result-count')).toHaveTextContent('0') + }) + + it('should handle time_consuming as string', () => { + // Arrange + mockStoreState.step = CrawlStep.finished + mockStoreState.crawlResult = { + data: [createMockCrawlResultItem()], + time_consuming: '2.5', + } + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.getByTestId('crawled-result-used-time')).toHaveTextContent('2.5') + }) + + it('should handle invalid time_consuming value', () => { + // Arrange + mockStoreState.step = CrawlStep.finished + mockStoreState.crawlResult = { + data: [createMockCrawlResultItem()], + time_consuming: 'invalid', + } + const props = createDefaultProps() + + // Act + render() + + // Assert - NaN should become 0 + expect(screen.getByTestId('crawled-result-used-time')).toHaveTextContent('0') + }) + + it('should handle undefined pipelineId gracefully', () => { + // Arrange + mockPipelineId = undefined + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(mockUsePublishedPipelinePreProcessingParams).toHaveBeenCalledWith( + { pipeline_id: undefined, node_id: 'node-1' }, + false, // enabled should be false when pipelineId is undefined + ) + }) + + it('should handle empty nodeId gracefully', () => { + // Arrange + const props = createDefaultProps({ nodeId: '' }) + + // Act + render() + + // Assert + expect(mockUsePublishedPipelinePreProcessingParams).toHaveBeenCalledWith( + { pipeline_id: 'pipeline-123', node_id: '' }, + false, // enabled should be false when nodeId is empty + ) + }) + + it('should handle undefined paramsConfig.variables (fallback to empty array)', () => { + // Arrange - Test the || [] fallback on line 169 + mockUsePublishedPipelinePreProcessingParams.mockReturnValue({ + data: { variables: undefined }, + isFetching: false, + }) + const props = createDefaultProps() + + // Act + render() + + // Assert - Options should receive empty array as variables + expect(screen.getByTestId('options-variables-count')).toHaveTextContent('0') + }) + + it('should handle undefined paramsConfig (fallback to empty array)', () => { + // Arrange - Test when paramsConfig is undefined + mockUsePublishedPipelinePreProcessingParams.mockReturnValue({ + data: undefined, + isFetching: false, + }) + const props = createDefaultProps() + + // Act + render() + + // Assert - Options should receive empty array as variables + expect(screen.getByTestId('options-variables-count')).toHaveTextContent('0') + }) + + it('should handle error without error message', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + + mockSsePost.mockImplementation((url, options, callbacks) => { + callbacks.onDataSourceNodeError({ + error: undefined, + }) + }) + + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByTestId('options-submit-btn')) + + // Assert - Should use fallback error message + await waitFor(() => { + expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.finished) + }) + }) + + it('should handle null total and completed in processing callback', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + + mockSsePost.mockImplementation((url, options, callbacks) => { + callbacks.onDataSourceNodeProcessing({ + total: null, + completed: null, + }) + }) + + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByTestId('options-submit-btn')) + + // Assert - Should handle null values gracefully (default to 0) + await waitFor(() => { + expect(mockSsePost).toHaveBeenCalled() + }) + }) + + it('should handle undefined time_consuming in completed callback', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + + mockSsePost.mockImplementation((url, options, callbacks) => { + callbacks.onDataSourceNodeCompleted({ + data: [createMockCrawlResultItem()], + time_consuming: undefined, + }) + }) + + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByTestId('options-submit-btn')) + + // Assert + await waitFor(() => { + expect(mockStoreState.setCrawlResult).toHaveBeenCalledWith({ + data: [expect.any(Object)], + time_consuming: 0, + }) + }) + }) + }) + + // ========================================== + // 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.step = CrawlStep.finished + mockStoreState.crawlResult = { + data: [createMockCrawlResultItem()], + time_consuming: 1.5, + } + const props = createDefaultProps(propVariation) + + // Act + render() + + // Assert + expect(screen.getByTestId('crawled-result')).toBeInTheDocument() + expect(screen.getByTestId('crawled-result-show-preview')).toHaveTextContent( + String(!propVariation.isInPipeline), + ) + expect(screen.getByTestId('crawled-result-multiple-choice')).toHaveTextContent( + String(propVariation.supportBatchUpload), + ) + }) + + it('should use default values for optional props', () => { + // Arrange + mockStoreState.step = CrawlStep.finished + mockStoreState.crawlResult = { + data: [createMockCrawlResultItem()], + time_consuming: 1.5, + } + const props: WebsiteCrawlProps = { + nodeId: 'node-1', + nodeData: createMockNodeData(), + onCredentialChange: jest.fn(), + // isInPipeline and supportBatchUpload are not provided + } + + // Act + render() + + // Assert - Default values: isInPipeline = false, supportBatchUpload = true + expect(screen.getByTestId('crawled-result-show-preview')).toHaveTextContent('true') + expect(screen.getByTestId('crawled-result-multiple-choice')).toHaveTextContent('true') + }) + }) + + // ========================================== + // Error Display + // ========================================== + describe('Error Display', () => { + it('should show ErrorMessage when crawl finishes with error', async () => { + // Arrange - Need to create a scenario where error message is set + mockStoreState.currentCredentialId = 'cred-1' + + // First render with init state + const props = createDefaultProps() + const { rerender } = render() + + // Simulate error by setting up ssePost to call error callback + mockSsePost.mockImplementation((url, options, callbacks) => { + callbacks.onDataSourceNodeError({ + error: 'Network error', + }) + }) + + // Trigger submit + fireEvent.click(screen.getByTestId('options-submit-btn')) + + // Now update store state to finished to simulate the state after error + mockStoreState.step = CrawlStep.finished + rerender() + + // Assert - The component should check for error message state + await waitFor(() => { + expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.finished) + }) + }) + + it('should not show ErrorMessage when crawl finishes without error', () => { + // Arrange + mockStoreState.step = CrawlStep.finished + mockStoreState.crawlResult = { + data: [createMockCrawlResultItem()], + time_consuming: 1.5, + } + const props = createDefaultProps() + + // Act + render() + + // Assert + expect(screen.queryByTestId('error-message')).not.toBeInTheDocument() + expect(screen.getByTestId('crawled-result')).toBeInTheDocument() + }) + }) + + // ========================================== + // Integration Tests + // ========================================== + describe('Integration', () => { + it('should complete full workflow: submit -> running -> completed', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + const mockCrawlData: CrawlResultItem[] = [ + createMockCrawlResultItem({ source_url: 'https://example.com/1' }), + createMockCrawlResultItem({ source_url: 'https://example.com/2' }), + ] + + mockSsePost.mockImplementation((url, options, callbacks) => { + // Simulate processing + callbacks.onDataSourceNodeProcessing({ + total: 10, + completed: 5, + }) + // Simulate completion + callbacks.onDataSourceNodeCompleted({ + data: mockCrawlData, + time_consuming: 2.5, + }) + }) + + const props = createDefaultProps() + render() + + // Act - Trigger submit + fireEvent.click(screen.getByTestId('options-submit-btn')) + + // Assert - Verify full flow + await waitFor(() => { + // Step should be set to running first + expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.running) + // Then result should be set + expect(mockStoreState.setCrawlResult).toHaveBeenCalledWith({ + data: mockCrawlData, + time_consuming: 2.5, + }) + // Pages should be selected + expect(mockStoreState.setWebsitePages).toHaveBeenCalledWith(mockCrawlData) + // Step should be set to finished + expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.finished) + }) + }) + + it('should handle error flow correctly', async () => { + // Arrange + mockStoreState.currentCredentialId = 'cred-1' + + mockSsePost.mockImplementation((url, options, callbacks) => { + callbacks.onDataSourceNodeError({ + error: 'Failed to crawl website', + }) + }) + + const props = createDefaultProps() + render() + + // Act + fireEvent.click(screen.getByTestId('options-submit-btn')) + + // Assert + await waitFor(() => { + expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.running) + expect(mockStoreState.setStep).toHaveBeenCalledWith(CrawlStep.finished) + }) + }) + + it('should handle credential change and allow new crawl', () => { + // Arrange + mockStoreState.currentCredentialId = 'initial-cred' + const mockOnCredentialChange = jest.fn() + const props = createDefaultProps({ onCredentialChange: mockOnCredentialChange }) + + // Act + render() + + // Change credential + fireEvent.click(screen.getByTestId('header-credential-change')) + + // Assert + expect(mockOnCredentialChange).toHaveBeenCalledWith('new-cred-id') + }) + + it('should handle preview selection after crawl completes', () => { + // Arrange + mockStoreState.step = CrawlStep.finished + mockStoreState.crawlResult = { + data: [ + createMockCrawlResultItem({ source_url: 'https://example.com/1' }), + createMockCrawlResultItem({ source_url: 'https://example.com/2' }), + ], + time_consuming: 1.5, + } + const props = createDefaultProps() + render() + + // Act - Preview first item + fireEvent.click(screen.getByTestId('crawled-result-preview')) + + // Assert + expect(mockStoreState.setCurrentWebsite).toHaveBeenCalled() + expect(mockStoreState.setPreviewIndex).toHaveBeenCalledWith(0) + }) + }) + + // ========================================== + // Component Memoization + // ========================================== + describe('Component Memoization', () => { + it('should be wrapped with React.memo', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { rerender } = render() + rerender() + + // Assert - Component should still render correctly after rerender + expect(screen.getByTestId('header')).toBeInTheDocument() + expect(screen.getByTestId('options')).toBeInTheDocument() + }) + + it('should not re-run callbacks when props are the same', () => { + // Arrange + const onCredentialChange = jest.fn() + const props = createDefaultProps({ onCredentialChange }) + + // Act + const { rerender } = render() + rerender() + + // Assert - The callback reference should be stable + fireEvent.click(screen.getByTestId('header-credential-change')) + expect(onCredentialChange).toHaveBeenCalledTimes(1) + }) + }) + + // ========================================== + // Styling + // ========================================== + describe('Styling', () => { + it('should apply correct container classes', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert + const rootDiv = container.firstChild as HTMLElement + expect(rootDiv).toHaveClass('flex', 'flex-col') + }) + + it('should apply correct classes to options container', () => { + // Arrange + const props = createDefaultProps() + + // Act + const { container } = render() + + // Assert + const optionsContainer = container.querySelector('.rounded-xl') + expect(optionsContainer).toBeInTheDocument() + }) + }) +})