test: add unit tests for base components (#32818)

Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
Coding On Star
2026-03-02 11:40:43 +08:00
committed by GitHub
parent 8cc775d9f2
commit 335b500aea
401 changed files with 820 additions and 819 deletions

View File

@@ -0,0 +1,69 @@
import { fireEvent, render, screen } from '@testing-library/react'
import AudioPreview from '../audio-preview'
describe('AudioPreview', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render audio element with correct source', () => {
render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />)
const audio = document.querySelector('audio')
expect(audio).toBeInTheDocument()
expect(audio).toHaveAttribute('title', 'Test Audio')
})
it('should render source element with correct src and type', () => {
render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />)
const source = document.querySelector('source')
expect(source).toHaveAttribute('src', 'https://example.com/audio.mp3')
expect(source).toHaveAttribute('type', 'audio/mpeg')
})
it('should render close button with icon', () => {
render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />)
const closeIcon = screen.getByTestId('close-btn')
expect(closeIcon).toBeInTheDocument()
})
it('should call onCancel when close button is clicked', () => {
const onCancel = vi.fn()
render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={onCancel} />)
const closeIcon = screen.getByTestId('close-btn')
fireEvent.click(closeIcon.parentElement!)
expect(onCancel).toHaveBeenCalled()
})
it('should stop propagation when backdrop is clicked', () => {
const { baseElement } = render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />)
const backdrop = baseElement.querySelector('[tabindex="-1"]')
const event = new MouseEvent('click', { bubbles: true })
const stopPropagation = vi.spyOn(event, 'stopPropagation')
backdrop!.dispatchEvent(event)
expect(stopPropagation).toHaveBeenCalled()
})
it('should call onCancel when Escape key is pressed', () => {
const onCancel = vi.fn()
render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={onCancel} />)
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
expect(onCancel).toHaveBeenCalled()
})
it('should render in a portal attached to document.body', () => {
render(<AudioPreview url="https://example.com/audio.mp3" title="Test Audio" onCancel={vi.fn()} />)
const audio = document.querySelector('audio')
expect(audio?.closest('[tabindex="-1"]')?.parentElement).toBe(document.body)
})
})

View File

@@ -0,0 +1,71 @@
import {
AUDIO_SIZE_LIMIT,
FILE_SIZE_LIMIT,
FILE_URL_REGEX,
IMG_SIZE_LIMIT,
MAX_FILE_UPLOAD_LIMIT,
VIDEO_SIZE_LIMIT,
} from '../constants'
describe('file-uploader constants', () => {
describe('size limit constants', () => {
it('should set IMG_SIZE_LIMIT to 10 MB', () => {
expect(IMG_SIZE_LIMIT).toBe(10 * 1024 * 1024)
})
it('should set FILE_SIZE_LIMIT to 15 MB', () => {
expect(FILE_SIZE_LIMIT).toBe(15 * 1024 * 1024)
})
it('should set AUDIO_SIZE_LIMIT to 50 MB', () => {
expect(AUDIO_SIZE_LIMIT).toBe(50 * 1024 * 1024)
})
it('should set VIDEO_SIZE_LIMIT to 100 MB', () => {
expect(VIDEO_SIZE_LIMIT).toBe(100 * 1024 * 1024)
})
it('should set MAX_FILE_UPLOAD_LIMIT to 10', () => {
expect(MAX_FILE_UPLOAD_LIMIT).toBe(10)
})
})
describe('FILE_URL_REGEX', () => {
it('should match http URLs', () => {
expect(FILE_URL_REGEX.test('http://example.com')).toBe(true)
expect(FILE_URL_REGEX.test('http://example.com/path/file.txt')).toBe(true)
})
it('should match https URLs', () => {
expect(FILE_URL_REGEX.test('https://example.com')).toBe(true)
expect(FILE_URL_REGEX.test('https://example.com/path/file.pdf')).toBe(true)
})
it('should match ftp URLs', () => {
expect(FILE_URL_REGEX.test('ftp://files.example.com')).toBe(true)
expect(FILE_URL_REGEX.test('ftp://files.example.com/data.csv')).toBe(true)
})
it('should reject URLs without a valid protocol', () => {
expect(FILE_URL_REGEX.test('example.com')).toBe(false)
expect(FILE_URL_REGEX.test('www.example.com')).toBe(false)
})
it('should reject empty strings', () => {
expect(FILE_URL_REGEX.test('')).toBe(false)
})
it('should reject unsupported protocols', () => {
expect(FILE_URL_REGEX.test('file:///local/path')).toBe(false)
expect(FILE_URL_REGEX.test('ssh://host')).toBe(false)
expect(FILE_URL_REGEX.test('data:text/plain;base64,abc')).toBe(false)
})
it('should reject partial protocol strings', () => {
expect(FILE_URL_REGEX.test('http:')).toBe(false)
expect(FILE_URL_REGEX.test('http:/')).toBe(false)
expect(FILE_URL_REGEX.test('https:')).toBe(false)
expect(FILE_URL_REGEX.test('ftp:')).toBe(false)
})
})
})

View File

@@ -0,0 +1,67 @@
import { fireEvent, render, screen } from '@testing-library/react'
import FileImageRender from '../file-image-render'
describe('FileImageRender', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render an image with the given URL', () => {
render(<FileImageRender imageUrl="https://example.com/image.png" />)
const img = screen.getByRole('img')
expect(img).toHaveAttribute('src', 'https://example.com/image.png')
})
it('should use default alt text when alt is not provided', () => {
render(<FileImageRender imageUrl="https://example.com/image.png" />)
expect(screen.getByAltText('Preview')).toBeInTheDocument()
})
it('should use custom alt text when provided', () => {
render(<FileImageRender imageUrl="https://example.com/image.png" alt="Custom alt" />)
expect(screen.getByAltText('Custom alt')).toBeInTheDocument()
})
it('should apply custom className to container', () => {
const { container } = render(
<FileImageRender imageUrl="https://example.com/image.png" className="custom-class" />,
)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should call onLoad when image loads', () => {
const onLoad = vi.fn()
render(<FileImageRender imageUrl="https://example.com/image.png" onLoad={onLoad} />)
fireEvent.load(screen.getByRole('img'))
expect(onLoad).toHaveBeenCalled()
})
it('should call onError when image fails to load', () => {
const onError = vi.fn()
render(<FileImageRender imageUrl="https://example.com/broken.png" onError={onError} />)
fireEvent.error(screen.getByRole('img'))
expect(onError).toHaveBeenCalled()
})
it('should add cursor-pointer to image when showDownloadAction is true', () => {
render(<FileImageRender imageUrl="https://example.com/image.png" showDownloadAction />)
const img = screen.getByRole('img')
expect(img).toHaveClass('cursor-pointer')
})
it('should not add cursor-pointer when showDownloadAction is false', () => {
render(<FileImageRender imageUrl="https://example.com/image.png" />)
const img = screen.getByRole('img')
expect(img).not.toHaveClass('cursor-pointer')
})
})

View File

@@ -0,0 +1,179 @@
import type { FileEntity } from '../types'
import type { FileUpload } from '@/app/components/base/features/types'
import { fireEvent, render } from '@testing-library/react'
import FileInput from '../file-input'
import { FileContextProvider } from '../store'
const mockHandleLocalFileUpload = vi.fn()
vi.mock('../hooks', () => ({
useFile: () => ({
handleLocalFileUpload: mockHandleLocalFileUpload,
}),
}))
const createFileConfig = (overrides: Partial<FileUpload> = {}): FileUpload => ({
enabled: true,
allowed_file_types: ['image'],
allowed_file_extensions: [],
number_limits: 5,
...overrides,
} as FileUpload)
function createStubFile(id: string): FileEntity {
return { id, name: `${id}.txt`, size: 0, type: '', progress: 100, transferMethod: 'local_file' as FileEntity['transferMethod'], supportFileType: 'document' }
}
function renderWithProvider(ui: React.ReactElement, fileIds: string[] = []) {
return render(
<FileContextProvider value={fileIds.map(createStubFile)}>
{ui}
</FileContextProvider>,
)
}
describe('FileInput', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render a file input element', () => {
renderWithProvider(<FileInput fileConfig={createFileConfig()} />)
const input = document.querySelector('input[type="file"]')
expect(input).toBeInTheDocument()
})
it('should set accept attribute based on allowed file types', () => {
renderWithProvider(<FileInput fileConfig={createFileConfig({ allowed_file_types: ['image'] })} />)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
expect(input.accept).toBe('.JPG,.JPEG,.PNG,.GIF,.WEBP,.SVG')
})
it('should use custom extensions when file type is custom', () => {
renderWithProvider(
<FileInput fileConfig={createFileConfig({
allowed_file_types: ['custom'] as unknown as FileUpload['allowed_file_types'],
allowed_file_extensions: ['.csv', '.xlsx'],
})}
/>,
)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
expect(input.accept).toBe('.csv,.xlsx')
})
it('should allow multiple files when number_limits > 1', () => {
renderWithProvider(<FileInput fileConfig={createFileConfig({ number_limits: 3 })} />)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
expect(input.multiple).toBe(true)
})
it('should not allow multiple files when number_limits is 1', () => {
renderWithProvider(<FileInput fileConfig={createFileConfig({ number_limits: 1 })} />)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
expect(input.multiple).toBe(false)
})
it('should be disabled when file limit is reached', () => {
renderWithProvider(
<FileInput fileConfig={createFileConfig({ number_limits: 3 })} />,
['1', '2', '3'],
)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
expect(input.disabled).toBe(true)
})
it('should not be disabled when file limit is not reached', () => {
renderWithProvider(
<FileInput fileConfig={createFileConfig({ number_limits: 3 })} />,
['1'],
)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
expect(input.disabled).toBe(false)
})
it('should call handleLocalFileUpload when files are selected', () => {
renderWithProvider(<FileInput fileConfig={createFileConfig()} />)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
const file = new File(['content'], 'test.jpg', { type: 'image/jpeg' })
fireEvent.change(input, { target: { files: [file] } })
expect(mockHandleLocalFileUpload).toHaveBeenCalledWith(file)
})
it('should respect number_limits when uploading multiple files', () => {
renderWithProvider(
<FileInput fileConfig={createFileConfig({ number_limits: 3 })} />,
['1', '2'],
)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
const file1 = new File(['content'], 'test1.jpg', { type: 'image/jpeg' })
const file2 = new File(['content'], 'test2.jpg', { type: 'image/jpeg' })
Object.defineProperty(input, 'files', {
value: [file1, file2],
})
fireEvent.change(input)
// Only 1 file should be uploaded (2 existing + 1 = 3 = limit)
expect(mockHandleLocalFileUpload).toHaveBeenCalledTimes(1)
expect(mockHandleLocalFileUpload).toHaveBeenCalledWith(file1)
})
it('should upload first file only when number_limits is not set', () => {
renderWithProvider(<FileInput fileConfig={createFileConfig({ number_limits: undefined })} />)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
const file = new File(['content'], 'test.jpg', { type: 'image/jpeg' })
fireEvent.change(input, { target: { files: [file] } })
expect(mockHandleLocalFileUpload).toHaveBeenCalledWith(file)
})
it('should not upload when targetFiles is null', () => {
renderWithProvider(<FileInput fileConfig={createFileConfig()} />)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(input, { target: { files: null } })
expect(mockHandleLocalFileUpload).not.toHaveBeenCalled()
})
it('should handle empty allowed_file_types', () => {
renderWithProvider(<FileInput fileConfig={createFileConfig({ allowed_file_types: undefined })} />)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
expect(input.accept).toBe('')
})
it('should handle custom type with undefined allowed_file_extensions', () => {
renderWithProvider(
<FileInput fileConfig={createFileConfig({
allowed_file_types: ['custom'] as unknown as FileUpload['allowed_file_types'],
allowed_file_extensions: undefined,
})}
/>,
)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
expect(input.accept).toBe('')
})
it('should clear input value on click', () => {
renderWithProvider(<FileInput fileConfig={createFileConfig()} />)
const input = document.querySelector('input[type="file"]') as HTMLInputElement
Object.defineProperty(input, 'value', { writable: true, value: 'some-file' })
fireEvent.click(input)
expect(input.value).toBe('')
})
})

