mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 20:09:20 +08:00
test: add unit tests for base components (#32818)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
867
web/app/components/base/file-uploader/__tests__/hooks.spec.ts
Normal file
867
web/app/components/base/file-uploader/__tests__/hooks.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
168
web/app/components/base/file-uploader/__tests__/store.spec.tsx
Normal file
168
web/app/components/base/file-uploader/__tests__/store.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
879
web/app/components/base/file-uploader/__tests__/utils.spec.ts
Normal file
879
web/app/components/base/file-uploader/__tests__/utils.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user