diff --git a/web/app/components/datasets/external-api/external-api-modal/Form.spec.tsx b/web/app/components/datasets/external-api/external-api-modal/Form.spec.tsx new file mode 100644 index 0000000000..346bcd00b7 --- /dev/null +++ b/web/app/components/datasets/external-api/external-api-modal/Form.spec.tsx @@ -0,0 +1,239 @@ +import type { CreateExternalAPIReq, FormSchema } from '../declarations' +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import Form from './Form' + +// Mock context for i18n doc link +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.example.com${path}`, +})) + +describe('Form', () => { + const defaultFormSchemas: FormSchema[] = [ + { + variable: 'name', + type: 'text', + label: { en_US: 'Name', zh_CN: '名称' }, + required: true, + }, + { + variable: 'endpoint', + type: 'text', + label: { en_US: 'API Endpoint', zh_CN: 'API 端点' }, + required: true, + }, + { + variable: 'api_key', + type: 'secret', + label: { en_US: 'API Key', zh_CN: 'API 密钥' }, + required: true, + }, + ] + + const defaultValue: CreateExternalAPIReq = { + name: '', + settings: { + endpoint: '', + api_key: '', + }, + } + + const defaultProps = { + value: defaultValue, + onChange: vi.fn(), + formSchemas: defaultFormSchemas, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render(
) + expect(container.querySelector('form')).toBeInTheDocument() + }) + + it('should render all form fields based on formSchemas', () => { + render() + expect(screen.getByLabelText(/name/i)).toBeInTheDocument() + expect(screen.getByLabelText(/api endpoint/i)).toBeInTheDocument() + expect(screen.getByLabelText(/api key/i)).toBeInTheDocument() + }) + + it('should render required indicator for required fields', () => { + render() + const labels = screen.getAllByText('*') + expect(labels.length).toBe(3) // All 3 fields are required + }) + + it('should render documentation link for endpoint field', () => { + render() + const docLink = screen.getByText('dataset.externalAPIPanelDocumentation') + expect(docLink).toBeInTheDocument() + expect(docLink.closest('a')).toHaveAttribute('href', expect.stringContaining('docs.example.com')) + }) + + it('should render password type input for secret fields', () => { + render() + const apiKeyInput = screen.getByLabelText(/api key/i) + expect(apiKeyInput).toHaveAttribute('type', 'password') + }) + + it('should render text type input for text fields', () => { + render() + const nameInput = screen.getByLabelText(/name/i) + expect(nameInput).toHaveAttribute('type', 'text') + }) + }) + + describe('Props', () => { + it('should apply custom className to form', () => { + const { container } = render() + expect(container.querySelector('form')).toHaveClass('custom-form-class') + }) + + it('should apply itemClassName to form items', () => { + const { container } = render() + const items = container.querySelectorAll('.custom-item-class') + expect(items.length).toBe(3) + }) + + it('should apply fieldLabelClassName to labels', () => { + const { container } = render() + const labels = container.querySelectorAll('label.custom-label-class') + expect(labels.length).toBe(3) + }) + + it('should apply inputClassName to inputs', () => { + render() + const inputs = screen.getAllByRole('textbox') + inputs.forEach((input) => { + expect(input).toHaveClass('custom-input-class') + }) + }) + + it('should display initial values', () => { + const valueWithData: CreateExternalAPIReq = { + name: 'Test API', + settings: { + endpoint: 'https://api.example.com', + api_key: 'secret-key', + }, + } + render() + expect(screen.getByLabelText(/name/i)).toHaveValue('Test API') + expect(screen.getByLabelText(/api endpoint/i)).toHaveValue('https://api.example.com') + expect(screen.getByLabelText(/api key/i)).toHaveValue('secret-key') + }) + }) + + describe('User Interactions', () => { + it('should call onChange when name field changes', () => { + const onChange = vi.fn() + render() + + const nameInput = screen.getByLabelText(/name/i) + fireEvent.change(nameInput, { target: { value: 'New API Name' } }) + + expect(onChange).toHaveBeenCalledWith({ + name: 'New API Name', + settings: { endpoint: '', api_key: '' }, + }) + }) + + it('should call onChange when endpoint field changes', () => { + const onChange = vi.fn() + render() + + const endpointInput = screen.getByLabelText(/api endpoint/i) + fireEvent.change(endpointInput, { target: { value: 'https://new-api.example.com' } }) + + expect(onChange).toHaveBeenCalledWith({ + name: '', + settings: { endpoint: 'https://new-api.example.com', api_key: '' }, + }) + }) + + it('should call onChange when api_key field changes', () => { + const onChange = vi.fn() + render() + + const apiKeyInput = screen.getByLabelText(/api key/i) + fireEvent.change(apiKeyInput, { target: { value: 'new-secret-key' } }) + + expect(onChange).toHaveBeenCalledWith({ + name: '', + settings: { endpoint: '', api_key: 'new-secret-key' }, + }) + }) + + it('should update settings without affecting name', () => { + const onChange = vi.fn() + const initialValue: CreateExternalAPIReq = { + name: 'Existing Name', + settings: { endpoint: '', api_key: '' }, + } + render() + + const endpointInput = screen.getByLabelText(/api endpoint/i) + fireEvent.change(endpointInput, { target: { value: 'https://api.example.com' } }) + + expect(onChange).toHaveBeenCalledWith({ + name: 'Existing Name', + settings: { endpoint: 'https://api.example.com', api_key: '' }, + }) + }) + }) + + describe('Edge Cases', () => { + it('should handle empty formSchemas', () => { + const { container } = render() + expect(container.querySelector('form')).toBeInTheDocument() + expect(screen.queryByRole('textbox')).not.toBeInTheDocument() + }) + + it('should handle optional field (required: false)', () => { + const schemasWithOptional: FormSchema[] = [ + { + variable: 'description', + type: 'text', + label: { en_US: 'Description' }, + required: false, + }, + ] + render() + expect(screen.queryByText('*')).not.toBeInTheDocument() + }) + + it('should fallback to en_US label when current language label is not available', () => { + const schemasWithEnOnly: FormSchema[] = [ + { + variable: 'test', + type: 'text', + label: { en_US: 'Test Field' }, + required: false, + }, + ] + render() + expect(screen.getByLabelText(/test field/i)).toBeInTheDocument() + }) + + it('should preserve existing settings when updating one field', () => { + const onChange = vi.fn() + const initialValue: CreateExternalAPIReq = { + name: '', + settings: { endpoint: 'https://existing.com', api_key: 'existing-key' }, + } + render() + + const endpointInput = screen.getByLabelText(/api endpoint/i) + fireEvent.change(endpointInput, { target: { value: 'https://new.com' } }) + + expect(onChange).toHaveBeenCalledWith({ + name: '', + settings: { endpoint: 'https://new.com', api_key: 'existing-key' }, + }) + }) + }) +}) diff --git a/web/app/components/datasets/external-api/external-api-modal/index.spec.tsx b/web/app/components/datasets/external-api/external-api-modal/index.spec.tsx new file mode 100644 index 0000000000..94c4deab04 --- /dev/null +++ b/web/app/components/datasets/external-api/external-api-modal/index.spec.tsx @@ -0,0 +1,424 @@ +import type { CreateExternalAPIReq } from '../declarations' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +// Import mocked service +import { createExternalAPI } from '@/service/datasets' + +import AddExternalAPIModal from './index' + +// Mock API service +vi.mock('@/service/datasets', () => ({ + createExternalAPI: vi.fn(), +})) + +// Mock toast context +const mockNotify = vi.fn() +vi.mock('@/app/components/base/toast', () => ({ + useToastContext: () => ({ + notify: mockNotify, + }), +})) + +describe('AddExternalAPIModal', () => { + const defaultProps = { + onSave: vi.fn(), + onCancel: vi.fn(), + isEditMode: false, + } + + const initialData: CreateExternalAPIReq = { + name: 'Test API', + settings: { + endpoint: 'https://api.example.com', + api_key: 'test-key-12345', + }, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument() + }) + + it('should render create title when not in edit mode', () => { + render() + expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument() + }) + + it('should render edit title when in edit mode', () => { + render() + expect(screen.getByText('dataset.editExternalAPIFormTitle')).toBeInTheDocument() + }) + + it('should render form fields', () => { + render() + expect(screen.getByLabelText(/name/i)).toBeInTheDocument() + expect(screen.getByLabelText(/api endpoint/i)).toBeInTheDocument() + expect(screen.getByLabelText(/api key/i)).toBeInTheDocument() + }) + + it('should render cancel and save buttons', () => { + render() + expect(screen.getByText('dataset.externalAPIForm.cancel')).toBeInTheDocument() + expect(screen.getByText('dataset.externalAPIForm.save')).toBeInTheDocument() + }) + + it('should render encryption notice', () => { + render() + expect(screen.getByText('PKCS1_OAEP')).toBeInTheDocument() + }) + + it('should render close button', () => { + render() + // Close button is rendered in a portal + const closeButton = document.body.querySelector('.action-btn') + expect(closeButton).toBeInTheDocument() + }) + }) + + describe('Edit Mode with Dataset Bindings', () => { + it('should show warning when editing with dataset bindings', () => { + const datasetBindings = [ + { id: 'ds-1', name: 'Dataset 1' }, + { id: 'ds-2', name: 'Dataset 2' }, + ] + render( + , + ) + expect(screen.getByText('dataset.editExternalAPIFormWarning.front')).toBeInTheDocument() + // Verify the count is displayed in the warning section + const warningElement = screen.getByText('dataset.editExternalAPIFormWarning.front').parentElement + expect(warningElement?.textContent).toContain('2') + }) + + it('should not show warning when no dataset bindings', () => { + render( + , + ) + expect(screen.queryByText('dataset.editExternalAPIFormWarning.front')).not.toBeInTheDocument() + }) + }) + + describe('Form Interactions', () => { + it('should update form values when input changes', () => { + render() + + const nameInput = screen.getByLabelText(/name/i) + fireEvent.change(nameInput, { target: { value: 'New API Name' } }) + expect(nameInput).toHaveValue('New API Name') + }) + + it('should initialize form with data in edit mode', () => { + render() + + expect(screen.getByLabelText(/name/i)).toHaveValue('Test API') + expect(screen.getByLabelText(/api endpoint/i)).toHaveValue('https://api.example.com') + expect(screen.getByLabelText(/api key/i)).toHaveValue('test-key-12345') + }) + + it('should disable save button when form has empty inputs', () => { + render() + + const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button') + expect(saveButton).toBeDisabled() + }) + + it('should enable save button when all fields are filled', () => { + render() + + const nameInput = screen.getByLabelText(/name/i) + const endpointInput = screen.getByLabelText(/api endpoint/i) + const apiKeyInput = screen.getByLabelText(/api key/i) + + fireEvent.change(nameInput, { target: { value: 'Test' } }) + fireEvent.change(endpointInput, { target: { value: 'https://test.com' } }) + fireEvent.change(apiKeyInput, { target: { value: 'key12345' } }) + + const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button') + expect(saveButton).not.toBeDisabled() + }) + }) + + describe('Create Mode - Save', () => { + it('should create API and call onSave on success', async () => { + const mockResponse = { + id: 'new-api-123', + tenant_id: 'tenant-1', + name: 'Test', + description: '', + settings: { endpoint: 'https://test.com', api_key: 'key12345' }, + dataset_bindings: [], + created_by: 'user-1', + created_at: '2021-01-01T00:00:00Z', + } + vi.mocked(createExternalAPI).mockResolvedValue(mockResponse) + const onSave = vi.fn() + const onCancel = vi.fn() + + render() + + const nameInput = screen.getByLabelText(/name/i) + const endpointInput = screen.getByLabelText(/api endpoint/i) + const apiKeyInput = screen.getByLabelText(/api key/i) + + fireEvent.change(nameInput, { target: { value: 'Test' } }) + fireEvent.change(endpointInput, { target: { value: 'https://test.com' } }) + fireEvent.change(apiKeyInput, { target: { value: 'key12345' } }) + + const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')! + fireEvent.click(saveButton) + + await waitFor(() => { + expect(createExternalAPI).toHaveBeenCalledWith({ + body: { + name: 'Test', + settings: { endpoint: 'https://test.com', api_key: 'key12345' }, + }, + }) + expect(mockNotify).toHaveBeenCalledWith({ + type: 'success', + message: 'External API saved successfully', + }) + expect(onSave).toHaveBeenCalledWith(mockResponse) + expect(onCancel).toHaveBeenCalled() + }) + }) + + it('should show error notification when API key is too short', async () => { + render() + + const nameInput = screen.getByLabelText(/name/i) + const endpointInput = screen.getByLabelText(/api endpoint/i) + const apiKeyInput = screen.getByLabelText(/api key/i) + + fireEvent.change(nameInput, { target: { value: 'Test' } }) + fireEvent.change(endpointInput, { target: { value: 'https://test.com' } }) + fireEvent.change(apiKeyInput, { target: { value: 'key' } }) // Less than 5 characters + + const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')! + fireEvent.click(saveButton) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'common.apiBasedExtension.modal.apiKey.lengthError', + }) + }) + }) + + it('should handle create API error', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(createExternalAPI).mockRejectedValue(new Error('Create failed')) + + render() + + const nameInput = screen.getByLabelText(/name/i) + const endpointInput = screen.getByLabelText(/api endpoint/i) + const apiKeyInput = screen.getByLabelText(/api key/i) + + fireEvent.change(nameInput, { target: { value: 'Test' } }) + fireEvent.change(endpointInput, { target: { value: 'https://test.com' } }) + fireEvent.change(apiKeyInput, { target: { value: 'key12345' } }) + + const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')! + fireEvent.click(saveButton) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith({ + type: 'error', + message: 'Failed to save/update External API', + }) + }) + + consoleSpy.mockRestore() + }) + }) + + describe('Edit Mode - Save', () => { + it('should call onEdit directly when editing without dataset bindings', async () => { + const onEdit = vi.fn().mockResolvedValue(undefined) + const onCancel = vi.fn() + + render( + , + ) + + const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')! + fireEvent.click(saveButton) + + await waitFor(() => { + // When no datasetBindings, onEdit is called directly with original form data + expect(onEdit).toHaveBeenCalledWith({ + name: 'Test API', + settings: { + endpoint: 'https://api.example.com', + api_key: 'test-key-12345', + }, + }) + }) + }) + + it('should show confirm dialog when editing with dataset bindings', async () => { + const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }] + const onEdit = vi.fn().mockResolvedValue(undefined) + + render( + , + ) + + const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')! + fireEvent.click(saveButton) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument() + }) + }) + + it('should proceed with save after confirming in edit mode with bindings', async () => { + vi.mocked(createExternalAPI).mockResolvedValue({ + id: 'api-123', + tenant_id: 'tenant-1', + name: 'Test API', + description: '', + settings: { endpoint: 'https://api.example.com', api_key: 'test-key-12345' }, + dataset_bindings: [], + created_by: 'user-1', + created_at: '2021-01-01T00:00:00Z', + }) + const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }] + const onCancel = vi.fn() + + render( + , + ) + + const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')! + fireEvent.click(saveButton) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument() + }) + + const confirmButton = screen.getByRole('button', { name: /confirm/i }) + fireEvent.click(confirmButton) + + await waitFor(() => { + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: 'success' }), + ) + }) + }) + + it('should close confirm dialog when cancel is clicked', async () => { + const datasetBindings = [{ id: 'ds-1', name: 'Dataset 1' }] + + render( + , + ) + + const saveButton = screen.getByText('dataset.externalAPIForm.save').closest('button')! + fireEvent.click(saveButton) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument() + }) + + // There are multiple cancel buttons, find the one in the confirm dialog + const cancelButtons = screen.getAllByRole('button', { name: /cancel/i }) + const confirmDialogCancelButton = cancelButtons[cancelButtons.length - 1] + fireEvent.click(confirmDialogCancelButton) + + await waitFor(() => { + // Confirm button should be gone after canceling + expect(screen.queryAllByRole('button', { name: /confirm/i })).toHaveLength(0) + }) + }) + }) + + describe('Cancel', () => { + it('should call onCancel when cancel button is clicked', () => { + const onCancel = vi.fn() + render() + + const cancelButton = screen.getByText('dataset.externalAPIForm.cancel').closest('button')! + fireEvent.click(cancelButton) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + + it('should call onCancel when close button is clicked', () => { + const onCancel = vi.fn() + render() + + // Close button is rendered in a portal + const closeButton = document.body.querySelector('.action-btn')! + fireEvent.click(closeButton) + + expect(onCancel).toHaveBeenCalledTimes(1) + }) + }) + + describe('Edge Cases', () => { + it('should handle undefined data in edit mode', () => { + render() + expect(screen.getByLabelText(/name/i)).toHaveValue('') + }) + + it('should handle null datasetBindings', () => { + render( + , + ) + expect(screen.queryByText('dataset.editExternalAPIFormWarning.front')).not.toBeInTheDocument() + }) + + it('should render documentation link in encryption notice', () => { + render() + const link = screen.getByRole('link', { name: 'PKCS1_OAEP' }) + expect(link).toHaveAttribute('href', 'https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html') + expect(link).toHaveAttribute('target', '_blank') + }) + }) +}) diff --git a/web/app/components/datasets/external-api/external-api-panel/index.spec.tsx b/web/app/components/datasets/external-api/external-api-panel/index.spec.tsx new file mode 100644 index 0000000000..55297132a9 --- /dev/null +++ b/web/app/components/datasets/external-api/external-api-panel/index.spec.tsx @@ -0,0 +1,207 @@ +import type { ExternalAPIItem } from '@/models/datasets' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import ExternalAPIPanel from './index' + +// Mock external contexts (only mock context providers, not base components) +const mockSetShowExternalKnowledgeAPIModal = vi.fn() +const mockMutateExternalKnowledgeApis = vi.fn() +let mockIsLoading = false +let mockExternalKnowledgeApiList: ExternalAPIItem[] = [] + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowExternalKnowledgeAPIModal: mockSetShowExternalKnowledgeAPIModal, + }), +})) + +vi.mock('@/context/external-knowledge-api-context', () => ({ + useExternalKnowledgeApi: () => ({ + externalKnowledgeApiList: mockExternalKnowledgeApiList, + mutateExternalKnowledgeApis: mockMutateExternalKnowledgeApis, + isLoading: mockIsLoading, + }), +})) + +vi.mock('@/context/i18n', () => ({ + useDocLink: () => (path: string) => `https://docs.example.com${path}`, +})) + +// Mock the ExternalKnowledgeAPICard to avoid mocking its internal dependencies +vi.mock('../external-knowledge-api-card', () => ({ + default: ({ api }: { api: ExternalAPIItem }) => ( +
{api.name}
+ ), +})) + +// i18n mock returns 'namespace.key' format + +describe('ExternalAPIPanel', () => { + const defaultProps = { + onClose: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + mockIsLoading = false + mockExternalKnowledgeApiList = [] + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument() + }) + + it('should render panel title and description', () => { + render() + expect(screen.getByText('dataset.externalAPIPanelTitle')).toBeInTheDocument() + expect(screen.getByText('dataset.externalAPIPanelDescription')).toBeInTheDocument() + }) + + it('should render documentation link', () => { + render() + const docLink = screen.getByText('dataset.externalAPIPanelDocumentation') + expect(docLink).toBeInTheDocument() + expect(docLink.closest('a')).toHaveAttribute('href', 'https://docs.example.com/guides/knowledge-base/connect-external-knowledge-base') + }) + + it('should render create button', () => { + render() + expect(screen.getByText('dataset.createExternalAPI')).toBeInTheDocument() + }) + + it('should render close button', () => { + const { container } = render() + const closeButton = container.querySelector('[class*="action-button"]') || screen.getAllByRole('button')[0] + expect(closeButton).toBeInTheDocument() + }) + }) + + describe('Loading State', () => { + it('should render loading indicator when isLoading is true', () => { + mockIsLoading = true + const { container } = render() + // Loading component should be rendered + const loadingElement = container.querySelector('[class*="loading"]') + || container.querySelector('.animate-spin') + || screen.queryByRole('status') + expect(loadingElement || container.textContent).toBeTruthy() + }) + }) + + describe('API List Rendering', () => { + it('should render empty list when no APIs exist', () => { + mockExternalKnowledgeApiList = [] + render() + expect(screen.queryByTestId(/api-card-/)).not.toBeInTheDocument() + }) + + it('should render API cards when APIs exist', () => { + mockExternalKnowledgeApiList = [ + { + id: 'api-1', + tenant_id: 'tenant-1', + name: 'Test API 1', + description: '', + settings: { endpoint: 'https://api1.example.com', api_key: 'key1' }, + dataset_bindings: [], + created_by: 'user-1', + created_at: '2021-01-01T00:00:00Z', + }, + { + id: 'api-2', + tenant_id: 'tenant-1', + name: 'Test API 2', + description: '', + settings: { endpoint: 'https://api2.example.com', api_key: 'key2' }, + dataset_bindings: [], + created_by: 'user-1', + created_at: '2021-01-01T00:00:00Z', + }, + ] + render() + expect(screen.getByTestId('api-card-api-1')).toBeInTheDocument() + expect(screen.getByTestId('api-card-api-2')).toBeInTheDocument() + expect(screen.getByText('Test API 1')).toBeInTheDocument() + expect(screen.getByText('Test API 2')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call onClose when close button is clicked', () => { + const onClose = vi.fn() + render() + // Find the close button (ActionButton with close icon) + const buttons = screen.getAllByRole('button') + const closeButton = buttons.find(btn => btn.querySelector('svg[class*="ri-close"]')) + || buttons[0] + fireEvent.click(closeButton) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('should open external API modal when create button is clicked', async () => { + render() + const createButton = screen.getByText('dataset.createExternalAPI').closest('button')! + fireEvent.click(createButton) + + await waitFor(() => { + expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledTimes(1) + expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledWith( + expect.objectContaining({ + payload: { name: '', settings: { endpoint: '', api_key: '' } }, + datasetBindings: [], + isEditMode: false, + }), + ) + }) + }) + + it('should call mutateExternalKnowledgeApis in onSaveCallback', async () => { + render() + const createButton = screen.getByText('dataset.createExternalAPI').closest('button')! + fireEvent.click(createButton) + + const callArgs = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0] + callArgs.onSaveCallback() + + expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled() + }) + + it('should call mutateExternalKnowledgeApis in onCancelCallback', async () => { + render() + const createButton = screen.getByText('dataset.createExternalAPI').closest('button')! + fireEvent.click(createButton) + + const callArgs = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0] + callArgs.onCancelCallback() + + expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled() + }) + }) + + describe('Edge Cases', () => { + it('should handle single API in list', () => { + mockExternalKnowledgeApiList = [ + { + id: 'single-api', + tenant_id: 'tenant-1', + name: 'Single API', + description: '', + settings: { endpoint: 'https://single.example.com', api_key: 'key' }, + dataset_bindings: [], + created_by: 'user-1', + created_at: '2021-01-01T00:00:00Z', + }, + ] + render() + expect(screen.getByTestId('api-card-single-api')).toBeInTheDocument() + }) + + it('should render documentation link with correct target', () => { + render() + const docLink = screen.getByText('dataset.externalAPIPanelDocumentation').closest('a') + expect(docLink).toHaveAttribute('target', '_blank') + }) + }) +}) diff --git a/web/app/components/datasets/external-api/external-knowledge-api-card/index.spec.tsx b/web/app/components/datasets/external-api/external-knowledge-api-card/index.spec.tsx new file mode 100644 index 0000000000..f8aacde3e1 --- /dev/null +++ b/web/app/components/datasets/external-api/external-knowledge-api-card/index.spec.tsx @@ -0,0 +1,382 @@ +import type { ExternalAPIItem } from '@/models/datasets' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +// Import mocked services +import { checkUsageExternalAPI, deleteExternalAPI, fetchExternalAPI } from '@/service/datasets' + +import ExternalKnowledgeAPICard from './index' + +// Mock API services +vi.mock('@/service/datasets', () => ({ + fetchExternalAPI: vi.fn(), + updateExternalAPI: vi.fn(), + deleteExternalAPI: vi.fn(), + checkUsageExternalAPI: vi.fn(), +})) + +// Mock contexts +const mockSetShowExternalKnowledgeAPIModal = vi.fn() +const mockMutateExternalKnowledgeApis = vi.fn() + +vi.mock('@/context/modal-context', () => ({ + useModalContext: () => ({ + setShowExternalKnowledgeAPIModal: mockSetShowExternalKnowledgeAPIModal, + }), +})) + +vi.mock('@/context/external-knowledge-api-context', () => ({ + useExternalKnowledgeApi: () => ({ + mutateExternalKnowledgeApis: mockMutateExternalKnowledgeApis, + }), +})) + +describe('ExternalKnowledgeAPICard', () => { + const mockApi: ExternalAPIItem = { + id: 'api-123', + tenant_id: 'tenant-1', + name: 'Test External API', + description: 'Test API description', + settings: { + endpoint: 'https://api.example.com/knowledge', + api_key: 'secret-key-123', + }, + dataset_bindings: [], + created_by: 'user-1', + created_at: '2021-01-01T00:00:00Z', + } + + const defaultProps = { + api: mockApi, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText('Test External API')).toBeInTheDocument() + }) + + it('should render API name', () => { + render() + expect(screen.getByText('Test External API')).toBeInTheDocument() + }) + + it('should render API endpoint', () => { + render() + expect(screen.getByText('https://api.example.com/knowledge')).toBeInTheDocument() + }) + + it('should render edit and delete buttons', () => { + const { container } = render() + const buttons = container.querySelectorAll('button') + expect(buttons.length).toBe(2) + }) + + it('should render API connection icon', () => { + const { container } = render() + const icon = container.querySelector('svg') + expect(icon).toBeInTheDocument() + }) + }) + + describe('User Interactions - Edit', () => { + it('should fetch API details and open modal when edit button is clicked', async () => { + const mockResponse: ExternalAPIItem = { + id: 'api-123', + tenant_id: 'tenant-1', + name: 'Test External API', + description: 'Test API description', + settings: { + endpoint: 'https://api.example.com/knowledge', + api_key: 'secret-key-123', + }, + dataset_bindings: [{ id: 'ds-1', name: 'Dataset 1' }], + created_by: 'user-1', + created_at: '2021-01-01T00:00:00Z', + } + vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse) + + const { container } = render() + const buttons = container.querySelectorAll('button') + const editButton = buttons[0] + + fireEvent.click(editButton) + + await waitFor(() => { + expect(fetchExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' }) + expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalledWith( + expect.objectContaining({ + payload: { + name: 'Test External API', + settings: { + endpoint: 'https://api.example.com/knowledge', + api_key: 'secret-key-123', + }, + }, + isEditMode: true, + datasetBindings: [{ id: 'ds-1', name: 'Dataset 1' }], + }), + ) + }) + }) + + it('should handle fetch error gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(fetchExternalAPI).mockRejectedValue(new Error('Fetch failed')) + + const { container } = render() + const buttons = container.querySelectorAll('button') + const editButton = buttons[0] + + fireEvent.click(editButton) + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + 'Error fetching external knowledge API data:', + expect.any(Error), + ) + }) + + consoleSpy.mockRestore() + }) + + it('should call mutate on save callback', async () => { + const mockResponse: ExternalAPIItem = { + id: 'api-123', + tenant_id: 'tenant-1', + name: 'Test External API', + description: 'Test API description', + settings: { + endpoint: 'https://api.example.com/knowledge', + api_key: 'secret-key-123', + }, + dataset_bindings: [], + created_by: 'user-1', + created_at: '2021-01-01T00:00:00Z', + } + vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse) + + const { container } = render() + const editButton = container.querySelectorAll('button')[0] + + fireEvent.click(editButton) + + await waitFor(() => { + expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalled() + }) + + // Simulate save callback + const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0] + modalCall.onSaveCallback() + + expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled() + }) + + it('should call mutate on cancel callback', async () => { + const mockResponse: ExternalAPIItem = { + id: 'api-123', + tenant_id: 'tenant-1', + name: 'Test External API', + description: 'Test API description', + settings: { + endpoint: 'https://api.example.com/knowledge', + api_key: 'secret-key-123', + }, + dataset_bindings: [], + created_by: 'user-1', + created_at: '2021-01-01T00:00:00Z', + } + vi.mocked(fetchExternalAPI).mockResolvedValue(mockResponse) + + const { container } = render() + const editButton = container.querySelectorAll('button')[0] + + fireEvent.click(editButton) + + await waitFor(() => { + expect(mockSetShowExternalKnowledgeAPIModal).toHaveBeenCalled() + }) + + // Simulate cancel callback + const modalCall = mockSetShowExternalKnowledgeAPIModal.mock.calls[0][0] + modalCall.onCancelCallback() + + expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled() + }) + }) + + describe('User Interactions - Delete', () => { + it('should check usage and show confirm dialog when delete button is clicked', async () => { + vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 }) + + const { container } = render() + const buttons = container.querySelectorAll('button') + const deleteButton = buttons[1] + + fireEvent.click(deleteButton) + + await waitFor(() => { + expect(checkUsageExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' }) + }) + + // Confirm dialog should be shown + await waitFor(() => { + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() + }) + }) + + it('should show usage count in confirm dialog when API is in use', async () => { + vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: true, count: 3 }) + + const { container } = render() + const deleteButton = container.querySelectorAll('button')[1] + + fireEvent.click(deleteButton) + + await waitFor(() => { + expect(screen.getByText(/3/)).toBeInTheDocument() + }) + }) + + it('should delete API and refresh list when confirmed', async () => { + vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 }) + vi.mocked(deleteExternalAPI).mockResolvedValue({ result: 'success' }) + + const { container } = render() + const deleteButton = container.querySelectorAll('button')[1] + + fireEvent.click(deleteButton) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument() + }) + + const confirmButton = screen.getByRole('button', { name: /confirm/i }) + fireEvent.click(confirmButton) + + await waitFor(() => { + expect(deleteExternalAPI).toHaveBeenCalledWith({ apiTemplateId: 'api-123' }) + expect(mockMutateExternalKnowledgeApis).toHaveBeenCalled() + }) + }) + + it('should close confirm dialog when cancel is clicked', async () => { + vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 }) + + const { container } = render() + const deleteButton = container.querySelectorAll('button')[1] + + fireEvent.click(deleteButton) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() + }) + + const cancelButton = screen.getByRole('button', { name: /cancel/i }) + fireEvent.click(cancelButton) + + await waitFor(() => { + expect(screen.queryByRole('button', { name: /confirm/i })).not.toBeInTheDocument() + }) + }) + + it('should handle delete error gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 }) + vi.mocked(deleteExternalAPI).mockRejectedValue(new Error('Delete failed')) + + const { container } = render() + const deleteButton = container.querySelectorAll('button')[1] + + fireEvent.click(deleteButton) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument() + }) + + const confirmButton = screen.getByRole('button', { name: /confirm/i }) + fireEvent.click(confirmButton) + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + 'Error deleting external knowledge API:', + expect.any(Error), + ) + }) + + consoleSpy.mockRestore() + }) + + it('should handle check usage error gracefully', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(checkUsageExternalAPI).mockRejectedValue(new Error('Check failed')) + + const { container } = render() + const deleteButton = container.querySelectorAll('button')[1] + + fireEvent.click(deleteButton) + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith( + 'Error checking external API usage:', + expect.any(Error), + ) + }) + + consoleSpy.mockRestore() + }) + }) + + describe('Hover State', () => { + it('should apply hover styles when delete button is hovered', () => { + const { container } = render() + const deleteButton = container.querySelectorAll('button')[1] + const cardContainer = container.querySelector('[class*="shadows-shadow"]') + + fireEvent.mouseEnter(deleteButton) + expect(cardContainer).toHaveClass('border-state-destructive-border') + expect(cardContainer).toHaveClass('bg-state-destructive-hover') + + fireEvent.mouseLeave(deleteButton) + expect(cardContainer).not.toHaveClass('border-state-destructive-border') + }) + }) + + describe('Edge Cases', () => { + it('should handle API with empty endpoint', () => { + const apiWithEmptyEndpoint: ExternalAPIItem = { + ...mockApi, + settings: { endpoint: '', api_key: 'key' }, + } + render() + expect(screen.getByText('Test External API')).toBeInTheDocument() + }) + + it('should handle delete response with unsuccessful result', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(checkUsageExternalAPI).mockResolvedValue({ is_using: false, count: 0 }) + vi.mocked(deleteExternalAPI).mockResolvedValue({ result: 'error' }) + + const { container } = render() + const deleteButton = container.querySelectorAll('button')[1] + + fireEvent.click(deleteButton) + + await waitFor(() => { + expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument() + }) + + const confirmButton = screen.getByRole('button', { name: /confirm/i }) + fireEvent.click(confirmButton) + + await waitFor(() => { + expect(consoleSpy).toHaveBeenCalledWith('Failed to delete external API') + }) + + consoleSpy.mockRestore() + }) + }) +}) diff --git a/web/app/components/datasets/extra-info/index.spec.tsx b/web/app/components/datasets/extra-info/index.spec.tsx new file mode 100644 index 0000000000..ce34ea26e3 --- /dev/null +++ b/web/app/components/datasets/extra-info/index.spec.tsx @@ -0,0 +1,1169 @@ +import type { DataSet, RelatedApp, RelatedAppResponse } from '@/models/datasets' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { AppModeEnum } from '@/types/app' + +// ============================================================================ +// Component Imports (after mocks) +// ============================================================================ + +import ApiAccess from './api-access' +import ApiAccessCard from './api-access/card' +import ExtraInfo from './index' +import Statistics from './statistics' + +// ============================================================================ +// Mock Setup +// ============================================================================ + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + }), + usePathname: () => '/test', + useSearchParams: () => new URLSearchParams(), +})) + +// Mock next/link +vi.mock('next/link', () => ({ + default: ({ children, href, ...props }: { children: React.ReactNode, href: string, [key: string]: unknown }) => ( + {children} + ), +})) + +// Dataset context mock data +const mockDataset: Partial = { + id: 'dataset-123', + name: 'Test Dataset', + enable_api: true, +} + +// Mock use-context-selector +vi.mock('use-context-selector', () => ({ + useContext: vi.fn(() => ({ dataset: mockDataset })), + useContextSelector: vi.fn((_, selector) => selector({ dataset: mockDataset })), + createContext: vi.fn(() => ({})), +})) + +// Mock dataset detail context +const mockMutateDatasetRes = vi.fn() +vi.mock('@/context/dataset-detail', () => ({ + default: {}, + useDatasetDetailContext: vi.fn(() => ({ + dataset: mockDataset, + mutateDatasetRes: mockMutateDatasetRes, + })), + useDatasetDetailContextWithSelector: vi.fn((selector: (v: { dataset?: typeof mockDataset, mutateDatasetRes?: () => void }) => unknown) => + selector({ dataset: mockDataset as DataSet, mutateDatasetRes: mockMutateDatasetRes }), + ), +})) + +// Mock app context for workspace permissions +let mockIsCurrentWorkspaceManager = true +vi.mock('@/context/app-context', () => ({ + useSelector: vi.fn((selector: (state: { isCurrentWorkspaceManager: boolean }) => unknown) => + selector({ isCurrentWorkspaceManager: mockIsCurrentWorkspaceManager }), + ), +})) + +// Mock service hooks +const mockEnableDatasetServiceApi = vi.fn(() => Promise.resolve({ result: 'success' })) +const mockDisableDatasetServiceApi = vi.fn(() => Promise.resolve({ result: 'success' })) + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useDatasetApiBaseUrl: vi.fn(() => ({ + data: { api_base_url: 'https://api.example.com' }, + isLoading: false, + })), + useEnableDatasetServiceApi: vi.fn(() => ({ + mutateAsync: mockEnableDatasetServiceApi, + isPending: false, + })), + useDisableDatasetServiceApi: vi.fn(() => ({ + mutateAsync: mockDisableDatasetServiceApi, + isPending: false, + })), +})) + +// Mock API access URL hook +vi.mock('@/hooks/use-api-access-url', () => ({ + useDatasetApiAccessUrl: vi.fn(() => 'https://docs.dify.ai/api-reference/datasets'), +})) + +// Mock docLink hook +vi.mock('@/context/i18n', () => ({ + useDocLink: vi.fn(() => (path: string) => `https://docs.example.com${path}`), +})) + +// Mock SecretKeyModal to avoid complex modal rendering +vi.mock('@/app/components/develop/secret-key/secret-key-modal', () => ({ + default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => ( + isShow + ? ( +
+ +
+ ) + : null + ), +})) + +// ============================================================================ +// Test Data Factory +// ============================================================================ + +const createMockRelatedApp = (overrides: Partial = {}): RelatedApp => ({ + id: 'app-1', + name: 'Test App', + mode: AppModeEnum.COMPLETION, + icon: 'icon-url', + icon_type: 'image', + icon_background: '#fff', + icon_url: '', + ...overrides, +}) + +const createMockRelatedAppsResponse = (count: number = 2): RelatedAppResponse => ({ + data: Array.from({ length: count }, (_, i) => + createMockRelatedApp({ id: `app-${i + 1}`, name: `App ${i + 1}` })), + total: count, +}) + +// ============================================================================ +// Statistics Component Tests +// ============================================================================ + +describe('Statistics', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render( + , + ) + + expect(screen.getByText('10')).toBeInTheDocument() + }) + + it('should render document count correctly', () => { + render( + , + ) + + expect(screen.getByText('42')).toBeInTheDocument() + }) + + it('should render related apps total correctly', () => { + const relatedApps = createMockRelatedAppsResponse(5) + + render( + , + ) + + expect(screen.getByText('5')).toBeInTheDocument() + }) + + it('should display translated document label', () => { + render( + , + ) + + expect(screen.getByText(/documents/i)).toBeInTheDocument() + }) + + it('should display translated related app label', () => { + render( + , + ) + + expect(screen.getByText(/relatedApp/i)).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should render placeholder when documentCount is undefined', () => { + render( + , + ) + + expect(screen.getByText('--')).toBeInTheDocument() + }) + + it('should render placeholder when relatedApps is undefined', () => { + render( + , + ) + + expect(screen.getAllByText('--').length).toBeGreaterThanOrEqual(1) + }) + + it('should handle zero document count', () => { + render( + , + ) + + expect(screen.getByText('0')).toBeInTheDocument() + }) + + it('should handle empty related apps array', () => { + const emptyRelatedApps: RelatedAppResponse = { data: [], total: 0 } + + render( + , + ) + + expect(screen.getByText('0')).toBeInTheDocument() + }) + + it('should handle large numbers correctly', () => { + render( + , + ) + + expect(screen.getByText('999999')).toBeInTheDocument() + expect(screen.getByText('100')).toBeInTheDocument() + }) + }) + + describe('Tooltip Interactions', () => { + it('should render tooltip trigger with info icon', () => { + render( + , + ) + + // Find the cursor-pointer element containing the relatedApp text + const tooltipTrigger = screen.getByText(/relatedApp/i).closest('.cursor-pointer') + expect(tooltipTrigger).toBeInTheDocument() + }) + + it('should render LinkedAppsPanel when related apps exist', async () => { + const relatedApps = createMockRelatedAppsResponse(3) + + render( + , + ) + + // The LinkedAppsPanel should be rendered inside the tooltip + // We can't easily test tooltip content in this context without more setup + // But we verify the condition logic works by checking component renders + expect(screen.getByText('3')).toBeInTheDocument() + }) + + it('should render NoLinkedAppsPanel when no related apps', () => { + const emptyRelatedApps: RelatedAppResponse = { data: [], total: 0 } + + render( + , + ) + + // Verify component renders correctly with empty apps + expect(screen.getByText('0')).toBeInTheDocument() + }) + }) + + describe('Props Variations', () => { + it('should handle expand=false', () => { + render( + , + ) + + // Component should still render with expand=false + expect(screen.getByText('10')).toBeInTheDocument() + }) + + it('should pass isMobile based on expand prop', () => { + // When expand is false, isMobile should be true (!expand) + render( + , + ) + + // Component renders - the isMobile logic is internal + expect(screen.getByText('10')).toBeInTheDocument() + }) + }) + + describe('Memoization', () => { + it('should be memoized with React.memo', () => { + const { rerender } = render( + , + ) + + // Rerender with same props + rerender( + , + ) + + // Component should not cause unnecessary re-renders + expect(screen.getByText('10')).toBeInTheDocument() + }) + }) +}) + +// ============================================================================ +// ApiAccess Component Tests +// ============================================================================ + +describe('ApiAccess', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render( + , + ) + + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + }) + + it('should render API title when expanded', () => { + render( + , + ) + + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + }) + + it('should not render API title when collapsed', () => { + render( + , + ) + + expect(screen.queryByText(/appMenus\.apiAccess/i)).not.toBeInTheDocument() + }) + + it('should render indicator when API is enabled', () => { + const { container } = render( + , + ) + + // Indicator component should be present + const indicatorElement = container.querySelector('.relative.flex.h-8') + expect(indicatorElement).toBeInTheDocument() + }) + + it('should render indicator when API is disabled', () => { + const { container } = render( + , + ) + + // Indicator component should be present + const indicatorElement = container.querySelector('.relative.flex.h-8') + expect(indicatorElement).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should toggle popup open state on click', async () => { + const user = userEvent.setup() + + render( + , + ) + + const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]') + expect(trigger).toBeInTheDocument() + + if (trigger) { + await user.click(trigger) + // After click, the Card component should be rendered in the portal + } + }) + + it('should apply hover styles on trigger', () => { + render( + , + ) + + const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('div[class*="cursor-pointer"]') + expect(trigger).toHaveClass('cursor-pointer') + }) + }) + + describe('Props Variations', () => { + it('should apply compressed layout when expand is false', () => { + const { container } = render( + , + ) + + // When collapsed, width should be w-8 + const triggerContainer = container.querySelector('[class*="w-8"]') + expect(triggerContainer).toBeInTheDocument() + }) + + it('should pass apiEnabled to Card component', async () => { + const user = userEvent.setup() + + render( + , + ) + + const trigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]') + if (trigger) { + await user.click(trigger) + // The apiEnabled should be passed to Card + } + }) + }) + + describe('Memoization', () => { + it('should be memoized with React.memo', () => { + const { rerender } = render( + , + ) + + rerender( + , + ) + + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + }) + }) +}) + +// ============================================================================ +// ApiAccessCard Component Tests +// ============================================================================ + +describe('ApiAccessCard', () => { + beforeEach(() => { + vi.clearAllMocks() + mockIsCurrentWorkspaceManager = true + mockEnableDatasetServiceApi.mockResolvedValue({ result: 'success' }) + mockDisableDatasetServiceApi.mockResolvedValue({ result: 'success' }) + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render( + , + ) + + expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument() + }) + + it('should display enabled status when API is enabled', () => { + render( + , + ) + + expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument() + }) + + it('should display disabled status when API is disabled', () => { + render( + , + ) + + expect(screen.getByText(/serviceApi\.disabled/i)).toBeInTheDocument() + }) + + it('should render API Reference link', () => { + render( + , + ) + + expect(screen.getByText(/overview\.apiInfo\.doc/i)).toBeInTheDocument() + }) + + it('should render switch component', () => { + render( + , + ) + + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call enableDatasetServiceApi when switch is toggled on', async () => { + const user = userEvent.setup() + + render( + , + ) + + const switchButton = screen.getByRole('switch') + await user.click(switchButton) + + await waitFor(() => { + expect(mockEnableDatasetServiceApi).toHaveBeenCalledWith('dataset-123') + }) + }) + + it('should call disableDatasetServiceApi when switch is toggled off', async () => { + const user = userEvent.setup() + + render( + , + ) + + const switchButton = screen.getByRole('switch') + await user.click(switchButton) + + await waitFor(() => { + expect(mockDisableDatasetServiceApi).toHaveBeenCalledWith('dataset-123') + }) + }) + + it('should call mutateDatasetRes after successful API toggle', async () => { + const user = userEvent.setup() + + render( + , + ) + + const switchButton = screen.getByRole('switch') + await user.click(switchButton) + + await waitFor(() => { + expect(mockMutateDatasetRes).toHaveBeenCalled() + }) + }) + + it('should not call mutateDatasetRes on API toggle failure', async () => { + mockEnableDatasetServiceApi.mockResolvedValueOnce({ result: 'fail' }) + const user = userEvent.setup() + + render( + , + ) + + const switchButton = screen.getByRole('switch') + await user.click(switchButton) + + await waitFor(() => { + expect(mockEnableDatasetServiceApi).toHaveBeenCalled() + }) + + // mutateDatasetRes should not be called on failure + expect(mockMutateDatasetRes).not.toHaveBeenCalled() + }) + + it('should have correct href for API Reference link', () => { + render( + , + ) + + const apiRefLink = screen.getByText(/overview\.apiInfo\.doc/i).closest('a') + expect(apiRefLink).toHaveAttribute('href', 'https://docs.dify.ai/api-reference/datasets') + }) + }) + + describe('Permission Handling', () => { + it('should disable switch when user is not workspace manager', () => { + mockIsCurrentWorkspaceManager = false + + render( + , + ) + + const switchButton = screen.getByRole('switch') + // Headless UI Switch uses CSS classes for disabled state + expect(switchButton).toHaveClass('!cursor-not-allowed') + expect(switchButton).toHaveClass('!opacity-50') + }) + + it('should enable switch when user is workspace manager', () => { + mockIsCurrentWorkspaceManager = true + + render( + , + ) + + const switchButton = screen.getByRole('switch') + expect(switchButton).not.toHaveClass('!cursor-not-allowed') + expect(switchButton).not.toHaveClass('!opacity-50') + }) + }) + + describe('Memoization', () => { + it('should be memoized with React.memo', () => { + const { rerender } = render( + , + ) + + rerender( + , + ) + + expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument() + }) + + it('should use useCallback for handlers', () => { + // Verify handlers are stable by rendering multiple times + const { rerender } = render( + , + ) + + rerender( + , + ) + + // Component should render without issues with memoized callbacks + expect(screen.getByRole('switch')).toBeInTheDocument() + }) + }) +}) + +// ============================================================================ +// ExtraInfo (Main Component) Tests +// ============================================================================ + +describe('ExtraInfo', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render( + , + ) + + // Should render ApiAccess component + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + }) + + it('should render Statistics when expand is true', () => { + render( + , + ) + + // Statistics shows document count + expect(screen.getByText('10')).toBeInTheDocument() + }) + + it('should not render Statistics when expand is false', () => { + render( + , + ) + + // Document count should not be visible when collapsed + expect(screen.queryByText('10')).not.toBeInTheDocument() + }) + + it('should always render ApiAccess regardless of expand state', () => { + const { rerender } = render( + , + ) + + // Check expanded state has ApiAccess title + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + + rerender( + , + ) + + // ApiAccess should still be present (but without title text when collapsed) + // The component is still rendered, just with different styling + }) + }) + + describe('Context Integration', () => { + it('should read apiEnabled from dataset detail context', () => { + render( + , + ) + + // Since mockDataset has enable_api: true, the indicator should be green + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + }) + + it('should read apiBaseUrl from useDatasetApiBaseUrl hook', () => { + render( + , + ) + + // Component should render with the mocked API base URL + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + }) + + it('should handle missing apiBaseInfo with fallback empty string', async () => { + const { useDatasetApiBaseUrl } = await import('@/service/knowledge/use-dataset') + vi.mocked(useDatasetApiBaseUrl).mockReturnValue({ + data: undefined, + isLoading: false, + } as ReturnType) + + render( + , + ) + + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + + // Reset mock + vi.mocked(useDatasetApiBaseUrl).mockReturnValue({ + data: { api_base_url: 'https://api.example.com' }, + isLoading: false, + } as ReturnType) + }) + + it('should handle missing apiEnabled with fallback false', async () => { + const { useDatasetDetailContextWithSelector } = await import('@/context/dataset-detail') + vi.mocked(useDatasetDetailContextWithSelector).mockImplementation((selector) => { + // Simulate dataset without enable_api by using a partial dataset + const partialDataset = { ...mockDataset } as Partial + delete (partialDataset as { enable_api?: boolean }).enable_api + return selector({ + dataset: partialDataset as DataSet, + mutateDatasetRes: vi.fn(), + }) + }) + + render( + , + ) + + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + + // Reset mock + vi.mocked(useDatasetDetailContextWithSelector).mockImplementation(selector => + selector({ dataset: mockDataset as DataSet, mutateDatasetRes: vi.fn() }), + ) + }) + }) + + describe('Props Variations', () => { + it('should pass expand prop to Statistics component', () => { + render( + , + ) + + expect(screen.getByText('10')).toBeInTheDocument() + }) + + it('should pass expand prop to ApiAccess component', () => { + render( + , + ) + + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + }) + + it('should pass documentCount to Statistics component', () => { + render( + , + ) + + expect(screen.getByText('99')).toBeInTheDocument() + }) + + it('should pass relatedApps to Statistics component', () => { + const relatedApps = createMockRelatedAppsResponse(7) + + render( + , + ) + + expect(screen.getByText('7')).toBeInTheDocument() + }) + }) + + describe('Edge Cases', () => { + it('should handle undefined documentCount', () => { + render( + , + ) + + expect(screen.getByText('--')).toBeInTheDocument() + }) + + it('should handle undefined relatedApps', () => { + render( + , + ) + + expect(screen.getByText('10')).toBeInTheDocument() + }) + + it('should handle all undefined optional props', () => { + render( + , + ) + + // Should render without crashing + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + }) + + it('should handle zero values correctly', () => { + const emptyRelatedApps: RelatedAppResponse = { data: [], total: 0 } + + render( + , + ) + + expect(screen.getAllByText('0')).toHaveLength(2) + }) + }) + + describe('Memoization', () => { + it('should be memoized with React.memo', () => { + const { rerender } = render( + , + ) + + // Rerender with same props + rerender( + , + ) + + expect(screen.getByText('10')).toBeInTheDocument() + }) + + it('should update when props change', () => { + const { rerender } = render( + , + ) + + expect(screen.getByText('10')).toBeInTheDocument() + + rerender( + , + ) + + expect(screen.getByText('20')).toBeInTheDocument() + }) + + it('should hide Statistics when expand changes to false', () => { + const { rerender } = render( + , + ) + + expect(screen.getByText('10')).toBeInTheDocument() + + rerender( + , + ) + + expect(screen.queryByText('10')).not.toBeInTheDocument() + }) + }) + + describe('Component Composition', () => { + it('should render Statistics before ApiAccess when expanded', () => { + const { container } = render( + , + ) + + // Statistics should appear before ApiAccess in DOM order + const elements = container.querySelectorAll('div') + expect(elements.length).toBeGreaterThan(0) + }) + + it('should render only ApiAccess when collapsed', () => { + render( + , + ) + + // Only ApiAccess should be rendered (without its title in collapsed state) + expect(screen.queryByText('10')).not.toBeInTheDocument() + }) + }) +}) + +// ============================================================================ +// Integration Tests +// ============================================================================ + +describe('ExtraInfo Integration', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should render complete expanded view with all child components', () => { + render( + , + ) + + // Statistics content + expect(screen.getByText('25')).toBeInTheDocument() + expect(screen.getByText('5')).toBeInTheDocument() + + // ApiAccess content + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + }) + + it('should handle complete user workflow: view stats and toggle API', async () => { + const user = userEvent.setup() + + render( + , + ) + + // Verify statistics are visible + expect(screen.getByText('10')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() + + // Click on ApiAccess to open the card + const apiAccessTrigger = screen.getByText(/appMenus\.apiAccess/i).closest('[class*="cursor-pointer"]') + if (apiAccessTrigger) + await user.click(apiAccessTrigger) + + // The popup should open with Card content (showing enabled/disabled status) + await waitFor(() => { + expect(screen.getByText(/serviceApi\.enabled/i)).toBeInTheDocument() + }) + }) + + it('should integrate with context correctly across all components', async () => { + render( + , + ) + + // The component tree should correctly receive context values + // apiEnabled from context affects ApiAccess indicator color + expect(screen.getByText(/appMenus\.apiAccess/i)).toBeInTheDocument() + }) +}) diff --git a/web/app/components/datasets/hit-testing/index.spec.tsx b/web/app/components/datasets/hit-testing/index.spec.tsx new file mode 100644 index 0000000000..45c68e44b1 --- /dev/null +++ b/web/app/components/datasets/hit-testing/index.spec.tsx @@ -0,0 +1,2654 @@ +import type { ReactNode } from 'react' +import type { DataSet, HitTesting, HitTestingChildChunk, HitTestingRecord, HitTestingResponse, Query } from '@/models/datasets' +import type { RetrievalConfig } from '@/types/app' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { FileAppearanceTypeEnum } from '@/app/components/base/file-uploader/types' +import { RETRIEVE_METHOD } from '@/types/app' + +// ============================================================================ +// Imports (after mocks) +// ============================================================================ + +import ChildChunksItem from './components/child-chunks-item' +import ChunkDetailModal from './components/chunk-detail-modal' +import EmptyRecords from './components/empty-records' +import Mask from './components/mask' +import QueryInput from './components/query-input' +import Textarea from './components/query-input/textarea' +import Records from './components/records' +import ResultItem from './components/result-item' +import ResultItemExternal from './components/result-item-external' +import ResultItemFooter from './components/result-item-footer' +import ResultItemMeta from './components/result-item-meta' +import Score from './components/score' +import HitTestingPage from './index' +import ModifyExternalRetrievalModal from './modify-external-retrieval-modal' +import ModifyRetrievalModal from './modify-retrieval-modal' +import { extensionToFileType } from './utils/extension-to-file-type' + +// Mock Toast +// Note: These components use real implementations for integration testing: +// - Toast, FloatRightContainer, Drawer, Pagination, Loading +// - RetrievalMethodConfig, EconomicalRetrievalMethodConfig +// - ImageUploaderInRetrievalTesting, retrieval-method-info, check-rerank-model + +// Mock RetrievalSettings to allow triggering onChange +vi.mock('@/app/components/datasets/external-knowledge-base/create/RetrievalSettings', () => ({ + default: ({ onChange }: { onChange: (data: { top_k?: number, score_threshold?: number, score_threshold_enabled?: boolean }) => void }) => { + return ( +
+ + + +
+ ) + }, +})) + +// ============================================================================ +// Mock Setup +// ============================================================================ + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + }), + usePathname: () => '/test', + useSearchParams: () => new URLSearchParams(), +})) + +// Mock use-context-selector +const mockDataset = { + id: 'dataset-1', + name: 'Test Dataset', + provider: 'vendor', + indexing_technique: 'high_quality' as const, + retrieval_model_dict: { + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_mode: undefined, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + weights: undefined, + top_k: 10, + score_threshold_enabled: false, + score_threshold: 0.5, + }, + is_multimodal: false, +} as Partial + +vi.mock('use-context-selector', () => ({ + useContext: vi.fn(() => ({ dataset: mockDataset })), + useContextSelector: vi.fn((_, selector) => selector({ dataset: mockDataset })), + createContext: vi.fn(() => ({})), +})) + +// Mock dataset detail context +vi.mock('@/context/dataset-detail', () => ({ + default: {}, + useDatasetDetailContext: vi.fn(() => ({ dataset: mockDataset })), + useDatasetDetailContextWithSelector: vi.fn((selector: (v: { dataset?: typeof mockDataset }) => unknown) => + selector({ dataset: mockDataset as DataSet }), + ), +})) + +// Mock service hooks +const mockRecordsRefetch = vi.fn() +const mockHitTestingMutateAsync = vi.fn() +const mockExternalHitTestingMutateAsync = vi.fn() + +vi.mock('@/service/knowledge/use-dataset', () => ({ + useDatasetTestingRecords: vi.fn(() => ({ + data: { + data: [], + total: 0, + page: 1, + limit: 10, + has_more: false, + }, + refetch: mockRecordsRefetch, + isLoading: false, + })), +})) + +vi.mock('@/service/knowledge/use-hit-testing', () => ({ + useHitTesting: vi.fn(() => ({ + mutateAsync: mockHitTestingMutateAsync, + isPending: false, + })), + useExternalKnowledgeBaseHitTesting: vi.fn(() => ({ + mutateAsync: mockExternalHitTestingMutateAsync, + isPending: false, + })), +})) + +// Mock breakpoints hook +vi.mock('@/hooks/use-breakpoints', () => ({ + default: vi.fn(() => 'pc'), + MediaType: { + mobile: 'mobile', + pc: 'pc', + }, +})) + +// Mock timestamp hook +vi.mock('@/hooks/use-timestamp', () => ({ + default: vi.fn(() => ({ + formatTime: vi.fn((timestamp: number, _format: string) => new Date(timestamp * 1000).toISOString()), + })), +})) + +// Mock use-common to avoid QueryClient issues in nested hooks +vi.mock('@/service/use-common', () => ({ + useFileUploadConfig: vi.fn(() => ({ + data: { + file_size_limit: 10, + batch_count_limit: 5, + image_file_size_limit: 5, + }, + isLoading: false, + })), +})) + +// Store ref to ImageUploader onChange for testing +let mockImageUploaderOnChange: ((files: Array<{ sourceUrl?: string, uploadedId?: string, mimeType: string, name: string, size: number, extension: string }>) => void) | null = null + +// Mock ImageUploaderInRetrievalTesting to capture onChange +vi.mock('@/app/components/datasets/common/image-uploader/image-uploader-in-retrieval-testing', () => ({ + default: ({ textArea, actionButton, onChange }: { + textArea: React.ReactNode + actionButton: React.ReactNode + onChange: (files: Array<{ sourceUrl?: string, uploadedId?: string, mimeType: string, name: string, size: number, extension: string }>) => void + }) => { + mockImageUploaderOnChange = onChange + return ( +
+ {textArea} + {actionButton} + +
+ ) + }, +})) + +// Mock docLink hook +vi.mock('@/context/i18n', () => ({ + useDocLink: vi.fn(() => () => 'https://docs.example.com'), +})) + +// Mock provider context for retrieval method config +vi.mock('@/context/provider-context', () => ({ + useProviderContext: vi.fn(() => ({ + supportRetrievalMethods: [ + 'semantic_search', + 'full_text_search', + 'hybrid_search', + ], + })), +})) + +// Mock model list hook - include all exports used by child components +vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({ + useModelList: vi.fn(() => ({ + data: [], + isLoading: false, + })), + useModelListAndDefaultModelAndCurrentProviderAndModel: vi.fn(() => ({ + modelList: [], + defaultModel: undefined, + currentProvider: undefined, + currentModel: undefined, + })), + useModelListAndDefaultModel: vi.fn(() => ({ + modelList: [], + defaultModel: undefined, + })), + useCurrentProviderAndModel: vi.fn(() => ({ + currentProvider: undefined, + currentModel: undefined, + })), + useDefaultModel: vi.fn(() => ({ + defaultModel: undefined, + })), +})) + +// ============================================================================ +// Test Wrapper with QueryClientProvider +// ============================================================================ + +const createTestQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + mutations: { + retry: false, + }, + }, +}) + +const TestWrapper = ({ children }: { children: ReactNode }) => { + const queryClient = createTestQueryClient() + return ( + + {children} + + ) +} + +const renderWithProviders = (ui: React.ReactElement) => { + return render(ui, { wrapper: TestWrapper }) +} + +// ============================================================================ +// Test Factories +// ============================================================================ + +const createMockSegment = (overrides = {}) => ({ + id: 'segment-1', + document: { + id: 'doc-1', + data_source_type: 'upload_file', + name: 'test-document.pdf', + doc_type: 'book' as const, + }, + content: 'Test segment content', + sign_content: 'Test signed content', + position: 1, + word_count: 100, + tokens: 50, + keywords: ['test', 'keyword'], + hit_count: 5, + index_node_hash: 'hash-123', + answer: '', + ...overrides, +}) + +const createMockHitTesting = (overrides = {}): HitTesting => ({ + segment: createMockSegment() as HitTesting['segment'], + content: createMockSegment() as HitTesting['content'], + score: 0.85, + tsne_position: { x: 0.5, y: 0.5 }, + child_chunks: null, + files: [], + ...overrides, +}) + +const createMockChildChunk = (overrides = {}): HitTestingChildChunk => ({ + id: 'child-chunk-1', + content: 'Child chunk content', + position: 1, + score: 0.9, + ...overrides, +}) + +const createMockRecord = (overrides = {}): HitTestingRecord => ({ + id: 'record-1', + source: 'hit_testing', + source_app_id: 'app-1', + created_by_role: 'account', + created_by: 'user-1', + created_at: 1609459200, + queries: [ + { content: 'Test query', content_type: 'text_query', file_info: null }, + ], + ...overrides, +}) + +const createMockRetrievalConfig = (overrides = {}): RetrievalConfig => ({ + search_method: RETRIEVE_METHOD.semantic, + reranking_enable: false, + reranking_mode: undefined, + reranking_model: { + reranking_provider_name: '', + reranking_model_name: '', + }, + weights: undefined, + top_k: 10, + score_threshold_enabled: false, + score_threshold: 0.5, + ...overrides, +} as RetrievalConfig) + +// ============================================================================ +// Utility Function Tests +// ============================================================================ + +describe('extensionToFileType', () => { + describe('PDF files', () => { + it('should return pdf type for pdf extension', () => { + expect(extensionToFileType('pdf')).toBe(FileAppearanceTypeEnum.pdf) + }) + }) + + describe('Word files', () => { + it('should return word type for doc extension', () => { + expect(extensionToFileType('doc')).toBe(FileAppearanceTypeEnum.word) + }) + + it('should return word type for docx extension', () => { + expect(extensionToFileType('docx')).toBe(FileAppearanceTypeEnum.word) + }) + }) + + describe('Markdown files', () => { + it('should return markdown type for md extension', () => { + expect(extensionToFileType('md')).toBe(FileAppearanceTypeEnum.markdown) + }) + + it('should return markdown type for mdx extension', () => { + expect(extensionToFileType('mdx')).toBe(FileAppearanceTypeEnum.markdown) + }) + + it('should return markdown type for markdown extension', () => { + expect(extensionToFileType('markdown')).toBe(FileAppearanceTypeEnum.markdown) + }) + }) + + describe('Excel files', () => { + it('should return excel type for csv extension', () => { + expect(extensionToFileType('csv')).toBe(FileAppearanceTypeEnum.excel) + }) + + it('should return excel type for xls extension', () => { + expect(extensionToFileType('xls')).toBe(FileAppearanceTypeEnum.excel) + }) + + it('should return excel type for xlsx extension', () => { + expect(extensionToFileType('xlsx')).toBe(FileAppearanceTypeEnum.excel) + }) + }) + + describe('Document files', () => { + it('should return document type for txt extension', () => { + expect(extensionToFileType('txt')).toBe(FileAppearanceTypeEnum.document) + }) + + it('should return document type for epub extension', () => { + expect(extensionToFileType('epub')).toBe(FileAppearanceTypeEnum.document) + }) + + it('should return document type for html extension', () => { + expect(extensionToFileType('html')).toBe(FileAppearanceTypeEnum.document) + }) + + it('should return document type for htm extension', () => { + expect(extensionToFileType('htm')).toBe(FileAppearanceTypeEnum.document) + }) + + it('should return document type for xml extension', () => { + expect(extensionToFileType('xml')).toBe(FileAppearanceTypeEnum.document) + }) + }) + + describe('PowerPoint files', () => { + it('should return ppt type for ppt extension', () => { + expect(extensionToFileType('ppt')).toBe(FileAppearanceTypeEnum.ppt) + }) + + it('should return ppt type for pptx extension', () => { + expect(extensionToFileType('pptx')).toBe(FileAppearanceTypeEnum.ppt) + }) + }) + + describe('Edge cases', () => { + it('should return custom type for unknown extension', () => { + expect(extensionToFileType('unknown')).toBe(FileAppearanceTypeEnum.custom) + }) + + it('should return custom type for empty string', () => { + expect(extensionToFileType('')).toBe(FileAppearanceTypeEnum.custom) + }) + }) +}) + +// ============================================================================ +// Score Component Tests +// ============================================================================ + +describe('Score', () => { + describe('Rendering', () => { + it('should render score with correct value', () => { + render() + expect(screen.getByText('0.85')).toBeInTheDocument() + expect(screen.getByText('score')).toBeInTheDocument() + }) + + it('should render nothing when value is null', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('should render nothing when value is NaN', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('should render nothing when value is 0', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) + }) + + describe('Props', () => { + it('should apply besideChunkName styles when prop is true', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('border-l-0') + }) + + it('should apply rounded styles when besideChunkName is false', () => { + const { container } = render() + const wrapper = container.firstChild as HTMLElement + expect(wrapper).toHaveClass('rounded-md') + }) + }) + + describe('Edge Cases', () => { + it('should display full score correctly', () => { + render() + expect(screen.getByText('1.00')).toBeInTheDocument() + }) + + it('should display very small score correctly', () => { + render() + expect(screen.getByText('0.01')).toBeInTheDocument() + }) + }) +}) + +// ============================================================================ +// Mask Component Tests +// ============================================================================ + +describe('Mask', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = render() + expect(container.firstChild).toBeInTheDocument() + }) + + it('should have gradient background class', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('bg-gradient-to-b') + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('custom-class') + }) + }) +}) + +// ============================================================================ +// EmptyRecords Component Tests +// ============================================================================ + +describe('EmptyRecords', () => { + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText(/noRecentTip/i)).toBeInTheDocument() + }) + + it('should render history icon', () => { + const { container } = render() + const icon = container.querySelector('svg') + expect(icon).toBeInTheDocument() + }) + }) +}) + +// ============================================================================ +// ResultItemMeta Component Tests +// ============================================================================ + +describe('ResultItemMeta', () => { + const defaultProps = { + labelPrefix: 'Chunk', + positionId: 1, + wordCount: 100, + score: 0.85, + } + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText(/100/)).toBeInTheDocument() + }) + + it('should render score component', () => { + render() + expect(screen.getByText('0.85')).toBeInTheDocument() + }) + + it('should render word count', () => { + render() + expect(screen.getByText(/100/)).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply custom className', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('custom-class') + }) + + it('should handle different position IDs', () => { + render() + // Position ID is passed to SegmentIndexTag + expect(screen.getByText(/42/)).toBeInTheDocument() + }) + }) +}) + +// ============================================================================ +// ResultItemFooter Component Tests +// ============================================================================ + +describe('ResultItemFooter', () => { + const mockShowDetailModal = vi.fn() + const defaultProps = { + docType: FileAppearanceTypeEnum.pdf, + docTitle: 'Test Document.pdf', + showDetailModal: mockShowDetailModal, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText('Test Document.pdf')).toBeInTheDocument() + }) + + it('should render open button', () => { + render() + expect(screen.getByText(/open/i)).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should call showDetailModal when open button is clicked', async () => { + render() + + const openButton = screen.getByText(/open/i).parentElement + if (openButton) + fireEvent.click(openButton) + + expect(mockShowDetailModal).toHaveBeenCalledTimes(1) + }) + }) +}) + +// ============================================================================ +// ChildChunksItem Component Tests +// ============================================================================ + +describe('ChildChunksItem', () => { + const mockChildChunk = createMockChildChunk() + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText(/Child chunk content/)).toBeInTheDocument() + }) + + it('should render position identifier', () => { + render() + // The C- and position number are in the same element + expect(screen.getByText(/C-/)).toBeInTheDocument() + }) + + it('should render score', () => { + render() + expect(screen.getByText('0.90')).toBeInTheDocument() + }) + }) + + describe('Props', () => { + it('should apply line-clamp when isShowAll is false', () => { + const { container } = render() + expect(container.firstChild).toHaveClass('line-clamp-2') + }) + + it('should not apply line-clamp when isShowAll is true', () => { + const { container } = render() + expect(container.firstChild).not.toHaveClass('line-clamp-2') + }) + }) +}) + +// ============================================================================ +// ResultItem Component Tests +// ============================================================================ + +describe('ResultItem', () => { + const mockHitTesting = createMockHitTesting() + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + // Document name should be visible + expect(screen.getByText('test-document.pdf')).toBeInTheDocument() + }) + + it('should render score', () => { + render() + expect(screen.getByText('0.85')).toBeInTheDocument() + }) + + it('should render document name in footer', () => { + render() + expect(screen.getByText('test-document.pdf')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should open detail modal when clicked', async () => { + render() + + const item = screen.getByText('test-document.pdf').closest('.cursor-pointer') + if (item) + fireEvent.click(item) + + await waitFor(() => { + expect(screen.getByText(/chunkDetail/i)).toBeInTheDocument() + }) + }) + }) + + describe('Parent-Child Retrieval', () => { + it('should render child chunks when present', () => { + const payloadWithChildren = createMockHitTesting({ + child_chunks: [createMockChildChunk()], + }) + + render() + expect(screen.getByText(/hitChunks/i)).toBeInTheDocument() + }) + + it('should toggle fold state when child chunks header is clicked', async () => { + const payloadWithChildren = createMockHitTesting({ + child_chunks: [createMockChildChunk()], + }) + + render() + + // Child chunks should be visible by default (not folded) + expect(screen.getByText(/Child chunk content/)).toBeInTheDocument() + + // Click to fold + const toggleButton = screen.getByText(/hitChunks/i).parentElement + if (toggleButton) { + fireEvent.click(toggleButton) + + await waitFor(() => { + expect(screen.queryByText(/Child chunk content/)).not.toBeInTheDocument() + }) + } + }) + }) + + describe('Keywords', () => { + it('should render keywords when present and no child chunks', () => { + const payload = createMockHitTesting({ + segment: createMockSegment({ keywords: ['keyword1', 'keyword2'] }), + child_chunks: null, + }) + + render() + expect(screen.getByText('keyword1')).toBeInTheDocument() + expect(screen.getByText('keyword2')).toBeInTheDocument() + }) + + it('should not render keywords when child chunks are present', () => { + const payload = createMockHitTesting({ + segment: createMockSegment({ keywords: ['keyword1'] }), + child_chunks: [createMockChildChunk()], + }) + + render() + expect(screen.queryByText('keyword1')).not.toBeInTheDocument() + }) + }) +}) + +// ============================================================================ +// ResultItemExternal Component Tests +// ============================================================================ + +describe('ResultItemExternal', () => { + const defaultProps = { + payload: { + content: 'External content', + title: 'External Title', + score: 0.75, + metadata: { + 'x-amz-bedrock-kb-source-uri': 'source-uri', + 'x-amz-bedrock-kb-data-source-id': 'data-source-id', + }, + }, + positionId: 1, + } + + describe('Rendering', () => { + it('should render without crashing', () => { + render() + expect(screen.getByText('External content')).toBeInTheDocument() + }) + + it('should render title in footer', () => { + render() + expect(screen.getByText('External Title')).toBeInTheDocument() + }) + + it('should render score', () => { + render() + expect(screen.getByText('0.75')).toBeInTheDocument() + }) + }) + + describe('User Interactions', () => { + it('should open detail modal when clicked', async () => { + render() + + const item = screen.getByText('External content').closest('.cursor-pointer') + if (item) + fireEvent.click(item) + + await waitFor(() => { + expect(screen.getByText(/chunkDetail/i)).toBeInTheDocument() + }) + }) + }) +}) + +// ============================================================================ +// Textarea Component Tests +// ============================================================================ + +describe('Textarea', () => { + const mockHandleTextChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render without crashing', () => { + render(