Files
dify/web/app/components/datasets/documents/components/operations.spec.tsx
Coding On Star c8abe1c306 test: add tests for dataset document detail (#31274)
Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
2026-01-27 15:43:27 +08:00

382 lines
12 KiB
TypeScript

import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DataSourceType } from '@/models/datasets'
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({}) }),
}))
// Mock utils
vi.mock('@/utils/download', () => ({
downloadUrl: vi.fn(),
}))
// Mock router
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
}),
}))
describe('Operations', () => {
const defaultDetail = {
name: 'Test Document',
enabled: true,
archived: false,
id: 'doc-123',
data_source_type: DataSourceType.FILE,
doc_form: 'text',
display_status: 'available',
}
const defaultProps = {
embeddingAvailable: true,
detail: defaultDetail,
datasetId: 'dataset-456',
onUpdate: vi.fn(),
scene: 'list' as const,
className: '',
}
beforeEach(() => {
vi.clearAllMocks()
})
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 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()
})
})
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')
})
it('should render disabled switch when document is disabled', () => {
const { container } = render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, enabled: false, archived: false }}
/>,
)
const switchEl = container.querySelector('[role="switch"]')
expect(switchEl).toHaveAttribute('aria-checked', 'false')
})
it('should show tooltip and disable switch when document is archived', () => {
const { container } = 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 waitFor(() => {
expect(screen.getByText(/list\.table\.rename/i)).toBeInTheDocument()
})
})
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)
await waitFor(() => {
expect(screen.getByText(/list\.action\.download/i)).toBeInTheDocument()
})
})
it('should show sync option for notion documents', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, data_source_type: 'notion_import' }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await waitFor(() => {
expect(screen.getByText(/list\.action\.sync/i)).toBeInTheDocument()
})
})
it('should show sync option for web documents', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, data_source_type: DataSourceType.WEB }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await waitFor(() => {
expect(screen.getByText(/list\.action\.sync/i)).toBeInTheDocument()
})
})
it('should show archive option for non-archived documents', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, archived: false }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await waitFor(() => {
expect(screen.getByText(/list\.action\.archive/i)).toBeInTheDocument()
})
})
it('should show unarchive option for archived documents', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, archived: true }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await waitFor(() => {
expect(screen.getByText(/list\.action\.unarchive/i)).toBeInTheDocument()
})
})
it('should show delete option', async () => {
render(<Operations {...defaultProps} />)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await waitFor(() => {
expect(screen.getByText(/list\.action\.delete/i)).toBeInTheDocument()
})
})
it('should show pause option when status is indexing', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, display_status: 'indexing', archived: false }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await waitFor(() => {
expect(screen.getByText(/list\.action\.pause/i)).toBeInTheDocument()
})
})
it('should show resume option when status is paused', async () => {
render(
<Operations
{...defaultProps}
detail={{ ...defaultDetail, display_status: 'paused', archived: false }}
/>,
)
const moreButton = document.querySelector('[class*="commonIcon"]')
if (moreButton)
fireEvent.click(moreButton)
await waitFor(() => {
expect(screen.getByText(/list\.action\.resume/i)).toBeInTheDocument()
})
})
})
describe('Delete Confirmation Modal', () => {
it('should show delete confirmation modal when delete is clicked', async () => {
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)
})
await waitFor(() => {
expect(screen.getByText(/list\.delete\.title/i)).toBeInTheDocument()
expect(screen.getByText(/list\.delete\.content/i)).toBeInTheDocument()
})
})
})
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', () => {
render(
<Operations
{...defaultProps}
detail={{
name: '',
enabled: false,
archived: false,
id: '',
data_source_type: '',
doc_form: '',
display_status: undefined,
}}
/>,
)
// Should not crash
expect(document.querySelector('.flex.items-center')).toBeInTheDocument()
})
it('should stop event propagation on click', () => {
const parentHandler = vi.fn()
render(
<div onClick={parentHandler}>
<Operations {...defaultProps} />
</div>,
)
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()
})
it('should handle custom className', () => {
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()
})
})
})