diff --git a/web/app/components/datasets/external-knowledge-base/connector/index.spec.tsx b/web/app/components/datasets/external-knowledge-base/connector/index.spec.tsx new file mode 100644 index 0000000000..a6353a101c --- /dev/null +++ b/web/app/components/datasets/external-knowledge-base/connector/index.spec.tsx @@ -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 => ({ + 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() { + let resolve: (value: T) => void = jest.fn() + const promise = new Promise((r) => { + resolve = r + }) + return { promise, resolve } +} + +// Helper to fill required form fields and submit +async function fillFormAndSubmit(user: ReturnType) { + 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() + + // 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + // 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + const buttons = screen.getAllByRole('button') + const backButton = buttons.find(btn => btn.classList.contains('rounded-full')) + await user.click(backButton!) + + expect(mockReplace).toHaveBeenCalledWith('/datasets') + }) + }) +}) diff --git a/web/app/components/datasets/external-knowledge-base/create/index.spec.tsx b/web/app/components/datasets/external-knowledge-base/create/index.spec.tsx new file mode 100644 index 0000000000..c315743424 --- /dev/null +++ b/web/app/components/datasets/external-knowledge-base/create/index.spec.tsx @@ -0,0 +1,1059 @@ +import React from 'react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import type { ExternalAPIItem } from '@/models/datasets' +import ExternalKnowledgeBaseCreate from './index' + +// Mock next/navigation +const mockReplace = jest.fn() +const mockRefresh = jest.fn() +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + replace: mockReplace, + push: jest.fn(), + refresh: mockRefresh, + }), +})) + +// 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 external context providers (these are external dependencies) +const mockSetShowExternalKnowledgeAPIModal = jest.fn() +jest.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowExternalKnowledgeAPIModal: mockSetShowExternalKnowledgeAPIModal, + }), +})) + +// Factory function to create mock ExternalAPIItem (following project conventions) +const createMockExternalAPIItem = (overrides: Partial = {}): 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' }, + }), +] + +const mockMutateExternalKnowledgeApis = jest.fn() +let mockExternalKnowledgeApiList: ExternalAPIItem[] = createDefaultMockApiList() + +jest.mock('@/context/external-knowledge-api-context', () => ({ + useExternalKnowledgeApi: () => ({ + externalKnowledgeApiList: mockExternalKnowledgeApiList, + mutateExternalKnowledgeApis: mockMutateExternalKnowledgeApis, + isLoading: false, + }), +})) + +// Helper to render component with default props +const renderComponent = (props: Partial> = {}) => { + const defaultProps = { + onConnect: jest.fn(), + loading: false, + } + return render() +} + +describe('ExternalKnowledgeBaseCreate', () => { + beforeEach(() => { + jest.clearAllMocks() + // Reset API list to default using factory function + mockExternalKnowledgeApiList = createDefaultMockApiList() + }) + + // Tests for basic rendering + describe('Rendering', () => { + it('should render without crashing', () => { + renderComponent() + + expect(screen.getByText('dataset.connectDataset')).toBeInTheDocument() + }) + + it('should render KnowledgeBaseInfo component with correct labels', () => { + renderComponent() + + // KnowledgeBaseInfo renders these labels + expect(screen.getByText('dataset.externalKnowledgeName')).toBeInTheDocument() + expect(screen.getByText('dataset.externalKnowledgeDescription')).toBeInTheDocument() + }) + + it('should render ExternalApiSelection component', () => { + renderComponent() + + // ExternalApiSelection renders this label + expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument() + expect(screen.getByText('dataset.externalKnowledgeId')).toBeInTheDocument() + }) + + it('should render RetrievalSettings component', () => { + renderComponent() + + // RetrievalSettings renders this label + expect(screen.getByText('dataset.retrievalSettings')).toBeInTheDocument() + }) + + it('should render InfoPanel component', () => { + renderComponent() + + // InfoPanel renders these texts + expect(screen.getByText('dataset.connectDatasetIntro.title')).toBeInTheDocument() + expect(screen.getByText('dataset.connectDatasetIntro.learnMore')).toBeInTheDocument() + }) + + it('should render helper text with translation keys', () => { + renderComponent() + + expect(screen.getByText('dataset.connectHelper.helper1')).toBeInTheDocument() + expect(screen.getByText('dataset.connectHelper.helper2')).toBeInTheDocument() + expect(screen.getByText('dataset.connectHelper.helper3')).toBeInTheDocument() + expect(screen.getByText('dataset.connectHelper.helper4')).toBeInTheDocument() + expect(screen.getByText('dataset.connectHelper.helper5')).toBeInTheDocument() + }) + + it('should render cancel and connect buttons', () => { + renderComponent() + + expect(screen.getByText('dataset.externalKnowledgeForm.cancel')).toBeInTheDocument() + expect(screen.getByText('dataset.externalKnowledgeForm.connect')).toBeInTheDocument() + }) + + it('should render documentation link with correct href', () => { + renderComponent() + + const docLink = screen.getByText('dataset.connectHelper.helper4') + expect(docLink).toHaveAttribute('href', 'https://docs.dify.ai/en/guides/knowledge-base/connect-external-knowledge-base') + expect(docLink).toHaveAttribute('target', '_blank') + expect(docLink).toHaveAttribute('rel', 'noopener noreferrer') + }) + }) + + // Tests for props handling + describe('Props', () => { + it('should pass loading prop to connect button', () => { + renderComponent({ loading: true }) + + const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + expect(connectButton).toBeInTheDocument() + }) + + it('should call onConnect with form data when connect button is clicked', async () => { + const user = userEvent.setup() + const onConnect = jest.fn() + renderComponent({ onConnect }) + + // Fill in name field (using the actual Input component) + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + fireEvent.change(nameInput, { target: { value: 'Test Knowledge Base' } }) + + // Fill in external knowledge id + const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + fireEvent.change(knowledgeIdInput, { target: { value: 'knowledge-456' } }) + + // Wait for useEffect to auto-select the first API + 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!) + + expect(onConnect).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Test Knowledge Base', + external_knowledge_id: 'knowledge-456', + external_knowledge_api_id: 'api-1', // Auto-selected first API + provider: 'external', + }), + ) + }) + + it('should not call onConnect when form is invalid and button is disabled', async () => { + const user = userEvent.setup() + const onConnect = jest.fn() + renderComponent({ onConnect }) + + const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + expect(connectButton).toBeDisabled() + + await user.click(connectButton!) + expect(onConnect).not.toHaveBeenCalled() + }) + }) + + // Tests for state management with real child components + describe('State Management', () => { + it('should initialize form data with default values', () => { + renderComponent() + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') as HTMLInputElement + const descriptionInput = screen.getByPlaceholderText('dataset.externalKnowledgeDescriptionPlaceholder') as HTMLTextAreaElement + + expect(nameInput.value).toBe('') + expect(descriptionInput.value).toBe('') + }) + + it('should update name when input changes', () => { + renderComponent() + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + fireEvent.change(nameInput, { target: { value: 'New Name' } }) + + expect((nameInput as HTMLInputElement).value).toBe('New Name') + }) + + it('should update description when textarea changes', () => { + renderComponent() + + const descriptionInput = screen.getByPlaceholderText('dataset.externalKnowledgeDescriptionPlaceholder') + fireEvent.change(descriptionInput, { target: { value: 'New Description' } }) + + expect((descriptionInput as HTMLTextAreaElement).value).toBe('New Description') + }) + + it('should update external_knowledge_id when input changes', () => { + renderComponent() + + const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + fireEvent.change(knowledgeIdInput, { target: { value: 'new-knowledge-id' } }) + + expect((knowledgeIdInput as HTMLInputElement).value).toBe('new-knowledge-id') + }) + + it('should apply filled text style when description has value', () => { + renderComponent() + + const descriptionInput = screen.getByPlaceholderText('dataset.externalKnowledgeDescriptionPlaceholder') as HTMLTextAreaElement + + // Initially empty - should have placeholder style + expect(descriptionInput.className).toContain('text-components-input-text-placeholder') + + // Add description - should have filled style + fireEvent.change(descriptionInput, { target: { value: 'Some description' } }) + expect(descriptionInput.className).toContain('text-components-input-text-filled') + }) + + it('should apply placeholder text style when description is empty', () => { + renderComponent() + + const descriptionInput = screen.getByPlaceholderText('dataset.externalKnowledgeDescriptionPlaceholder') as HTMLTextAreaElement + + // Add then clear description + fireEvent.change(descriptionInput, { target: { value: 'Some description' } }) + fireEvent.change(descriptionInput, { target: { value: '' } }) + + expect(descriptionInput.className).toContain('text-components-input-text-placeholder') + }) + }) + + // Tests for form validation + describe('Form Validation', () => { + it('should disable connect button when name is empty', async () => { + renderComponent() + + // Fill knowledge id but not name + const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + fireEvent.change(knowledgeIdInput, { target: { value: 'knowledge-456' } }) + + const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + expect(connectButton).toBeDisabled() + }) + + it('should disable connect button when name is only whitespace', async () => { + renderComponent() + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + + fireEvent.change(nameInput, { target: { value: ' ' } }) + fireEvent.change(knowledgeIdInput, { target: { value: 'knowledge-456' } }) + + const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + expect(connectButton).toBeDisabled() + }) + + it('should disable connect button when external_knowledge_id is empty', () => { + renderComponent() + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + fireEvent.change(nameInput, { target: { value: 'Test Name' } }) + + const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + expect(connectButton).toBeDisabled() + }) + + it('should enable connect button when all required fields are filled', async () => { + renderComponent() + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + + fireEvent.change(nameInput, { target: { value: 'Test Name' } }) + fireEvent.change(knowledgeIdInput, { target: { value: 'knowledge-456' } }) + + // Wait for auto-selection of API + await waitFor(() => { + const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + expect(connectButton).not.toBeDisabled() + }) + }) + }) + + // Tests for user interactions + describe('User Interactions', () => { + it('should navigate back when back button is clicked', async () => { + const user = userEvent.setup() + renderComponent() + + const buttons = screen.getAllByRole('button') + const backButton = buttons.find(btn => btn.classList.contains('rounded-full')) + await user.click(backButton!) + + expect(mockReplace).toHaveBeenCalledWith('/datasets') + }) + + it('should navigate back when cancel button is clicked', async () => { + const user = userEvent.setup() + renderComponent() + + const cancelButton = screen.getByText('dataset.externalKnowledgeForm.cancel').closest('button') + await user.click(cancelButton!) + + expect(mockReplace).toHaveBeenCalledWith('/datasets') + }) + + it('should call onConnect with complete form data when connect is clicked', async () => { + const user = userEvent.setup() + const onConnect = jest.fn() + renderComponent({ onConnect }) + + // Fill all fields using real components + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + const descriptionInput = screen.getByPlaceholderText('dataset.externalKnowledgeDescriptionPlaceholder') + const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + + fireEvent.change(nameInput, { target: { value: 'My Knowledge Base' } }) + fireEvent.change(descriptionInput, { target: { value: 'Test description' } }) + fireEvent.change(knowledgeIdInput, { target: { value: 'knowledge-abc' } }) + + 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!) + + expect(onConnect).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'My Knowledge Base', + description: 'Test description', + external_knowledge_id: 'knowledge-abc', + provider: 'external', + }), + ) + }) + + it('should allow user to type in all input fields', async () => { + const user = userEvent.setup() + renderComponent() + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + const descriptionInput = screen.getByPlaceholderText('dataset.externalKnowledgeDescriptionPlaceholder') + const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + + await user.type(nameInput, 'Typed Name') + await user.type(descriptionInput, 'Typed Description') + await user.type(knowledgeIdInput, 'typed-knowledge') + + expect((nameInput as HTMLInputElement).value).toBe('Typed Name') + expect((descriptionInput as HTMLTextAreaElement).value).toBe('Typed Description') + expect((knowledgeIdInput as HTMLInputElement).value).toBe('typed-knowledge') + }) + }) + + // Tests for ExternalApiSelection integration + describe('ExternalApiSelection Integration', () => { + it('should auto-select first API when API list is available', async () => { + const user = userEvent.setup() + const onConnect = jest.fn() + renderComponent({ onConnect }) + + 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() + }) + + const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + await user.click(connectButton!) + + // Should have auto-selected the first API + expect(onConnect).toHaveBeenCalledWith( + expect.objectContaining({ + external_knowledge_api_id: 'api-1', + }), + ) + }) + + it('should display API selector when APIs are available', () => { + renderComponent() + + // The ExternalApiSelect should show the first selected API name + expect(screen.getByText('Test API 1')).toBeInTheDocument() + }) + + it('should allow selecting different API from dropdown', async () => { + const user = userEvent.setup() + const onConnect = jest.fn() + renderComponent({ onConnect }) + + // Click on the API selector to open dropdown + const apiSelector = screen.getByText('Test API 1') + await user.click(apiSelector) + + // Select the second API + const secondApi = screen.getByText('Test API 2') + await user.click(secondApi) + + // Fill required fields + 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() + }) + + const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + await user.click(connectButton!) + + // Should have selected the second API + expect(onConnect).toHaveBeenCalledWith( + expect.objectContaining({ + external_knowledge_api_id: 'api-2', + }), + ) + }) + + it('should show add API button when no APIs are available', () => { + // Set empty API list + mockExternalKnowledgeApiList = [] + renderComponent() + + // Should show "no external knowledge" button + expect(screen.getByText('dataset.noExternalKnowledge')).toBeInTheDocument() + }) + + it('should open add API modal when add button is clicked', async () => { + const user = userEvent.setup() + // Set empty API list + mockExternalKnowledgeApiList = [] + renderComponent() + + // Click the add button + const addButton = screen.getByText('dataset.noExternalKnowledge').closest('button') + await user.click(addButton!) + + // Should call the modal context function + expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledWith( + expect.objectContaining({ + payload: { name: '', settings: { endpoint: '', api_key: '' } }, + isEditMode: false, + }), + ) + }) + + it('should call mutate and router.refresh on modal save callback', async () => { + const user = userEvent.setup() + // Set empty API list + mockExternalKnowledgeApiList = [] + renderComponent() + + // Click the add button + const addButton = screen.getByText('dataset.noExternalKnowledge').closest('button') + await user.click(addButton!) + + // Get the callback and invoke it + const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0] + await modalCall.onSaveCallback() + + expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled() + expect(mockRefresh).toHaveBeenCalled() + }) + + it('should call mutate on modal cancel callback', async () => { + const user = userEvent.setup() + // Set empty API list + mockExternalKnowledgeApiList = [] + renderComponent() + + // Click the add button + const addButton = screen.getByText('dataset.noExternalKnowledge').closest('button') + await user.click(addButton!) + + // Get the callback and invoke it + const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0] + modalCall.onCancelCallback() + + expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled() + }) + + it('should display API URL in dropdown', async () => { + const user = userEvent.setup() + renderComponent() + + // Click on the API selector to open dropdown + const apiSelector = screen.getByText('Test API 1') + await user.click(apiSelector) + + // Should show API URLs + expect(screen.getByText('https://api1.example.com')).toBeInTheDocument() + expect(screen.getByText('https://api2.example.com')).toBeInTheDocument() + }) + + it('should show create new API option in dropdown', async () => { + const user = userEvent.setup() + renderComponent() + + // Click on the API selector to open dropdown + const apiSelector = screen.getByText('Test API 1') + await user.click(apiSelector) + + // Should show create new API option + expect(screen.getByText('dataset.createNewExternalAPI')).toBeInTheDocument() + }) + + it('should open add API modal when clicking create new API in dropdown', async () => { + const user = userEvent.setup() + renderComponent() + + // Click on the API selector to open dropdown + const apiSelector = screen.getByText('Test API 1') + await user.click(apiSelector) + + // Click on create new API option + const createNewApiOption = screen.getByText('dataset.createNewExternalAPI') + await user.click(createNewApiOption) + + // Should call the modal context function + expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledWith( + expect.objectContaining({ + payload: { name: '', settings: { endpoint: '', api_key: '' } }, + isEditMode: false, + }), + ) + }) + + it('should call mutate and refresh on save callback from ExternalApiSelect dropdown', async () => { + const user = userEvent.setup() + renderComponent() + + // Click on the API selector to open dropdown + const apiSelector = screen.getByText('Test API 1') + await user.click(apiSelector) + + // Click on create new API option + const createNewApiOption = screen.getByText('dataset.createNewExternalAPI') + await user.click(createNewApiOption) + + // Get the callback from the modal call and invoke it + const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0] + await modalCall.onSaveCallback() + + expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled() + expect(mockRefresh).toHaveBeenCalled() + }) + + it('should call mutate on cancel callback from ExternalApiSelect dropdown', async () => { + const user = userEvent.setup() + renderComponent() + + // Click on the API selector to open dropdown + const apiSelector = screen.getByText('Test API 1') + await user.click(apiSelector) + + // Click on create new API option + const createNewApiOption = screen.getByText('dataset.createNewExternalAPI') + await user.click(createNewApiOption) + + // Get the callback from the modal call and invoke it + const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0] + modalCall.onCancelCallback() + + expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled() + }) + + it('should close dropdown after selecting an API', async () => { + const user = userEvent.setup() + renderComponent() + + // Click on the API selector to open dropdown + const apiSelector = screen.getByText('Test API 1') + await user.click(apiSelector) + + // Dropdown should be open - API URLs visible + expect(screen.getByText('https://api1.example.com')).toBeInTheDocument() + + // Select the second API + const secondApi = screen.getByText('Test API 2') + await user.click(secondApi) + + // Dropdown should be closed - API URLs not visible + expect(screen.queryByText('https://api1.example.com')).not.toBeInTheDocument() + }) + + it('should toggle dropdown open/close on selector click', async () => { + const user = userEvent.setup() + renderComponent() + + // Click to open + const apiSelector = screen.getByText('Test API 1') + await user.click(apiSelector) + expect(screen.getByText('https://api1.example.com')).toBeInTheDocument() + + // Click again to close + await user.click(apiSelector) + expect(screen.queryByText('https://api1.example.com')).not.toBeInTheDocument() + }) + }) + + // Tests for callback stability + describe('Callback Stability', () => { + it('should maintain stable navBackHandle callback reference', async () => { + const user = userEvent.setup() + const { rerender } = render( + , + ) + + const buttons = screen.getAllByRole('button') + const backButton = buttons.find(btn => btn.classList.contains('rounded-full')) + await user.click(backButton!) + + expect(mockReplace).toHaveBeenCalledTimes(1) + + rerender() + + await user.click(backButton!) + expect(mockReplace).toHaveBeenCalledTimes(2) + }) + + it('should not recreate handlers on prop changes', async () => { + const user = userEvent.setup() + const onConnect1 = jest.fn() + const onConnect2 = jest.fn() + + const { rerender } = render( + , + ) + + // 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: 'knowledge' } }) + + // Rerender with new callback + rerender() + + 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!) + + // Should use the new callback + expect(onConnect1).not.toHaveBeenCalled() + expect(onConnect2).toHaveBeenCalled() + }) + }) + + // Tests for edge cases + describe('Edge Cases', () => { + it('should handle empty description gracefully', async () => { + const user = userEvent.setup() + const onConnect = jest.fn() + renderComponent({ onConnect }) + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + + fireEvent.change(nameInput, { target: { value: 'Test' } }) + fireEvent.change(knowledgeIdInput, { target: { value: 'knowledge' } }) + + 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!) + + expect(onConnect).toHaveBeenCalledWith( + expect.objectContaining({ + description: '', + }), + ) + }) + + it('should handle special characters in name', () => { + renderComponent() + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + const specialName = 'Test Name' + + fireEvent.change(nameInput, { target: { value: specialName } }) + + expect((nameInput as HTMLInputElement).value).toBe(specialName) + }) + + it('should handle very long input values', () => { + renderComponent() + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + const longName = 'A'.repeat(1000) + + fireEvent.change(nameInput, { target: { value: longName } }) + + expect((nameInput as HTMLInputElement).value).toBe(longName) + }) + + it('should handle rapid sequential updates', () => { + renderComponent() + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + + // Rapid updates + for (let i = 0; i < 10; i++) + fireEvent.change(nameInput, { target: { value: `Name ${i}` } }) + + expect((nameInput as HTMLInputElement).value).toBe('Name 9') + }) + + it('should preserve provider value as external', async () => { + const user = userEvent.setup() + const onConnect = jest.fn() + renderComponent({ onConnect }) + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + + fireEvent.change(nameInput, { target: { value: 'Test' } }) + fireEvent.change(knowledgeIdInput, { target: { value: 'knowledge' } }) + + 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!) + + expect(onConnect).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'external', + }), + ) + }) + }) + + // Tests for loading state + describe('Loading State', () => { + it('should pass loading state to connect button', () => { + renderComponent({ loading: true }) + + const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + expect(connectButton).toBeInTheDocument() + }) + + it('should render correctly when not loading', () => { + renderComponent({ loading: false }) + + const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + expect(connectButton).toBeInTheDocument() + }) + }) + + // Tests for RetrievalSettings integration + describe('RetrievalSettings Integration', () => { + it('should toggle score threshold enabled when switch is clicked', async () => { + const user = userEvent.setup() + const onConnect = jest.fn() + renderComponent({ onConnect }) + + // Find and click the switch for score threshold + const switches = screen.getAllByRole('switch') + const scoreThresholdSwitch = switches[0] // The score threshold switch + await user.click(scoreThresholdSwitch) + + // Fill required fields + 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() + }) + + const connectButton = screen.getByText('dataset.externalKnowledgeForm.connect').closest('button') + await user.click(connectButton!) + + expect(onConnect).toHaveBeenCalledWith( + expect.objectContaining({ + external_retrieval_model: expect.objectContaining({ + score_threshold_enabled: true, + }), + }), + ) + }) + + it('should display retrieval settings labels', () => { + renderComponent() + + // Should show the retrieval settings section title + expect(screen.getByText('dataset.retrievalSettings')).toBeInTheDocument() + // Should show Top K and Score Threshold labels + expect(screen.getByText('appDebug.datasetConfig.top_k')).toBeInTheDocument() + expect(screen.getByText('appDebug.datasetConfig.score_threshold')).toBeInTheDocument() + }) + }) + + // Direct unit tests for RetrievalSettings component to cover all branches + describe('RetrievalSettings Component Direct Tests', () => { + // Import RetrievalSettings directly for unit testing + const RetrievalSettings = require('./RetrievalSettings').default + + it('should render with isInHitTesting mode', () => { + const onChange = jest.fn() + render( + , + ) + + // In hit testing mode, the title should not be shown + expect(screen.queryByText('dataset.retrievalSettings')).not.toBeInTheDocument() + }) + + it('should render with isInRetrievalSetting mode', () => { + const onChange = jest.fn() + render( + , + ) + + // In retrieval setting mode, the title should not be shown + expect(screen.queryByText('dataset.retrievalSettings')).not.toBeInTheDocument() + }) + + it('should call onChange with score_threshold_enabled when switch is toggled', async () => { + const user = userEvent.setup() + const onChange = jest.fn() + render( + , + ) + + // Find and click the switch + const switches = screen.getAllByRole('switch') + await user.click(switches[0]) + + expect(onChange).toHaveBeenCalledWith({ score_threshold_enabled: true }) + }) + + it('should call onChange with top_k when top k value changes', () => { + const onChange = jest.fn() + render( + , + ) + + // The TopKItem should render an input + const inputs = screen.getAllByRole('spinbutton') + const topKInput = inputs[0] + fireEvent.change(topKInput, { target: { value: '8' } }) + + expect(onChange).toHaveBeenCalledWith({ top_k: 8 }) + }) + + it('should call onChange with score_threshold when threshold value changes', () => { + const onChange = jest.fn() + render( + , + ) + + // The ScoreThresholdItem should render an input + const inputs = screen.getAllByRole('spinbutton') + const scoreThresholdInput = inputs[1] + fireEvent.change(scoreThresholdInput, { target: { value: '0.8' } }) + + expect(onChange).toHaveBeenCalledWith({ score_threshold: 0.8 }) + }) + }) + + // Tests for complete form submission flow + describe('Complete Form Submission Flow', () => { + it('should submit form with all default retrieval settings', async () => { + const user = userEvent.setup() + const onConnect = jest.fn() + renderComponent({ onConnect }) + + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + + fireEvent.change(nameInput, { target: { value: 'Test KB' } }) + fireEvent.change(knowledgeIdInput, { target: { value: 'kb-1' } }) + + 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!) + + expect(onConnect).toHaveBeenCalledWith({ + name: 'Test KB', + description: '', + external_knowledge_api_id: 'api-1', + external_knowledge_id: 'kb-1', + external_retrieval_model: { + top_k: 4, + score_threshold: 0.5, + score_threshold_enabled: false, + }, + provider: 'external', + }) + }) + + it('should submit form with modified retrieval settings', async () => { + const user = userEvent.setup() + const onConnect = jest.fn() + renderComponent({ onConnect }) + + // Toggle score threshold switch + const switches = screen.getAllByRole('switch') + const scoreThresholdSwitch = switches[0] + await user.click(scoreThresholdSwitch) + + // Fill required fields + const nameInput = screen.getByPlaceholderText('dataset.externalKnowledgeNamePlaceholder') + const knowledgeIdInput = screen.getByPlaceholderText('dataset.externalKnowledgeIdPlaceholder') + + fireEvent.change(nameInput, { target: { value: 'Custom KB' } }) + fireEvent.change(knowledgeIdInput, { target: { value: 'custom-kb' } }) + + 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!) + + expect(onConnect).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Custom KB', + external_retrieval_model: expect.objectContaining({ + score_threshold_enabled: true, + }), + }), + ) + }) + }) + + // Tests for accessibility + describe('Accessibility', () => { + it('should have accessible buttons', () => { + renderComponent() + + const buttons = screen.getAllByRole('button') + expect(buttons.length).toBeGreaterThanOrEqual(3) // back, cancel, connect + }) + + it('should have proper link attributes for external links', () => { + renderComponent() + + const externalLink = screen.getByText('dataset.connectHelper.helper4') + expect(externalLink.tagName).toBe('A') + expect(externalLink).toHaveAttribute('target', '_blank') + expect(externalLink).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it('should have labels for form inputs', () => { + renderComponent() + + // Check labels exist + expect(screen.getByText('dataset.externalKnowledgeName')).toBeInTheDocument() + expect(screen.getByText('dataset.externalKnowledgeDescription')).toBeInTheDocument() + expect(screen.getByText('dataset.externalKnowledgeId')).toBeInTheDocument() + }) + }) +}) diff --git a/web/jest.setup.ts b/web/jest.setup.ts index ef9ede0492..383ba412a2 100644 --- a/web/jest.setup.ts +++ b/web/jest.setup.ts @@ -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() }) diff --git a/web/package.json b/web/package.json index 478abceb45..e7d732c7b6 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 02b1c9b592..af7856329e 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -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 diff --git a/web/testing/testing.md b/web/testing/testing.md index f03451230d..bf1b89af00 100644 --- a/web/testing/testing.md +++ b/web/testing/testing.md @@ -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` 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