View File

@@ -0,0 +1,142 @@
import type { FileEntity } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { TransferMethod } from '@/types/app'
import FileListInLog from '../file-list-in-log'
const createFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
id: `file-${Math.random()}`,
name: 'test.txt',
size: 1024,
type: 'text/plain',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'document',
...overrides,
})
describe('FileListInLog', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return null when fileList is empty', () => {
const { container } = render(<FileListInLog fileList={[]} />)
expect(container.firstChild).toBeNull()
})
it('should render collapsed view by default', () => {
const fileList = [{ varName: 'files', list: [createFile()] }]
render(<FileListInLog fileList={fileList} />)
expect(screen.getByText(/runDetail\.fileListDetail/)).toBeInTheDocument()
})
it('should render expanded view when isExpanded is true', () => {
const fileList = [{ varName: 'files', list: [createFile()] }]
render(<FileListInLog fileList={fileList} isExpanded />)
expect(screen.getByText(/runDetail\.fileListLabel/)).toBeInTheDocument()
expect(screen.getByText('files')).toBeInTheDocument()
})
it('should toggle between collapsed and expanded on click', () => {
const fileList = [{ varName: 'files', list: [createFile()] }]
render(<FileListInLog fileList={fileList} />)
expect(screen.getByText(/runDetail\.fileListDetail/)).toBeInTheDocument()
const detailLink = screen.getByText(/runDetail\.fileListDetail/)
fireEvent.click(detailLink.parentElement!)
expect(screen.getByText(/runDetail\.fileListLabel/)).toBeInTheDocument()
})
it('should render image files with an img element in collapsed view', () => {
const fileList = [{
varName: 'files',
list: [createFile({
name: 'photo.png',
supportFileType: 'image',
url: 'https://example.com/photo.png',
})],
}]
render(<FileListInLog fileList={fileList} />)
const img = screen.getByRole('img')
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute('src', 'https://example.com/photo.png')
})
it('should render non-image files with an SVG icon in collapsed view', () => {
const fileList = [{
varName: 'files',
list: [createFile({
name: 'doc.pdf',
supportFileType: 'document',
})],
}]
render(<FileListInLog fileList={fileList} />)
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
it('should render file details in expanded view', () => {
const file = createFile({ name: 'report.txt' })
const fileList = [{ varName: 'files', list: [file] }]
render(<FileListInLog fileList={fileList} isExpanded />)
expect(screen.getByText('report.txt')).toBeInTheDocument()
})
it('should render multiple var groups in expanded view', () => {
const fileList = [
{ varName: 'images', list: [createFile({ name: 'a.jpg' })] },
{ varName: 'documents', list: [createFile({ name: 'b.pdf' })] },
]
render(<FileListInLog fileList={fileList} isExpanded />)
expect(screen.getByText('images')).toBeInTheDocument()
expect(screen.getByText('documents')).toBeInTheDocument()
})
it('should apply noBorder class when noBorder is true', () => {
const fileList = [{ varName: 'files', list: [createFile()] }]
const { container } = render(<FileListInLog fileList={fileList} noBorder />)
expect(container.firstChild).not.toHaveClass('border-t')
})
it('should apply noPadding class when noPadding is true', () => {
const fileList = [{ varName: 'files', list: [createFile()] }]
const { container } = render(<FileListInLog fileList={fileList} noPadding />)
expect(container.firstChild).toHaveClass('!p-0')
})
it('should render image file with empty url when both base64Url and url are undefined', () => {
const fileList = [{
varName: 'files',
list: [createFile({
name: 'photo.png',
supportFileType: 'image',
base64Url: undefined,
url: undefined,
})],
}]
render(<FileListInLog fileList={fileList} />)
const img = screen.getByRole('img')
expect(img).toBeInTheDocument()
})
it('should collapse when label is clicked in expanded view', () => {
const fileList = [{ varName: 'files', list: [createFile()] }]
render(<FileListInLog fileList={fileList} isExpanded />)
const label = screen.getByText(/runDetail\.fileListLabel/)
fireEvent.click(label)
expect(screen.getByText(/runDetail\.fileListDetail/)).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,85 @@
import type { FileAppearanceTypeEnum } from '../types'
import { render } from '@testing-library/react'
import FileTypeIcon from '../file-type-icon'
describe('FileTypeIcon', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('icon rendering per file type', () => {
const fileTypeToColor: Array<{ type: keyof typeof FileAppearanceTypeEnum, color: string }> = [
{ type: 'pdf', color: 'text-[#EA3434]' },
{ type: 'image', color: 'text-[#00B2EA]' },
{ type: 'video', color: 'text-[#844FDA]' },
{ type: 'audio', color: 'text-[#FF3093]' },
{ type: 'document', color: 'text-[#6F8BB5]' },
{ type: 'code', color: 'text-[#BCC0D1]' },
{ type: 'markdown', color: 'text-[#309BEC]' },
{ type: 'custom', color: 'text-[#BCC0D1]' },
{ type: 'excel', color: 'text-[#01AC49]' },
{ type: 'word', color: 'text-[#2684FF]' },
{ type: 'ppt', color: 'text-[#FF650F]' },
{ type: 'gif', color: 'text-[#00B2EA]' },
]
it.each(fileTypeToColor)(
'should render $type icon with correct color',
({ type, color }) => {
const { container } = render(<FileTypeIcon type={type} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
expect(icon).toHaveClass(color)
},
)
})
it('should render document icon when type is unknown', () => {
const { container } = render(<FileTypeIcon type={'nonexistent' as unknown as keyof typeof FileAppearanceTypeEnum} />)
const icon = container.querySelector('svg')
expect(icon).toBeInTheDocument()
expect(icon).toHaveClass('text-[#6F8BB5]')
})
describe('size variants', () => {
const sizeMap: Array<{ size: 'sm' | 'md' | 'lg' | 'xl', expectedClass: string }> = [
{ size: 'sm', expectedClass: 'size-4' },
{ size: 'md', expectedClass: 'size-[18px]' },
{ size: 'lg', expectedClass: 'size-5' },
{ size: 'xl', expectedClass: 'size-6' },
]
it.each(sizeMap)(
'should apply $expectedClass when size is $size',
({ size, expectedClass }) => {
const { container } = render(<FileTypeIcon type="pdf" size={size} />)
const icon = container.querySelector('svg')
expect(icon).toHaveClass(expectedClass)
},
)
it('should default to sm size when no size is provided', () => {
const { container } = render(<FileTypeIcon type="pdf" />)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('size-4')
})
})
it('should apply custom className when provided', () => {
const { container } = render(<FileTypeIcon type="pdf" className="extra-class" />)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('extra-class')
})
it('should always include shrink-0 class', () => {
const { container } = render(<FileTypeIcon type="document" />)
const icon = container.querySelector('svg')
expect(icon).toHaveClass('shrink-0')
})
})

View File

@@ -0,0 +1,867 @@
import type { FileEntity } from '../types'
import type { FileUpload } from '@/app/components/base/features/types'
import type { FileUploadConfigResponse } from '@/models/common'
import { act, renderHook } from '@testing-library/react'
import { useFile, useFileSizeLimit } from '../hooks'
const mockNotify = vi.fn()
vi.mock('next/navigation', () => ({
useParams: () => ({ token: undefined }),
}))
// Exception: hook requires toast context that isn't available without a provider wrapper
vi.mock('@/app/components/base/toast', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
const mockSetFiles = vi.fn()
let mockStoreFiles: FileEntity[] = []
vi.mock('../store', () => ({
useFileStore: () => ({
getState: () => ({
files: mockStoreFiles,
setFiles: mockSetFiles,
}),
}),
}))
const mockFileUpload = vi.fn()
const mockIsAllowedFileExtension = vi.fn().mockReturnValue(true)
const mockGetSupportFileType = vi.fn().mockReturnValue('document')
vi.mock('../utils', () => ({
fileUpload: (...args: unknown[]) => mockFileUpload(...args),
getFileUploadErrorMessage: vi.fn().mockReturnValue('Upload error'),
getSupportFileType: (...args: unknown[]) => mockGetSupportFileType(...args),
isAllowedFileExtension: (...args: unknown[]) => mockIsAllowedFileExtension(...args),
}))
const mockUploadRemoteFileInfo = vi.fn()
vi.mock('@/service/common', () => ({
uploadRemoteFileInfo: (...args: unknown[]) => mockUploadRemoteFileInfo(...args),
}))
vi.mock('uuid', () => ({
v4: () => 'mock-uuid',
}))
describe('useFileSizeLimit', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should return default limits when no config is provided', () => {
const { result } = renderHook(() => useFileSizeLimit())
expect(result.current.imgSizeLimit).toBe(10 * 1024 * 1024)
expect(result.current.docSizeLimit).toBe(15 * 1024 * 1024)
expect(result.current.audioSizeLimit).toBe(50 * 1024 * 1024)
expect(result.current.videoSizeLimit).toBe(100 * 1024 * 1024)
expect(result.current.maxFileUploadLimit).toBe(10)
})
it('should use config values when provided', () => {
const config: FileUploadConfigResponse = {
image_file_size_limit: 20,
file_size_limit: 30,
audio_file_size_limit: 100,
video_file_size_limit: 200,
workflow_file_upload_limit: 20,
} as FileUploadConfigResponse
const { result } = renderHook(() => useFileSizeLimit(config))
expect(result.current.imgSizeLimit).toBe(20 * 1024 * 1024)
expect(result.current.docSizeLimit).toBe(30 * 1024 * 1024)
expect(result.current.audioSizeLimit).toBe(100 * 1024 * 1024)
expect(result.current.videoSizeLimit).toBe(200 * 1024 * 1024)
expect(result.current.maxFileUploadLimit).toBe(20)
})
it('should fall back to defaults when config values are zero', () => {
const config = {
image_file_size_limit: 0,
file_size_limit: 0,
audio_file_size_limit: 0,
video_file_size_limit: 0,
workflow_file_upload_limit: 0,
} as FileUploadConfigResponse
const { result } = renderHook(() => useFileSizeLimit(config))
expect(result.current.imgSizeLimit).toBe(10 * 1024 * 1024)
expect(result.current.docSizeLimit).toBe(15 * 1024 * 1024)
expect(result.current.audioSizeLimit).toBe(50 * 1024 * 1024)
expect(result.current.videoSizeLimit).toBe(100 * 1024 * 1024)
expect(result.current.maxFileUploadLimit).toBe(10)
})
})
describe('useFile', () => {
const defaultFileConfig: FileUpload = {
enabled: true,
allowed_file_types: ['image', 'document'],
allowed_file_extensions: [],
number_limits: 5,
} as FileUpload
beforeEach(() => {
vi.clearAllMocks()
mockStoreFiles = []
mockIsAllowedFileExtension.mockReturnValue(true)
mockGetSupportFileType.mockReturnValue('document')
})
it('should return all file handler functions', () => {
const { result } = renderHook(() => useFile(defaultFileConfig))
expect(result.current.handleAddFile).toBeDefined()
expect(result.current.handleUpdateFile).toBeDefined()
expect(result.current.handleRemoveFile).toBeDefined()
expect(result.current.handleReUploadFile).toBeDefined()
expect(result.current.handleLoadFileFromLink).toBeDefined()
expect(result.current.handleLoadFileFromLinkSuccess).toBeDefined()
expect(result.current.handleLoadFileFromLinkError).toBeDefined()
expect(result.current.handleClearFiles).toBeDefined()
expect(result.current.handleLocalFileUpload).toBeDefined()
expect(result.current.handleClipboardPasteFile).toBeDefined()
expect(result.current.handleDragFileEnter).toBeDefined()
expect(result.current.handleDragFileOver).toBeDefined()
expect(result.current.handleDragFileLeave).toBeDefined()
expect(result.current.handleDropFile).toBeDefined()
expect(result.current.isDragActive).toBe(false)
})
it('should add a file via handleAddFile', () => {
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleAddFile({
id: 'test-id',
name: 'test.txt',
type: 'text/plain',
size: 100,
progress: 0,
transferMethod: 'local_file',
supportFileType: 'document',
} as FileEntity)
expect(mockSetFiles).toHaveBeenCalled()
})
it('should update a file via handleUpdateFile', () => {
mockStoreFiles = [{ id: 'file-1', name: 'a.txt', progress: 0 }] as FileEntity[]
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleUpdateFile({ id: 'file-1', name: 'a.txt', progress: 50 } as FileEntity)
expect(mockSetFiles).toHaveBeenCalled()
})
it('should not update file when id is not found', () => {
mockStoreFiles = [{ id: 'file-1', name: 'a.txt' }] as FileEntity[]
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleUpdateFile({ id: 'nonexistent' } as FileEntity)
expect(mockSetFiles).toHaveBeenCalled()
})
it('should remove a file via handleRemoveFile', () => {
mockStoreFiles = [{ id: 'file-1', name: 'a.txt' }] as FileEntity[]
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleRemoveFile('file-1')
expect(mockSetFiles).toHaveBeenCalled()
})
it('should clear all files via handleClearFiles', () => {
mockStoreFiles = [{ id: 'a' }] as FileEntity[]
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleClearFiles()
expect(mockSetFiles).toHaveBeenCalledWith([])
})
describe('handleReUploadFile', () => {
it('should re-upload a file and call fileUpload', () => {
const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' })
mockStoreFiles = [{
id: 'file-1',
name: 'test.txt',
type: 'text/plain',
size: 100,
progress: -1,
transferMethod: 'local_file',
supportFileType: 'document',
originalFile,
}] as FileEntity[]
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleReUploadFile('file-1')
expect(mockSetFiles).toHaveBeenCalled()
expect(mockFileUpload).toHaveBeenCalled()
})
it('should not re-upload when file id is not found', () => {
mockStoreFiles = []
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleReUploadFile('nonexistent')
expect(mockFileUpload).not.toHaveBeenCalled()
})
it('should handle progress callback during re-upload', () => {
const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' })
mockStoreFiles = [{
id: 'file-1',
name: 'test.txt',
type: 'text/plain',
size: 100,
progress: -1,
transferMethod: 'local_file',
supportFileType: 'document',
originalFile,
}] as FileEntity[]
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleReUploadFile('file-1')
const uploadCall = mockFileUpload.mock.calls[0][0]
uploadCall.onProgressCallback(50)
expect(mockSetFiles).toHaveBeenCalled()
})
it('should handle success callback during re-upload', () => {
const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' })
mockStoreFiles = [{
id: 'file-1',
name: 'test.txt',
type: 'text/plain',
size: 100,
progress: -1,
transferMethod: 'local_file',
supportFileType: 'document',
originalFile,
}] as FileEntity[]
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleReUploadFile('file-1')
const uploadCall = mockFileUpload.mock.calls[0][0]
uploadCall.onSuccessCallback({ id: 'uploaded-1' })
expect(mockSetFiles).toHaveBeenCalled()
})
it('should handle error callback during re-upload', () => {
const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' })
mockStoreFiles = [{
id: 'file-1',
name: 'test.txt',
type: 'text/plain',
size: 100,
progress: -1,
transferMethod: 'local_file',
supportFileType: 'document',
originalFile,
}] as FileEntity[]
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleReUploadFile('file-1')
const uploadCall = mockFileUpload.mock.calls[0][0]
uploadCall.onErrorCallback(new Error('fail'))
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
})
})
describe('handleLoadFileFromLink', () => {
it('should run startProgressTimer to increment file progress', () => {
vi.useFakeTimers()
mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {})) // never resolves
// Set up a file in the store that has progress 0
mockStoreFiles = [{
id: 'mock-uuid',
name: 'https://example.com/file.txt',
type: '',
size: 0,
progress: 0,
transferMethod: 'remote_url',
supportFileType: '',
}] as FileEntity[]
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLoadFileFromLink('https://example.com/file.txt')
// Advance timer to trigger the interval
vi.advanceTimersByTime(200)
expect(mockSetFiles).toHaveBeenCalled()
vi.useRealTimers()
})
it('should add file and call uploadRemoteFileInfo', () => {
mockUploadRemoteFileInfo.mockResolvedValue({
id: 'remote-1',
mime_type: 'text/plain',
size: 100,
name: 'remote.txt',
url: 'https://example.com/remote.txt',
})
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLoadFileFromLink('https://example.com/file.txt')
expect(mockSetFiles).toHaveBeenCalled()
expect(mockUploadRemoteFileInfo).toHaveBeenCalledWith('https://example.com/file.txt', false)
})
it('should remove file when extension is not allowed', async () => {
mockIsAllowedFileExtension.mockReturnValue(false)
mockUploadRemoteFileInfo.mockResolvedValue({
id: 'remote-1',
mime_type: 'text/plain',
size: 100,
name: 'remote.txt',
url: 'https://example.com/remote.txt',
})
const { result } = renderHook(() => useFile(defaultFileConfig))
await act(async () => {
result.current.handleLoadFileFromLink('https://example.com/file.txt')
await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled())
})
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
})
it('should use empty arrays when allowed_file_types and allowed_file_extensions are undefined', async () => {
mockIsAllowedFileExtension.mockReturnValue(false)
mockUploadRemoteFileInfo.mockResolvedValue({
id: 'remote-1',
mime_type: 'text/plain',
size: 100,
name: 'remote.txt',
url: 'https://example.com/remote.txt',
})
const configWithUndefined = {
...defaultFileConfig,
allowed_file_types: undefined,
allowed_file_extensions: undefined,
} as unknown as FileUpload
const { result } = renderHook(() => useFile(configWithUndefined))
await act(async () => {
result.current.handleLoadFileFromLink('https://example.com/file.txt')
await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled())
})
expect(mockIsAllowedFileExtension).toHaveBeenCalledWith('remote.txt', 'text/plain', [], [])
})
it('should remove file when remote upload fails', async () => {
mockUploadRemoteFileInfo.mockRejectedValue(new Error('network error'))
const { result } = renderHook(() => useFile(defaultFileConfig))
await act(async () => {
result.current.handleLoadFileFromLink('https://example.com/file.txt')
await vi.waitFor(() => expect(mockNotify).toHaveBeenCalled())
})
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
})
it('should remove file when size limit is exceeded on remote upload', async () => {
mockGetSupportFileType.mockReturnValue('image')
mockUploadRemoteFileInfo.mockResolvedValue({
id: 'remote-1',
mime_type: 'image/png',
size: 20 * 1024 * 1024,
name: 'large.png',
url: 'https://example.com/large.png',
})
const { result } = renderHook(() => useFile(defaultFileConfig))
await act(async () => {
result.current.handleLoadFileFromLink('https://example.com/large.png')
await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled())
})
// File should be removed because image exceeds 10MB limit
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
})
it('should update file on successful remote upload within limits', async () => {
mockUploadRemoteFileInfo.mockResolvedValue({
id: 'remote-1',
mime_type: 'text/plain',
size: 100,
name: 'remote.txt',
url: 'https://example.com/remote.txt',
})
const { result } = renderHook(() => useFile(defaultFileConfig))
await act(async () => {
result.current.handleLoadFileFromLink('https://example.com/remote.txt')
await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled())
})
// setFiles should be called: once for add, once for update
expect(mockSetFiles).toHaveBeenCalled()
})
it('should stop progress timer when file reaches 80 percent', () => {
vi.useFakeTimers()
mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {}))
// Set up a file already at 80% progress
mockStoreFiles = [{
id: 'mock-uuid',
name: 'https://example.com/file.txt',
type: '',
size: 0,
progress: 80,
transferMethod: 'remote_url',
supportFileType: '',
}] as FileEntity[]
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLoadFileFromLink('https://example.com/file.txt')
// At progress 80, the timer should stop (clearTimeout path)
vi.advanceTimersByTime(200)
vi.useRealTimers()
})
it('should stop progress timer when progress is negative', () => {
vi.useFakeTimers()
mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {}))
// Set up a file with negative progress (error state)
mockStoreFiles = [{
id: 'mock-uuid',
name: 'https://example.com/file.txt',
type: '',
size: 0,
progress: -1,
transferMethod: 'remote_url',
supportFileType: '',
}] as FileEntity[]
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLoadFileFromLink('https://example.com/file.txt')
vi.advanceTimersByTime(200)
vi.useRealTimers()
})
})
describe('handleLocalFileUpload', () => {
let capturedListeners: Record<string, (() => void)[]>
let mockReaderResult: string | null
beforeEach(() => {
capturedListeners = {}
mockReaderResult = 'data:text/plain;base64,Y29udGVudA=='
class MockFileReader {
result: string | null = null
addEventListener(event: string, handler: () => void) {
if (!capturedListeners[event])
capturedListeners[event] = []
capturedListeners[event].push(handler)
}
readAsDataURL() {
this.result = mockReaderResult
capturedListeners.load?.forEach(handler => handler())
}
}
vi.stubGlobal('FileReader', MockFileReader)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('should upload a local file', () => {
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLocalFileUpload(file)
expect(mockSetFiles).toHaveBeenCalled()
})
it('should reject file with unsupported extension', () => {
mockIsAllowedFileExtension.mockReturnValue(false)
const file = new File(['content'], 'test.xyz', { type: 'application/xyz' })
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLocalFileUpload(file)
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
expect(mockSetFiles).not.toHaveBeenCalled()
})
it('should use empty arrays when allowed_file_types and allowed_file_extensions are undefined', () => {
mockIsAllowedFileExtension.mockReturnValue(false)
const file = new File(['content'], 'test.xyz', { type: 'application/xyz' })
const configWithUndefined = {
...defaultFileConfig,
allowed_file_types: undefined,
allowed_file_extensions: undefined,
} as unknown as FileUpload
const { result } = renderHook(() => useFile(configWithUndefined))
result.current.handleLocalFileUpload(file)
expect(mockIsAllowedFileExtension).toHaveBeenCalledWith('test.xyz', 'application/xyz', [], [])
})
it('should reject file when upload is disabled and noNeedToCheckEnable is false', () => {
const disabledConfig = { ...defaultFileConfig, enabled: false } as FileUpload
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
const { result } = renderHook(() => useFile(disabledConfig, false))
result.current.handleLocalFileUpload(file)
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
})
it('should reject image file exceeding size limit', () => {
mockGetSupportFileType.mockReturnValue('image')
const largeFile = new File([new ArrayBuffer(20 * 1024 * 1024)], 'large.png', { type: 'image/png' })
Object.defineProperty(largeFile, 'size', { value: 20 * 1024 * 1024 })
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLocalFileUpload(largeFile)
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
})
it('should reject audio file exceeding size limit', () => {
mockGetSupportFileType.mockReturnValue('audio')
const largeFile = new File([], 'large.mp3', { type: 'audio/mpeg' })
Object.defineProperty(largeFile, 'size', { value: 60 * 1024 * 1024 })
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLocalFileUpload(largeFile)
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
})
it('should reject video file exceeding size limit', () => {
mockGetSupportFileType.mockReturnValue('video')
const largeFile = new File([], 'large.mp4', { type: 'video/mp4' })
Object.defineProperty(largeFile, 'size', { value: 200 * 1024 * 1024 })
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLocalFileUpload(largeFile)
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
})
it('should reject document file exceeding size limit', () => {
mockGetSupportFileType.mockReturnValue('document')
const largeFile = new File([], 'large.pdf', { type: 'application/pdf' })
Object.defineProperty(largeFile, 'size', { value: 20 * 1024 * 1024 })
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLocalFileUpload(largeFile)
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
})
it('should reject custom file exceeding document size limit', () => {
mockGetSupportFileType.mockReturnValue('custom')
const largeFile = new File([], 'large.xyz', { type: 'application/octet-stream' })
Object.defineProperty(largeFile, 'size', { value: 20 * 1024 * 1024 })
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLocalFileUpload(largeFile)
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
})
it('should allow custom file within document size limit', () => {
mockGetSupportFileType.mockReturnValue('custom')
const file = new File(['content'], 'file.xyz', { type: 'application/octet-stream' })
Object.defineProperty(file, 'size', { value: 1024 })
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLocalFileUpload(file)
expect(mockNotify).not.toHaveBeenCalled()
expect(mockSetFiles).toHaveBeenCalled()
})
it('should allow document file within size limit', () => {
mockGetSupportFileType.mockReturnValue('document')
const file = new File(['content'], 'small.pdf', { type: 'application/pdf' })
Object.defineProperty(file, 'size', { value: 1024 })
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLocalFileUpload(file)
expect(mockNotify).not.toHaveBeenCalled()
expect(mockSetFiles).toHaveBeenCalled()
})
it('should allow file with unknown type (default case)', () => {
mockGetSupportFileType.mockReturnValue('unknown')
const file = new File(['content'], 'test.bin', { type: 'application/octet-stream' })
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLocalFileUpload(file)
// Should not be rejected - unknown type passes checkSizeLimit
expect(mockNotify).not.toHaveBeenCalled()
})
it('should allow image file within size limit', () => {
mockGetSupportFileType.mockReturnValue('image')
const file = new File(['content'], 'small.png', { type: 'image/png' })
Object.defineProperty(file, 'size', { value: 1024 })
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLocalFileUpload(file)
expect(mockNotify).not.toHaveBeenCalled()
expect(mockSetFiles).toHaveBeenCalled()
})
it('should allow audio file within size limit', () => {
mockGetSupportFileType.mockReturnValue('audio')
const file = new File(['content'], 'small.mp3', { type: 'audio/mpeg' })
Object.defineProperty(file, 'size', { value: 1024 })
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLocalFileUpload(file)
expect(mockNotify).not.toHaveBeenCalled()
expect(mockSetFiles).toHaveBeenCalled()
})
it('should allow video file within size limit', () => {
mockGetSupportFileType.mockReturnValue('video')
const file = new File(['content'], 'small.mp4', { type: 'video/mp4' })
Object.defineProperty(file, 'size', { value: 1024 })
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLocalFileUpload(file)
expect(mockNotify).not.toHaveBeenCalled()
expect(mockSetFiles).toHaveBeenCalled()
})
it('should set base64Url for image files during upload', () => {
mockGetSupportFileType.mockReturnValue('image')
const file = new File(['content'], 'photo.png', { type: 'image/png' })
Object.defineProperty(file, 'size', { value: 1024 })
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLocalFileUpload(file)
expect(mockSetFiles).toHaveBeenCalled()
// The file should have been added with base64Url set (for image type)
const addedFiles = mockSetFiles.mock.calls[0][0]
expect(addedFiles[0].base64Url).toBe('data:text/plain;base64,Y29udGVudA==')
})
it('should set empty base64Url for non-image files during upload', () => {
mockGetSupportFileType.mockReturnValue('document')
const file = new File(['content'], 'doc.pdf', { type: 'application/pdf' })
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLocalFileUpload(file)
expect(mockSetFiles).toHaveBeenCalled()
const addedFiles = mockSetFiles.mock.calls[0][0]
expect(addedFiles[0].base64Url).toBe('')
})
it('should call fileUpload with callbacks after FileReader loads', () => {
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLocalFileUpload(file)
expect(mockFileUpload).toHaveBeenCalled()
const uploadCall = mockFileUpload.mock.calls[0][0]
// Test progress callback
uploadCall.onProgressCallback(50)
expect(mockSetFiles).toHaveBeenCalled()
// Test success callback
uploadCall.onSuccessCallback({ id: 'uploaded-1' })
expect(mockSetFiles).toHaveBeenCalled()
})
it('should handle fileUpload error callback', () => {
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLocalFileUpload(file)
const uploadCall = mockFileUpload.mock.calls[0][0]
uploadCall.onErrorCallback(new Error('upload failed'))
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
})
it('should handle FileReader error event', () => {
capturedListeners = {}
const errorListeners: (() => void)[] = []
class ErrorFileReader {
result: string | null = null
addEventListener(event: string, handler: () => void) {
if (event === 'error')
errorListeners.push(handler)
if (!capturedListeners[event])
capturedListeners[event] = []
capturedListeners[event].push(handler)
}
readAsDataURL() {
// Simulate error instead of load
errorListeners.forEach(handler => handler())
}
}
vi.stubGlobal('FileReader', ErrorFileReader)
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
const { result } = renderHook(() => useFile(defaultFileConfig))
result.current.handleLocalFileUpload(file)
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
})
})
describe('handleClipboardPasteFile', () => {
it('should handle file paste from clipboard', () => {
const file = new File(['content'], 'pasted.png', { type: 'image/png' })
const { result } = renderHook(() => useFile(defaultFileConfig))
const event = {
clipboardData: {
files: [file],
getData: () => '',
},
preventDefault: vi.fn(),
} as unknown as React.ClipboardEvent<HTMLTextAreaElement>
result.current.handleClipboardPasteFile(event)
expect(event.preventDefault).toHaveBeenCalled()
})
it('should not handle paste when text is present', () => {
const file = new File(['content'], 'pasted.png', { type: 'image/png' })
const { result } = renderHook(() => useFile(defaultFileConfig))
const event = {
clipboardData: {
files: [file],
getData: () => 'some text',
},
preventDefault: vi.fn(),
} as unknown as React.ClipboardEvent<HTMLTextAreaElement>
result.current.handleClipboardPasteFile(event)
expect(event.preventDefault).not.toHaveBeenCalled()
})
})
describe('drag and drop handlers', () => {
it('should set isDragActive on drag enter', () => {
const { result } = renderHook(() => useFile(defaultFileConfig))
const event = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent<HTMLElement>
act(() => {
result.current.handleDragFileEnter(event)
})
expect(result.current.isDragActive).toBe(true)
})
it('should call preventDefault on drag over', () => {
const { result } = renderHook(() => useFile(defaultFileConfig))
const event = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent<HTMLElement>
result.current.handleDragFileOver(event)
expect(event.preventDefault).toHaveBeenCalled()
})
it('should unset isDragActive on drag leave', () => {
const { result } = renderHook(() => useFile(defaultFileConfig))
const enterEvent = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent<HTMLElement>
act(() => {
result.current.handleDragFileEnter(enterEvent)
})
expect(result.current.isDragActive).toBe(true)
const leaveEvent = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent<HTMLElement>
act(() => {
result.current.handleDragFileLeave(leaveEvent)
})
expect(result.current.isDragActive).toBe(false)
})
it('should handle file drop', () => {
const file = new File(['content'], 'dropped.txt', { type: 'text/plain' })
const { result } = renderHook(() => useFile(defaultFileConfig))
const event = {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: { files: [file] },
} as unknown as React.DragEvent<HTMLElement>
act(() => {
result.current.handleDropFile(event)
})
expect(event.preventDefault).toHaveBeenCalled()
expect(result.current.isDragActive).toBe(false)
})
it('should not upload when no file is dropped', () => {
const { result } = renderHook(() => useFile(defaultFileConfig))
const event = {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer: { files: [] },
} as unknown as React.DragEvent<HTMLElement>
act(() => {
result.current.handleDropFile(event)
})
// No file upload should be triggered
expect(mockSetFiles).not.toHaveBeenCalled()
})
})
describe('noop handlers', () => {
it('should have handleLoadFileFromLinkSuccess as noop', () => {
const { result } = renderHook(() => useFile(defaultFileConfig))
expect(() => result.current.handleLoadFileFromLinkSuccess()).not.toThrow()
})
it('should have handleLoadFileFromLinkError as noop', () => {
const { result } = renderHook(() => useFile(defaultFileConfig))
expect(() => result.current.handleLoadFileFromLinkError()).not.toThrow()
})
})
})

