mirror of https://github.com/langgenius/dify.git
add @testing-library/user-event and create tests for external-knowledge-base/ (#29323)
Co-authored-by: CodingOnStar <hanxujiang@dify.ai> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
f722fdfa6d
commit
681c06186e
|
|
@ -0,0 +1,367 @@
|
|||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ExternalAPIItem } from '@/models/datasets'
|
||||
import ExternalKnowledgeBaseConnector from './index'
|
||||
import { createExternalKnowledgeBase } from '@/service/datasets'
|
||||
|
||||
// Mock next/navigation
|
||||
const mockRouterBack = jest.fn()
|
||||
const mockReplace = jest.fn()
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
back: mockRouterBack,
|
||||
replace: mockReplace,
|
||||
push: jest.fn(),
|
||||
refresh: jest.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock react-i18next
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock useDocLink hook
|
||||
jest.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path?: string) => `https://docs.dify.ai/en${path || ''}`,
|
||||
}))
|
||||
|
||||
// Mock toast context
|
||||
const mockNotify = jest.fn()
|
||||
jest.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: mockNotify,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock modal context
|
||||
jest.mock('@/context/modal-context', () => ({
|
||||
useModalContext: () => ({
|
||||
setShowExternalKnowledgeAPIModal: jest.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock API service
|
||||
jest.mock('@/service/datasets', () => ({
|
||||
createExternalKnowledgeBase: jest.fn(),
|
||||
}))
|
||||
|
||||
// Factory function to create mock ExternalAPIItem
|
||||
const createMockExternalAPIItem = (overrides: Partial<ExternalAPIItem> = {}): ExternalAPIItem => ({
|
||||
id: 'api-default',
|
||||
tenant_id: 'tenant-1',
|
||||
name: 'Default API',
|
||||
description: 'Default API description',
|
||||
settings: {
|
||||
endpoint: 'https://api.example.com',
|
||||
api_key: 'test-api-key',
|
||||
},
|
||||
dataset_bindings: [],
|
||||
created_by: 'user-1',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
// Default mock API list
|
||||
const createDefaultMockApiList = (): ExternalAPIItem[] => [
|
||||
createMockExternalAPIItem({
|
||||
id: 'api-1',
|
||||
name: 'Test API 1',
|
||||
settings: { endpoint: 'https://api1.example.com', api_key: 'key-1' },
|
||||
}),
|
||||
createMockExternalAPIItem({
|
||||
id: 'api-2',
|
||||
name: 'Test API 2',
|
||||
settings: { endpoint: 'https://api2.example.com', api_key: 'key-2' },
|
||||
}),
|
||||
]
|
||||
|
||||
let mockExternalKnowledgeApiList: ExternalAPIItem[] = createDefaultMockApiList()
|
||||
|
||||
jest.mock('@/context/external-knowledge-api-context', () => ({
|
||||
useExternalKnowledgeApi: () => ({
|
||||
externalKnowledgeApiList: mockExternalKnowledgeApiList,
|
||||
mutateExternalKnowledgeApis: jest.fn(),
|
||||
isLoading: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Suppress console.error helper
|
||||
const suppressConsoleError = () => jest.spyOn(console, 'error').mockImplementation(jest.fn())
|
||||
|
||||
// Helper to create a pending promise with external resolver
|
||||
function createPendingPromise<T>() {
|
||||
let resolve: (value: T) => void = jest.fn()
|
||||
const promise = new Promise<T>((r) => {
|
||||
resolve = r
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
// Helper to fill required form fields and submit
|
||||
async function fillFormAndSubmit(user: ReturnType<typeof userEvent.setup>) {
|
||||
const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder')
|
||||
const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder')
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'Test Knowledge Base' } })
|
||||
fireEvent.change(knowledgeIdInput, { target: { value: 'kb-123' } })
|
||||
|
||||
// Wait for button to be enabled
|
||||
await waitFor(() => {
|
||||
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
|
||||
expect(connectButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
|
||||
await user.click(connectButton!)
|
||||
}
|
||||
|
||||
describe('ExternalKnowledgeBaseConnector', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockExternalKnowledgeApiList = createDefaultMockApiList()
|
||||
;(createExternalKnowledgeBase as jest.Mock).mockResolvedValue({ id: 'new-kb-id' })
|
||||
})
|
||||
|
||||
// Tests for rendering with real ExternalKnowledgeBaseCreate component
|
||||
describe('Rendering', () => {
|
||||
it('should render the create form with all required elements', () => {
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
// Verify main title and form elements
|
||||
expect(screen.getByText('dataset.connectDataset')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.externalKnowledgeName')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.externalKnowledgeId')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.retrievalSettings')).toBeInTheDocument()
|
||||
|
||||
// Verify buttons
|
||||
expect(screen.getByText('dataset.externalKnowledgeForm.cancel')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.externalKnowledgeForm.connect')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render connect button disabled initially', () => {
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
|
||||
expect(connectButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for API success flow
|
||||
describe('API Success Flow', () => {
|
||||
it('should call API and show success notification when form is submitted', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
await fillFormAndSubmit(user)
|
||||
|
||||
// Verify API was called with form data
|
||||
await waitFor(() => {
|
||||
expect(createExternalKnowledgeBase).toHaveBeenCalledWith({
|
||||
body: expect.objectContaining({
|
||||
name: 'Test Knowledge Base',
|
||||
external_knowledge_id: 'kb-123',
|
||||
external_knowledge_api_id: 'api-1',
|
||||
provider: 'external',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
// Verify success notification
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'External Knowledge Base Connected Successfully',
|
||||
})
|
||||
|
||||
// Verify navigation back
|
||||
expect(mockRouterBack).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should include retrieval settings in API call', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
await fillFormAndSubmit(user)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createExternalKnowledgeBase).toHaveBeenCalledWith({
|
||||
body: expect.objectContaining({
|
||||
external_retrieval_model: expect.objectContaining({
|
||||
top_k: 4,
|
||||
score_threshold: 0.5,
|
||||
score_threshold_enabled: false,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for API error flow
|
||||
describe('API Error Flow', () => {
|
||||
it('should show error notification when API fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
const consoleErrorSpy = suppressConsoleError()
|
||||
;(createExternalKnowledgeBase as jest.Mock).mockRejectedValue(new Error('Network Error'))
|
||||
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
await fillFormAndSubmit(user)
|
||||
|
||||
// Verify error notification
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Failed to connect External Knowledge Base',
|
||||
})
|
||||
})
|
||||
|
||||
// Verify no navigation
|
||||
expect(mockRouterBack).not.toHaveBeenCalled()
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should show error notification when API returns invalid result', async () => {
|
||||
const user = userEvent.setup()
|
||||
const consoleErrorSpy = suppressConsoleError()
|
||||
;(createExternalKnowledgeBase as jest.Mock).mockResolvedValue({})
|
||||
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
await fillFormAndSubmit(user)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'Failed to connect External Knowledge Base',
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockRouterBack).not.toHaveBeenCalled()
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for loading state
|
||||
describe('Loading State', () => {
|
||||
it('should show loading state during API call', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
// Create a promise that won't resolve immediately
|
||||
const { promise, resolve: resolvePromise } = createPendingPromise<{ id: string }>()
|
||||
;(createExternalKnowledgeBase as jest.Mock).mockReturnValue(promise)
|
||||
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
// Fill form
|
||||
const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder')
|
||||
const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder')
|
||||
fireEvent.change(nameInput, { target: { value: 'Test' } })
|
||||
fireEvent.change(knowledgeIdInput, { target: { value: 'kb-1' } })
|
||||
|
||||
await waitFor(() => {
|
||||
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
|
||||
expect(connectButton).not.toBeDisabled()
|
||||
})
|
||||
|
||||
// Click connect
|
||||
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
|
||||
await user.click(connectButton!)
|
||||
|
||||
// Button should show loading (the real Button component has loading prop)
|
||||
await waitFor(() => {
|
||||
expect(createExternalKnowledgeBase).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Resolve the promise
|
||||
resolvePromise({ id: 'new-id' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'success',
|
||||
message: 'External Knowledge Base Connected Successfully',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for form validation (integration with real create component)
|
||||
describe('Form Validation', () => {
|
||||
it('should keep button disabled when only name is filled', () => {
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder')
|
||||
fireEvent.change(nameInput, { target: { value: 'Test' } })
|
||||
|
||||
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
|
||||
expect(connectButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should keep button disabled when only knowledge id is filled', () => {
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder')
|
||||
fireEvent.change(knowledgeIdInput, { target: { value: 'kb-1' } })
|
||||
|
||||
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
|
||||
expect(connectButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable button when all required fields are filled', async () => {
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder')
|
||||
const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder')
|
||||
|
||||
fireEvent.change(nameInput, { target: { value: 'Test' } })
|
||||
fireEvent.change(knowledgeIdInput, { target: { value: 'kb-1' } })
|
||||
|
||||
await waitFor(() => {
|
||||
const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button')
|
||||
expect(connectButton).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Tests for user interactions
|
||||
describe('User Interactions', () => {
|
||||
it('should allow typing in form fields', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder')
|
||||
const descriptionInput = screen.getByPlaceholderText('dataset.externalKnowledgeDescriptionPlaceholder')
|
||||
|
||||
await user.type(nameInput, 'My Knowledge Base')
|
||||
await user.type(descriptionInput, 'My Description')
|
||||
|
||||
expect((nameInput as HTMLInputElement).value).toBe('My Knowledge Base')
|
||||
expect((descriptionInput as HTMLTextAreaElement).value).toBe('My Description')
|
||||
})
|
||||
|
||||
it('should handle cancel button click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
const cancelButton = screen.getByText('dataset.externalKnowledgeForm.cancel').closest('button')
|
||||
await user.click(cancelButton!)
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith('/datasets')
|
||||
})
|
||||
|
||||
it('should handle back button click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ExternalKnowledgeBaseConnector />)
|
||||
|
||||
const buttons = screen.getAllByRole('button')
|
||||
const backButton = buttons.find(btn => btn.classList.contains('rounded-full'))
|
||||
await user.click(backButton!)
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith('/datasets')
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +1,19 @@
|
|||
import '@testing-library/jest-dom'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
|
||||
// Fix for @headlessui/react compatibility with happy-dom
|
||||
// headlessui tries to set focus property which is read-only in happy-dom
|
||||
if (typeof window !== 'undefined') {
|
||||
// Ensure window.focus is writable for headlessui
|
||||
if (!Object.getOwnPropertyDescriptor(window, 'focus')?.writable) {
|
||||
Object.defineProperty(window, 'focus', {
|
||||
value: jest.fn(),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@
|
|||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
|
|
|
|||
|
|
@ -416,6 +416,9 @@ importers:
|
|||
'@testing-library/react':
|
||||
specifier: ^16.3.0
|
||||
version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||
'@testing-library/user-event':
|
||||
specifier: ^14.6.1
|
||||
version: 14.6.1(@testing-library/dom@10.4.1)
|
||||
'@types/jest':
|
||||
specifier: ^29.5.14
|
||||
version: 29.5.14
|
||||
|
|
|
|||
|
|
@ -145,8 +145,17 @@ Treat component state as part of the public behavior: confirm the initial render
|
|||
- ✅ When creating lightweight provider stubs, mirror the real default values and surface helper builders (for example `createMockWorkflowContext`).
|
||||
- ✅ Reset shared stores (React context, Zustand, TanStack Query cache) between tests to avoid leaking state. Prefer helper factory functions over module-level singletons in specs.
|
||||
- ✅ For hooks that read from context, use `renderHook` with a custom wrapper that supplies required providers.
|
||||
- ✅ **Use factory functions for mock data**: Import actual types and create factory functions with complete defaults (see [Test Data Builders](#9-test-data-builders-anti-hardcoding) section).
|
||||
- ✅ If it's need to mock some common context provider used across many components (for example, `ProviderContext`), put it in __mocks__/context(for example, `__mocks__/context/provider-context`). To dynamically control the mock behavior (for example, toggling plan type), use module-level variables to track state and change them(for example, `context/provider-context-mock.spec.tsx`).
|
||||
- ✅ Use factory functions to create mock data with TypeScript types. This ensures type safety and makes tests more maintainable.
|
||||
|
||||
If it's need to mock some common context provider used across many components (for example, `ProviderContext`), put it in __mocks__/context(for example, `__mocks__/context/provider-context`). To dynamically control the mock behavior (for example, toggling plan type), use module-level variables to track state and change them(for example, `context/provier-context-mock.spec.tsx`).
|
||||
**Rules**:
|
||||
|
||||
1. **Import actual types**: Always import types from the source (`@/models/`, `@/types/`, etc.) instead of defining inline types.
|
||||
1. **Provide complete defaults**: Factory functions should return complete objects with all required fields filled with sensible defaults.
|
||||
1. **Allow partial overrides**: Accept `Partial<T>` to enable flexible customization for specific test cases.
|
||||
1. **Create list factories**: For array data, create a separate factory function that composes item factories.
|
||||
1. **Reference**: See `__mocks__/provider-context.ts` for reusable context mock factories used across multiple test files.
|
||||
|
||||
### 4. Performance Optimization
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue