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:
Coding On Star 2025-12-10 12:46:52 +08:00 committed by GitHub
parent f722fdfa6d
commit 681c06186e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 1453 additions and 1 deletions

View File

@ -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

View File

@ -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()
})

View File

@ -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",

View File

@ -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

View File

@ -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