import type { DataSet, RelatedApp, RelatedAppResponse } from '@/models/datasets' import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { describe, expect, it, vi } from 'vitest' import { AppModeEnum } from '@/types/app' // ============================================================================ // Component Imports (after mocks) // ============================================================================ import ApiAccess from './api-access' import ApiAccessCard from './api-access/card' import ExtraInfo from './index' import Statistics from './statistics' // ============================================================================ // Mock Setup // ============================================================================ // Mock next/navigation vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), }), usePathname: () => '/test', useSearchParams: () => new URLSearchParams(), })) // Mock next/link vi.mock('next/link', () => ({ default: ({ children, href, ...props }: { children: React.ReactNode, href: string, [key: string]: unknown }) => ( {children} ), })) // Dataset context mock data const mockDataset: Partial = { id: 'dataset-123', name: 'Test Dataset', enable_api: true, } // Mock use-context-selector vi.mock('use-context-selector', () => ({ useContext: vi.fn(() => ({ dataset: mockDataset })), useContextSelector: vi.fn((_, selector) => selector({ dataset: mockDataset })), createContext: vi.fn(() => ({})), })) // Mock dataset detail context const mockMutateDatasetRes = vi.fn() vi.mock('@/context/dataset-detail', () => ({ default: {}, useDatasetDetailContext: vi.fn(() => ({ dataset: mockDataset, mutateDatasetRes: mockMutateDatasetRes, })), useDatasetDetailContextWithSelector: vi.fn((selector: (v: { dataset?: typeof mockDataset, mutateDatasetRes?: () => void }) => unknown) => selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }), ), })) // Mock app context for workspace permissions let mockIsCurrentWorkspaceManager = true vi.mock('@/context/app-context', () => ({ useSelector: vi.fn((selector: (state: { isCurrentWorkspaceManager: boolean }) => unknown) => selector({ isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager }), ), })) // Mock service hooks const mockEnableDatasetServiceApi = vi.fn(() => Promise.resolve({ result: 'success' })) const mockDisableDatasetServiceApi = vi.fn(() => Promise.resolve({ result: 'success' })) vi.mock('@/service/knowledge/use-dataset', () => ({ useDatasetApiBaseUrl: vi.fn(() => ({ data: { api_base_url: 'https://api.example.com' }, isLoading: false, })), useEnableDatasetServiceApi: vi.fn(() => ({ mutateAsync: mockEnableDatasetServiceApi, isPending: false, })), useDisableDatasetServiceApi: vi.fn(() => ({ mutateAsync: mockDisableDatasetServiceApi, isPending: false, })), })) // Mock API access URL hook vi.mock('@/hooks/use-api-access-url', () => ({ useDatasetApiAccessUrl: vi.fn(() => 'https://docs.dify.ai/api-reference/datasets'), })) // Mock docLink hook vi.mock('@/context/i18n', () => ({ useDocLink: vi.fn(() => (path: string) => `https://docs.example.com${path}`), })) // Mock SecretKeyModal to avoid complex modal rendering vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => ( isShow ? (
) : null ), })) // ============================================================================ // Test Data Factory // ============================================================================ const createMockRelatedApp = (overrides: Partial = {}): RelatedApp => ({ id: 'app-1', name: 'Test App', mode: AppModeEnum.COMPLETION, icon: 'icon-url', icon_type: 'image', icon_background: '#fff', icon_url: '', ...overrides, }) const createMockRelatedAppsResponse = (count: number = 2): RelatedAppResponse => ({ data: Array.from({ length: count }, (_, i) => createMockRelatedApp({ id: `app-${i + 1}`, name: `App ${i + 1}` })), total: count, }) // ============================================================================ // Statistics Component Tests // ============================================================================ describe('Statistics', () => { beforeEach(() => { vi.clearAllMocks() }) describe('Rendering', () => { it('should render without crashing', () => { render( , ) expect(screen.getByText('10')).toBeInTheDocument() }) it('should render document count correctly', () => { render( , ) expect(screen.getByText('42')).toBeInTheDocument() }) it('should render related apps total correctly', () => { const relatedApps = createMockRelatedAppsResponse(5) render( , ) expect(screen.getByText('5')).toBeInTheDocument() }) it('should display translated document label', () => { render( , ) expect(screen.getByText(/documents/i)).toBeInTheDocument() }) it('should display translated related app label', () => { render( , ) expect(screen.getByText(/relatedApp/i)).toBeInTheDocument() }) }) describe('Edge Cases', () => { it('should render placeholder when documentCount is undefined', () => { render( , ) expect(screen.getByText('--')).toBeInTheDocument() }) it('should render placeholder when relatedApps is undefined', () => { render( , ) expect(screen.getAllByText('--').length).toBeGreaterThanOrEqual(1) }) it('should handle zero document count', () => { render( , ) expect(screen.getByText('0')).toBeInTheDocument() }) it('should handle empty related apps array', () => { const emptyRelatedApps: RelatedAppResponse = { data: [], total: 0 } render( , ) expect(screen.getByText('0')).toBeInTheDocument() }) it('should handle large numbers correctly', () => { render( , ) expect(screen.getByText('999999')).toBeInTheDocument() expect(screen.getByText('100')).toBeInTheDocument() }) }) describe('Tooltip Interactions', () => { it('should render tooltip trigger with info icon', () => { render( , ) // Find the cursor-pointer element containing the relatedApp text const tooltipTrigger = screen.getByText(/relatedApp/i).closest('.cursor-pointer') expect(tooltipTrigger).toBeInTheDocument() }) it('should render LinkedAppsPanel when related apps exist', async () => { const relatedApps = createMockRelatedAppsResponse(3) render( , ) // The LinkedAppsPanel should be rendered inside the tooltip // We can't easily test tooltip content in this context without more setup // But we verify the condition logic works by checking component renders expect(screen.getByText('3')).toBeInTheDocument() }) it('should render NoLinkedAppsPanel when no related apps', () => { const emptyRelatedApps: RelatedAppResponse = { data: [], total: 0 } render( , ) // Verify component renders correctly with empty apps expect(screen.getByText('0')).toBeInTheDocument() }) }) describe('Props Variations', () => { it('should handle expand=false', () => { render( , ) // Component should still render with expand=false expect(screen.getByText('10')).toBeInTheDocument() }) it('should pass isMobile based on expand prop', () => { // When expand is false, isMobile should be true (!expand) render( , ) // Component renders - the isMobile logic is internal expect(screen.getByText('10')).toBeInTheDocument() }) }) describe('Memoization', () => { it('should be memoized with React.memo', () => { const { rerender } = render( , ) // Rerender with same props rerender( , ) // Component should not cause unnecessary re-renders expect(screen.getByText('10')).toBeInTheDocument() }) }) }) // ============================================================================ // ApiAccess Component Tests // ============================================================================ describe('ApiAccess', () => { beforeEach(() => { vi.clearAllMocks() }) describe('Rendering', () => { it('should render without crashing', () => { render( , ) expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() }) it('should render API title when expanded', () => { render( , ) expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() }) it('should not render API title when collapsed', () => { render( , ) expect(screen.queryByText(/appMenus\.apiAccess/i)).not.toBeInTheDocument() }) it('should render indicator when API is enabled', () => { const { container } = render( , ) // Indicator component should be present const indicatorElement = container.querySelector('.relative.flex.h-8') expect(indicatorElement).toBeInTheDocument() }) it('should render indicator when API is disabled', () => { const { container } = render( , ) // Indicator component should be present const indicatorElement = container.querySelector('.relative.flex.h-8') expect(indicatorElement).toBeInTheDocument() }) }) describe('User Interactions', () => { it('should toggle popup open state on click', async () => { const user = userEvent.setup() render( , ) const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]') expect(trigger).toBeInTheDocument() if (trigger) { await user.click(trigger) // After click, the Card component should be rendered in the portal } }) it('should apply hover styles on trigger', () => { render( , ) const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('div[class*="cursor-pointer"]') expect(trigger).toHaveClass('cursor-pointer') }) }) describe('Props Variations', () => { it('should apply compressed layout when expand is false', () => { const { container } = render( , ) // When collapsed, width should be w-8 const triggerContainer = container.querySelector('[class*="w-8"]') expect(triggerContainer).toBeInTheDocument() }) it('should pass apiEnabled to Card component', async () => { const user = userEvent.setup() render( , ) const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]') if (trigger) { await user.click(trigger) // The apiEnabled should be passed to Card } }) }) describe('Memoization', () => { it('should be memoized with React.memo', () => { const { rerender } = render( , ) rerender( , ) expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() }) }) }) // ============================================================================ // ApiAccessCard Component Tests // ============================================================================ describe('ApiAccessCard', () => { beforeEach(() => { vi.clearAllMocks() mockIsCurrentWorkspaceManager = true mockEnableDatasetServiceApi.mockResolvedValue({ result: 'success' }) mockDisableDatasetServiceApi.mockResolvedValue({ result: 'success' }) }) describe('Rendering', () => { it('should render without crashing', () => { render( , ) expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument() }) it('should display enabled status when API is enabled', () => { render( , ) expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument() }) it('should display disabled status when API is disabled', () => { render( , ) expect(screen.getByText(/serviceApi\.disabled/i)).toBeInTheDocument() }) it('should render API Reference link', () => { render( , ) expect(screen.getByText(/overview\.apiInfo\.doc/i)).toBeInTheDocument() }) it('should render switch component', () => { render( , ) expect(screen.getByRole('switch')).toBeInTheDocument() }) }) describe('User Interactions', () => { it('should call enableDatasetServiceApi when switch is toggled on', async () => { const user = userEvent.setup() render( , ) const switchButton = screen.getByRole('switch') await user.click(switchButton) await waitFor(() => { expect(mockEnableDatasetServiceApi).toHaveBeenCalledWith('dataset-123') }) }) it('should call disableDatasetServiceApi when switch is toggled off', async () => { const user = userEvent.setup() render( , ) const switchButton = screen.getByRole('switch') await user.click(switchButton) await waitFor(() => { expect(mockDisableDatasetServiceApi).toHaveBeenCalledWith('dataset-123') }) }) it('should call mutateDatasetRes after successful API toggle', async () => { const user = userEvent.setup() render( , ) const switchButton = screen.getByRole('switch') await user.click(switchButton) await waitFor(() => { expect(mockMutateDatasetRes).toHaveBeenCalled() }) }) it('should not call mutateDatasetRes on API toggle failure', async () => { mockEnableDatasetServiceApi.mockResolvedValueOnce({ result: 'fail' }) const user = userEvent.setup() render( , ) const switchButton = screen.getByRole('switch') await user.click(switchButton) await waitFor(() => { expect(mockEnableDatasetServiceApi).toHaveBeenCalled() }) // mutateDatasetRes should not be called on failure expect(mockMutateDatasetRes).not.toHaveBeenCalled() }) it('should have correct href for API Reference link', () => { render( , ) const apiRefLink = screen.getByText(/overview\.apiInfo\.doc/i).closest('a') expect(apiRefLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets') }) }) describe('Permission Handling', () => { it('should disable switch when user is not workspace manager', () => { mockIsCurrentWorkspaceManager = false render( , ) const switchButton = screen.getByRole('switch') // Headless UI Switch uses CSS classes for disabled state expect(switchButton).toHaveClass('!cursor-not-allowed') expect(switchButton).toHaveClass('!opacity-50') }) it('should enable switch when user is workspace manager', () => { mockIsCurrentWorkspaceManager = true render( , ) const switchButton = screen.getByRole('switch') expect(switchButton).not.toHaveClass('!cursor-not-allowed') expect(switchButton).not.toHaveClass('!opacity-50') }) }) describe('Memoization', () => { it('should be memoized with React.memo', () => { const { rerender } = render( , ) rerender( , ) expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument() }) it('should use useCallback for handlers', () => { // Verify handlers are stable by rendering multiple times const { rerender } = render( , ) rerender( , ) // Component should render without issues with memoized callbacks expect(screen.getByRole('switch')).toBeInTheDocument() }) }) }) // ============================================================================ // ExtraInfo (Main Component) Tests // ============================================================================ describe('ExtraInfo', () => { beforeEach(() => { vi.clearAllMocks() }) describe('Rendering', () => { it('should render without crashing', () => { render( , ) // Should render ApiAccess component expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() }) it('should render Statistics when expand is true', () => { render( , ) // Statistics shows document count expect(screen.getByText('10')).toBeInTheDocument() }) it('should not render Statistics when expand is false', () => { render( , ) // Document count should not be visible when collapsed expect(screen.queryByText('10')).not.toBeInTheDocument() }) it('should always render ApiAccess regardless of expand state', () => { const { rerender } = render( , ) // Check expanded state has ApiAccess title expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() rerender( , ) // ApiAccess should still be present (but without title text when collapsed) // The component is still rendered, just with different styling }) }) describe('Context Integration', () => { it('should read apiEnabled from dataset detail context', () => { render( , ) // Since mockDataset has enable_api: true, the indicator should be green expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() }) it('should read apiBaseUrl from useDatasetApiBaseUrl hook', () => { render( , ) // Component should render with the mocked API base URL expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() }) it('should handle missing apiBaseInfo with fallback empty string', async () => { const { useDatasetApiBaseUrl } = await import('@/service/knowledge/use-dataset') vi.mocked(useDatasetApiBaseUrl).mockReturnValue({ data: undefined, isLoading: false, } as ReturnType) render( , ) expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() // Reset mock vi.mocked(useDatasetApiBaseUrl).mockReturnValue({ data: { api_base_url: 'https://api.example.com' }, isLoading: false, } as ReturnType) }) it('should handle missing apiEnabled with fallback false', async () => { const { useDatasetDetailContextWithSelector } = await import('@/context/dataset-detail') vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector) => { // Simulate dataset without enable_api by using a partial dataset const partialDataset = { ...mockDataset } as Partial delete (partialDataset as { enable_api?: boolean }).enable_api return selector({ dataset: partialDataset as DataSet, mutateDatasetRes: vi.fn(), }) }) render( , ) expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() // Reset mock vi.mocked(useDatasetDetailContextWithSelector).mockImplementation(selector => selector({ dataset: mockDataset as DataSet, mutateDatasetRes: vi.fn() }), ) }) }) describe('Props Variations', () => { it('should pass expand prop to Statistics component', () => { render( , ) expect(screen.getByText('10')).toBeInTheDocument() }) it('should pass expand prop to ApiAccess component', () => { render( , ) expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() }) it('should pass documentCount to Statistics component', () => { render( , ) expect(screen.getByText('99')).toBeInTheDocument() }) it('should pass relatedApps to Statistics component', () => { const relatedApps = createMockRelatedAppsResponse(7) render( , ) expect(screen.getByText('7')).toBeInTheDocument() }) }) describe('Edge Cases', () => { it('should handle undefined documentCount', () => { render( , ) expect(screen.getByText('--')).toBeInTheDocument() }) it('should handle undefined relatedApps', () => { render( , ) expect(screen.getByText('10')).toBeInTheDocument() }) it('should handle all undefined optional props', () => { render( , ) // Should render without crashing expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() }) it('should handle zero values correctly', () => { const emptyRelatedApps: RelatedAppResponse = { data: [], total: 0 } render( , ) expect(screen.getAllByText('0')).toHaveLength(2) }) }) describe('Memoization', () => { it('should be memoized with React.memo', () => { const { rerender } = render( , ) // Rerender with same props rerender( , ) expect(screen.getByText('10')).toBeInTheDocument() }) it('should update when props change', () => { const { rerender } = render( , ) expect(screen.getByText('10')).toBeInTheDocument() rerender( , ) expect(screen.getByText('20')).toBeInTheDocument() }) it('should hide Statistics when expand changes to false', () => { const { rerender } = render( , ) expect(screen.getByText('10')).toBeInTheDocument() rerender( , ) expect(screen.queryByText('10')).not.toBeInTheDocument() }) }) describe('Component Composition', () => { it('should render Statistics before ApiAccess when expanded', () => { const { container } = render( , ) // Statistics should appear before ApiAccess in DOM order const elements = container.querySelectorAll('div') expect(elements.length).toBeGreaterThan(0) }) it('should render only ApiAccess when collapsed', () => { render( , ) // Only ApiAccess should be rendered (without its title in collapsed state) expect(screen.queryByText('10')).not.toBeInTheDocument() }) }) }) // ============================================================================ // Integration Tests // ============================================================================ describe('ExtraInfo Integration', () => { beforeEach(() => { vi.clearAllMocks() }) it('should render complete expanded view with all child components', () => { render( , ) // Statistics content expect(screen.getByText('25')).toBeInTheDocument() expect(screen.getByText('5')).toBeInTheDocument() // ApiAccess content expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() }) it('should handle complete user workflow: view stats and toggle API', async () => { const user = userEvent.setup() render( , ) // Verify statistics are visible expect(screen.getByText('10')).toBeInTheDocument() expect(screen.getByText('3')).toBeInTheDocument() // Click on ApiAccess to open the card const apiAccessTrigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]') if (apiAccessTrigger) await user.click(apiAccessTrigger) // The popup should open with Card content (showing enabled/disabled status) await waitFor(() => { expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument() }) }) it('should integrate with context correctly across all components', async () => { render( , ) // The component tree should correctly receive context values // apiEnabled from context affects ApiAccess indicator color expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() }) })