test: add tests for file-upload components (#32373)

Co-authored-by: sahil <sahil@infocusp.com>
This commit is contained in:
Saumya Talwani
2026-02-24 13:46:06 +05:30
committed by GitHub
parent a040b9428d
commit 9819f7d69c
25 changed files with 3680 additions and 127 deletions

View File

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

View File

@@ -1,5 +1,4 @@
import type { FC } from 'react'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { createPortal } from 'react-dom'
@@ -36,7 +35,7 @@ const AudioPreview: FC<AudioPreviewProps> = ({
className="absolute right-6 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]"
onClick={onCancel}
>
<RiCloseLine className="h-4 w-4 text-gray-500" />
<span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="close-btn" />
</div>
</div>,
document.body,

View File

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

View File

@@ -0,0 +1,173 @@
import type { FileEntity } from '../types'
import type { FileUpload } from '@/app/components/base/features/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { FileContextProvider } from '../store'
import FileFromLinkOrLocal from './index'
let mockFiles: FileEntity[] = []
function createStubFile(id: string): FileEntity {
return { id, name: `${id}.txt`, size: 0, type: '', progress: 100, transferMethod: 'local_file' as FileEntity['transferMethod'], supportFileType: 'document' }
}
const mockHandleLoadFileFromLink = vi.fn()
vi.mock('../hooks', () => ({
useFile: () => ({
handleLoadFileFromLink: mockHandleLoadFileFromLink,
}),
}))
const createFileConfig = (overrides: Partial<FileUpload> = {}): FileUpload => ({
enabled: true,
allowed_file_types: ['image'],
allowed_file_extensions: [],
number_limits: 5,
...overrides,
} as FileUpload)
function renderAndOpen(props: Partial<React.ComponentProps<typeof FileFromLinkOrLocal>> = {}) {
const trigger = props.trigger ?? ((open: boolean) => <button data-testid="trigger">{open ? 'Close' : 'Open'}</button>)
const result = render(
<FileContextProvider value={mockFiles}>
<FileFromLinkOrLocal
trigger={trigger}
fileConfig={props.fileConfig ?? createFileConfig()}
{...props}
/>
</FileContextProvider>,
)
fireEvent.click(screen.getByTestId('trigger'))
return result
}
describe('FileFromLinkOrLocal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFiles = []
})
it('should render trigger element', () => {
const trigger = (open: boolean) => (
<button data-testid="trigger">
Open
{open ? 'close' : 'open'}
</button>
)
render(
<FileContextProvider value={mockFiles}>
<FileFromLinkOrLocal trigger={trigger} fileConfig={createFileConfig()} />
</FileContextProvider>,
)
expect(screen.getByTestId('trigger')).toBeInTheDocument()
})
it('should render URL input when showFromLink is true', () => {
renderAndOpen({ showFromLink: true })
expect(screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/)).toBeInTheDocument()
})
it('should render upload button when showFromLocal is true', () => {
renderAndOpen({ showFromLocal: true })
expect(screen.getByText(/fileUploader\.uploadFromComputer/)).toBeInTheDocument()
})
it('should render OR divider when both link and local are shown', () => {
renderAndOpen({ showFromLink: true, showFromLocal: true })
expect(screen.getByText('OR')).toBeInTheDocument()
})
it('should not render OR divider when only link is shown', () => {
renderAndOpen({ showFromLink: true, showFromLocal: false })
expect(screen.queryByText('OR')).not.toBeInTheDocument()
})
it('should show error when invalid URL is submitted', () => {
renderAndOpen({ showFromLink: true })
const input = screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/)
fireEvent.change(input, { target: { value: 'invalid-url' } })
const okButton = screen.getByText(/operation\.ok/)
fireEvent.click(okButton)
expect(screen.getByText(/fileUploader\.pasteFileLinkInvalid/)).toBeInTheDocument()
})
it('should clear error when input changes', () => {
renderAndOpen({ showFromLink: true })
const input = screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/)
fireEvent.change(input, { target: { value: 'invalid-url' } })
fireEvent.click(screen.getByText(/operation\.ok/))
expect(screen.getByText(/fileUploader\.pasteFileLinkInvalid/)).toBeInTheDocument()
fireEvent.change(input, { target: { value: 'https://example.com' } })
expect(screen.queryByText(/fileUploader\.pasteFileLinkInvalid/)).not.toBeInTheDocument()
})
it('should disable ok button when url is empty', () => {
renderAndOpen({ showFromLink: true })
const okButton = screen.getByText(/operation\.ok/)
expect(okButton.closest('button')).toBeDisabled()
})
it('should disable inputs when file limit is reached', () => {
mockFiles = ['1', '2', '3', '4', '5'].map(createStubFile)
renderAndOpen({ fileConfig: createFileConfig({ number_limits: 5 }), showFromLink: true, showFromLocal: true })
const input = screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/)
expect(input).toBeDisabled()
})
it('should not submit when url is empty', () => {
renderAndOpen({ showFromLink: true })
const okButton = screen.getByText(/operation\.ok/)
fireEvent.click(okButton)
expect(screen.queryByText(/fileUploader\.pasteFileLinkInvalid/)).not.toBeInTheDocument()
})
it('should call handleLoadFileFromLink when valid URL is submitted', () => {
renderAndOpen({ showFromLink: true })
const input = screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/)
fireEvent.change(input, { target: { value: 'https://example.com/file.pdf' } })
fireEvent.click(screen.getByText(/operation\.ok/))
expect(mockHandleLoadFileFromLink).toHaveBeenCalledWith('https://example.com/file.pdf')
})
it('should clear URL input after successful submission', () => {
renderAndOpen({ showFromLink: true })
const input = screen.getByPlaceholderText(/fileUploader\.pasteFileLinkInputPlaceholder/) as HTMLInputElement
fireEvent.change(input, { target: { value: 'https://example.com/file.pdf' } })
fireEvent.click(screen.getByText(/operation\.ok/))
expect(input.value).toBe('')
})
it('should toggle open state when trigger is clicked', () => {
const trigger = (open: boolean) => <button data-testid="trigger">{open ? 'Close' : 'Open'}</button>
render(
<FileContextProvider value={mockFiles}>
<FileFromLinkOrLocal trigger={trigger} fileConfig={createFileConfig()} showFromLink />
</FileContextProvider>,
)
const triggerButton = screen.getByTestId('trigger')
expect(triggerButton).toHaveTextContent('Open')
fireEvent.click(triggerButton)
expect(triggerButton).toHaveTextContent('Close')
})
})

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,407 @@
import type { FileEntity } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { PreviewMode } from '@/app/components/base/features/types'
import { TransferMethod } from '@/types/app'
import FileInAttachmentItem from './file-item'
vi.mock('@/utils/download', () => ({
downloadUrl: vi.fn(),
}))
vi.mock('@/utils/format', () => ({
formatFileSize: (size: number) => `${size}B`,
}))
const createFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
id: 'file-1',
name: 'document.pdf',
size: 2048,
type: 'application/pdf',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'document',
uploadedId: 'uploaded-1',
url: 'https://example.com/document.pdf',
...overrides,
})
describe('FileInAttachmentItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render file name and extension', () => {
render(<FileInAttachmentItem file={createFile()} />)
expect(screen.getByText(/document\.pdf/i)).toBeInTheDocument()
expect(screen.getByText(/^pdf$/i)).toBeInTheDocument()
})
it('should render file size', () => {
render(<FileInAttachmentItem file={createFile({ size: 2048 })} />)
expect(screen.getByText(/2048B/)).toBeInTheDocument()
})
it('should render FileTypeIcon for non-image files', () => {
const { container } = render(<FileInAttachmentItem file={createFile()} />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('should render FileImageRender for image files', () => {
render(
<FileInAttachmentItem file={createFile({
supportFileType: 'image',
base64Url: 'data:image/png;base64,abc',
})}
/>,
)
const img = screen.getByRole('img')
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute('src', 'data:image/png;base64,abc')
})
it('should render delete button when showDeleteAction is true', () => {
render(<FileInAttachmentItem file={createFile()} showDeleteAction />)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThanOrEqual(1)
})
it('should not render delete button when showDeleteAction is false', () => {
render(<FileInAttachmentItem file={createFile()} showDeleteAction={false} />)
// With showDeleteAction=false, showDownloadAction defaults to true,
// so there should be exactly 1 button (the download button)
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(1)
})
it('should call onRemove when delete button is clicked', () => {
const onRemove = vi.fn()
// Disable download to isolate the delete button
render(<FileInAttachmentItem file={createFile()} showDeleteAction showDownloadAction={false} onRemove={onRemove} />)
const deleteBtn = screen.getByRole('button')
fireEvent.click(deleteBtn)
expect(onRemove).toHaveBeenCalledWith('file-1')
})
it('should render download button when showDownloadAction is true', () => {
render(<FileInAttachmentItem file={createFile()} showDownloadAction />)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThanOrEqual(1)
})
it('should render progress circle when file is uploading', () => {
const { container } = render(<FileInAttachmentItem file={createFile({ progress: 50, uploadedId: undefined })} />)
// ProgressCircle renders an SVG with a <circle> and <path> element
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
const circle = container.querySelector('circle')
expect(circle).toBeInTheDocument()
})
it('should render replay icon when upload failed', () => {
const { container } = render(<FileInAttachmentItem file={createFile({ progress: -1 })} />)
// ReplayLine renders an SVG with data-icon="ReplayLine"
const replayIcon = container.querySelector('[data-icon="ReplayLine"]')
expect(replayIcon).toBeInTheDocument()
})
it('should call onReUpload when replay icon is clicked', () => {
const onReUpload = vi.fn()
const { container } = render(<FileInAttachmentItem file={createFile({ progress: -1 })} onReUpload={onReUpload} />)
const replayIcon = container.querySelector('[data-icon="ReplayLine"]')
const replayBtn = replayIcon!.closest('button')
fireEvent.click(replayBtn!)
expect(onReUpload).toHaveBeenCalledWith('file-1')
})
it('should indicate error state when progress is -1', () => {
const { container } = render(<FileInAttachmentItem file={createFile({ progress: -1 })} />)
// Error state is confirmed by the presence of the replay icon
const replayIcon = container.querySelector('[data-icon="ReplayLine"]')
expect(replayIcon).toBeInTheDocument()
})
it('should render eye icon for previewable image files', () => {
render(
<FileInAttachmentItem
file={createFile({
supportFileType: 'image',
url: 'https://example.com/img.png',
})}
canPreview
/>,
)
// canPreview + image renders an extra button for the eye icon
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThanOrEqual(2)
})
it('should show image preview when eye icon is clicked', () => {
render(
<FileInAttachmentItem
file={createFile({
supportFileType: 'image',
url: 'https://example.com/img.png',
})}
canPreview
/>,
)
// The eye button is rendered before the download button for image files
const buttons = screen.getAllByRole('button')
// Click the eye button (the first action button for image preview)
fireEvent.click(buttons[0])
// ImagePreview renders a portal with an img element
const previewImages = document.querySelectorAll('img')
// There should be at least 2 images: the file thumbnail + the preview
expect(previewImages.length).toBeGreaterThanOrEqual(2)
})
it('should close image preview when close is clicked', () => {
render(
<FileInAttachmentItem
file={createFile({
supportFileType: 'image',
url: 'https://example.com/img.png',
})}
canPreview
/>,
)
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0])
// ImagePreview renders via createPortal with class "image-preview-container"
const previewContainer = document.querySelector('.image-preview-container')!
expect(previewContainer).toBeInTheDocument()
// Close button is the last clickable div with an SVG in the preview container
const closeIcon = screen.getByTestId('image-preview-close-button')
fireEvent.click(closeIcon.parentElement!)
// Preview should be removed
expect(document.querySelector('.image-preview-container')).not.toBeInTheDocument()
})
it('should call downloadUrl when download button is clicked', async () => {
const { downloadUrl } = await import('@/utils/download')
render(<FileInAttachmentItem file={createFile()} showDownloadAction />)
// Download button is the only action button when showDeleteAction is not set
const downloadBtn = screen.getByRole('button')
fireEvent.click(downloadBtn)
expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({
fileName: expect.stringMatching(/document\.pdf/i),
}))
})
it('should open new page when previewMode is NewPage and clicked', () => {
const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
render(
<FileInAttachmentItem
file={createFile({ url: 'https://example.com/doc.pdf' })}
canPreview
previewMode={PreviewMode.NewPage}
/>,
)
// Click the file name text to trigger the row click handler
fireEvent.click(screen.getByText(/document\.pdf/i))
expect(windowOpen).toHaveBeenCalledWith('https://example.com/doc.pdf', '_blank')
windowOpen.mockRestore()
})
it('should fallback to base64Url when url is empty for NewPage preview', () => {
const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
render(
<FileInAttachmentItem
file={createFile({ url: undefined, base64Url: 'data:image/png;base64,abc' })}
canPreview
previewMode={PreviewMode.NewPage}
/>,
)
fireEvent.click(screen.getByText(/document\.pdf/i))
expect(windowOpen).toHaveBeenCalledWith('data:image/png;base64,abc', '_blank')
windowOpen.mockRestore()
})
it('should open empty string when both url and base64Url are empty for NewPage preview', () => {
const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
render(
<FileInAttachmentItem
file={createFile({ url: undefined, base64Url: undefined })}
canPreview
previewMode={PreviewMode.NewPage}
/>,
)
fireEvent.click(screen.getByText(/document\.pdf/i))
expect(windowOpen).toHaveBeenCalledWith('', '_blank')
windowOpen.mockRestore()
})
it('should not open new page when previewMode is not NewPage', () => {
const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
render(
<FileInAttachmentItem
file={createFile()}
canPreview
previewMode={PreviewMode.CurrentPage}
/>,
)
fireEvent.click(screen.getByText(/document\.pdf/i))
expect(windowOpen).not.toHaveBeenCalled()
windowOpen.mockRestore()
})
it('should use url for image render fallback when base64Url is empty', () => {
render(
<FileInAttachmentItem file={createFile({
supportFileType: 'image',
base64Url: undefined,
url: 'https://example.com/img.png',
})}
/>,
)
const img = screen.getByRole('img')
expect(img).toHaveAttribute('src', 'https://example.com/img.png')
})
it('should render image element even when both urls are empty', () => {
render(
<FileInAttachmentItem file={createFile({
supportFileType: 'image',
base64Url: undefined,
url: undefined,
})}
/>,
)
const img = screen.getByRole('img')
expect(img).toBeInTheDocument()
})
it('should not render eye icon when canPreview is false for image files', () => {
render(
<FileInAttachmentItem
file={createFile({
supportFileType: 'image',
url: 'https://example.com/img.png',
})}
canPreview={false}
/>,
)
// Without canPreview, only the download button should render
const buttons = screen.getAllByRole('button')
expect(buttons).toHaveLength(1)
})
it('should download using base64Url when url is not available', async () => {
const { downloadUrl } = await import('@/utils/download')
render(
<FileInAttachmentItem
file={createFile({ url: undefined, base64Url: 'data:application/pdf;base64,abc' })}
showDownloadAction
/>,
)
const downloadBtn = screen.getByRole('button')
fireEvent.click(downloadBtn)
expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({
url: 'data:application/pdf;base64,abc',
}))
})
it('should not render file size when size is 0', () => {
render(<FileInAttachmentItem file={createFile({ size: 0 })} />)
expect(screen.queryByText(/0B/)).not.toBeInTheDocument()
})
it('should not render extension when ext is empty', () => {
render(<FileInAttachmentItem file={createFile({ name: 'noext' })} />)
// The file name should still show
expect(screen.getByText(/noext/)).toBeInTheDocument()
})
it('should show image preview with empty url when url is undefined', () => {
render(
<FileInAttachmentItem
file={createFile({
supportFileType: 'image',
url: undefined,
base64Url: undefined,
})}
canPreview
/>,
)
const buttons = screen.getAllByRole('button')
// Click the eye preview button
fireEvent.click(buttons[0])
// setImagePreviewUrl(url || '') = setImagePreviewUrl('')
// Empty string is falsy, so preview should NOT render
expect(document.querySelector('.image-preview-container')).not.toBeInTheDocument()
})
it('should download with empty url when both url and base64Url are undefined', async () => {
const { downloadUrl } = await import('@/utils/download')
render(
<FileInAttachmentItem
file={createFile({ url: undefined, base64Url: undefined })}
showDownloadAction
/>,
)
const downloadBtn = screen.getByRole('button')
fireEvent.click(downloadBtn)
expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({
url: '',
}))
})
it('should call downloadUrl with empty url when both url and base64Url are falsy', async () => {
const { downloadUrl } = await import('@/utils/download')
render(
<FileInAttachmentItem
file={createFile({ url: '', base64Url: '' })}
showDownloadAction
/>,
)
const downloadBtn = screen.getByRole('button')
fireEvent.click(downloadBtn)
expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({
url: '',
}))
})
})

