dify/web/app/components/app-sidebar/dataset-info/__tests__/index.spec.tsx

583 lines
18 KiB
TypeScript

import type { DataSet } from '@/models/datasets'
import { createEvent, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import {
ChunkingMode,
DatasetPermission,
DataSourceType,
} from '@/models/datasets'
import { RETRIEVE_METHOD } from '@/types/app'
import { DatasetACLPermission } from '@/utils/permission'
import DatasetInfo from '..'
import Dropdown from '../dropdown'
import Menu from '../menu'
import MenuItem from '../menu-item'
let mockDataset: DataSet
const mockPush = vi.fn()
const mockReplace = vi.fn()
const mockInvalidDatasetList = vi.fn()
const mockInvalidDatasetDetail = vi.fn()
const mockExportPipeline = vi.fn()
const mockCheckIsUsedInApp = vi.fn()
const mockDeleteDataset = vi.fn()
const TestEditIcon = () => <span aria-hidden className="i-ri-edit-line" />
let mockIsRbacEnabled = true
const mockAppContextState = vi.hoisted(() => ({
current: {
userProfile: { id: 'user-1' },
workspacePermissionKeys: [] as string[],
},
}))
const render = (ui: Parameters<typeof renderWithSystemFeatures>[0]) => renderWithSystemFeatures(ui, {
systemFeatures: {
rbac_enabled: mockIsRbacEnabled,
},
})
const createDataset = (overrides: Partial<DataSet> = {}): DataSet => ({
id: 'dataset-1',
name: 'Dataset Name',
indexing_status: 'completed',
icon_info: {
icon: '📙',
icon_background: '#FFF4ED',
icon_type: 'emoji',
icon_url: '',
},
description: 'Dataset description',
permission: DatasetPermission.onlyMe,
data_source_type: DataSourceType.FILE,
indexing_technique: 'high_quality' as DataSet['indexing_technique'],
created_by: 'user-1',
updated_by: 'user-1',
updated_at: 1690000000,
app_count: 0,
doc_form: ChunkingMode.text,
document_count: 1,
total_document_count: 1,
word_count: 1000,
provider: 'internal',
embedding_model: 'text-embedding-3',
embedding_model_provider: 'openai',
embedding_available: true,
retrieval_model_dict: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 5,
score_threshold_enabled: false,
score_threshold: 0,
},
retrieval_model: {
search_method: RETRIEVE_METHOD.semantic,
reranking_enable: false,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 5,
score_threshold_enabled: false,
score_threshold: 0,
},
tags: [],
external_knowledge_info: {
external_knowledge_id: '',
external_knowledge_api_id: '',
external_knowledge_api_name: '',
external_knowledge_api_endpoint: '',
},
external_retrieval_model: {
top_k: 0,
score_threshold: 0,
score_threshold_enabled: false,
},
built_in_field_enabled: false,
runtime_mode: 'rag_pipeline',
enable_api: false,
is_multimodal: false,
permission_keys: [
DatasetACLPermission.Edit,
DatasetACLPermission.Delete,
DatasetACLPermission.ImportExportDSL,
],
...overrides,
})
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: mockPush,
replace: mockReplace,
}),
}))
vi.mock('@/context/dataset-detail', () => ({
useDatasetDetailContextWithSelector: (selector: (state: { dataset?: DataSet }) => unknown) => selector({ dataset: mockDataset }),
}))
vi.mock('@/context/account-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current)
})
vi.mock('@/context/workspace-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current)
})
vi.mock('@/context/permission-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current)
})
vi.mock('@/context/version-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current)
})
vi.mock('@/context/system-features-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState.current)
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } = await import('@/__tests__/utils/mock-app-context-state')
return createAppContextStateJotaiMock(importOriginal)
})
vi.mock('@/service/knowledge/use-dataset', () => ({
datasetDetailQueryKeyPrefix: ['dataset', 'detail'],
useInvalidDatasetList: () => mockInvalidDatasetList,
}))
vi.mock('@/service/use-base', () => ({
useInvalid: () => mockInvalidDatasetDetail,
}))
vi.mock('@/service/use-pipeline', () => ({
useExportPipelineDSL: () => ({
mutateAsync: mockExportPipeline,
}),
}))
vi.mock('@/service/datasets', () => ({
checkIsUsedInApp: (...args: unknown[]) => mockCheckIsUsedInApp(...args),
deleteDataset: (...args: unknown[]) => mockDeleteDataset(...args),
}))
vi.mock('@/hooks/use-knowledge', () => ({
useKnowledge: () => ({
formatIndexingTechniqueAndMethod: () => 'indexing-technique',
}),
}))
vi.mock('@/app/components/datasets/rename-modal', () => ({
default: ({
show,
onClose,
onSuccess,
}: {
show: boolean
onClose: () => void
onSuccess?: () => void
}) => {
if (!show)
return null
return (
<div data-testid="rename-modal">
<button type="button" onClick={onSuccess}>Success</button>
<button type="button" onClick={onClose}>Close</button>
</div>
)
},
}))
const openMenu = async (user: ReturnType<typeof userEvent.setup>) => {
const trigger = screen.getByRole('button')
await user.click(trigger)
}
describe('DatasetInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsRbacEnabled = true
mockDataset = createDataset()
})
// Rendering of dataset summary details based on expand and dataset state.
describe('Rendering', () => {
it('should show dataset details when expanded', () => {
// Arrange
mockDataset = createDataset({ is_published: false })
render(<DatasetInfo expand />)
// Assert
expect(screen.getByText('Dataset Name')).toBeInTheDocument()
expect(screen.queryByText('Dataset description')).not.toBeInTheDocument()
expect(screen.getByText('dataset.chunkingMode.general')).toBeInTheDocument()
expect(screen.getByText('indexing-technique')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.more' })).toBeInTheDocument()
})
it('should show external tag when provider is external', () => {
// Arrange
mockDataset = createDataset({ provider: 'external', is_published: false })
render(<DatasetInfo expand />)
// Assert
expect(screen.getByText('dataset.externalTag')).toBeInTheDocument()
expect(screen.queryByText('dataset.chunkingMode.general')).not.toBeInTheDocument()
})
it('should hide detailed fields when collapsed', () => {
// Arrange
render(<DatasetInfo expand={false} />)
// Assert
expect(screen.queryByText('Dataset Name')).not.toBeInTheDocument()
expect(screen.queryByText('Dataset description')).not.toBeInTheDocument()
})
})
})
describe('MenuItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Event handling for menu item interactions.
describe('Interactions', () => {
it('should call handler when clicked', async () => {
const user = userEvent.setup()
const handleClick = vi.fn()
// Arrange
render(<MenuItem name="Edit" Icon={TestEditIcon} handleClick={handleClick} />)
// Act
await user.click(screen.getByText('Edit'))
// Assert
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should stop propagation before invoking the handler', () => {
const parentClick = vi.fn()
const handleClick = vi.fn()
render(
<div role="button" tabIndex={0} onClick={parentClick} onKeyDown={parentClick}>
<MenuItem name="Edit" Icon={TestEditIcon} handleClick={handleClick} />
</div>,
)
fireEvent.click(screen.getByText('Edit'))
expect(handleClick).toHaveBeenCalledTimes(1)
expect(parentClick).not.toHaveBeenCalled()
})
it('should not crash when no click handler is provided', () => {
render(<MenuItem name="Edit" Icon={TestEditIcon} />)
const event = createEvent.click(screen.getByText('Edit'))
fireEvent(screen.getByText('Edit'), event)
expect(event.defaultPrevented).toBe(true)
})
})
})
describe('Menu', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDataset = createDataset()
})
// Rendering of menu options based on runtime mode and delete visibility.
describe('Rendering', () => {
it('should show edit, export, and delete options when rag pipeline and deletable', () => {
// Arrange
mockDataset = createDataset({ runtime_mode: 'rag_pipeline' })
render(
<Menu
showDelete
openRenameModal={vi.fn()}
handleExportPipeline={vi.fn()}
detectIsUsedByApp={vi.fn()}
/>,
)
// Assert
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
expect(screen.getByText('datasetPipeline.operations.exportPipeline')).toBeInTheDocument()
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
})
it('should show resource access option when enabled', () => {
render(
<Menu
showDelete={false}
showAccessConfig
openRenameModal={vi.fn()}
handleExportPipeline={vi.fn()}
detectIsUsedByApp={vi.fn()}
openAccessConfig={vi.fn()}
/>,
)
expect(screen.getByText('common.settings.resourceAccess')).toBeInTheDocument()
})
it('should hide export and delete options when not rag pipeline and not deletable', () => {
// Arrange
mockDataset = createDataset({ runtime_mode: 'general' })
render(
<Menu
showDelete={false}
openRenameModal={vi.fn()}
handleExportPipeline={vi.fn()}
detectIsUsedByApp={vi.fn()}
/>,
)
// Assert
expect(screen.getByText('common.operation.edit')).toBeInTheDocument()
expect(screen.queryByText('datasetPipeline.operations.exportPipeline')).not.toBeInTheDocument()
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
})
describe('Interactions', () => {
it('should invoke the rename callback when edit is clicked', async () => {
const user = userEvent.setup()
const openRenameModal = vi.fn()
render(
<Menu
showDelete
openRenameModal={openRenameModal}
handleExportPipeline={vi.fn()}
detectIsUsedByApp={vi.fn()}
/>,
)
await user.click(screen.getByText('common.operation.edit'))
expect(openRenameModal).toHaveBeenCalledTimes(1)
})
it('should invoke export and delete callbacks from their menu items', async () => {
const user = userEvent.setup()
const handleExportPipeline = vi.fn()
const detectIsUsedByApp = vi.fn()
render(
<Menu
showDelete
openRenameModal={vi.fn()}
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}
/>,
)
await user.click(screen.getByText('datasetPipeline.operations.exportPipeline'))
await user.click(screen.getByText('common.operation.delete'))
expect(handleExportPipeline).toHaveBeenCalledTimes(1)
expect(detectIsUsedByApp).toHaveBeenCalledTimes(1)
})
it('should invoke access config callback from its menu item', async () => {
const user = userEvent.setup()
const openAccessConfig = vi.fn()
render(
<Menu
showDelete={false}
showAccessConfig
openRenameModal={vi.fn()}
handleExportPipeline={vi.fn()}
detectIsUsedByApp={vi.fn()}
openAccessConfig={openAccessConfig}
/>,
)
await user.click(screen.getByText('common.settings.resourceAccess'))
expect(openAccessConfig).toHaveBeenCalledTimes(1)
})
})
})
describe('Dropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDataset = createDataset({ pipeline_id: 'pipeline-1', runtime_mode: 'rag_pipeline' })
mockIsRbacEnabled = true
mockExportPipeline.mockResolvedValue({ data: 'pipeline-content' })
mockCheckIsUsedInApp.mockResolvedValue({ is_using: false })
mockDeleteDataset.mockResolvedValue({})
if (!('createObjectURL' in URL)) {
Object.defineProperty(URL, 'createObjectURL', {
value: vi.fn(),
writable: true,
})
}
if (!('revokeObjectURL' in URL)) {
Object.defineProperty(URL, 'revokeObjectURL', {
value: vi.fn(),
writable: true,
})
}
})
// Rendering behavior based on dataset ACL permission keys.
describe('Rendering', () => {
it('should hide delete option when dataset lacks delete ACL permission', async () => {
const user = userEvent.setup()
// Arrange
mockDataset = createDataset({
pipeline_id: 'pipeline-1',
runtime_mode: 'rag_pipeline',
permission_keys: [
DatasetACLPermission.Edit,
DatasetACLPermission.ImportExportDSL,
],
})
render(<Dropdown expand />)
// Act
await openMenu(user)
// Assert
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
it('should show resource access option when dataset only has access config ACL permission', async () => {
const user = userEvent.setup()
// Arrange
mockDataset = createDataset({
runtime_mode: 'general',
permission_keys: [DatasetACLPermission.AccessConfig],
})
render(<Dropdown expand />)
// Act
await openMenu(user)
// Assert
expect(screen.getByText('common.settings.resourceAccess')).toBeInTheDocument()
expect(screen.queryByText('common.operation.edit')).not.toBeInTheDocument()
expect(screen.queryByText('common.operation.delete')).not.toBeInTheDocument()
})
it('should hide resource access option when RBAC is disabled', async () => {
const user = userEvent.setup()
// Arrange
mockIsRbacEnabled = false
mockDataset = createDataset({
runtime_mode: 'general',
permission_keys: [DatasetACLPermission.AccessConfig, DatasetACLPermission.Delete],
})
render(<Dropdown expand />)
// Act
await openMenu(user)
// Assert
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
expect(screen.queryByText('common.settings.resourceAccess')).not.toBeInTheDocument()
})
})
// User interactions that trigger modals and exports.
describe('Interactions', () => {
it('should open rename modal when edit is clicked', async () => {
const user = userEvent.setup()
// Arrange
render(<Dropdown expand />)
// Act
await openMenu(user)
await user.click(screen.getByText('common.operation.edit'))
// Assert
expect(screen.getByTestId('rename-modal')).toBeInTheDocument()
})
it('should export pipeline when export is clicked', async () => {
const user = userEvent.setup()
const anchorClickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click')
const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL')
// Arrange
render(<Dropdown expand />)
// Act
await openMenu(user)
await user.click(screen.getByText('datasetPipeline.operations.exportPipeline'))
// Assert
await waitFor(() => {
expect(mockExportPipeline).toHaveBeenCalledWith({
pipelineId: 'pipeline-1',
include: false,
})
})
expect(createObjectURLSpy).toHaveBeenCalledTimes(1)
expect(anchorClickSpy).toHaveBeenCalledTimes(1)
})
it('should show delete confirmation when delete is clicked', async () => {
const user = userEvent.setup()
// Arrange
render(<Dropdown expand />)
// Act
await openMenu(user)
await user.click(screen.getByText('common.operation.delete'))
// Assert
await waitFor(() => {
expect(screen.getByText('dataset.deleteDatasetConfirmContent')).toBeInTheDocument()
})
})
it('should delete dataset and redirect when confirm is clicked', async () => {
const user = userEvent.setup()
// Arrange
render(<Dropdown expand />)
// Act
await openMenu(user)
await user.click(screen.getByText('common.operation.delete'))
await user.click(await screen.findByRole('button', { name: 'common.operation.confirm' }))
// Assert
await waitFor(() => {
expect(mockDeleteDataset).toHaveBeenCalledWith('dataset-1')
})
expect(mockInvalidDatasetList).toHaveBeenCalledTimes(1)
expect(mockReplace).toHaveBeenCalledWith('/datasets')
})
it('should navigate to dataset access config when resource access is clicked', async () => {
const user = userEvent.setup()
// Arrange
mockDataset = createDataset({
runtime_mode: 'general',
permission_keys: [DatasetACLPermission.AccessConfig],
})
render(<Dropdown expand />)
// Act
await openMenu(user)
await user.click(screen.getByText('common.settings.resourceAccess'))
// Assert
expect(mockPush).toHaveBeenCalledWith('/datasets/dataset-1/access-config')
})
})
})