test: add comprehensive tests (#31649)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Coding On Star
2026-01-29 11:16:26 +08:00
committed by GitHub
parent b48a10d7ec
commit 8f414af34e
68 changed files with 18982 additions and 2105 deletions

View File

@ -0,0 +1,24 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import ApiIndex from './index'
afterEach(() => {
cleanup()
})
describe('ApiIndex', () => {
it('should render without crashing', () => {
render(<ApiIndex />)
expect(screen.getByText('index')).toBeInTheDocument()
})
it('should render a div with text "index"', () => {
const { container } = render(<ApiIndex />)
expect(container.firstChild).toBeInstanceOf(HTMLDivElement)
expect(container.textContent).toBe('index')
})
it('should be a valid function component', () => {
expect(typeof ApiIndex).toBe('function')
})
})

View File

@ -0,0 +1,111 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { ChunkContainer, ChunkLabel, QAPreview } from './chunk'
afterEach(() => {
cleanup()
})
describe('ChunkLabel', () => {
it('should render label text', () => {
render(<ChunkLabel label="Chunk 1" characterCount={100} />)
expect(screen.getByText('Chunk 1')).toBeInTheDocument()
})
it('should render character count', () => {
render(<ChunkLabel label="Chunk 1" characterCount={150} />)
expect(screen.getByText('150 characters')).toBeInTheDocument()
})
it('should render separator dot', () => {
render(<ChunkLabel label="Chunk 1" characterCount={100} />)
expect(screen.getByText('·')).toBeInTheDocument()
})
it('should render with zero character count', () => {
render(<ChunkLabel label="Empty Chunk" characterCount={0} />)
expect(screen.getByText('0 characters')).toBeInTheDocument()
})
it('should render with large character count', () => {
render(<ChunkLabel label="Large Chunk" characterCount={999999} />)
expect(screen.getByText('999999 characters')).toBeInTheDocument()
})
})
describe('ChunkContainer', () => {
it('should render label and character count', () => {
render(<ChunkContainer label="Container 1" characterCount={200}>Content</ChunkContainer>)
expect(screen.getByText('Container 1')).toBeInTheDocument()
expect(screen.getByText('200 characters')).toBeInTheDocument()
})
it('should render children content', () => {
render(<ChunkContainer label="Container 1" characterCount={200}>Test Content</ChunkContainer>)
expect(screen.getByText('Test Content')).toBeInTheDocument()
})
it('should render with complex children', () => {
render(
<ChunkContainer label="Container" characterCount={100}>
<div data-testid="child-div">
<span>Nested content</span>
</div>
</ChunkContainer>,
)
expect(screen.getByTestId('child-div')).toBeInTheDocument()
expect(screen.getByText('Nested content')).toBeInTheDocument()
})
it('should render empty children', () => {
render(<ChunkContainer label="Empty" characterCount={0}>{null}</ChunkContainer>)
expect(screen.getByText('Empty')).toBeInTheDocument()
})
})
describe('QAPreview', () => {
const mockQA = {
question: 'What is the meaning of life?',
answer: 'The meaning of life is 42.',
}
it('should render question text', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('What is the meaning of life?')).toBeInTheDocument()
})
it('should render answer text', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('The meaning of life is 42.')).toBeInTheDocument()
})
it('should render Q label', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('Q')).toBeInTheDocument()
})
it('should render A label', () => {
render(<QAPreview qa={mockQA} />)
expect(screen.getByText('A')).toBeInTheDocument()
})
it('should render with empty strings', () => {
render(<QAPreview qa={{ question: '', answer: '' }} />)
expect(screen.getByText('Q')).toBeInTheDocument()
expect(screen.getByText('A')).toBeInTheDocument()
})
it('should render with long text', () => {
const longQuestion = 'Q'.repeat(500)
const longAnswer = 'A'.repeat(500)
render(<QAPreview qa={{ question: longQuestion, answer: longAnswer }} />)
expect(screen.getByText(longQuestion)).toBeInTheDocument()
expect(screen.getByText(longAnswer)).toBeInTheDocument()
})
it('should render with special characters', () => {
render(<QAPreview qa={{ question: 'What about <script>?', answer: '& special chars!' }} />)
expect(screen.getByText('What about <script>?')).toBeInTheDocument()
expect(screen.getByText('& special chars!')).toBeInTheDocument()
})
})

View File

@ -1,6 +1,6 @@
import type { ErrorDocsResponse } from '@/models/datasets'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { retryErrorDocs } from '@/service/datasets'
import { useDatasetErrorDocs } from '@/service/knowledge/use-dataset'
import RetryButton from './index-failed'
@ -19,6 +19,11 @@ vi.mock('@/service/datasets', () => ({
const mockUseDatasetErrorDocs = vi.mocked(useDatasetErrorDocs)
const mockRetryErrorDocs = vi.mocked(retryErrorDocs)
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
// Helper to create mock query result
const createMockQueryResult = (
data: ErrorDocsResponse | undefined,
@ -139,6 +144,11 @@ describe('RetryButton (IndexFailed)', () => {
document_ids: ['doc1', 'doc2'],
})
})
// Wait for all state updates to complete
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
})
it('should refetch error docs after successful retry', async () => {
@ -202,8 +212,13 @@ describe('RetryButton (IndexFailed)', () => {
const retryButton = screen.getByText(/retry/i)
fireEvent.click(retryButton)
// Wait for retry to complete and state to update
await waitFor(() => {
expect(mockRetryErrorDocs).toHaveBeenCalled()
})
// Button should still be visible after failed retry
await waitFor(() => {
// Button should still be visible after failed retry
expect(screen.getByText(/retry/i)).toBeInTheDocument()
})
})
@ -275,6 +290,11 @@ describe('RetryButton (IndexFailed)', () => {
document_ids: [],
})
})
// Wait for all state updates to complete
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalled()
})
})
})
})

View File