View File

@@ -0,0 +1,207 @@
import type { FileEntity } from '../types'
import type { FileUpload } from '@/app/components/base/features/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { TransferMethod } from '@/types/app'
import FileUploaderInAttachmentWrapper from './index'
const mockHandleRemoveFile = vi.fn()
const mockHandleReUploadFile = vi.fn()
vi.mock('../hooks', () => ({
useFile: () => ({
handleRemoveFile: mockHandleRemoveFile,
handleReUploadFile: mockHandleReUploadFile,
}),
}))
vi.mock('@/utils/format', () => ({
formatFileSize: (size: number) => `${size}B`,
}))
vi.mock('@/utils/download', () => ({
downloadUrl: vi.fn(),
}))
const createFileConfig = (overrides: Partial<FileUpload> = {}): FileUpload => ({
enabled: true,
allowed_file_types: ['image'],
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
allowed_file_extensions: [],
number_limits: 5,
...overrides,
} as unknown as FileUpload)
const createFile = (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('FileUploaderInAttachmentWrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render without crashing', () => {
render(
<FileUploaderInAttachmentWrapper
onChange={vi.fn()}
fileConfig={createFileConfig()}
/>,
)
// FileContextProvider wraps children with a Zustand context — verify children render
expect(screen.getAllByRole('button').length).toBeGreaterThan(0)
})
it('should render upload buttons when not disabled', () => {
render(
<FileUploaderInAttachmentWrapper
onChange={vi.fn()}
fileConfig={createFileConfig()}
/>,
)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThan(0)
})
it('should not render upload buttons when disabled', () => {
render(
<FileUploaderInAttachmentWrapper
onChange={vi.fn()}
fileConfig={createFileConfig()}
isDisabled
/>,
)
expect(screen.queryByText(/fileUploader\.uploadFromComputer/)).not.toBeInTheDocument()
})
it('should render file items for each file', () => {
const files = [
createFile({ id: 'f1', name: 'a.txt' }),
createFile({ id: 'f2', name: 'b.txt' }),
]
render(
<FileUploaderInAttachmentWrapper
value={files}
onChange={vi.fn()}
fileConfig={createFileConfig()}
/>,
)
expect(screen.getByText(/a\.txt/i)).toBeInTheDocument()
expect(screen.getByText(/b\.txt/i)).toBeInTheDocument()
})
it('should render local upload button for local_file method', () => {
render(
<FileUploaderInAttachmentWrapper
onChange={vi.fn()}
fileConfig={createFileConfig({
allowed_file_upload_methods: [TransferMethod.local_file],
} as unknown as Partial<FileUpload>)}
/>,
)
expect(screen.getByText(/fileUploader\.uploadFromComputer/)).toBeInTheDocument()
})
it('should render link upload option for remote_url method', () => {
render(
<FileUploaderInAttachmentWrapper
onChange={vi.fn()}
fileConfig={createFileConfig({
allowed_file_upload_methods: [TransferMethod.remote_url],
} as unknown as Partial<FileUpload>)}
/>,
)
expect(screen.getByText(/fileUploader\.pasteFileLink/)).toBeInTheDocument()
})
it('should call handleRemoveFile when remove button is clicked', () => {
const files = [createFile({ id: 'f1', name: 'a.txt' })]
render(
<FileUploaderInAttachmentWrapper
value={files}
onChange={vi.fn()}
fileConfig={createFileConfig()}
/>,
)
// Find the file item row, then locate the delete button within it
const fileNameEl = screen.getByText(/a\.txt/i)
const fileRow = fileNameEl.closest('[title="a.txt"]')?.parentElement?.parentElement
const deleteBtn = fileRow?.querySelector('button:last-of-type')
fireEvent.click(deleteBtn!)
expect(mockHandleRemoveFile).toHaveBeenCalledWith('f1')
})
it('should apply open style on remote_url trigger when portal is open', () => {
render(
<FileUploaderInAttachmentWrapper
onChange={vi.fn()}
fileConfig={createFileConfig({
allowed_file_upload_methods: [TransferMethod.remote_url],
} as unknown as Partial<FileUpload>)}
/>,
)
// Click the remote_url button to open the portal
const linkButton = screen.getByText(/fileUploader\.pasteFileLink/)
fireEvent.click(linkButton)
// The button should still be in the document
expect(linkButton.closest('button')).toBeInTheDocument()
})
it('should disable upload buttons when file limit is reached', () => {
const files = [
createFile({ id: 'f1' }),
createFile({ id: 'f2' }),
createFile({ id: 'f3' }),
createFile({ id: 'f4' }),
createFile({ id: 'f5' }),
]
render(
<FileUploaderInAttachmentWrapper
value={files}
onChange={vi.fn()}
fileConfig={createFileConfig({ number_limits: 5 })}
/>,
)
const buttons = screen.getAllByRole('button')
const disabledButtons = buttons.filter(btn => btn.hasAttribute('disabled'))
expect(disabledButtons.length).toBeGreaterThan(0)
})
it('should call handleReUploadFile when reupload button is clicked', () => {
const files = [createFile({ id: 'f1', name: 'a.txt', progress: -1 })]
const { container } = render(
<FileUploaderInAttachmentWrapper
value={files}
onChange={vi.fn()}
fileConfig={createFileConfig()}
/>,
)
// ReplayLine is inside ActionButton (a <button>) with data-icon attribute
const replayIcon = container.querySelector('svg[data-icon="ReplayLine"]')
const replayBtn = replayIcon!.closest('button')
fireEvent.click(replayBtn!)
expect(mockHandleReUploadFile).toHaveBeenCalledWith('f1')
})
})