View File

@@ -0,0 +1,142 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import PdfPreview from '../pdf-preview'
vi.mock('../pdf-highlighter-adapter', () => ({
PdfLoader: ({ children, beforeLoad }: { children: (doc: unknown) => ReactNode, beforeLoad: ReactNode }) => (
<div data-testid="pdf-loader">
{beforeLoad}
{children({ numPages: 1 })}
</div>
),
PdfHighlighter: ({ enableAreaSelection, highlightTransform, scrollRef, onScrollChange, onSelectionFinished }: {
enableAreaSelection?: (event: MouseEvent) => boolean
highlightTransform?: () => ReactNode
scrollRef?: (ref: unknown) => void
onScrollChange?: () => void
onSelectionFinished?: () => unknown
}) => {
enableAreaSelection?.(new MouseEvent('click'))
highlightTransform?.()
scrollRef?.(null)
onScrollChange?.()
onSelectionFinished?.()
return <div data-testid="pdf-highlighter" />
},
}))
describe('PdfPreview', () => {
const mockOnCancel = vi.fn()
const getScaleContainer = () => {
const container = document.querySelector('div[style*="transform"]') as HTMLDivElement | null
expect(container).toBeInTheDocument()
return container!
}
const getControl = (rightClass: 'right-24' | 'right-16' | 'right-6') => {
const control = document.querySelector(`div.absolute.${rightClass}.top-6`) as HTMLDivElement | null
expect(control).toBeInTheDocument()
return control!
}
beforeEach(() => {
vi.clearAllMocks()
window.innerWidth = 1024
fireEvent(window, new Event('resize'))
})
it('should render the pdf preview portal with overlay and loading indicator', () => {
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
expect(document.querySelector('[tabindex="-1"]')).toBeInTheDocument()
expect(screen.getByTestId('pdf-loader')).toBeInTheDocument()
expect(screen.getByTestId('pdf-highlighter')).toBeInTheDocument()
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('should render zoom in, zoom out, and close icon SVGs', () => {
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
const svgs = document.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThanOrEqual(3)
})
it('should zoom in when zoom in control is clicked', () => {
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
fireEvent.click(getControl('right-16'))
expect(getScaleContainer().getAttribute('style')).toContain('scale(1.2)')
})
it('should zoom out when zoom out control is clicked', () => {
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
fireEvent.click(getControl('right-24'))
expect(getScaleContainer().getAttribute('style')).toMatch(/scale\(0\.8333/)
})
it('should keep non-1 scale when zooming out from a larger scale', () => {
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
fireEvent.click(getControl('right-16'))
fireEvent.click(getControl('right-16'))
fireEvent.click(getControl('right-24'))
expect(getScaleContainer().getAttribute('style')).toContain('scale(1.2)')
})
it('should reset scale back to 1 when zooming in then out', () => {
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
fireEvent.click(getControl('right-16'))
fireEvent.click(getControl('right-24'))
expect(getScaleContainer().getAttribute('style')).toContain('scale(1)')
})
it('should zoom in when ArrowUp key is pressed', () => {
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
fireEvent.keyDown(document, { key: 'ArrowUp', code: 'ArrowUp' })
expect(getScaleContainer().getAttribute('style')).toContain('scale(1.2)')
})
it('should zoom out when ArrowDown key is pressed', () => {
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
fireEvent.keyDown(document, { key: 'ArrowDown', code: 'ArrowDown' })
expect(getScaleContainer().getAttribute('style')).toMatch(/scale\(0\.8333/)
})
it('should call onCancel when close control is clicked', () => {
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
fireEvent.click(getControl('right-6'))
expect(mockOnCancel).toHaveBeenCalled()
})
it('should call onCancel when Escape key is pressed', () => {
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
expect(mockOnCancel).toHaveBeenCalled()
})
it('should render the overlay and stop click propagation', () => {
render(<PdfPreview url="https://example.com/doc.pdf" onCancel={mockOnCancel} />)
const overlay = document.querySelector('[tabindex="-1"]')
expect(overlay).toBeInTheDocument()
const event = new MouseEvent('click', { bubbles: true })
const stopPropagation = vi.spyOn(event, 'stopPropagation')
overlay!.dispatchEvent(event)
expect(stopPropagation).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,168 @@
import type { FileEntity } from '../types'
import { render, renderHook, screen } from '@testing-library/react'
import { TransferMethod } from '@/types/app'
import { createFileStore, FileContext, FileContextProvider, useFileStore, useStore } from '../store'
const createMockFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
id: 'file-1',
name: 'test.txt',
size: 1024,
type: 'text/plain',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'document',
...overrides,
})
describe('createFileStore', () => {
it('should create a store with empty files by default', () => {
const store = createFileStore()
expect(store.getState().files).toEqual([])
})
it('should create a store with empty array when value is falsy', () => {
const store = createFileStore(undefined)
expect(store.getState().files).toEqual([])
})
it('should create a store with initial files', () => {
const files = [createMockFile()]
const store = createFileStore(files)
expect(store.getState().files).toEqual(files)
})
it('should spread initial value to create a new array', () => {
const files = [createMockFile()]
const store = createFileStore(files)
expect(store.getState().files).not.toBe(files)
expect(store.getState().files).toEqual(files)
})
it('should update files via setFiles', () => {
const store = createFileStore()
const newFiles = [createMockFile()]
store.getState().setFiles(newFiles)
expect(store.getState().files).toEqual(newFiles)
})
it('should call onChange when setFiles is called', () => {
const onChange = vi.fn()
const store = createFileStore([], onChange)
const newFiles = [createMockFile()]
store.getState().setFiles(newFiles)
expect(onChange).toHaveBeenCalledWith(newFiles)
})
it('should not throw when onChange is not provided', () => {
const store = createFileStore()
expect(() => store.getState().setFiles([])).not.toThrow()
})
})
describe('useStore', () => {
it('should return selected state from the store', () => {
const files = [createMockFile()]
const store = createFileStore(files)
const { result } = renderHook(() => useStore(s => s.files), {
wrapper: ({ children }) => (
<FileContext.Provider value={store}>{children}</FileContext.Provider>
),
})
expect(result.current).toEqual(files)
})
it('should throw when used without FileContext.Provider', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => {
renderHook(() => useStore(s => s.files))
}).toThrow('Missing FileContext.Provider in the tree')
consoleError.mockRestore()
})
})
describe('useFileStore', () => {
it('should return the store from context', () => {
const store = createFileStore()
const { result } = renderHook(() => useFileStore(), {
wrapper: ({ children }) => (
<FileContext.Provider value={store}>{children}</FileContext.Provider>
),
})
expect(result.current).toBe(store)
})
})
describe('FileContextProvider', () => {
it('should render children', () => {
render(
<FileContextProvider>
<div data-testid="child">Hello</div>
</FileContextProvider>,
)
expect(screen.getByTestId('child')).toBeInTheDocument()
})
it('should provide a store to children', () => {
const TestChild = () => {
const files = useStore(s => s.files)
return <div data-testid="files">{files.length}</div>
}
render(
<FileContextProvider>
<TestChild />
</FileContextProvider>,
)
expect(screen.getByTestId('files')).toHaveTextContent('0')
})
it('should initialize store with value prop', () => {
const files = [createMockFile()]
const TestChild = () => {
const storeFiles = useStore(s => s.files)
return <div data-testid="files">{storeFiles.length}</div>
}
render(
<FileContextProvider value={files}>
<TestChild />
</FileContextProvider>,
)
expect(screen.getByTestId('files')).toHaveTextContent('1')
})
it('should reuse store on re-render instead of creating a new one', () => {
const TestChild = () => {
const storeFiles = useStore(s => s.files)
return <div data-testid="files">{storeFiles.length}</div>
}
const { rerender } = render(
<FileContextProvider>
<TestChild />
</FileContextProvider>,
)
expect(screen.getByTestId('files')).toHaveTextContent('0')
// Re-render with new value prop - store should be reused (storeRef.current exists)
rerender(
<FileContextProvider value={[createMockFile()]}>
<TestChild />
</FileContextProvider>,
)
// Store was created once on first render, so the value prop change won't create a new store
// The files count should still be 0 since storeRef.current is already set
expect(screen.getByTestId('files')).toHaveTextContent('0')
})
})

View File

@@ -0,0 +1,879 @@
import type { FileEntity } from '../types'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { upload } from '@/service/base'
import { TransferMethod } from '@/types/app'
import { FILE_EXTS } from '../../prompt-editor/constants'
import { FileAppearanceTypeEnum } from '../types'
import {
fileIsUploaded,
fileUpload,
getFileAppearanceType,
getFileExtension,
getFileNameFromUrl,
getFilesInLogs,
getFileUploadErrorMessage,
getProcessedFiles,
getProcessedFilesFromResponse,
getSupportFileExtensionList,
getSupportFileType,
isAllowedFileExtension,
} from '../utils'
vi.mock('@/service/base', () => ({
upload: vi.fn(),
}))
describe('file-uploader utils', () => {
beforeEach(() => {
vi.resetAllMocks()
})
describe('getFileUploadErrorMessage', () => {
const createMockT = () => vi.fn().mockImplementation((key: string) => key) as unknown as import('i18next').TFunction
it('should return forbidden message when error code is forbidden', () => {
const error = { response: { code: 'forbidden', message: 'Access denied' } }
expect(getFileUploadErrorMessage(error, 'default', createMockT())).toBe('Access denied')
})
it('should return file_extension_blocked translation when error code matches', () => {
const error = { response: { code: 'file_extension_blocked' } }
expect(getFileUploadErrorMessage(error, 'default', createMockT())).toBe('fileUploader.fileExtensionBlocked')
})
it('should return default message for other errors', () => {
const error = { response: { code: 'unknown_error' } }
expect(getFileUploadErrorMessage(error, 'Upload failed', createMockT())).toBe('Upload failed')
})
it('should return default message when error has no response', () => {
expect(getFileUploadErrorMessage(null, 'Upload failed', createMockT())).toBe('Upload failed')
})
})
describe('fileUpload', () => {
it('should handle successful file upload', async () => {
const mockFile = new File(['test'], 'test.txt')
const mockCallbacks = {
onProgressCallback: vi.fn(),
onSuccessCallback: vi.fn(),
onErrorCallback: vi.fn(),
}
vi.mocked(upload).mockResolvedValue({ id: '123' })
fileUpload({
file: mockFile,
...mockCallbacks,
})
expect(upload).toHaveBeenCalled()
// Wait for the promise to resolve and call onSuccessCallback
await vi.waitFor(() => {
expect(mockCallbacks.onSuccessCallback).toHaveBeenCalledWith({ id: '123' })
})
})
it('should call onErrorCallback when upload fails', async () => {
const mockFile = new File(['test'], 'test.txt')
const mockCallbacks = {
onProgressCallback: vi.fn(),
onSuccessCallback: vi.fn(),
onErrorCallback: vi.fn(),
}
const uploadError = new Error('Upload failed')
vi.mocked(upload).mockRejectedValue(uploadError)
fileUpload({
file: mockFile,
...mockCallbacks,
})
await vi.waitFor(() => {
expect(mockCallbacks.onErrorCallback).toHaveBeenCalledWith(uploadError)
})
})
it('should call onProgressCallback when progress event is computable', () => {
const mockFile = new File(['test'], 'test.txt')
const mockCallbacks = {
onProgressCallback: vi.fn(),
onSuccessCallback: vi.fn(),
onErrorCallback: vi.fn(),
}
vi.mocked(upload).mockImplementation(({ onprogress }) => {
// Simulate a progress event
if (onprogress)
onprogress.call({} as XMLHttpRequest, { lengthComputable: true, loaded: 50, total: 100 } as ProgressEvent)
return Promise.resolve({ id: '123' })
})
fileUpload({
file: mockFile,
...mockCallbacks,
})
expect(mockCallbacks.onProgressCallback).toHaveBeenCalledWith(50)
})
it('should not call onProgressCallback when progress event is not computable', () => {
const mockFile = new File(['test'], 'test.txt')
const mockCallbacks = {
onProgressCallback: vi.fn(),
onSuccessCallback: vi.fn(),
onErrorCallback: vi.fn(),
}
vi.mocked(upload).mockImplementation(({ onprogress }) => {
if (onprogress)
onprogress.call({} as XMLHttpRequest, { lengthComputable: false, loaded: 0, total: 0 } as ProgressEvent)
return Promise.resolve({ id: '123' })
})
fileUpload({
file: mockFile,
...mockCallbacks,
})
expect(mockCallbacks.onProgressCallback).not.toHaveBeenCalled()
})
})
describe('getFileExtension', () => {
it('should get extension from mimetype', () => {
expect(getFileExtension('file', 'application/pdf')).toBe('pdf')
})
it('should get extension from mimetype and file name', () => {
expect(getFileExtension('file.pdf', 'application/pdf')).toBe('pdf')
})
it('should get extension from mimetype with multiple ext candidates with filename hint', () => {
expect(getFileExtension('file.pem', 'application/x-x509-ca-cert')).toBe('pem')
})
it('should get extension from mimetype with multiple ext candidates without filename hint', () => {
const ext = getFileExtension('file', 'application/x-x509-ca-cert')
// mime returns Set(['der', 'crt', 'pem']), first value is used when no filename hint
expect(['der', 'crt', 'pem']).toContain(ext)
})
it('should get extension from filename when mimetype is empty', () => {
expect(getFileExtension('file.txt', '')).toBe('txt')
expect(getFileExtension('file.txt.docx', '')).toBe('docx')
expect(getFileExtension('file', '')).toBe('')
})
it('should return empty string for remote files', () => {
expect(getFileExtension('file.txt', '', true)).toBe('')
})
it('should fall back to filename extension for unknown mimetype', () => {
expect(getFileExtension('file.txt', 'application/unknown')).toBe('txt')
})
it('should return empty string for unknown mimetype without filename extension', () => {
expect(getFileExtension('file', 'application/unknown')).toBe('')
})
})
describe('getFileAppearanceType', () => {
it('should identify gif files', () => {
expect(getFileAppearanceType('image.gif', 'image/gif'))
.toBe(FileAppearanceTypeEnum.gif)
})
it('should identify image files', () => {
expect(getFileAppearanceType('image.jpg', 'image/jpeg'))
.toBe(FileAppearanceTypeEnum.image)
expect(getFileAppearanceType('image.jpeg', 'image/jpeg'))
.toBe(FileAppearanceTypeEnum.image)
expect(getFileAppearanceType('image.png', 'image/png'))
.toBe(FileAppearanceTypeEnum.image)
expect(getFileAppearanceType('image.webp', 'image/webp'))
.toBe(FileAppearanceTypeEnum.image)
expect(getFileAppearanceType('image.svg', 'image/svg+xml'))
.toBe(FileAppearanceTypeEnum.image)
})
it('should identify video files', () => {
expect(getFileAppearanceType('video.mp4', 'video/mp4'))
.toBe(FileAppearanceTypeEnum.video)
expect(getFileAppearanceType('video.mov', 'video/quicktime'))
.toBe(FileAppearanceTypeEnum.video)
expect(getFileAppearanceType('video.mpeg', 'video/mpeg'))
.toBe(FileAppearanceTypeEnum.video)
expect(getFileAppearanceType('video.webm', 'video/webm'))
.toBe(FileAppearanceTypeEnum.video)
})
it('should identify audio files', () => {
expect(getFileAppearanceType('audio.mp3', 'audio/mpeg'))
.toBe(FileAppearanceTypeEnum.audio)
expect(getFileAppearanceType('audio.m4a', 'audio/mp4'))
.toBe(FileAppearanceTypeEnum.audio)
expect(getFileAppearanceType('audio.wav', 'audio/wav'))
.toBe(FileAppearanceTypeEnum.audio)
expect(getFileAppearanceType('audio.amr', 'audio/AMR'))
.toBe(FileAppearanceTypeEnum.audio)
expect(getFileAppearanceType('audio.mpga', 'audio/mpeg'))
.toBe(FileAppearanceTypeEnum.audio)
})
it('should identify code files', () => {
expect(getFileAppearanceType('index.html', 'text/html'))
.toBe(FileAppearanceTypeEnum.code)
})
it('should identify PDF files', () => {
expect(getFileAppearanceType('doc.pdf', 'application/pdf'))
.toBe(FileAppearanceTypeEnum.pdf)
})
it('should identify markdown files', () => {
expect(getFileAppearanceType('file.md', 'text/markdown'))
.toBe(FileAppearanceTypeEnum.markdown)
expect(getFileAppearanceType('file.markdown', 'text/markdown'))
.toBe(FileAppearanceTypeEnum.markdown)
expect(getFileAppearanceType('file.mdx', 'text/mdx'))
.toBe(FileAppearanceTypeEnum.markdown)
})
it('should identify excel files', () => {
expect(getFileAppearanceType('doc.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'))
.toBe(FileAppearanceTypeEnum.excel)
expect(getFileAppearanceType('doc.xls', 'application/vnd.ms-excel'))
.toBe(FileAppearanceTypeEnum.excel)
})
it('should identify word files', () => {
expect(getFileAppearanceType('doc.doc', 'application/msword'))
.toBe(FileAppearanceTypeEnum.word)
expect(getFileAppearanceType('doc.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'))
.toBe(FileAppearanceTypeEnum.word)
})
it('should identify ppt files', () => {
expect(getFileAppearanceType('doc.ppt', 'application/vnd.ms-powerpoint'))
.toBe(FileAppearanceTypeEnum.ppt)
expect(getFileAppearanceType('doc.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'))
.toBe(FileAppearanceTypeEnum.ppt)
})
it('should identify document files', () => {
expect(getFileAppearanceType('file.txt', 'text/plain'))
.toBe(FileAppearanceTypeEnum.document)
expect(getFileAppearanceType('file.csv', 'text/csv'))
.toBe(FileAppearanceTypeEnum.document)
expect(getFileAppearanceType('file.msg', 'application/vnd.ms-outlook'))
.toBe(FileAppearanceTypeEnum.document)
expect(getFileAppearanceType('file.eml', 'message/rfc822'))
.toBe(FileAppearanceTypeEnum.document)
expect(getFileAppearanceType('file.xml', 'application/xml'))
.toBe(FileAppearanceTypeEnum.document)
expect(getFileAppearanceType('file.epub', 'application/epub+zip'))
.toBe(FileAppearanceTypeEnum.document)
})
it('should fall back to filename extension for unknown mimetype', () => {
expect(getFileAppearanceType('file.txt', 'application/unknown'))
.toBe(FileAppearanceTypeEnum.document)
})
it('should return custom type for unrecognized extensions', () => {
expect(getFileAppearanceType('file.xyz', 'application/xyz'))
.toBe(FileAppearanceTypeEnum.custom)
})
})
describe('getSupportFileType', () => {
it('should return custom type when isCustom is true', () => {
expect(getSupportFileType('file.txt', '', true))
.toBe(SupportUploadFileTypes.custom)
})
it('should return file type when isCustom is false', () => {
expect(getSupportFileType('file.txt', 'text/plain'))
.toBe(SupportUploadFileTypes.document)
})
})
describe('getProcessedFiles', () => {
it('should process files correctly', () => {
const files = [{
id: '123',
name: 'test.txt',
size: 1024,
type: 'text/plain',
progress: 100,
supportFileType: 'document',
transferMethod: TransferMethod.remote_url,
url: 'http://example.com',
uploadedId: '123',
}]
const result = getProcessedFiles(files)
expect(result[0]).toEqual({
type: 'document',
transfer_method: TransferMethod.remote_url,
url: 'http://example.com',
upload_file_id: '123',
})
})
it('should fallback to empty string when url is missing', () => {
const files = [{
id: '123',
name: 'test.txt',
size: 1024,
type: 'text/plain',
progress: 100,
supportFileType: 'document',
transferMethod: TransferMethod.local_file,
url: undefined,
uploadedId: '123',
}] as unknown as FileEntity[]
const result = getProcessedFiles(files)
expect(result[0].url).toBe('')
})
it('should fallback to empty string when uploadedId is missing', () => {
const files = [{
id: '123',
name: 'test.txt',
size: 1024,
type: 'text/plain',
progress: 100,
supportFileType: 'document',
transferMethod: TransferMethod.local_file,
url: 'http://example.com',
uploadedId: undefined,
}] as unknown as FileEntity[]
const result = getProcessedFiles(files)
expect(result[0].upload_file_id).toBe('')
})
it('should filter out files with progress -1', () => {
const files = [
{
id: '1',
name: 'good.txt',
progress: 100,
supportFileType: 'document',
transferMethod: TransferMethod.local_file,
url: 'http://example.com',
uploadedId: '1',
},
{
id: '2',
name: 'bad.txt',
progress: -1,
supportFileType: 'document',
transferMethod: TransferMethod.local_file,
url: 'http://example.com',
uploadedId: '2',
},
] as unknown as FileEntity[]
const result = getProcessedFiles(files)
expect(result).toHaveLength(1)
expect(result[0].upload_file_id).toBe('1')
})
})
describe('getProcessedFilesFromResponse', () => {
it('should process files correctly without type correction', () => {
const files = [{
related_id: '2a38e2ca-1295-415d-a51d-65d4ff9912d9',
extension: '.jpeg',
filename: 'test.jpeg',
size: 2881761,
mime_type: 'image/jpeg',
transfer_method: TransferMethod.local_file,
type: 'image',
url: 'https://upload.dify.dev/files/xxx/file-preview',
upload_file_id: '2a38e2ca-1295-415d-a51d-65d4ff9912d9',
remote_url: '',
}]
const result = getProcessedFilesFromResponse(files)
expect(result[0]).toEqual({
id: '2a38e2ca-1295-415d-a51d-65d4ff9912d9',
name: 'test.jpeg',
size: 2881761,
type: 'image/jpeg',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'image',
uploadedId: '2a38e2ca-1295-415d-a51d-65d4ff9912d9',
url: 'https://upload.dify.dev/files/xxx/file-preview',
})
})
it('should correct image file misclassified as document', () => {
const files = [{
related_id: '123',
extension: '.jpg',
filename: 'image.jpg',
size: 1024,
mime_type: 'image/jpeg',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/image.jpg',
upload_file_id: '123',
remote_url: '',
}]
const result = getProcessedFilesFromResponse(files)
expect(result[0].supportFileType).toBe('image')
})
it('should correct video file misclassified as document', () => {
const files = [{
related_id: '123',
extension: '.mp4',
filename: 'video.mp4',
size: 1024,
mime_type: 'video/mp4',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/video.mp4',
upload_file_id: '123',
remote_url: '',
}]
const result = getProcessedFilesFromResponse(files)
expect(result[0].supportFileType).toBe('video')
})
it('should correct audio file misclassified as document', () => {
const files = [{
related_id: '123',
extension: '.mp3',
filename: 'audio.mp3',
size: 1024,
mime_type: 'audio/mpeg',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/audio.mp3',
upload_file_id: '123',
remote_url: '',
}]
const result = getProcessedFilesFromResponse(files)
expect(result[0].supportFileType).toBe('audio')
})
it('should correct document file misclassified as image', () => {
const files = [{
related_id: '123',
extension: '.pdf',
filename: 'document.pdf',
size: 1024,
mime_type: 'application/pdf',
transfer_method: TransferMethod.local_file,
type: 'image',
url: 'https://example.com/document.pdf',
upload_file_id: '123',
remote_url: '',
}]
const result = getProcessedFilesFromResponse(files)
expect(result[0].supportFileType).toBe('document')
})
it('should NOT correct when filename and MIME type conflict', () => {
const files = [{
related_id: '123',
extension: '.pdf',
filename: 'document.pdf',
size: 1024,
mime_type: 'image/jpeg',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/document.pdf',
upload_file_id: '123',
remote_url: '',
}]
const result = getProcessedFilesFromResponse(files)
expect(result[0].supportFileType).toBe('document')
})
it('should NOT correct when filename and MIME type both point to same type', () => {
const files = [{
related_id: '123',
extension: '.jpg',
filename: 'image.jpg',
size: 1024,
mime_type: 'image/jpeg',
transfer_method: TransferMethod.local_file,
type: 'image',
url: 'https://example.com/image.jpg',
upload_file_id: '123',
remote_url: '',
}]
const result = getProcessedFilesFromResponse(files)
expect(result[0].supportFileType).toBe('image')
})
it('should handle files with missing filename', () => {
const files = [{
related_id: '123',
extension: '',
filename: '',
size: 1024,
mime_type: 'image/jpeg',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/file',
upload_file_id: '123',
remote_url: '',
}]
const result = getProcessedFilesFromResponse(files)
expect(result[0].supportFileType).toBe('document')
})
it('should handle files with missing MIME type', () => {
const files = [{
related_id: '123',
extension: '.jpg',
filename: 'image.jpg',
size: 1024,
mime_type: '',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/image.jpg',
upload_file_id: '123',
remote_url: '',
}]
const result = getProcessedFilesFromResponse(files)
expect(result[0].supportFileType).toBe('document')
})
it('should handle files with unknown extensions', () => {
const files = [{
related_id: '123',
extension: '.unknown',
filename: 'file.unknown',
size: 1024,
mime_type: 'application/unknown',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/file.unknown',
upload_file_id: '123',
remote_url: '',
}]
const result = getProcessedFilesFromResponse(files)
expect(result[0].supportFileType).toBe('document')
})
it('should handle multiple different file types correctly', () => {
const files = [
{
related_id: '1',
extension: '.jpg',
filename: 'correct-image.jpg',
mime_type: 'image/jpeg',
type: 'image',
size: 1024,
transfer_method: TransferMethod.local_file,
url: 'https://example.com/correct-image.jpg',
upload_file_id: '1',
remote_url: '',
},
{
related_id: '2',
extension: '.png',
filename: 'misclassified-image.png',
mime_type: 'image/png',
type: 'document',
size: 2048,
transfer_method: TransferMethod.local_file,
url: 'https://example.com/misclassified-image.png',
upload_file_id: '2',
remote_url: '',
},
{
related_id: '3',
extension: '.pdf',
filename: 'conflicted.pdf',
mime_type: 'image/jpeg',
type: 'document',
size: 3072,
transfer_method: TransferMethod.local_file,
url: 'https://example.com/conflicted.pdf',
upload_file_id: '3',
remote_url: '',
},
]
const result = getProcessedFilesFromResponse(files)
expect(result[0].supportFileType).toBe('image') // correct, no change
expect(result[1].supportFileType).toBe('image') // corrected from document to image
expect(result[2].supportFileType).toBe('document') // conflict, no change
})
})
describe('getFileNameFromUrl', () => {
it('should extract filename from URL', () => {
expect(getFileNameFromUrl('http://example.com/path/file.txt'))
.toBe('file.txt')
})
it('should return empty string for URL ending with slash', () => {
expect(getFileNameFromUrl('http://example.com/path/'))
.toBe('')
})
})
describe('getSupportFileExtensionList', () => {
it('should handle custom file types', () => {
const result = getSupportFileExtensionList(
[SupportUploadFileTypes.custom],
['.pdf', '.txt', '.doc'],
)
expect(result).toEqual(['PDF', 'TXT', 'DOC'])
})
it('should handle standard file types', () => {
const mockFileExts = {
image: ['JPG', 'PNG'],
document: ['PDF', 'TXT'],
video: ['MP4', 'MOV'],
}
// Temporarily mock FILE_EXTS
const originalFileExts = { ...FILE_EXTS }
Object.assign(FILE_EXTS, mockFileExts)
const result = getSupportFileExtensionList(
['image', 'document'],
[],
)
expect(result).toEqual(['JPG', 'PNG', 'PDF', 'TXT'])
// Restore original FILE_EXTS
Object.assign(FILE_EXTS, originalFileExts)
})
it('should return empty array for empty inputs', () => {
const result = getSupportFileExtensionList([], [])
expect(result).toEqual([])
})
it('should prioritize custom types over standard types', () => {
const mockFileExts = {
image: ['JPG', 'PNG'],
}
// Temporarily mock FILE_EXTS
const originalFileExts = { ...FILE_EXTS }
Object.assign(FILE_EXTS, mockFileExts)
const result = getSupportFileExtensionList(
[SupportUploadFileTypes.custom, 'image'],
['.csv', '.xml'],
)
expect(result).toEqual(['CSV', 'XML'])
// Restore original FILE_EXTS
Object.assign(FILE_EXTS, originalFileExts)
})
})
describe('isAllowedFileExtension', () => {
it('should validate allowed file extensions', () => {
expect(isAllowedFileExtension(
'test.pdf',
'application/pdf',
['document'],
['.pdf'],
)).toBe(true)
})
})
describe('getFilesInLogs', () => {
const mockFileData = {
dify_model_identity: '__dify__file__',
related_id: '123',
filename: 'test.pdf',
size: 1024,
mime_type: 'application/pdf',
transfer_method: 'local_file',
type: 'document',
url: 'http://example.com/test.pdf',
}
it('should handle empty or null input', () => {
expect(getFilesInLogs(null)).toEqual([])
expect(getFilesInLogs({})).toEqual([])
expect(getFilesInLogs(undefined)).toEqual([])
})
it('should process single file object', () => {
const input = {
file1: mockFileData,
}
const expected = [{
varName: 'file1',
list: [{
id: '123',
name: 'test.pdf',
size: 1024,
type: 'application/pdf',
progress: 100,
transferMethod: 'local_file',
supportFileType: 'document',
uploadedId: '123',
url: 'http://example.com/test.pdf',
}],
}]
expect(getFilesInLogs(input)).toEqual(expected)
})
it('should process array of files', () => {
const input = {
files: [mockFileData, mockFileData],
}
const expected = [{
varName: 'files',
list: [
{
id: '123',
name: 'test.pdf',
size: 1024,
type: 'application/pdf',
progress: 100,
transferMethod: 'local_file',
supportFileType: 'document',
uploadedId: '123',
url: 'http://example.com/test.pdf',
},
{
id: '123',
name: 'test.pdf',
size: 1024,
type: 'application/pdf',
progress: 100,
transferMethod: 'local_file',
supportFileType: 'document',
uploadedId: '123',
url: 'http://example.com/test.pdf',
},
],
}]
expect(getFilesInLogs(input)).toEqual(expected)
})
it('should ignore non-file objects and arrays', () => {
const input = {
regularString: 'not a file',
regularNumber: 123,
regularArray: [1, 2, 3],
regularObject: { key: 'value' },
file: mockFileData,
}
const expected = [{
varName: 'file',
list: [{
id: '123',
name: 'test.pdf',
size: 1024,
type: 'application/pdf',
progress: 100,
transferMethod: 'local_file',
supportFileType: 'document',
uploadedId: '123',
url: 'http://example.com/test.pdf',
}],
}]
expect(getFilesInLogs(input)).toEqual(expected)
})
it('should handle mixed file types in array', () => {
const input = {
mixedFiles: [
mockFileData,
{ notAFile: true },
mockFileData,
],
}
const expected = [{
varName: 'mixedFiles',
list: [
{
id: '123',
name: 'test.pdf',
size: 1024,
type: 'application/pdf',
progress: 100,
transferMethod: 'local_file',
supportFileType: 'document',
uploadedId: '123',
url: 'http://example.com/test.pdf',
},
{
id: undefined,
name: undefined,
progress: 100,
size: 0,
supportFileType: undefined,
transferMethod: undefined,
type: undefined,
uploadedId: undefined,
url: undefined,
},
{
id: '123',
name: 'test.pdf',
size: 1024,
type: 'application/pdf',
progress: 100,
transferMethod: 'local_file',
supportFileType: 'document',
uploadedId: '123',
url: 'http://example.com/test.pdf',
},
],
}]
expect(getFilesInLogs(input)).toEqual(expected)
})
})
describe('fileIsUploaded', () => {
it('should identify uploaded files', () => {
expect(fileIsUploaded({
uploadedId: '123',
progress: 100,
} as Partial<FileEntity> as FileEntity)).toBe(true)
})
it('should identify remote files as uploaded', () => {
expect(fileIsUploaded({
transferMethod: TransferMethod.remote_url,
progress: 100,
} as Partial<FileEntity> as FileEntity)).toBe(true)
})
})
})

View File

@@ -0,0 +1,69 @@
import { fireEvent, render } from '@testing-library/react'
import VideoPreview from '../video-preview'
describe('VideoPreview', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render video element with correct title', () => {
render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />)
const video = document.querySelector('video')
expect(video).toBeInTheDocument()
expect(video).toHaveAttribute('title', 'Test Video')
})
it('should render source element with correct src and type', () => {
render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />)
const source = document.querySelector('source')
expect(source).toHaveAttribute('src', 'https://example.com/video.mp4')
expect(source).toHaveAttribute('type', 'video/mp4')
})
it('should render close button with icon', () => {
const { getByTestId } = render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />)
const closeIcon = getByTestId('video-preview-close-btn')
expect(closeIcon).toBeInTheDocument()
})
it('should call onCancel when close button is clicked', () => {
const onCancel = vi.fn()
const { getByTestId } = render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={onCancel} />)
const closeIcon = getByTestId('video-preview-close-btn')
fireEvent.click(closeIcon.parentElement!)
expect(onCancel).toHaveBeenCalled()
})
it('should stop propagation when backdrop is clicked', () => {
const { baseElement } = render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />)
const backdrop = baseElement.querySelector('[tabindex="-1"]')
const event = new MouseEvent('click', { bubbles: true })
const stopPropagation = vi.spyOn(event, 'stopPropagation')
backdrop!.dispatchEvent(event)
expect(stopPropagation).toHaveBeenCalled()
})
it('should call onCancel when Escape key is pressed', () => {
const onCancel = vi.fn()
render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={onCancel} />)
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' })
expect(onCancel).toHaveBeenCalled()
})
it('should render in a portal attached to document.body', () => {
render(<VideoPreview url="https://example.com/video.mp4" title="Test Video" onCancel={vi.fn()} />)
const video = document.querySelector('video')
expect(video?.closest('[tabindex="-1"]')?.parentElement).toBe(document.body)
})
})