@ -1,381 +1,644 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataSourceType } from '@/models/datasets'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Operations from './operations'
// Mock services
vi.mock('@/service/knowledge/use-document', () => ({
useDocumentArchive: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useDocumentUnArchive: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useDocumentEnable: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useDocumentDisable: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useDocumentDelete: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useDocumentDownload: () => ({ mutateAsync: vi.fn().mockResolvedValue({ url: 'https://example.com/download' }), isPending: false }),
useSyncDocument: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useSyncWebsite: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useDocumentPause: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useDocumentResume: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
useDocumentSummary: () => ({ mutateAsync: vi.fn().mockResolvedValue({}) }),
}))
// Mock utils
vi.mock('@/utils/download', () => ({
downloadUrl: vi.fn(),
}))
// Mock router
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock next/navigation
const mockPush = vi.fn()
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
}),
}))
// Mock ToastContext
const mockNotify = vi.fn()
vi.mock('@/app/components/base/toast', () => ({
ToastContext: {
Provider: ({ children }: { children: React.ReactNode }) => children,
},
}))
vi.mock('use-context-selector', () => ({
useContext: () => ({
notify: mockNotify,
}),
}))
// Mock document service hooks
const mockArchive = vi.fn()
const mockUnArchive = vi.fn()
const mockEnable = vi.fn()
const mockDisable = vi.fn()
const mockDelete = vi.fn()
const mockDownload = vi.fn()
const mockSync = vi.fn()
const mockSyncWebsite = vi.fn()
const mockPause = vi.fn()
const mockResume = vi.fn()
let isDownloadPending = false
const mockGenerateSummary = vi.fn()
vi.mock('@/service/knowledge/use-document', () => ({
useDocumentArchive: () => ({ mutateAsync: mockArchive }),
useDocumentUnArchive: () => ({ mutateAsync: mockUnArchive }),
useDocumentEnable: () => ({ mutateAsync: mockEnable }),
useDocumentDisable: () => ({ mutateAsync: mockDisable }),
useDocumentDelete: () => ({ mutateAsync: mockDelete }),
useDocumentDownload: () => ({ mutateAsync: mockDownload, isPending: isDownloadPending }),
useSyncDocument: () => ({ mutateAsync: mockSync }),
useSyncWebsite: () => ({ mutateAsync: mockSyncWebsite }),
useDocumentPause: () => ({ mutateAsync: mockPause }),
useDocumentResume: () => ({ mutateAsync: mockResume }),
useDocumentSummary: () => ({ mutateAsync: mockGenerateSummary }),
}))
// Mock downloadUrl utility
const mockDownloadUrl = vi.fn()
vi.mock('@/utils/download', () => ({
downloadUrl: (params: { url: string, fileName: string }) => mockDownloadUrl(params),
}))
afterEach(() => {
cleanup()
vi.clearAllMocks()
isDownloadPending = false
})
describe('Operations', () => {
const mockOnUpdate = vi.fn()
const mockOnSelectedIdChange = vi.fn()
const defaultDetail = {
id: 'doc-1',
name: 'Test Document',
enabled: true,
archived: false,
id: 'doc-123',
data_source_type: DataSourceType.FILE,
doc_form: 'text',
data_source_type: 'upload_file',
doc_form: 'text_model',
display_status: 'available',
}
const defaultProps = {
embeddingAvailable: true,
datasetId: 'dataset-1',
detail: defaultDetail,
datasetId: 'dataset-456',
onUpdate: vi.fn(),
scene: 'list' as const,
className: '',
onUpdate: mockOnUpdate,
}
beforeEach(() => {
vi.clearAllMocks()
mockArchive.mockResolvedValue({})
mockUnArchive.mockResolvedValue({})
mockEnable.mockResolvedValue({})
mockDisable.mockResolvedValue({})
mockDelete.mockResolvedValue({})
mockDownload.mockResolvedValue({ url: 'https://example.com/download' })
mockSync.mockResolvedValue({})
mockSyncWebsite.mockResolvedValue({})
mockPause.mockResolvedValue({})
mockResume.mockResolvedValue({})
})
describe('Rendering', () => {
describe('rendering', () => {
it('should render without crashing', () => {
render(<Operations {...defaultProps} />)
// Should render at least the container
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
it('should render switch in list scene', () => {
const { container } = render(<Operations {...defaultProps} scene="list" />)
// Switch component should be rendered
const switchEl = container.querySelector('[role="switch"]')
expect(switchEl).toBeInTheDocument()
it('should render buttons when embeddingAvailable', () => {
render(<Operations {...defaultProps} />)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
})
it('should render settings button when embedding is available', () => {
const { container } = render(<Operations {...defaultProps} />)
// Settings button has RiEqualizer2Line icon inside
const settingsButton = container.querySelector('button.mr-2.cursor-pointer')
expect(settingsButton).toBeInTheDocument()
it('should not render settings when embeddingAvailable is false', () => {
render(<Operations {...defaultProps} embeddingAvailable={false} />)
expect(screen.queryByText('list.action.settings')).not.toBeInTheDocument()
})
it('should render disabled switch when embeddingAvailable is false in list scene', () => {
render(<Operations {...defaultProps} embeddingAvailable={false} scene="list" />)
// Switch component uses opacity-50 class when disabled
const disabledSwitch = document.querySelector('.\\!opacity-50')
expect(disabledSwitch).toBeInTheDocument()
})
})
describe('Switch Behavior', () => {
it('should render enabled switch when document is enabled', () => {
const { container } = render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, enabled: true, archived: false }}
/>,
)
const switchEl = container.querySelector('[role="switch"]')
expect(switchEl).toHaveAttribute('aria-checked', 'true')
describe('switch toggle', () => {
it('should render switch in list scene', () => {
render(<Operations {...defaultProps} scene="list" />)
const switches = document.querySelectorAll('[role="switch"], [class*="switch"]')
expect(switches.length).toBeGreaterThan(0)
})
it('should render disabled switch when document is disabled', () => {
const { container } = render(
it('should render disabled switch when archived', () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, enabled: false, archived: false }}
scene="list"
detail={{ ...defaultDetail, archived: true }}
/>,
)
const switchEl = container.querySelector('[role="switch"]')
expect(switchEl).toHaveAttribute('aria-checked', 'false')
const disabledSwitch = document.querySelector('[disabled]')
expect(disabledSwitch).toBeDefined()
})
it('should show tooltip and disable switch when document is archived', () => {
const { container } = render(
it('should call enable when switch is toggled on', async () => {
vi.useFakeTimers()
render(
<Operations
{...defaultProps}
scene="list"
detail={{ ...defaultDetail, enabled: false }}
/>,
)
const switchElement = document.querySelector('[role="switch"]')
await act(async () => {
fireEvent.click(switchElement!)
})
// Wait for debounce
await act(async () => {
vi.advanceTimersByTime(600)
})
expect(mockEnable).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
vi.useRealTimers()
})
it('should call disable when switch is toggled off', async () => {
vi.useFakeTimers()
render(
<Operations
{...defaultProps}
scene="list"
detail={{ ...defaultDetail, enabled: true }}
/>,
)
const switchElement = document.querySelector('[role="switch"]')
await act(async () => {
fireEvent.click(switchElement!)
})
// Wait for debounce
await act(async () => {
vi.advanceTimersByTime(600)
})
expect(mockDisable).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
vi.useRealTimers()
})
it('should not call enable if already enabled', async () => {
vi.useFakeTimers()
render(
<Operations
{...defaultProps}
scene="list"
detail={{ ...defaultDetail, enabled: true }}
/>,
)
// Simulate trying to enable when already enabled - this won't happen via switch click
// because the switch would toggle to disable. But handleSwitch has early returns
vi.useRealTimers()
})
})
describe('settings navigation', () => {
it('should navigate to settings when settings button is clicked', async () => {
render(<Operations {...defaultProps} />)
// Get the first button which is the settings button
const buttons = screen.getAllByRole('button')
const settingsButton = buttons[0]
await act(async () => {
fireEvent.click(settingsButton)
})
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/documents/doc-1/settings')
})
})
describe('detail scene', () => {
it('should render differently in detail scene', () => {
render(<Operations {...defaultProps} scene="detail" />)
const container = document.querySelector('.flex.items-center')
expect(container).toBeInTheDocument()
})
it('should not render switch in detail scene', () => {
render(<Operations {...defaultProps} scene="detail" />)
// In detail scene, there should be no switch
const switchInParent = document.querySelector('.flex.items-center > [role="switch"]')
expect(switchInParent).toBeNull()
})
})
describe('selectedIds handling', () => {
it('should accept selectedIds prop', () => {
render(
<Operations
{...defaultProps}
selectedIds={['doc-1', 'doc-2']}
onSelectedIdChange={mockOnSelectedIdChange}
/>,
)
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
})
describe('popover menu actions', () => {
const openPopover = async () => {
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
if (moreButton) {
await act(async () => {
fireEvent.click(moreButton)
})
}
}
it('should open popover when more button is clicked', async () => {
render(<Operations {...defaultProps} />)
await openPopover()
// Check if popover content is visible
expect(screen.getByText('list.table.rename')).toBeInTheDocument()
})
it('should call archive when archive action is clicked', async () => {
render(<Operations {...defaultProps} />)
await openPopover()
const archiveButton = screen.getByText('list.action.archive')
await act(async () => {
fireEvent.click(archiveButton)
})
await waitFor(() => {
expect(mockArchive).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
it('should call un_archive when unarchive action is clicked', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, archived: true }}
/>,
)
const switchEl = container.querySelector('[role="switch"]')
// Archived documents have visually disabled switch (CSS-based)
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
})
})
describe('Embedding Not Available', () => {
it('should show disabled switch when embedding not available in list scene', () => {
const { container } = render(
<Operations
{...defaultProps}
embeddingAvailable={false}
scene="list"
/>,
)
const switchEl = container.querySelector('[role="switch"]')
// Switch is visually disabled (CSS-based)
expect(switchEl).toHaveClass('!cursor-not-allowed', '!opacity-50')
})
it('should not show settings or popover when embedding not available', () => {
render(
<Operations
{...defaultProps}
embeddingAvailable={false}
/>,
)
expect(screen.queryByRole('button', { name: /settings/i })).not.toBeInTheDocument()
})
})
describe('More Actions Popover', () => {
it('should show rename option for non-archived documents', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, archived: false }}
/>,
)
// Click on the more actions button
const moreButton = document.querySelector('[class*="commonIcon"]')
expect(moreButton).toBeInTheDocument()
if (moreButton)
fireEvent.click(moreButton)
await openPopover()
const unarchiveButton = screen.getByText('list.action.unarchive')
await act(async () => {
fireEvent.click(unarchiveButton)
})
await waitFor(() => {
expect(screen.getByText(/list\.table\.rename/i)).toBeInTheDocument()
expect(mockUnArchive).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
it('should show download option for FILE type documents', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, data_source_type: DataSourceType.FILE }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
it('should show delete confirmation modal when delete is clicked', async () => {
render(<Operations {...defaultProps} />)
await openPopover()
const deleteButton = screen.getByText('list.action.delete')
await act(async () => {
fireEvent.click(deleteButton)
})
// Check if confirmation modal is shown
expect(screen.getByText('list.delete.title')).toBeInTheDocument()
})
it('should call delete when confirm is clicked in delete modal', async () => {
render(<Operations {...defaultProps} />)
await openPopover()
const deleteButton = screen.getByText('list.action.delete')
await act(async () => {
fireEvent.click(deleteButton)
})
// Click confirm button
const confirmButton = screen.getByText('operation.sure')
await act(async () => {
fireEvent.click(confirmButton)
})
await waitFor(() => {
expect(screen.getByText(/list\.action\.download/i)).toBeInTheDocument()
expect(mockDelete).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
it('should show sync option for notion documents', async () => {
it('should close delete modal when cancel is clicked', async () => {
render(<Operations {...defaultProps} />)
await openPopover()
const deleteButton = screen.getByText('list.action.delete')
await act(async () => {
fireEvent.click(deleteButton)
})
// Verify modal is shown
expect(screen.getByText('list.delete.title')).toBeInTheDocument()
// Find and click the cancel button (text: operation.cancel)
const cancelButton = screen.getByText('operation.cancel')
await act(async () => {
fireEvent.click(cancelButton)
})
// Modal should be closed - title shouldn't be visible
await waitFor(() => {
expect(screen.queryByText('list.delete.title')).not.toBeInTheDocument()
})
})
it('should update selectedIds after delete operation', async () => {
render(
<Operations
{...defaultProps}
selectedIds={['doc-1', 'doc-2']}
onSelectedIdChange={mockOnSelectedIdChange}
/>,
)
await openPopover()
const deleteButton = screen.getByText('list.action.delete')
await act(async () => {
fireEvent.click(deleteButton)
})
const confirmButton = screen.getByText('operation.sure')
await act(async () => {
fireEvent.click(confirmButton)
})
await waitFor(() => {
expect(mockOnSelectedIdChange).toHaveBeenCalledWith(['doc-2'])
})
})
it('should show rename modal when rename is clicked', async () => {
render(<Operations {...defaultProps} />)
await openPopover()
const renameButton = screen.getByText('list.table.rename')
await act(async () => {
fireEvent.click(renameButton)
})
// Rename modal should be shown
await waitFor(() => {
expect(screen.getByDisplayValue('Test Document')).toBeInTheDocument()
})
})
it('should call sync for notion data source', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, data_source_type: 'notion_import' }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await openPopover()
const syncButton = screen.getByText('list.action.sync')
await act(async () => {
fireEvent.click(syncButton)
})
await waitFor(() => {
expect(screen.getByText(/list\.action\.sync/i)).toBeInTheDocument()
expect(mockSync).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
it('should show sync option for web documents', async () => {
it('should call syncWebsite for web data source', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, data_source_type: DataSourceType.WEB }}
detail={{ ...defaultDetail, data_source_type: 'website_crawl' }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await openPopover()
const syncButton = screen.getByText('list.action.sync')
await act(async () => {
fireEvent.click(syncButton)
})
await waitFor(() => {
expect(screen.getByText(/list\.action\.sync/i)).toBeInTheDocument()
expect(mockSyncWebsite).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
it('should show archive option for non-archived documents', async () => {
it('should call pause when pause action is clicked', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, archived: false }}
detail={{ ...defaultDetail, display_status: 'indexing' }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await openPopover()
const pauseButton = screen.getByText('list.action.pause')
await act(async () => {
fireEvent.click(pauseButton)
})
await waitFor(() => {
expect(screen.getByText(/list\.action\.archive/i)).toBeInTheDocument()
expect(mockPause).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
it('should show unarchive option for archived documents', async () => {
it('should call resume when resume action is clicked', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, archived: true }}
detail={{ ...defaultDetail, display_status: 'paused' }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await openPopover()
const resumeButton = screen.getByText('list.action.resume')
await act(async () => {
fireEvent.click(resumeButton)
})
await waitFor(() => {
expect(screen.getByText(/list\.action\.unarchive/i)).toBeInTheDocument()
expect(mockResume).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
it('should show delete option', async () => {
it('should download file when download action is clicked', async () => {
render(<Operations {...defaultProps} />)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await openPopover()
const downloadButton = screen.getByText('list.action.download')
await act(async () => {
fireEvent.click(downloadButton)
})
await waitFor(() => {
expect(screen.getByText(/list\.action\.delete/i)).toBeInTheDocument()
expect(mockDownload).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
expect(mockDownloadUrl).toHaveBeenCalledWith({ url: 'https://example.com/download', fileName: 'Test Document' })
})
})
it('should show pause option when status is indexing', async () => {
it('should show download option for archived file data source', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, display_status: 'indexing', archived: false }}
detail={{ ...defaultDetail, archived: true, data_source_type: 'upload_file' }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await waitFor(() => {
expect(screen.getByText(/list\.action\.pause/i)).toBeInTheDocument()
})
await openPopover()
expect(screen.getByText('list.action.download')).toBeInTheDocument()
})
it('should show resume option when status is paused', async () => {
it('should download archived file when download is clicked', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, display_status: 'paused', archived: false }}
detail={{ ...defaultDetail, archived: true, data_source_type: 'upload_file' }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await openPopover()
const downloadButton = screen.getByText('list.action.download')
await act(async () => {
fireEvent.click(downloadButton)
})
await waitFor(() => {
expect(screen.getByText(/list\.action\.resume/i)).toBeInTheDocument()
expect(mockDownload).toHaveBeenCalledWith({ datasetId: 'dataset-1', documentId: 'doc-1' })
})
})
})
describe('Delete Confirmation Modal', () => {
it('should show delete confirmation modal when delete is clicked', async () => {
describe('error handling', () => {
it('should show error notification when operation fails', async () => {
mockArchive.mockRejectedValue(new Error('API Error'))
render(<Operations {...defaultProps} />)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await waitFor(() => {
const deleteButton = screen.getByText(/list\.action\.delete/i)
fireEvent.click(deleteButton)
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
if (moreButton) {
await act(async () => {
fireEvent.click(moreButton)
})
}
const archiveButton = screen.getByText('list.action.archive')
await act(async () => {
fireEvent.click(archiveButton)
})
await waitFor(() => {
expect(screen.getByText(/list\.delete\.title/i)).toBeInTheDocument()
expect(screen.getByText(/list\.delete\.content/i)).toBeInTheDocument()
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'actionMsg.modifiedUnsuccessfully',
})
})
})
it('should show error notification when download fails', async () => {
mockDownload.mockRejectedValue(new Error('Download Error'))
render(<Operations {...defaultProps} />)
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
if (moreButton) {
await act(async () => {
fireEvent.click(moreButton)
})
}
const downloadButton = screen.getByText('list.action.download')
await act(async () => {
fireEvent.click(downloadButton)
})
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'actionMsg.downloadUnsuccessfully',
})
})
})
it('should show error notification when download returns no url', async () => {
mockDownload.mockResolvedValue({})
render(<Operations {...defaultProps} />)
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
if (moreButton) {
await act(async () => {
fireEvent.click(moreButton)
})
}
const downloadButton = screen.getByText('list.action.download')
await act(async () => {
fireEvent.click(downloadButton)
})
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'actionMsg.downloadUnsuccessfully',
})
})
})
})
describe('Scene Variations', () => {
it('should render correctly in detail scene', () => {
render(<Operations {...defaultProps} scene="detail" />)
// Settings button should still be visible
expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
})
it('should apply different styles in detail scene', () => {
const { container } = render(<Operations {...defaultProps} scene="detail" />)
// The component should render without the list-specific styles
expect(container.firstChild).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle undefined detail properties', () => {
describe('display status', () => {
it('should render pause action when status is indexing', () => {
render(
<Operations
{...defaultProps}
detail={{
name: '',
enabled: false,
archived: false,
id: '',
data_source_type: '',
doc_form: '',
display_status: undefined,
}}
detail={{ ...defaultDetail, display_status: 'indexing' }}
/>,
)
// Should not crash
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
it('should stop event propagation on click', () => {
const parentHandler = vi.fn()
it('should render resume action when status is paused', () => {
render(
<div onClick={parentHandler}>
<Operations {...defaultProps} />
</div>,
<Operations
{...defaultProps}
detail={{ ...defaultDetail, display_status: 'paused' }}
/>,
)
const container = document.querySelector('.flex.items-center')
if (container)
fireEvent.click(container)
// Parent handler should not be called due to stopPropagation
expect(parentHandler).not.toHaveBeenCalled()
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
it('should handle custom className', () => {
it('should not show pause/resume for available status', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, display_status: 'available' }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
if (moreButton) {
await act(async () => {
fireEvent.click(moreButton)
})
}
expect(screen.queryByText('list.action.pause')).not.toBeInTheDocument()
expect(screen.queryByText('list.action.resume')).not.toBeInTheDocument()
})
})
describe('data source types', () => {
it('should handle notion data source type', () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, data_source_type: 'notion_import' }}
/>,
)
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
it('should handle web data source type', () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, data_source_type: 'website_crawl' }}
/>,
)
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
it('should not show download for non-file data source', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, data_source_type: 'notion_import' }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')?.parentElement
if (moreButton) {
await act(async () => {
fireEvent.click(moreButton)
})
}
expect(screen.queryByText('list.action.download')).not.toBeInTheDocument()
})
})
describe('memoization', () => {
it('should be wrapped with React.memo', () => {
expect((Operations as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})
describe('className prop', () => {
it('should accept custom className prop', () => {
// The className is passed to CustomPopover, verify component renders without errors
render(<Operations {...defaultProps} className="custom-class" />)
// Component should render with the custom class
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
})
describe('Selected IDs Handling', () => {
it('should pass selectedIds to operations', () => {
render(
<Operations
{...defaultProps}
selectedIds={['doc-123', 'doc-456']}
onSelectedIdChange={vi.fn()}
/>,
)
// Component should render correctly with selectedIds
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,38 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import EmptyFolder from './empty-folder'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
afterEach(() => {
cleanup()
})
describe('EmptyFolder', () => {
it('should render without crashing', () => {
render(<EmptyFolder />)
expect(screen.getByText('onlineDrive.emptyFolder')).toBeInTheDocument()
})
it('should render the empty folder text', () => {
render(<EmptyFolder />)
expect(screen.getByText('onlineDrive.emptyFolder')).toBeInTheDocument()
})
it('should have proper styling classes', () => {
const { container } = render(<EmptyFolder />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('flex')
expect(wrapper).toHaveClass('items-center')
expect(wrapper).toHaveClass('justify-center')
})
it('should be wrapped with React.memo', () => {
expect((EmptyFolder as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})

View File

@ -1,792 +1,137 @@
import type { DataSet } from '@/models/datasets'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ============================================================================
// Component Imports (after mocks)
// ============================================================================
import Card from './card'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import ApiAccess from './index'
// ============================================================================
// Mock Setup
// ============================================================================
// Mock next/navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
usePathname: () => '/test',
useSearchParams: () => new URLSearchParams(),
}))
// Mock next/link
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: { children: React.ReactNode, href: string, [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
),
}))
// Dataset context mock data
const mockDataset: Partial<DataSet> = {
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()
// Mock context and hooks for Card component
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 }),
),
useDatasetDetailContextWithSelector: vi.fn(() => 'test-dataset-id'),
}))
// 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 }),
),
useSelector: vi.fn(() => true),
}))
// Mock service hooks
const mockEnableDatasetServiceApi = vi.fn(() => Promise.resolve({ result: 'success' }))
const mockDisableDatasetServiceApi = vi.fn(() => Promise.resolve({ result: 'success' }))
vi.mock('@/hooks/use-api-access-url', () => ({
useDatasetApiAccessUrl: vi.fn(() => 'https://api.example.com/docs'),
}))
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,
})),
useEnableDatasetServiceApi: vi.fn(() => ({ mutateAsync: vi.fn() })),
useDisableDatasetServiceApi: vi.fn(() => ({ mutateAsync: vi.fn() })),
}))
// Mock API access URL hook
vi.mock('@/hooks/use-api-access-url', () => ({
useDatasetApiAccessUrl: vi.fn(() => 'https://docs.dify.ai/api-reference/datasets'),
}))
// ============================================================================
// ApiAccess Component Tests
// ============================================================================
afterEach(() => {
cleanup()
})
describe('ApiAccess', () => {
beforeEach(() => {
vi.clearAllMocks()
it('should render without crashing', () => {
render(<ApiAccess expand={true} apiEnabled={true} />)
expect(screen.getByText('appMenus.apiAccess')).toBeInTheDocument()
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<ApiAccess expand={true} apiEnabled={true} />)
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
})
it('should render API title when expanded', () => {
render(<ApiAccess expand={true} apiEnabled={true} />)
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
})
it('should not render API title when collapsed', () => {
render(<ApiAccess expand={false} apiEnabled={true} />)
expect(screen.queryByText(/appMenus\.apiAccess/i)).not.toBeInTheDocument()
})
it('should render ApiAggregate icon', () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
})
it('should render Indicator component', () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
const indicatorElement = container.querySelector('.relative.flex.h-8')
expect(indicatorElement).toBeInTheDocument()
})
it('should render with proper container padding', () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('p-3', 'pt-2')
})
it('should render API access text when expanded', () => {
render(<ApiAccess expand={true} apiEnabled={true} />)
expect(screen.getByText('appMenus.apiAccess')).toBeInTheDocument()
})
// --------------------------------------------------------------------------
// Props Variations Tests
// --------------------------------------------------------------------------
describe('Props Variations', () => {
it('should apply compressed layout when expand is false', () => {
const { container } = render(<ApiAccess expand={false} apiEnabled={true} />)
const triggerContainer = container.querySelector('[class*="w-8"]')
expect(triggerContainer).toBeInTheDocument()
})
it('should apply full width when expand is true', () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = container.querySelector('.w-full')
expect(trigger).toBeInTheDocument()
})
it('should pass apiEnabled=true to Indicator with green color', () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
// Indicator uses color prop - test the visual presence
const indicatorContainer = container.querySelector('.relative.flex.h-8')
expect(indicatorContainer).toBeInTheDocument()
})
it('should pass apiEnabled=false to Indicator with yellow color', () => {
const { container } = render(<ApiAccess expand={false} apiEnabled={false} />)
const indicatorContainer = container.querySelector('.relative.flex.h-8')
expect(indicatorContainer).toBeInTheDocument()
})
it('should position Indicator absolutely when collapsed', () => {
const { container } = render(<ApiAccess expand={false} apiEnabled={true} />)
// When collapsed, Indicator has 'absolute -right-px -top-px' classes
const triggerDiv = container.querySelector('[class*="w-8"][class*="justify-center"]')
expect(triggerDiv).toBeInTheDocument()
})
it('should not render API access text when collapsed', () => {
render(<ApiAccess expand={false} apiEnabled={true} />)
expect(screen.queryByText('appMenus.apiAccess')).not.toBeInTheDocument()
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should toggle popup open state on click', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
expect(trigger).toBeInTheDocument()
if (trigger)
await user.click(trigger)
// After click, the popup should toggle (Card should be rendered via portal)
})
it('should apply hover styles on trigger', () => {
render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('div[class*="cursor-pointer"]')
expect(trigger).toHaveClass('cursor-pointer')
})
it('should toggle open state from false to true on first click', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// The handleToggle function should flip open from false to true
})
it('should toggle open state back to false on second click', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger) {
await user.click(trigger) // open
await user.click(trigger) // close
}
// The handleToggle function should flip open from true to false
})
it('should apply open state styling when popup is open', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// When open, the trigger should have bg-state-base-hover class
})
it('should render with apiEnabled=true', () => {
render(<ApiAccess expand={true} apiEnabled={true} />)
expect(screen.getByText('appMenus.apiAccess')).toBeInTheDocument()
})
// --------------------------------------------------------------------------
// Portal and Card Integration Tests
// --------------------------------------------------------------------------
describe('Portal and Card Integration', () => {
it('should render Card component inside portal when open', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// Wait for portal content to appear
await waitFor(() => {
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
})
})
it('should pass apiEnabled prop to Card component', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={false} />)
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
await waitFor(() => {
expect(screen.getByText(/serviceApi\.disabled/i)).toBeInTheDocument()
})
})
it('should use correct portal placement configuration', () => {
render(<ApiAccess expand={true} apiEnabled={true} />)
// PortalToFollowElem is configured with placement="top-start"
// The component should render without errors
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
})
it('should use correct portal offset configuration', () => {
render(<ApiAccess expand={true} apiEnabled={true} />)
// PortalToFollowElem is configured with offset={{ mainAxis: 4, crossAxis: -4 }}
// The component should render without errors
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle rapid toggle clicks gracefully', async () => {
const user = userEvent.setup()
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
// Use a more specific selector to find the trigger in the main component
const trigger = container.querySelector('.p-3 [class*="cursor-pointer"]')
if (trigger) {
// Rapid clicks
await user.click(trigger)
await user.click(trigger)
await user.click(trigger)
}
// Component should handle state changes without errors - use getAllByText since Card may be open
const elements = screen.getAllByText(/appMenus\.apiAccess/i)
expect(elements.length).toBeGreaterThan(0)
})
it('should render correctly when both expand and apiEnabled are false', () => {
render(<ApiAccess expand={false} apiEnabled={false} />)
// Should render without title but with indicator
expect(screen.queryByText(/appMenus\.apiAccess/i)).not.toBeInTheDocument()
})
it('should maintain state across prop changes', () => {
const { rerender } = render(<ApiAccess expand={true} apiEnabled={true} />)
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
rerender(<ApiAccess expand={true} apiEnabled={false} />)
// Component should still render after prop change
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<ApiAccess expand={true} apiEnabled={true} />)
rerender(<ApiAccess expand={true} apiEnabled={true} />)
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
})
it('should not re-render unnecessarily with same props', () => {
const { rerender } = render(<ApiAccess expand={true} apiEnabled={true} />)
rerender(<ApiAccess expand={true} apiEnabled={true} />)
rerender(<ApiAccess expand={true} apiEnabled={true} />)
expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument()
})
})
})
// ============================================================================
// Card Component Tests
// ============================================================================
describe('Card (api-access)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCurrentWorkspaceManager = true
mockEnableDatasetServiceApi.mockResolvedValue({ result: 'success' })
mockDisableDatasetServiceApi.mockResolvedValue({ result: 'success' })
})
// --------------------------------------------------------------------------
// Rendering Tests
// --------------------------------------------------------------------------
describe('Rendering', () => {
it('should render without crashing', () => {
render(<Card apiEnabled={true} />)
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
})
it('should display enabled status when API is enabled', () => {
render(<Card apiEnabled={true} />)
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
})
it('should display disabled status when API is disabled', () => {
render(<Card apiEnabled={false} />)
expect(screen.getByText(/serviceApi\.disabled/i)).toBeInTheDocument()
})
it('should render switch component', () => {
render(<Card apiEnabled={true} />)
expect(screen.getByRole('switch')).toBeInTheDocument()
})
it('should render API Reference link', () => {
render(<Card apiEnabled={true} />)
expect(screen.getByText(/overview\.apiInfo\.doc/i)).toBeInTheDocument()
})
it('should render Indicator component', () => {
const { container } = render(<Card apiEnabled={true} />)
// Indicator is rendered - verify card structure
const cardContainer = container.querySelector('.w-\\[208px\\]')
expect(cardContainer).toBeInTheDocument()
})
it('should render description tip text', () => {
render(<Card apiEnabled={true} />)
expect(screen.getByText(/appMenus\.apiAccessTip/i)).toBeInTheDocument()
})
it('should apply success text color when enabled', () => {
render(<Card apiEnabled={true} />)
const statusText = screen.getByText(/serviceApi\.enabled/i)
expect(statusText).toHaveClass('text-text-success')
})
it('should apply warning text color when disabled', () => {
render(<Card apiEnabled={false} />)
const statusText = screen.getByText(/serviceApi\.disabled/i)
expect(statusText).toHaveClass('text-text-warning')
})
})
// --------------------------------------------------------------------------
// User Interactions Tests
// --------------------------------------------------------------------------
describe('User Interactions', () => {
it('should call enableDatasetServiceApi when switch is toggled on', async () => {
const user = userEvent.setup()
render(<Card apiEnabled={false} />)
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(<Card apiEnabled={true} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockDisableDatasetServiceApi).toHaveBeenCalledWith('dataset-123')
})
})
it('should call mutateDatasetRes after successful API enable', async () => {
const user = userEvent.setup()
render(<Card apiEnabled={false} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockMutateDatasetRes).toHaveBeenCalled()
})
})
it('should call mutateDatasetRes after successful API disable', async () => {
const user = userEvent.setup()
render(<Card apiEnabled={true} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockMutateDatasetRes).toHaveBeenCalled()
})
})
it('should not call mutateDatasetRes on API enable failure', async () => {
mockEnableDatasetServiceApi.mockResolvedValueOnce({ result: 'fail' })
const user = userEvent.setup()
render(<Card apiEnabled={false} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockEnableDatasetServiceApi).toHaveBeenCalled()
})
expect(mockMutateDatasetRes).not.toHaveBeenCalled()
})
it('should not call mutateDatasetRes on API disable failure', async () => {
mockDisableDatasetServiceApi.mockResolvedValueOnce({ result: 'fail' })
const user = userEvent.setup()
render(<Card apiEnabled={true} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockDisableDatasetServiceApi).toHaveBeenCalled()
})
expect(mockMutateDatasetRes).not.toHaveBeenCalled()
})
it('should have correct href for API Reference link', () => {
render(<Card apiEnabled={true} />)
const apiRefLink = screen.getByText(/overview\.apiInfo\.doc/i).closest('a')
expect(apiRefLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets')
})
it('should open API Reference in new tab', () => {
render(<Card apiEnabled={true} />)
const apiRefLink = screen.getByText(/overview\.apiInfo\.doc/i).closest('a')
expect(apiRefLink).toHaveAttribute('target', '_blank')
expect(apiRefLink).toHaveAttribute('rel', 'noopener noreferrer')
})
})
// --------------------------------------------------------------------------
// Permission Handling Tests
// --------------------------------------------------------------------------
describe('Permission Handling', () => {
it('should disable switch when user is not workspace manager', () => {
mockIsCurrentWorkspaceManager = false
render(<Card apiEnabled={true} />)
const switchButton = screen.getByRole('switch')
expect(switchButton).toHaveClass('!cursor-not-allowed')
expect(switchButton).toHaveClass('!opacity-50')
})
it('should enable switch when user is workspace manager', () => {
mockIsCurrentWorkspaceManager = true
render(<Card apiEnabled={true} />)
const switchButton = screen.getByRole('switch')
expect(switchButton).not.toHaveClass('!cursor-not-allowed')
expect(switchButton).not.toHaveClass('!opacity-50')
})
it('should not trigger API call when switch is disabled and clicked', async () => {
mockIsCurrentWorkspaceManager = false
const user = userEvent.setup()
render(<Card apiEnabled={false} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
// API should not be called when disabled
expect(mockEnableDatasetServiceApi).not.toHaveBeenCalled()
})
})
// --------------------------------------------------------------------------
// Edge Cases Tests
// --------------------------------------------------------------------------
describe('Edge Cases', () => {
it('should handle empty datasetId gracefully', async () => {
const { useDatasetDetailContextWithSelector } = await import('@/context/dataset-detail')
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector) => {
return selector({
dataset: { ...mockDataset, id: '' } as DataSet,
mutateDatasetRes: mockMutateDatasetRes,
})
})
const user = userEvent.setup()
render(<Card apiEnabled={false} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockEnableDatasetServiceApi).toHaveBeenCalledWith('')
})
// Reset mock
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation(selector =>
selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }),
)
})
it('should handle undefined datasetId gracefully when enabling API', async () => {
const { useDatasetDetailContextWithSelector } = await import('@/context/dataset-detail')
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector) => {
const partialDataset = { ...mockDataset } as Partial<DataSet>
delete partialDataset.id
return selector({
dataset: partialDataset as DataSet,
mutateDatasetRes: mockMutateDatasetRes,
})
})
const user = userEvent.setup()
render(<Card apiEnabled={false} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
// Should use fallback empty string
expect(mockEnableDatasetServiceApi).toHaveBeenCalledWith('')
})
// Reset mock
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation(selector =>
selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }),
)
})
it('should handle undefined datasetId gracefully when disabling API', async () => {
const { useDatasetDetailContextWithSelector } = await import('@/context/dataset-detail')
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector) => {
const partialDataset = { ...mockDataset } as Partial<DataSet>
delete partialDataset.id
return selector({
dataset: partialDataset as DataSet,
mutateDatasetRes: mockMutateDatasetRes,
})
})
const user = userEvent.setup()
render(<Card apiEnabled={true} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
// Should use fallback empty string for disableDatasetServiceApi
expect(mockDisableDatasetServiceApi).toHaveBeenCalledWith('')
})
// Reset mock
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation(selector =>
selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }),
)
})
it('should handle undefined mutateDatasetRes gracefully', async () => {
const { useDatasetDetailContextWithSelector } = await import('@/context/dataset-detail')
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector) => {
return selector({
dataset: mockDataset as DataSet,
mutateDatasetRes: undefined,
})
})
const user = userEvent.setup()
render(<Card apiEnabled={false} />)
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
await waitFor(() => {
expect(mockEnableDatasetServiceApi).toHaveBeenCalled()
})
// Should not throw error when mutateDatasetRes is undefined
// Reset mock
vi.mocked(useDatasetDetailContextWithSelector).mockImplementation(selector =>
selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }),
)
})
})
// --------------------------------------------------------------------------
// Memoization Tests
// --------------------------------------------------------------------------
describe('Memoization', () => {
it('should be memoized with React.memo', () => {
const { rerender } = render(<Card apiEnabled={true} />)
rerender(<Card apiEnabled={true} />)
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
})
it('should use useCallback for onToggle handler', () => {
const { rerender } = render(<Card apiEnabled={true} />)
rerender(<Card apiEnabled={true} />)
// Component should render without issues with memoized callbacks
expect(screen.getByRole('switch')).toBeInTheDocument()
})
it('should update when apiEnabled prop changes', () => {
const { rerender } = render(<Card apiEnabled={true} />)
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
rerender(<Card apiEnabled={false} />)
expect(screen.getByText(/serviceApi\.disabled/i)).toBeInTheDocument()
})
})
})
// ============================================================================
// Integration Tests
// ============================================================================
describe('ApiAccess Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsCurrentWorkspaceManager = true
mockEnableDatasetServiceApi.mockResolvedValue({ result: 'success' })
mockDisableDatasetServiceApi.mockResolvedValue({ result: 'success' })
})
it('should open Card popup and toggle API status', async () => {
const user = userEvent.setup()
it('should render with apiEnabled=false', () => {
render(<ApiAccess expand={true} apiEnabled={false} />)
expect(screen.getByText('appMenus.apiAccess')).toBeInTheDocument()
})
// Open popup
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
it('should be wrapped with React.memo', () => {
expect((ApiAccess as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
// Wait for Card to appear
await waitFor(() => {
expect(screen.getByText(/serviceApi\.disabled/i)).toBeInTheDocument()
describe('toggle functionality', () => {
it('should toggle open state when trigger is clicked', async () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = container.querySelector('.cursor-pointer')
expect(trigger).toBeInTheDocument()
// Click to open
await act(async () => {
fireEvent.click(trigger!)
})
// The component should update its state - check for state change via class
expect(trigger).toBeInTheDocument()
})
// Toggle API on
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
it('should toggle open state multiple times', async () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
const trigger = container.querySelector('.cursor-pointer')
await waitFor(() => {
expect(mockEnableDatasetServiceApi).toHaveBeenCalledWith('dataset-123')
// First click - open
await act(async () => {
fireEvent.click(trigger!)
})
// Second click - close
await act(async () => {
fireEvent.click(trigger!)
})
expect(trigger).toBeInTheDocument()
})
it('should work when collapsed', async () => {
const { container } = render(<ApiAccess expand={false} apiEnabled={true} />)
const trigger = container.querySelector('.cursor-pointer')
await act(async () => {
fireEvent.click(trigger!)
})
expect(trigger).toBeInTheDocument()
})
})
it('should complete full workflow: open -> view status -> toggle -> verify callback', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={true} />)
// Open popup
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// Verify enabled status is shown
await waitFor(() => {
expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument()
describe('indicator color', () => {
it('should render with green indicator when apiEnabled is true', () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
// Indicator component should be present
const indicator = container.querySelector('.shrink-0')
expect(indicator).toBeInTheDocument()
})
// Toggle API off
const switchButton = screen.getByRole('switch')
await user.click(switchButton)
// Verify API call and callback
await waitFor(() => {
expect(mockDisableDatasetServiceApi).toHaveBeenCalledWith('dataset-123')
expect(mockMutateDatasetRes).toHaveBeenCalled()
it('should render with yellow indicator when apiEnabled is false', () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={false} />)
const indicator = container.querySelector('.shrink-0')
expect(indicator).toBeInTheDocument()
})
})
it('should navigate to API Reference from Card', async () => {
const user = userEvent.setup()
render(<ApiAccess expand={true} apiEnabled={true} />)
// Open popup
const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]')
if (trigger)
await user.click(trigger)
// Wait for Card to appear
await waitFor(() => {
expect(screen.getByText(/overview\.apiInfo\.doc/i)).toBeInTheDocument()
describe('layout', () => {
it('should have justify-center when collapsed', () => {
const { container } = render(<ApiAccess expand={false} apiEnabled={true} />)
const trigger = container.querySelector('.justify-center')
expect(trigger).toBeInTheDocument()
})
// Verify link
const apiRefLink = screen.getByText(/overview\.apiInfo\.doc/i).closest('a')
expect(apiRefLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets')
it('should not have justify-center when expanded', () => {
const { container } = render(<ApiAccess expand={true} apiEnabled={true} />)
const innerDiv = container.querySelector('.cursor-pointer')
// When expanded, should have gap-2 and text, not justify-center
expect(innerDiv).not.toHaveClass('justify-center')
})
})
})

View File

@ -0,0 +1,87 @@
import type { RelatedApp, RelatedAppResponse } from '@/models/datasets'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AppModeEnum } from '@/types/app'
import Statistics from './statistics'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock useDocLink
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
afterEach(() => {
cleanup()
})
describe('Statistics', () => {
const mockRelatedApp: RelatedApp = {
id: 'app-1',
name: 'Test App',
mode: AppModeEnum.CHAT,
icon_type: 'emoji',
icon: '🤖',
icon_background: '#ffffff',
icon_url: '',
}
const mockRelatedApps: RelatedAppResponse = {
data: [mockRelatedApp],
total: 1,
}
it('should render document count', () => {
render(<Statistics expand={true} documentCount={5} relatedApps={mockRelatedApps} />)
expect(screen.getByText('5')).toBeInTheDocument()
})
it('should render document label', () => {
render(<Statistics expand={true} documentCount={5} relatedApps={mockRelatedApps} />)
expect(screen.getByText('datasetMenus.documents')).toBeInTheDocument()
})
it('should render related apps total', () => {
render(<Statistics expand={true} documentCount={5} relatedApps={mockRelatedApps} />)
expect(screen.getByText('1')).toBeInTheDocument()
})
it('should render related app label', () => {
render(<Statistics expand={true} documentCount={5} relatedApps={mockRelatedApps} />)
expect(screen.getByText('datasetMenus.relatedApp')).toBeInTheDocument()
})
it('should render -- for undefined document count', () => {
render(<Statistics expand={true} relatedApps={mockRelatedApps} />)
expect(screen.getByText('--')).toBeInTheDocument()
})
it('should render -- for undefined related apps total', () => {
render(<Statistics expand={true} documentCount={5} />)
const dashes = screen.getAllByText('--')
expect(dashes.length).toBeGreaterThan(0)
})
it('should render with zero document count', () => {
render(<Statistics expand={true} documentCount={0} relatedApps={mockRelatedApps} />)
expect(screen.getByText('0')).toBeInTheDocument()
})
it('should render with empty related apps', () => {
const emptyRelatedApps: RelatedAppResponse = {
data: [],
total: 0,
}
render(<Statistics expand={true} documentCount={5} relatedApps={emptyRelatedApps} />)
expect(screen.getByText('0')).toBeInTheDocument()
})
it('should be wrapped with React.memo', () => {
expect((Statistics as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})

View File

@ -0,0 +1,21 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import DatasetsLoading from './loading'
afterEach(() => {
cleanup()
})
describe('DatasetsLoading', () => {
it('should render null', () => {
const { container } = render(<DatasetsLoading />)
expect(container.firstChild).toBeNull()
})
it('should not throw on multiple renders', () => {
expect(() => {
render(<DatasetsLoading />)
render(<DatasetsLoading />)
}).not.toThrow()
})
})

View File

@ -0,0 +1,58 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import NoLinkedAppsPanel from './no-linked-apps-panel'
// Mock react-i18next
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
// Mock useDocLink
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
afterEach(() => {
cleanup()
})
describe('NoLinkedAppsPanel', () => {
it('should render without crashing', () => {
render(<NoLinkedAppsPanel />)
expect(screen.getByText('datasetMenus.emptyTip')).toBeInTheDocument()
})
it('should render the empty tip text', () => {
render(<NoLinkedAppsPanel />)
expect(screen.getByText('datasetMenus.emptyTip')).toBeInTheDocument()
})
it('should render the view doc link', () => {
render(<NoLinkedAppsPanel />)
expect(screen.getByText('datasetMenus.viewDoc')).toBeInTheDocument()
})
it('should render link with correct href', () => {
render(<NoLinkedAppsPanel />)
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', 'https://docs.example.com/use-dify/knowledge/integrate-knowledge-within-application')
})
it('should render link with target="_blank"', () => {
render(<NoLinkedAppsPanel />)
const link = screen.getByRole('link')
expect(link).toHaveAttribute('target', '_blank')
})
it('should render link with rel="noopener noreferrer"', () => {
render(<NoLinkedAppsPanel />)
const link = screen.getByRole('link')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should be wrapped with React.memo', () => {
expect((NoLinkedAppsPanel as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
})
})

View File

@ -0,0 +1,25 @@
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import DatasetPreview from './index'
afterEach(() => {
cleanup()
})
describe('DatasetPreview', () => {
it('should render null', () => {
const { container } = render(<DatasetPreview />)
expect(container.firstChild).toBeNull()
})
it('should be a valid function component', () => {
expect(typeof DatasetPreview).toBe('function')
})
it('should not throw on multiple renders', () => {
expect(() => {
render(<DatasetPreview />)
render(<DatasetPreview />)
}).not.toThrow()
})
})