View File

@@ -0,0 +1,246 @@
import type { FileEntity } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { TransferMethod } from '@/types/app'
import FileImageItem from './file-image-item'
vi.mock('@/utils/download', () => ({
downloadUrl: vi.fn(),
}))
const createFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
id: 'file-1',
name: 'photo.png',
size: 4096,
type: 'image/png',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'image',
uploadedId: 'uploaded-1',
base64Url: 'data:image/png;base64,abc',
url: 'https://example.com/photo.png',
...overrides,
})
describe('FileImageItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render an image with the base64 URL', () => {
render(<FileImageItem file={createFile()} />)
const img = screen.getByRole('img')
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute('src', 'data:image/png;base64,abc')
})
it('should use url when base64Url is not available', () => {
render(<FileImageItem file={createFile({ base64Url: undefined })} />)
const img = screen.getByRole('img')
expect(img).toHaveAttribute('src', 'https://example.com/photo.png')
})
it('should render delete button when showDeleteAction is true', () => {
render(<FileImageItem file={createFile()} showDeleteAction />)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThanOrEqual(1)
})
it('should call onRemove when delete button is clicked', () => {
const onRemove = vi.fn()
render(<FileImageItem file={createFile()} showDeleteAction onRemove={onRemove} />)
const buttons = screen.getAllByRole('button')
fireEvent.click(buttons[0])
expect(onRemove).toHaveBeenCalledWith('file-1')
})
it('should render progress circle when file is uploading', () => {
const { container } = render(
<FileImageItem file={createFile({ progress: 50, uploadedId: undefined })} />,
)
const svgs = container.querySelectorAll('svg')
const progressSvg = Array.from(svgs).find(svg => svg.querySelector('circle'))
expect(progressSvg).toBeInTheDocument()
})
it('should render replay icon when upload failed', () => {
const { container } = render(<FileImageItem file={createFile({ progress: -1 })} />)
// ReplayLine renders as an SVG icon with data-icon attribute
const replaySvg = container.querySelector('svg[data-icon="ReplayLine"]')
expect(replaySvg).toBeInTheDocument()
})
it('should call onReUpload when replay icon is clicked', () => {
const onReUpload = vi.fn()
const { container } = render(
<FileImageItem file={createFile({ progress: -1 })} onReUpload={onReUpload} />,
)
const replaySvg = container.querySelector('svg[data-icon="ReplayLine"]')
fireEvent.click(replaySvg!)
expect(onReUpload).toHaveBeenCalledWith('file-1')
})
it('should show image preview when clicked and canPreview is true', () => {
render(<FileImageItem file={createFile()} canPreview />)
// Click the wrapper div (parent of the img element)
const img = screen.getByRole('img')
fireEvent.click(img.parentElement!)
// ImagePreview renders via createPortal with class "image-preview-container", not role="dialog"
expect(document.querySelector('.image-preview-container')).toBeInTheDocument()
})
it('should not show image preview when canPreview is false', () => {
render(<FileImageItem file={createFile()} canPreview={false} />)
const img = screen.getByRole('img')
fireEvent.click(img.parentElement!)
expect(document.querySelector('.image-preview-container')).not.toBeInTheDocument()
})
it('should close image preview when close is clicked', () => {
render(<FileImageItem file={createFile()} canPreview />)
const img = screen.getByRole('img')
fireEvent.click(img.parentElement!)
// ImagePreview renders via createPortal with class "image-preview-container"
const previewContainer = document.querySelector('.image-preview-container')!
expect(previewContainer).toBeInTheDocument()
// Close button is the last clickable div with an SVG in the preview container
const closeIcon = screen.getByTestId('image-preview-close-button')
fireEvent.click(closeIcon.parentElement!)
expect(document.querySelector('.image-preview-container')).not.toBeInTheDocument()
})
it('should render download overlay when showDownloadAction is true', () => {
const { container } = render(<FileImageItem file={createFile()} showDownloadAction />)
// The download icon SVG should be present
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThanOrEqual(1)
})
it('should call downloadUrl when download button is clicked', async () => {
const { downloadUrl } = await import('@/utils/download')
const { container } = render(<FileImageItem file={createFile()} showDownloadAction />)
// Find the RiDownloadLine SVG (it doesn't have data-icon attribute, unlike ReplayLine)
const svgs = container.querySelectorAll('svg')
const downloadSvg = Array.from(svgs).find(
svg => !svg.hasAttribute('data-icon') && !svg.querySelector('circle'),
)
fireEvent.click(downloadSvg!.parentElement!)
expect(downloadUrl).toHaveBeenCalled()
})
it('should not render delete button when showDeleteAction is false', () => {
render(<FileImageItem file={createFile()} />)
expect(screen.queryAllByRole('button')).toHaveLength(0)
})
it('should use url when both base64Url and url fallback for image render', () => {
render(<FileImageItem file={createFile({ base64Url: undefined, url: 'https://example.com/img.png' })} />)
const img = screen.getByRole('img')
expect(img).toHaveAttribute('src', 'https://example.com/img.png')
})
it('should render image element even when both base64Url and url are undefined', () => {
render(<FileImageItem file={createFile({ base64Url: undefined, url: undefined })} />)
const img = screen.getByRole('img')
expect(img).toBeInTheDocument()
})
it('should use url with attachment param for download_url when url is available', async () => {
const { downloadUrl } = await import('@/utils/download')
const file = createFile({ url: 'https://example.com/photo.png' })
const { container } = render(<FileImageItem file={file} showDownloadAction />)
// The download SVG should be rendered
const svgs = container.querySelectorAll('svg')
expect(svgs.length).toBeGreaterThanOrEqual(1)
const downloadSvg = Array.from(svgs).find(
svg => !svg.hasAttribute('data-icon') && !svg.querySelector('circle'),
)
fireEvent.click(downloadSvg!.parentElement!)
expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({
url: expect.stringContaining('as_attachment=true'),
}))
})
it('should use base64Url for download_url when url is not available', async () => {
const { downloadUrl } = await import('@/utils/download')
const file = createFile({ url: undefined, base64Url: 'data:image/png;base64,abc' })
const { container } = render(<FileImageItem file={file} showDownloadAction />)
const svgs = container.querySelectorAll('svg')
const downloadSvg = Array.from(svgs).find(
svg => !svg.hasAttribute('data-icon') && !svg.querySelector('circle'),
)
fireEvent.click(downloadSvg!.parentElement!)
expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({
url: 'data:image/png;base64,abc',
}))
})
it('should set preview url using base64Url when available', () => {
render(<FileImageItem file={createFile({ base64Url: 'data:image/png;base64,abc', url: 'https://example.com/photo.png' })} canPreview />)
const img = screen.getByRole('img')
fireEvent.click(img.parentElement!)
expect(document.querySelector('.image-preview-container')).toBeInTheDocument()
})
it('should set preview url using url when base64Url is not available', () => {
render(<FileImageItem file={createFile({ base64Url: undefined, url: 'https://example.com/photo.png' })} canPreview />)
const img = screen.getByRole('img')
fireEvent.click(img.parentElement!)
expect(document.querySelector('.image-preview-container')).toBeInTheDocument()
})
it('should set preview url to empty string when both base64Url and url are undefined', () => {
render(<FileImageItem file={createFile({ base64Url: undefined, url: undefined })} canPreview />)
const img = screen.getByRole('img')
fireEvent.click(img.parentElement!)
// Preview won't show because imagePreviewUrl is empty string (falsy)
expect(document.querySelector('.image-preview-container')).not.toBeInTheDocument()
})
it('should call downloadUrl with correct params when download button is clicked', async () => {
const { downloadUrl } = await import('@/utils/download')
const file = createFile({ url: 'https://example.com/photo.png', name: 'photo.png' })
const { container } = render(<FileImageItem file={file} showDownloadAction />)
const svgs = container.querySelectorAll('svg')
const downloadSvg = Array.from(svgs).find(
svg => !svg.hasAttribute('data-icon') && !svg.querySelector('circle'),
)
fireEvent.click(downloadSvg!.parentElement!)
expect(downloadUrl).toHaveBeenCalledWith(expect.objectContaining({
url: expect.stringContaining('as_attachment=true'),
fileName: 'photo.png',
}))
})
})

View File

@@ -0,0 +1,337 @@
import type { FileEntity } from '../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { TransferMethod } from '@/types/app'
import FileItem from './file-item'
vi.mock('@/utils/download', () => ({
downloadUrl: vi.fn(),
}))
vi.mock('@/utils/format', () => ({
formatFileSize: (size: number) => `${size}B`,
}))
vi.mock('../dynamic-pdf-preview', () => ({
default: ({ url, onCancel }: { url: string, onCancel: () => void }) => (
<div data-testid="pdf-preview" data-url={url}>
<button data-testid="pdf-close" onClick={onCancel}>Close PDF</button>
</div>
),
}))
const createFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
id: 'file-1',
name: 'document.pdf',
size: 2048,
type: 'application/pdf',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'document',
uploadedId: 'uploaded-1',
url: 'https://example.com/document.pdf',
...overrides,
})
describe('FileItem (chat-input)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render file name', () => {
render(<FileItem file={createFile()} />)
expect(screen.getByText(/document\.pdf/i)).toBeInTheDocument()
})
it('should render file extension and size', () => {
const { container } = render(<FileItem file={createFile()} />)
// Extension and size are rendered as text nodes in the metadata div
expect(container.textContent).toContain('pdf')
expect(container.textContent).toContain('2048B')
})
it('should render FileTypeIcon', () => {
const { container } = render(<FileItem file={createFile()} />)
const fileTypeIcon = container.querySelector('svg')
expect(fileTypeIcon).toBeInTheDocument()
})
it('should render delete button when showDeleteAction is true', () => {
render(<FileItem file={createFile()} showDeleteAction />)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThanOrEqual(1)
})
it('should call onRemove when delete button is clicked', () => {
const onRemove = vi.fn()
render(<FileItem file={createFile()} showDeleteAction onRemove={onRemove} />)
const delete_button = screen.getByTestId('delete-button')
fireEvent.click(delete_button)
expect(onRemove).toHaveBeenCalledWith('file-1')
})
it('should render progress circle when file is uploading', () => {
const { container } = render(
<FileItem file={createFile({ progress: 50, uploadedId: undefined })} />,
)
const progressSvg = container.querySelector('svg circle')
expect(progressSvg).toBeInTheDocument()
})
it('should render replay icon when upload failed', () => {
render(<FileItem file={createFile({ progress: -1 })} />)
const replayIcon = screen.getByTestId('replay-icon')
expect(replayIcon).toBeInTheDocument()
})
it('should call onReUpload when replay icon is clicked', () => {
const onReUpload = vi.fn()
render(
<FileItem file={createFile({ progress: -1 })} onReUpload={onReUpload} />,
)
const replayIcon = screen.getByTestId('replay-icon')
fireEvent.click(replayIcon!)
expect(onReUpload).toHaveBeenCalledWith('file-1')
})
it('should have error styling when upload failed', () => {
const { container } = render(<FileItem file={createFile({ progress: -1 })} />)
const fileItemContainer = container.firstChild as HTMLElement
expect(fileItemContainer).toHaveClass('border-state-destructive-border')
expect(fileItemContainer).toHaveClass('bg-state-destructive-hover-alt')
})
it('should show audio preview when audio file name is clicked', async () => {
render(
<FileItem
file={createFile({
name: 'audio.mp3',
type: 'audio/mpeg',
url: 'https://example.com/audio.mp3',
})}
canPreview
/>,
)
fireEvent.click(screen.getByText(/audio\.mp3/i))
const audioElement = document.querySelector('audio')
expect(audioElement).toBeInTheDocument()
})
it('should show video preview when video file name is clicked', () => {
render(
<FileItem
file={createFile({
name: 'video.mp4',
type: 'video/mp4',
url: 'https://example.com/video.mp4',
})}
canPreview
/>,
)
fireEvent.click(screen.getByText(/video\.mp4/i))
const videoElement = document.querySelector('video')
expect(videoElement).toBeInTheDocument()
})
it('should show pdf preview when pdf file name is clicked', () => {
render(
<FileItem
file={createFile({
name: 'doc.pdf',
type: 'application/pdf',
url: 'https://example.com/doc.pdf',
})}
canPreview
/>,
)
fireEvent.click(screen.getByText(/doc\.pdf/i))
expect(screen.getByTestId('pdf-preview')).toBeInTheDocument()
})
it('should close audio preview', () => {
render(
<FileItem
file={createFile({
name: 'audio.mp3',
type: 'audio/mpeg',
url: 'https://example.com/audio.mp3',
})}
canPreview
/>,
)
fireEvent.click(screen.getByText(/audio\.mp3/i))
expect(document.querySelector('audio')).toBeInTheDocument()
const deleteButton = screen.getByTestId('close-btn')
fireEvent.click(deleteButton)
expect(document.querySelector('audio')).not.toBeInTheDocument()
})
it('should render download button when showDownloadAction is true and url exists', () => {
render(<FileItem file={createFile()} showDownloadAction />)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThanOrEqual(1)
})
it('should call downloadUrl when download button is clicked', async () => {
const { downloadUrl } = await import('@/utils/download')
render(<FileItem file={createFile()} showDownloadAction />)
const downloadBtn = screen.getByTestId('download-button')
fireEvent.click(downloadBtn)
expect(downloadUrl).toHaveBeenCalled()
})
it('should not render download button when showDownloadAction is false', () => {
render(<FileItem file={createFile()} showDownloadAction={false} />)
const buttons = screen.queryAllByRole('button')
expect(buttons).toHaveLength(0)
})
it('should not show preview when canPreview is false', () => {
render(
<FileItem
file={createFile({
name: 'audio.mp3',
type: 'audio/mpeg',
})}
canPreview={false}
/>,
)
fireEvent.click(screen.getByText(/audio\.mp3/i))
expect(document.querySelector('audio')).not.toBeInTheDocument()
})
it('should close video preview', () => {
render(
<FileItem
file={createFile({
name: 'video.mp4',
type: 'video/mp4',
url: 'https://example.com/video.mp4',
})}
canPreview
/>,
)
fireEvent.click(screen.getByText(/video\.mp4/i))
expect(document.querySelector('video')).toBeInTheDocument()
const closeBtn = screen.getByTestId('video-preview-close-btn')
fireEvent.click(closeBtn)
expect(document.querySelector('video')).not.toBeInTheDocument()
})
it('should close pdf preview', () => {
render(
<FileItem
file={createFile({
name: 'doc.pdf',
type: 'application/pdf',
url: 'https://example.com/doc.pdf',
})}
canPreview
/>,
)
fireEvent.click(screen.getByText(/doc\.pdf/i))
expect(screen.getByTestId('pdf-preview')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('pdf-close'))
expect(screen.queryByTestId('pdf-preview')).not.toBeInTheDocument()
})
it('should use createObjectURL when no url or base64Url but has originalFile', () => {
const mockUrl = 'blob:http://localhost/test-blob'
const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue(mockUrl)
const file = createFile({
name: 'audio.mp3',
type: 'audio/mpeg',
url: undefined,
base64Url: undefined,
originalFile: new File(['content'], 'audio.mp3', { type: 'audio/mpeg' }),
})
render(<FileItem file={file} canPreview />)
fireEvent.click(screen.getByText(/audio\.mp3/i))
expect(document.querySelector('audio')).toBeInTheDocument()
expect(createObjectURLSpy).toHaveBeenCalled()
createObjectURLSpy.mockRestore()
})
it('should not use createObjectURL when no originalFile and no urls', () => {
const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL')
const file = createFile({
name: 'audio.mp3',
type: 'audio/mpeg',
url: undefined,
base64Url: undefined,
originalFile: undefined,
})
render(<FileItem file={file} canPreview />)
fireEvent.click(screen.getByText(/audio\.mp3/i))
expect(createObjectURLSpy).not.toHaveBeenCalled()
createObjectURLSpy.mockRestore()
expect(document.querySelector('audio')).not.toBeInTheDocument()
})
it('should not render download button when download_url is falsy', () => {
render(
<FileItem
file={createFile({ url: undefined, base64Url: undefined })}
showDownloadAction
/>,
)
const buttons = screen.queryAllByRole('button')
expect(buttons).toHaveLength(0)
})
it('should render download button when base64Url is available as download_url', () => {
render(
<FileItem
file={createFile({ url: undefined, base64Url: 'data:application/pdf;base64,abc' })}
showDownloadAction
/>,
)
const buttons = screen.getAllByRole('button')
expect(buttons.length).toBeGreaterThanOrEqual(1)
})
it('should not render extension separator when ext is empty', () => {
render(<FileItem file={createFile({ name: 'noext' })} />)
expect(screen.getByText(/noext/)).toBeInTheDocument()
})
it('should not render file size when size is 0', () => {
render(<FileItem file={createFile({ size: 0 })} />)
expect(screen.queryByText(/0B/)).not.toBeInTheDocument()
})
})

View File

@@ -1,15 +1,10 @@
import type { FileEntity } from '../types'
import {
RiCloseLine,
RiDownloadLine,
} from '@remixicon/react'
import { useState } from 'react'
import ActionButton from '@/app/components/base/action-button'
import Button from '@/app/components/base/button'
import AudioPreview from '@/app/components/base/file-uploader/audio-preview'
import PdfPreview from '@/app/components/base/file-uploader/dynamic-pdf-preview'
import VideoPreview from '@/app/components/base/file-uploader/video-preview'
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import { cn } from '@/utils/classnames'
import { downloadUrl } from '@/utils/download'
@@ -62,20 +57,21 @@ const FileItem = ({
<Button
className="absolute -right-1.5 -top-1.5 z-[11] hidden h-5 w-5 rounded-full p-0 group-hover/file-item:flex"
onClick={() => onRemove?.(id)}
data-testid="delete-button"
>
<RiCloseLine className="h-4 w-4 text-components-button-secondary-text" />
<span className="i-ri-close-line h-4 w-4 text-components-button-secondary-text" />
</Button>
)
}
<div
className="system-xs-medium mb-1 line-clamp-2 h-8 cursor-pointer break-all text-text-tertiary"
className="mb-1 line-clamp-2 h-8 cursor-pointer break-all text-text-tertiary system-xs-medium"
title={name}
onClick={() => canPreview && setPreviewUrl(tmp_preview_url || '')}
>
{name}
</div>
<div className="relative flex items-center justify-between">
<div className="system-2xs-medium-uppercase flex items-center text-text-tertiary">
<div className="flex items-center text-text-tertiary system-2xs-medium-uppercase">
<FileTypeIcon
size="sm"
type={getFileAppearanceType(name, type)}
@@ -102,8 +98,9 @@ const FileItem = ({
e.stopPropagation()
downloadUrl({ url: download_url || '', fileName: name, target: '_blank' })
}}
data-testid="download-button"
>
<RiDownloadLine className="h-3.5 w-3.5 text-text-tertiary" />
<span className="i-ri-download-line h-3.5 w-3.5 text-text-tertiary" />
</ActionButton>
)
}
@@ -118,10 +115,7 @@ const FileItem = ({
}
{
uploadError && (
<ReplayLine
className="h-4 w-4 text-text-tertiary"
onClick={() => onReUpload?.(id)}
/>
<span className="i-custom-vender-other-replay-line h-4 w-4 cursor-pointer text-text-tertiary" onClick={() => onReUpload?.(id)} data-testid="replay-icon" role="button" tabIndex={0} />
)
}
</div>

View File

@@ -0,0 +1,137 @@
import type { FileEntity } from '../types'
import type { FileUpload } from '@/app/components/base/features/types'
import { render, screen } from '@testing-library/react'
import { TransferMethod } from '@/types/app'
import { FileContextProvider } from '../store'
import { FileList, FileListInChatInput } from './file-list'
vi.mock('../hooks', () => ({
useFile: () => ({
handleRemoveFile: vi.fn(),
handleReUploadFile: vi.fn(),
}),
}))
vi.mock('@/utils/format', () => ({
formatFileSize: (size: number) => `${size}B`,
}))
vi.mock('@/utils/download', () => ({
downloadUrl: vi.fn(),
}))
const createFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
id: `file-${Math.random()}`,
name: 'document.pdf',
size: 1024,
type: 'application/pdf',
progress: 100,
transferMethod: TransferMethod.local_file,
supportFileType: 'document',
...overrides,
})
describe('FileList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render FileImageItem for image files', () => {
const files = [createFile({
name: 'photo.png',
type: 'image/png',
supportFileType: 'image',
base64Url: 'data:image/png;base64,abc',
})]
render(<FileList files={files} />)
expect(screen.getByRole('img')).toBeInTheDocument()
})
it('should render FileItem for non-image files', () => {
const files = [createFile({
name: 'document.pdf',
supportFileType: 'document',
})]
render(<FileList files={files} />)
expect(screen.getByText(/document\.pdf/i)).toBeInTheDocument()
})
it('should render both image and non-image files', () => {
const files = [
createFile({
name: 'photo.png',
type: 'image/png',
supportFileType: 'image',
base64Url: 'data:image/png;base64,abc',
}),
createFile({ name: 'doc.pdf', supportFileType: 'document' }),
]
render(<FileList files={files} />)
expect(screen.getByRole('img')).toBeInTheDocument()
expect(screen.getByText(/doc\.pdf/i)).toBeInTheDocument()
})
it('should render empty list when no files', () => {
const { container } = render(<FileList files={[]} />)
expect(container.firstChild).toBeInTheDocument()
expect(screen.queryAllByRole('img')).toHaveLength(0)
})
it('should apply custom className', () => {
const { container } = render(<FileList files={[]} className="custom-class" />)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should render multiple files', () => {
const files = [
createFile({ name: 'a.pdf' }),
createFile({ name: 'b.pdf' }),
createFile({ name: 'c.pdf' }),
]
render(<FileList files={files} />)
expect(screen.getByText(/a\.pdf/i)).toBeInTheDocument()
expect(screen.getByText(/b\.pdf/i)).toBeInTheDocument()
expect(screen.getByText(/c\.pdf/i)).toBeInTheDocument()
})
})
describe('FileListInChatInput', () => {
let mockStoreFiles: FileEntity[] = []
beforeEach(() => {
vi.clearAllMocks()
mockStoreFiles = []
})
it('should render FileList with files from store', () => {
mockStoreFiles = [createFile({ name: 'test.pdf' })]
const fileConfig = { enabled: true, allowed_file_types: ['document'] } as FileUpload
render(
<FileContextProvider value={mockStoreFiles}>
<FileListInChatInput fileConfig={fileConfig} />
</FileContextProvider>,
)
expect(screen.getByText(/test\.pdf/i)).toBeInTheDocument()
})
it('should render empty FileList when store has no files', () => {
const fileConfig = { enabled: true, allowed_file_types: ['document'] } as FileUpload
render(
<FileContextProvider value={mockStoreFiles}>
<FileListInChatInput fileConfig={fileConfig} />
</FileContextProvider>,
)
expect(screen.queryAllByRole('img')).toHaveLength(0)
expect(screen.queryByText(/\.pdf/i)).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,101 @@
import type { FileUpload } from '@/app/components/base/features/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { FileContextProvider } from '../store'
import FileUploaderInChatInput from './index'
vi.mock('@/types/app', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/types/app')>()
return {
...actual,
TransferMethod: {
local_file: 'local_file',
remote_url: 'remote_url',
},
}
})
vi.mock('../hooks', () => ({
useFile: () => ({
handleLoadFileFromLink: vi.fn(),
}),
}))
function renderWithProvider(ui: React.ReactElement) {
return render(
<FileContextProvider>
{ui}
</FileContextProvider>,
)
}
const createFileConfig = (overrides: Partial<FileUpload> = {}): FileUpload => ({
enabled: true,
allowed_file_types: ['image'],
allowed_file_upload_methods: ['local_file', 'remote_url'],
allowed_file_extensions: [],
number_limits: 5,
...overrides,
} as unknown as FileUpload)
describe('FileUploaderInChatInput', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render an attachment icon SVG', () => {
renderWithProvider(<FileUploaderInChatInput fileConfig={createFileConfig()} />)
const button = screen.getByRole('button')
expect(button.querySelector('svg')).toBeInTheDocument()
})
it('should render FileFromLinkOrLocal when not readonly', () => {
renderWithProvider(<FileUploaderInChatInput fileConfig={createFileConfig()} />)
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
expect(button).not.toBeDisabled()
})
it('should render only the trigger button when readonly', () => {
renderWithProvider(<FileUploaderInChatInput fileConfig={createFileConfig()} readonly />)
const button = screen.getByRole('button')
expect(button).toBeDisabled()
})
it('should render button with attachment icon for local_file upload method', () => {
renderWithProvider(
<FileUploaderInChatInput fileConfig={createFileConfig({
allowed_file_upload_methods: ['local_file'],
} as unknown as Partial<FileUpload>)}
/>,
)
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
expect(button.querySelector('svg')).toBeInTheDocument()
})
it('should render button with attachment icon for remote_url upload method', () => {
renderWithProvider(
<FileUploaderInChatInput fileConfig={createFileConfig({
allowed_file_upload_methods: ['remote_url'],
} as unknown as Partial<FileUpload>)}
/>,
)
const button = screen.getByRole('button')
expect(button).toBeInTheDocument()
expect(button.querySelector('svg')).toBeInTheDocument()
})
it('should apply open state styling when trigger is activated', () => {
renderWithProvider(<FileUploaderInChatInput fileConfig={createFileConfig()} />)
const button = screen.getByRole('button')
fireEvent.click(button)
expect(button).toBeInTheDocument()
})
})

View File

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

View File

@@ -0,0 +1,7 @@
import { PdfHighlighter, PdfLoader } from 'react-pdf-highlighter'
import 'react-pdf-highlighter/dist/style.css'
export {
PdfHighlighter,
PdfLoader,
}

View File

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

View File

@@ -6,11 +6,10 @@ import * as React from 'react'
import { useState } from 'react'
import { createPortal } from 'react-dom'
import { useHotkeys } from 'react-hotkeys-hook'
import { PdfHighlighter, PdfLoader } from 'react-pdf-highlighter'
import Loading from '@/app/components/base/loading'
import Tooltip from '@/app/components/base/tooltip'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import 'react-pdf-highlighter/dist/style.css'
import { PdfHighlighter, PdfLoader } from './pdf-highlighter-adapter'
type PdfPreviewProps = {
url: string

View File

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

View File

@@ -1,4 +1,4 @@
import mime from 'mime'
import type { FileEntity } from './types'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { upload } from '@/service/base'
import { TransferMethod } from '@/types/app'
@@ -11,6 +11,7 @@ import {
getFileExtension,
getFileNameFromUrl,
getFilesInLogs,
getFileUploadErrorMessage,
getProcessedFiles,
getProcessedFilesFromResponse,
getSupportFileExtensionList,
@@ -18,23 +19,40 @@ import {
isAllowedFileExtension,
} from './utils'
vi.mock('mime', () => ({
default: {
getAllExtensions: vi.fn(),
},
}))
vi.mock('@/service/base', () => ({
upload: vi.fn(),
}))
describe('file-uploader utils', () => {
beforeEach(() => {
vi.clearAllMocks()
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', () => {
it('should handle successful file upload', async () => {
const mockFile = new File(['test'], 'test.txt')
const mockCallbacks = {
onProgressCallback: vi.fn(),
@@ -50,32 +68,102 @@ describe('file-uploader utils', () => {
})
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', () => {
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf']))
expect(getFileExtension('file', 'application/pdf')).toBe('pdf')
})
it('should get extension from mimetype and file name 1', () => {
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['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', () => {
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['der', 'crt', 'pem']))
expect(getFileExtension('file.pem', 'application/x-x509-ca-cert')).toBe('pem')
})
it('should get extension from mimetype with multiple ext candidates without filename hint', () => {
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['der', 'crt', 'pem']))
expect(getFileExtension('file', 'application/x-x509-ca-cert')).toBe('der')
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 if mimetype fails', () => {
vi.mocked(mime.getAllExtensions).mockReturnValue(null)
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('')
@@ -84,164 +172,123 @@ describe('file-uploader utils', () => {
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', () => {
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['gif']))
expect(getFileAppearanceType('image.gif', 'image/gif'))
.toBe(FileAppearanceTypeEnum.gif)
})
it('should identify image files', () => {
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['jpg']))
expect(getFileAppearanceType('image.jpg', 'image/jpeg'))
.toBe(FileAppearanceTypeEnum.image)
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['jpeg']))
expect(getFileAppearanceType('image.jpeg', 'image/jpeg'))
.toBe(FileAppearanceTypeEnum.image)
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['png']))
expect(getFileAppearanceType('image.png', 'image/png'))
.toBe(FileAppearanceTypeEnum.image)
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['webp']))
expect(getFileAppearanceType('image.webp', 'image/webp'))
.toBe(FileAppearanceTypeEnum.image)
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['svg']))
expect(getFileAppearanceType('image.svg', 'image/svgxml'))
expect(getFileAppearanceType('image.svg', 'image/svg+xml'))
.toBe(FileAppearanceTypeEnum.image)
})
it('should identify video files', () => {
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mp4']))
expect(getFileAppearanceType('video.mp4', 'video/mp4'))
.toBe(FileAppearanceTypeEnum.video)
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mov']))
expect(getFileAppearanceType('video.mov', 'video/quicktime'))
.toBe(FileAppearanceTypeEnum.video)
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mpeg']))
expect(getFileAppearanceType('video.mpeg', 'video/mpeg'))
.toBe(FileAppearanceTypeEnum.video)
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['webm']))
expect(getFileAppearanceType('video.web', 'video/webm'))
expect(getFileAppearanceType('video.webm', 'video/webm'))
.toBe(FileAppearanceTypeEnum.video)
})
it('should identify audio files', () => {
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mp3']))
expect(getFileAppearanceType('audio.mp3', 'audio/mpeg'))
.toBe(FileAppearanceTypeEnum.audio)
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['m4a']))
expect(getFileAppearanceType('audio.m4a', 'audio/mp4'))
.toBe(FileAppearanceTypeEnum.audio)
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['wav']))
expect(getFileAppearanceType('audio.wav', 'audio/vnd.wav'))
expect(getFileAppearanceType('audio.wav', 'audio/wav'))
.toBe(FileAppearanceTypeEnum.audio)
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['amr']))
expect(getFileAppearanceType('audio.amr', 'audio/AMR'))
.toBe(FileAppearanceTypeEnum.audio)
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mpga']))
expect(getFileAppearanceType('audio.mpga', 'audio/mpeg'))
.toBe(FileAppearanceTypeEnum.audio)
})
it('should identify code files', () => {
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['html']))
expect(getFileAppearanceType('index.html', 'text/html'))
.toBe(FileAppearanceTypeEnum.code)
})
it('should identify PDF files', () => {
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf']))
expect(getFileAppearanceType('doc.pdf', 'application/pdf'))
.toBe(FileAppearanceTypeEnum.pdf)
})
it('should identify markdown files', () => {
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['md']))
expect(getFileAppearanceType('file.md', 'text/markdown'))
.toBe(FileAppearanceTypeEnum.markdown)
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['markdown']))
expect(getFileAppearanceType('file.markdown', 'text/markdown'))
.toBe(FileAppearanceTypeEnum.markdown)
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mdx']))
expect(getFileAppearanceType('file.mdx', 'text/mdx'))
.toBe(FileAppearanceTypeEnum.markdown)
})
it('should identify excel files', () => {
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['xlsx']))
expect(getFileAppearanceType('doc.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'))
.toBe(FileAppearanceTypeEnum.excel)
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['xls']))
expect(getFileAppearanceType('doc.xls', 'application/vnd.ms-excel'))
.toBe(FileAppearanceTypeEnum.excel)
})
it('should identify word files', () => {
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['doc']))
expect(getFileAppearanceType('doc.doc', 'application/msword'))
.toBe(FileAppearanceTypeEnum.word)
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['docx']))
expect(getFileAppearanceType('doc.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'))
.toBe(FileAppearanceTypeEnum.word)
})
it('should identify word files', () => {
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['ppt']))
it('should identify ppt files', () => {
expect(getFileAppearanceType('doc.ppt', 'application/vnd.ms-powerpoint'))
.toBe(FileAppearanceTypeEnum.ppt)
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pptx']))
expect(getFileAppearanceType('doc.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'))
.toBe(FileAppearanceTypeEnum.ppt)
})
it('should identify document files', () => {
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['txt']))
expect(getFileAppearanceType('file.txt', 'text/plain'))
.toBe(FileAppearanceTypeEnum.document)
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['csv']))
expect(getFileAppearanceType('file.csv', 'text/csv'))
.toBe(FileAppearanceTypeEnum.document)
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['msg']))
expect(getFileAppearanceType('file.msg', 'application/vnd.ms-outlook'))
.toBe(FileAppearanceTypeEnum.document)
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['eml']))
expect(getFileAppearanceType('file.eml', 'message/rfc822'))
.toBe(FileAppearanceTypeEnum.document)
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['xml']))
expect(getFileAppearanceType('file.xml', 'application/rssxml'))
expect(getFileAppearanceType('file.xml', 'application/xml'))
.toBe(FileAppearanceTypeEnum.document)
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['epub']))
expect(getFileAppearanceType('file.epub', 'application/epubzip'))
expect(getFileAppearanceType('file.epub', 'application/epub+zip'))
.toBe(FileAppearanceTypeEnum.document)
})
it('should handle null mime extension', () => {
vi.mocked(mime.getAllExtensions).mockReturnValue(null)
expect(getFileAppearanceType('file.txt', 'text/plain'))
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', () => {
@@ -278,25 +325,70 @@ describe('file-uploader utils', () => {
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', () => {
beforeEach(() => {
vi.mocked(mime.getAllExtensions).mockImplementation((mimeType: string) => {
const mimeMap: Record<string, Set<string>> = {
'image/jpeg': new Set(['jpg', 'jpeg']),
'image/png': new Set(['png']),
'image/gif': new Set(['gif']),
'video/mp4': new Set(['mp4']),
'audio/mp3': new Set(['mp3']),
'application/pdf': new Set(['pdf']),
'text/plain': new Set(['txt']),
'application/json': new Set(['json']),
}
return mimeMap[mimeType] || new Set()
})
})
it('should process files correctly without type correction', () => {
const files = [{
related_id: '2a38e2ca-1295-415d-a51d-65d4ff9912d9',
@@ -367,7 +459,7 @@ describe('file-uploader utils', () => {
extension: '.mp3',
filename: 'audio.mp3',
size: 1024,
mime_type: 'audio/mp3',
mime_type: 'audio/mpeg',
transfer_method: TransferMethod.local_file,
type: 'document',
url: 'https://example.com/audio.mp3',
@@ -415,7 +507,7 @@ describe('file-uploader utils', () => {
expect(result[0].supportFileType).toBe('document')
})
it('should NOT correct when filename and MIME type both point to wrong type', () => {
it('should NOT correct when filename and MIME type both point to same type', () => {
const files = [{
related_id: '123',
extension: '.jpg',
@@ -540,6 +632,11 @@ describe('file-uploader utils', () => {
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', () => {
@@ -599,7 +696,6 @@ describe('file-uploader utils', () => {
describe('isAllowedFileExtension', () => {
it('should validate allowed file extensions', () => {
vi.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf']))
expect(isAllowedFileExtension(
'test.pdf',
'application/pdf',

View File

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

View File

@@ -1,5 +1,4 @@
import type { FC } from 'react'
import { RiCloseLine } from '@remixicon/react'
import * as React from 'react'
import { createPortal } from 'react-dom'
import { useHotkeys } from 'react-hotkeys-hook'
@@ -35,7 +34,7 @@ const VideoPreview: FC<VideoPreviewProps> = ({
className="absolute right-6 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/[0.08] backdrop-blur-[2px]"
onClick={onCancel}
>
<RiCloseLine className="h-4 w-4 text-gray-500" />
<span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="video-preview-close-btn" />
</div>
</div>,
document.body,

View File

@@ -1,5 +1,5 @@
import type { FC } from 'react'
import { RiAddBoxLine, RiCloseLine, RiDownloadCloud2Line, RiFileCopyLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
import { RiAddBoxLine, RiDownloadCloud2Line, RiFileCopyLine, RiZoomInLine, RiZoomOutLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { t } from 'i18next'
import * as React from 'react'
@@ -256,7 +256,7 @@ const ImagePreview: FC<ImagePreviewProps> = ({
className="absolute right-6 top-6 flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-white/8 backdrop-blur-[2px]"
onClick={onCancel}
>
<RiCloseLine className="h-4 w-4 text-gray-500" />
<span className="i-ri-close-line h-4 w-4 text-gray-500" data-testid="image-preview-close-button" />
</div>
</Tooltip>
</div>,

View File

@@ -1953,11 +1953,6 @@
"count": 1
}
},
"app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 2
}
},
"app/components/base/file-uploader/hooks.ts": {
"ts/no-explicit-any": {
"count": 3
@@ -1969,9 +1964,6 @@
}
},
"app/components/base/file-uploader/utils.spec.ts": {
"test/no-identical-title": {
"count": 1
},
"ts/no-explicit-any": {
"count": 2
}