mirror of
https://github.com/langgenius/dify.git
synced 2026-02-24 18:05:11 +00:00
test: add tests for file-upload components (#32373)
Co-authored-by: sahil <sahil@infocusp.com>
This commit is contained in:
69
web/app/components/base/file-uploader/audio-preview.spec.tsx
Normal file
69
web/app/components/base/file-uploader/audio-preview.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
71
web/app/components/base/file-uploader/constants.spec.ts
Normal file
71
web/app/components/base/file-uploader/constants.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
179
web/app/components/base/file-uploader/file-input.spec.tsx
Normal file
179
web/app/components/base/file-uploader/file-input.spec.tsx
Normal 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('')
|
||||
})
|
||||
})
|
||||
142
web/app/components/base/file-uploader/file-list-in-log.spec.tsx
Normal file
142
web/app/components/base/file-uploader/file-list-in-log.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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: '',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
867
web/app/components/base/file-uploader/hooks.spec.ts
Normal file
867
web/app/components/base/file-uploader/hooks.spec.ts
Normal file
@@ -0,0 +1,867 @@
|
||||
import type { FileEntity } from './types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import type { FileUploadConfigResponse } from '@/models/common'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useFile, useFileSizeLimit } from './hooks'
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ token: undefined }),
|
||||
}))
|
||||
|
||||
// Exception: hook requires toast context that isn't available without a provider wrapper
|
||||
vi.mock('@/app/components/base/toast', () => ({
|
||||
useToastContext: () => ({
|
||||
notify: mockNotify,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockSetFiles = vi.fn()
|
||||
let mockStoreFiles: FileEntity[] = []
|
||||
vi.mock('./store', () => ({
|
||||
useFileStore: () => ({
|
||||
getState: () => ({
|
||||
files: mockStoreFiles,
|
||||
setFiles: mockSetFiles,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockFileUpload = vi.fn()
|
||||
const mockIsAllowedFileExtension = vi.fn().mockReturnValue(true)
|
||||
const mockGetSupportFileType = vi.fn().mockReturnValue('document')
|
||||
vi.mock('./utils', () => ({
|
||||
fileUpload: (...args: unknown[]) => mockFileUpload(...args),
|
||||
getFileUploadErrorMessage: vi.fn().mockReturnValue('Upload error'),
|
||||
getSupportFileType: (...args: unknown[]) => mockGetSupportFileType(...args),
|
||||
isAllowedFileExtension: (...args: unknown[]) => mockIsAllowedFileExtension(...args),
|
||||
}))
|
||||
|
||||
const mockUploadRemoteFileInfo = vi.fn()
|
||||
vi.mock('@/service/common', () => ({
|
||||
uploadRemoteFileInfo: (...args: unknown[]) => mockUploadRemoteFileInfo(...args),
|
||||
}))
|
||||
|
||||
vi.mock('uuid', () => ({
|
||||
v4: () => 'mock-uuid',
|
||||
}))
|
||||
|
||||
describe('useFileSizeLimit', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should return default limits when no config is provided', () => {
|
||||
const { result } = renderHook(() => useFileSizeLimit())
|
||||
|
||||
expect(result.current.imgSizeLimit).toBe(10 * 1024 * 1024)
|
||||
expect(result.current.docSizeLimit).toBe(15 * 1024 * 1024)
|
||||
expect(result.current.audioSizeLimit).toBe(50 * 1024 * 1024)
|
||||
expect(result.current.videoSizeLimit).toBe(100 * 1024 * 1024)
|
||||
expect(result.current.maxFileUploadLimit).toBe(10)
|
||||
})
|
||||
|
||||
it('should use config values when provided', () => {
|
||||
const config: FileUploadConfigResponse = {
|
||||
image_file_size_limit: 20,
|
||||
file_size_limit: 30,
|
||||
audio_file_size_limit: 100,
|
||||
video_file_size_limit: 200,
|
||||
workflow_file_upload_limit: 20,
|
||||
} as FileUploadConfigResponse
|
||||
|
||||
const { result } = renderHook(() => useFileSizeLimit(config))
|
||||
|
||||
expect(result.current.imgSizeLimit).toBe(20 * 1024 * 1024)
|
||||
expect(result.current.docSizeLimit).toBe(30 * 1024 * 1024)
|
||||
expect(result.current.audioSizeLimit).toBe(100 * 1024 * 1024)
|
||||
expect(result.current.videoSizeLimit).toBe(200 * 1024 * 1024)
|
||||
expect(result.current.maxFileUploadLimit).toBe(20)
|
||||
})
|
||||
|
||||
it('should fall back to defaults when config values are zero', () => {
|
||||
const config = {
|
||||
image_file_size_limit: 0,
|
||||
file_size_limit: 0,
|
||||
audio_file_size_limit: 0,
|
||||
video_file_size_limit: 0,
|
||||
workflow_file_upload_limit: 0,
|
||||
} as FileUploadConfigResponse
|
||||
|
||||
const { result } = renderHook(() => useFileSizeLimit(config))
|
||||
|
||||
expect(result.current.imgSizeLimit).toBe(10 * 1024 * 1024)
|
||||
expect(result.current.docSizeLimit).toBe(15 * 1024 * 1024)
|
||||
expect(result.current.audioSizeLimit).toBe(50 * 1024 * 1024)
|
||||
expect(result.current.videoSizeLimit).toBe(100 * 1024 * 1024)
|
||||
expect(result.current.maxFileUploadLimit).toBe(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useFile', () => {
|
||||
const defaultFileConfig: FileUpload = {
|
||||
enabled: true,
|
||||
allowed_file_types: ['image', 'document'],
|
||||
allowed_file_extensions: [],
|
||||
number_limits: 5,
|
||||
} as FileUpload
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStoreFiles = []
|
||||
mockIsAllowedFileExtension.mockReturnValue(true)
|
||||
mockGetSupportFileType.mockReturnValue('document')
|
||||
})
|
||||
|
||||
it('should return all file handler functions', () => {
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
expect(result.current.handleAddFile).toBeDefined()
|
||||
expect(result.current.handleUpdateFile).toBeDefined()
|
||||
expect(result.current.handleRemoveFile).toBeDefined()
|
||||
expect(result.current.handleReUploadFile).toBeDefined()
|
||||
expect(result.current.handleLoadFileFromLink).toBeDefined()
|
||||
expect(result.current.handleLoadFileFromLinkSuccess).toBeDefined()
|
||||
expect(result.current.handleLoadFileFromLinkError).toBeDefined()
|
||||
expect(result.current.handleClearFiles).toBeDefined()
|
||||
expect(result.current.handleLocalFileUpload).toBeDefined()
|
||||
expect(result.current.handleClipboardPasteFile).toBeDefined()
|
||||
expect(result.current.handleDragFileEnter).toBeDefined()
|
||||
expect(result.current.handleDragFileOver).toBeDefined()
|
||||
expect(result.current.handleDragFileLeave).toBeDefined()
|
||||
expect(result.current.handleDropFile).toBeDefined()
|
||||
expect(result.current.isDragActive).toBe(false)
|
||||
})
|
||||
|
||||
it('should add a file via handleAddFile', () => {
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
result.current.handleAddFile({
|
||||
id: 'test-id',
|
||||
name: 'test.txt',
|
||||
type: 'text/plain',
|
||||
size: 100,
|
||||
progress: 0,
|
||||
transferMethod: 'local_file',
|
||||
supportFileType: 'document',
|
||||
} as FileEntity)
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update a file via handleUpdateFile', () => {
|
||||
mockStoreFiles = [{ id: 'file-1', name: 'a.txt', progress: 0 }] as FileEntity[]
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
result.current.handleUpdateFile({ id: 'file-1', name: 'a.txt', progress: 50 } as FileEntity)
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not update file when id is not found', () => {
|
||||
mockStoreFiles = [{ id: 'file-1', name: 'a.txt' }] as FileEntity[]
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
result.current.handleUpdateFile({ id: 'nonexistent' } as FileEntity)
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should remove a file via handleRemoveFile', () => {
|
||||
mockStoreFiles = [{ id: 'file-1', name: 'a.txt' }] as FileEntity[]
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
result.current.handleRemoveFile('file-1')
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should clear all files via handleClearFiles', () => {
|
||||
mockStoreFiles = [{ id: 'a' }] as FileEntity[]
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
result.current.handleClearFiles()
|
||||
expect(mockSetFiles).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
describe('handleReUploadFile', () => {
|
||||
it('should re-upload a file and call fileUpload', () => {
|
||||
const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
mockStoreFiles = [{
|
||||
id: 'file-1',
|
||||
name: 'test.txt',
|
||||
type: 'text/plain',
|
||||
size: 100,
|
||||
progress: -1,
|
||||
transferMethod: 'local_file',
|
||||
supportFileType: 'document',
|
||||
originalFile,
|
||||
}] as FileEntity[]
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
result.current.handleReUploadFile('file-1')
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
expect(mockFileUpload).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not re-upload when file id is not found', () => {
|
||||
mockStoreFiles = []
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
result.current.handleReUploadFile('nonexistent')
|
||||
expect(mockFileUpload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle progress callback during re-upload', () => {
|
||||
const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
mockStoreFiles = [{
|
||||
id: 'file-1',
|
||||
name: 'test.txt',
|
||||
type: 'text/plain',
|
||||
size: 100,
|
||||
progress: -1,
|
||||
transferMethod: 'local_file',
|
||||
supportFileType: 'document',
|
||||
originalFile,
|
||||
}] as FileEntity[]
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleReUploadFile('file-1')
|
||||
|
||||
const uploadCall = mockFileUpload.mock.calls[0][0]
|
||||
uploadCall.onProgressCallback(50)
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle success callback during re-upload', () => {
|
||||
const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
mockStoreFiles = [{
|
||||
id: 'file-1',
|
||||
name: 'test.txt',
|
||||
type: 'text/plain',
|
||||
size: 100,
|
||||
progress: -1,
|
||||
transferMethod: 'local_file',
|
||||
supportFileType: 'document',
|
||||
originalFile,
|
||||
}] as FileEntity[]
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleReUploadFile('file-1')
|
||||
|
||||
const uploadCall = mockFileUpload.mock.calls[0][0]
|
||||
uploadCall.onSuccessCallback({ id: 'uploaded-1' })
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle error callback during re-upload', () => {
|
||||
const originalFile = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
mockStoreFiles = [{
|
||||
id: 'file-1',
|
||||
name: 'test.txt',
|
||||
type: 'text/plain',
|
||||
size: 100,
|
||||
progress: -1,
|
||||
transferMethod: 'local_file',
|
||||
supportFileType: 'document',
|
||||
originalFile,
|
||||
}] as FileEntity[]
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleReUploadFile('file-1')
|
||||
|
||||
const uploadCall = mockFileUpload.mock.calls[0][0]
|
||||
uploadCall.onErrorCallback(new Error('fail'))
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleLoadFileFromLink', () => {
|
||||
it('should run startProgressTimer to increment file progress', () => {
|
||||
vi.useFakeTimers()
|
||||
mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {})) // never resolves
|
||||
|
||||
// Set up a file in the store that has progress 0
|
||||
mockStoreFiles = [{
|
||||
id: 'mock-uuid',
|
||||
name: 'https://example.com/file.txt',
|
||||
type: '',
|
||||
size: 0,
|
||||
progress: 0,
|
||||
transferMethod: 'remote_url',
|
||||
supportFileType: '',
|
||||
}] as FileEntity[]
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLoadFileFromLink('https://example.com/file.txt')
|
||||
|
||||
// Advance timer to trigger the interval
|
||||
vi.advanceTimersByTime(200)
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should add file and call uploadRemoteFileInfo', () => {
|
||||
mockUploadRemoteFileInfo.mockResolvedValue({
|
||||
id: 'remote-1',
|
||||
mime_type: 'text/plain',
|
||||
size: 100,
|
||||
name: 'remote.txt',
|
||||
url: 'https://example.com/remote.txt',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLoadFileFromLink('https://example.com/file.txt')
|
||||
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
expect(mockUploadRemoteFileInfo).toHaveBeenCalledWith('https://example.com/file.txt', false)
|
||||
})
|
||||
|
||||
it('should remove file when extension is not allowed', async () => {
|
||||
mockIsAllowedFileExtension.mockReturnValue(false)
|
||||
mockUploadRemoteFileInfo.mockResolvedValue({
|
||||
id: 'remote-1',
|
||||
mime_type: 'text/plain',
|
||||
size: 100,
|
||||
name: 'remote.txt',
|
||||
url: 'https://example.com/remote.txt',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
await act(async () => {
|
||||
result.current.handleLoadFileFromLink('https://example.com/file.txt')
|
||||
await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should use empty arrays when allowed_file_types and allowed_file_extensions are undefined', async () => {
|
||||
mockIsAllowedFileExtension.mockReturnValue(false)
|
||||
mockUploadRemoteFileInfo.mockResolvedValue({
|
||||
id: 'remote-1',
|
||||
mime_type: 'text/plain',
|
||||
size: 100,
|
||||
name: 'remote.txt',
|
||||
url: 'https://example.com/remote.txt',
|
||||
})
|
||||
|
||||
const configWithUndefined = {
|
||||
...defaultFileConfig,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
} as unknown as FileUpload
|
||||
|
||||
const { result } = renderHook(() => useFile(configWithUndefined))
|
||||
await act(async () => {
|
||||
result.current.handleLoadFileFromLink('https://example.com/file.txt')
|
||||
await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
expect(mockIsAllowedFileExtension).toHaveBeenCalledWith('remote.txt', 'text/plain', [], [])
|
||||
})
|
||||
|
||||
it('should remove file when remote upload fails', async () => {
|
||||
mockUploadRemoteFileInfo.mockRejectedValue(new Error('network error'))
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
await act(async () => {
|
||||
result.current.handleLoadFileFromLink('https://example.com/file.txt')
|
||||
await vi.waitFor(() => expect(mockNotify).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should remove file when size limit is exceeded on remote upload', async () => {
|
||||
mockGetSupportFileType.mockReturnValue('image')
|
||||
mockUploadRemoteFileInfo.mockResolvedValue({
|
||||
id: 'remote-1',
|
||||
mime_type: 'image/png',
|
||||
size: 20 * 1024 * 1024,
|
||||
name: 'large.png',
|
||||
url: 'https://example.com/large.png',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
await act(async () => {
|
||||
result.current.handleLoadFileFromLink('https://example.com/large.png')
|
||||
await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
// File should be removed because image exceeds 10MB limit
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should update file on successful remote upload within limits', async () => {
|
||||
mockUploadRemoteFileInfo.mockResolvedValue({
|
||||
id: 'remote-1',
|
||||
mime_type: 'text/plain',
|
||||
size: 100,
|
||||
name: 'remote.txt',
|
||||
url: 'https://example.com/remote.txt',
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
await act(async () => {
|
||||
result.current.handleLoadFileFromLink('https://example.com/remote.txt')
|
||||
await vi.waitFor(() => expect(mockUploadRemoteFileInfo).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
// setFiles should be called: once for add, once for update
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop progress timer when file reaches 80 percent', () => {
|
||||
vi.useFakeTimers()
|
||||
mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {}))
|
||||
|
||||
// Set up a file already at 80% progress
|
||||
mockStoreFiles = [{
|
||||
id: 'mock-uuid',
|
||||
name: 'https://example.com/file.txt',
|
||||
type: '',
|
||||
size: 0,
|
||||
progress: 80,
|
||||
transferMethod: 'remote_url',
|
||||
supportFileType: '',
|
||||
}] as FileEntity[]
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLoadFileFromLink('https://example.com/file.txt')
|
||||
|
||||
// At progress 80, the timer should stop (clearTimeout path)
|
||||
vi.advanceTimersByTime(200)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should stop progress timer when progress is negative', () => {
|
||||
vi.useFakeTimers()
|
||||
mockUploadRemoteFileInfo.mockReturnValue(new Promise(() => {}))
|
||||
|
||||
// Set up a file with negative progress (error state)
|
||||
mockStoreFiles = [{
|
||||
id: 'mock-uuid',
|
||||
name: 'https://example.com/file.txt',
|
||||
type: '',
|
||||
size: 0,
|
||||
progress: -1,
|
||||
transferMethod: 'remote_url',
|
||||
supportFileType: '',
|
||||
}] as FileEntity[]
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLoadFileFromLink('https://example.com/file.txt')
|
||||
|
||||
vi.advanceTimersByTime(200)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleLocalFileUpload', () => {
|
||||
let capturedListeners: Record<string, (() => void)[]>
|
||||
let mockReaderResult: string | null
|
||||
|
||||
beforeEach(() => {
|
||||
capturedListeners = {}
|
||||
mockReaderResult = 'data:text/plain;base64,Y29udGVudA=='
|
||||
|
||||
class MockFileReader {
|
||||
result: string | null = null
|
||||
addEventListener(event: string, handler: () => void) {
|
||||
if (!capturedListeners[event])
|
||||
capturedListeners[event] = []
|
||||
capturedListeners[event].push(handler)
|
||||
}
|
||||
|
||||
readAsDataURL() {
|
||||
this.result = mockReaderResult
|
||||
capturedListeners.load?.forEach(handler => handler())
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('FileReader', MockFileReader)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('should upload a local file', () => {
|
||||
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reject file with unsupported extension', () => {
|
||||
mockIsAllowedFileExtension.mockReturnValue(false)
|
||||
const file = new File(['content'], 'test.xyz', { type: 'application/xyz' })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
expect(mockSetFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use empty arrays when allowed_file_types and allowed_file_extensions are undefined', () => {
|
||||
mockIsAllowedFileExtension.mockReturnValue(false)
|
||||
const file = new File(['content'], 'test.xyz', { type: 'application/xyz' })
|
||||
|
||||
const configWithUndefined = {
|
||||
...defaultFileConfig,
|
||||
allowed_file_types: undefined,
|
||||
allowed_file_extensions: undefined,
|
||||
} as unknown as FileUpload
|
||||
|
||||
const { result } = renderHook(() => useFile(configWithUndefined))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockIsAllowedFileExtension).toHaveBeenCalledWith('test.xyz', 'application/xyz', [], [])
|
||||
})
|
||||
|
||||
it('should reject file when upload is disabled and noNeedToCheckEnable is false', () => {
|
||||
const disabledConfig = { ...defaultFileConfig, enabled: false } as FileUpload
|
||||
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
|
||||
const { result } = renderHook(() => useFile(disabledConfig, false))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should reject image file exceeding size limit', () => {
|
||||
mockGetSupportFileType.mockReturnValue('image')
|
||||
const largeFile = new File([new ArrayBuffer(20 * 1024 * 1024)], 'large.png', { type: 'image/png' })
|
||||
Object.defineProperty(largeFile, 'size', { value: 20 * 1024 * 1024 })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(largeFile)
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should reject audio file exceeding size limit', () => {
|
||||
mockGetSupportFileType.mockReturnValue('audio')
|
||||
const largeFile = new File([], 'large.mp3', { type: 'audio/mpeg' })
|
||||
Object.defineProperty(largeFile, 'size', { value: 60 * 1024 * 1024 })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(largeFile)
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should reject video file exceeding size limit', () => {
|
||||
mockGetSupportFileType.mockReturnValue('video')
|
||||
const largeFile = new File([], 'large.mp4', { type: 'video/mp4' })
|
||||
Object.defineProperty(largeFile, 'size', { value: 200 * 1024 * 1024 })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(largeFile)
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should reject document file exceeding size limit', () => {
|
||||
mockGetSupportFileType.mockReturnValue('document')
|
||||
const largeFile = new File([], 'large.pdf', { type: 'application/pdf' })
|
||||
Object.defineProperty(largeFile, 'size', { value: 20 * 1024 * 1024 })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(largeFile)
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should reject custom file exceeding document size limit', () => {
|
||||
mockGetSupportFileType.mockReturnValue('custom')
|
||||
const largeFile = new File([], 'large.xyz', { type: 'application/octet-stream' })
|
||||
Object.defineProperty(largeFile, 'size', { value: 20 * 1024 * 1024 })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(largeFile)
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should allow custom file within document size limit', () => {
|
||||
mockGetSupportFileType.mockReturnValue('custom')
|
||||
const file = new File(['content'], 'file.xyz', { type: 'application/octet-stream' })
|
||||
Object.defineProperty(file, 'size', { value: 1024 })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow document file within size limit', () => {
|
||||
mockGetSupportFileType.mockReturnValue('document')
|
||||
const file = new File(['content'], 'small.pdf', { type: 'application/pdf' })
|
||||
Object.defineProperty(file, 'size', { value: 1024 })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow file with unknown type (default case)', () => {
|
||||
mockGetSupportFileType.mockReturnValue('unknown')
|
||||
const file = new File(['content'], 'test.bin', { type: 'application/octet-stream' })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
// Should not be rejected - unknown type passes checkSizeLimit
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow image file within size limit', () => {
|
||||
mockGetSupportFileType.mockReturnValue('image')
|
||||
const file = new File(['content'], 'small.png', { type: 'image/png' })
|
||||
Object.defineProperty(file, 'size', { value: 1024 })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow audio file within size limit', () => {
|
||||
mockGetSupportFileType.mockReturnValue('audio')
|
||||
const file = new File(['content'], 'small.mp3', { type: 'audio/mpeg' })
|
||||
Object.defineProperty(file, 'size', { value: 1024 })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow video file within size limit', () => {
|
||||
mockGetSupportFileType.mockReturnValue('video')
|
||||
const file = new File(['content'], 'small.mp4', { type: 'video/mp4' })
|
||||
Object.defineProperty(file, 'size', { value: 1024 })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockNotify).not.toHaveBeenCalled()
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set base64Url for image files during upload', () => {
|
||||
mockGetSupportFileType.mockReturnValue('image')
|
||||
const file = new File(['content'], 'photo.png', { type: 'image/png' })
|
||||
Object.defineProperty(file, 'size', { value: 1024 })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
// The file should have been added with base64Url set (for image type)
|
||||
const addedFiles = mockSetFiles.mock.calls[0][0]
|
||||
expect(addedFiles[0].base64Url).toBe('data:text/plain;base64,Y29udGVudA==')
|
||||
})
|
||||
|
||||
it('should set empty base64Url for non-image files during upload', () => {
|
||||
mockGetSupportFileType.mockReturnValue('document')
|
||||
const file = new File(['content'], 'doc.pdf', { type: 'application/pdf' })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
const addedFiles = mockSetFiles.mock.calls[0][0]
|
||||
expect(addedFiles[0].base64Url).toBe('')
|
||||
})
|
||||
|
||||
it('should call fileUpload with callbacks after FileReader loads', () => {
|
||||
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockFileUpload).toHaveBeenCalled()
|
||||
const uploadCall = mockFileUpload.mock.calls[0][0]
|
||||
|
||||
// Test progress callback
|
||||
uploadCall.onProgressCallback(50)
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
|
||||
// Test success callback
|
||||
uploadCall.onSuccessCallback({ id: 'uploaded-1' })
|
||||
expect(mockSetFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle fileUpload error callback', () => {
|
||||
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
const uploadCall = mockFileUpload.mock.calls[0][0]
|
||||
uploadCall.onErrorCallback(new Error('upload failed'))
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
|
||||
it('should handle FileReader error event', () => {
|
||||
capturedListeners = {}
|
||||
const errorListeners: (() => void)[] = []
|
||||
|
||||
class ErrorFileReader {
|
||||
result: string | null = null
|
||||
addEventListener(event: string, handler: () => void) {
|
||||
if (event === 'error')
|
||||
errorListeners.push(handler)
|
||||
if (!capturedListeners[event])
|
||||
capturedListeners[event] = []
|
||||
capturedListeners[event].push(handler)
|
||||
}
|
||||
|
||||
readAsDataURL() {
|
||||
// Simulate error instead of load
|
||||
errorListeners.forEach(handler => handler())
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('FileReader', ErrorFileReader)
|
||||
|
||||
const file = new File(['content'], 'test.txt', { type: 'text/plain' })
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
result.current.handleLocalFileUpload(file)
|
||||
|
||||
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleClipboardPasteFile', () => {
|
||||
it('should handle file paste from clipboard', () => {
|
||||
const file = new File(['content'], 'pasted.png', { type: 'image/png' })
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
const event = {
|
||||
clipboardData: {
|
||||
files: [file],
|
||||
getData: () => '',
|
||||
},
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as React.ClipboardEvent<HTMLTextAreaElement>
|
||||
|
||||
result.current.handleClipboardPasteFile(event)
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not handle paste when text is present', () => {
|
||||
const file = new File(['content'], 'pasted.png', { type: 'image/png' })
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
const event = {
|
||||
clipboardData: {
|
||||
files: [file],
|
||||
getData: () => 'some text',
|
||||
},
|
||||
preventDefault: vi.fn(),
|
||||
} as unknown as React.ClipboardEvent<HTMLTextAreaElement>
|
||||
|
||||
result.current.handleClipboardPasteFile(event)
|
||||
expect(event.preventDefault).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('drag and drop handlers', () => {
|
||||
it('should set isDragActive on drag enter', () => {
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
const event = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent<HTMLElement>
|
||||
act(() => {
|
||||
result.current.handleDragFileEnter(event)
|
||||
})
|
||||
|
||||
expect(result.current.isDragActive).toBe(true)
|
||||
})
|
||||
|
||||
it('should call preventDefault on drag over', () => {
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
const event = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent<HTMLElement>
|
||||
result.current.handleDragFileOver(event)
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should unset isDragActive on drag leave', () => {
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
const enterEvent = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent<HTMLElement>
|
||||
act(() => {
|
||||
result.current.handleDragFileEnter(enterEvent)
|
||||
})
|
||||
expect(result.current.isDragActive).toBe(true)
|
||||
|
||||
const leaveEvent = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.DragEvent<HTMLElement>
|
||||
act(() => {
|
||||
result.current.handleDragFileLeave(leaveEvent)
|
||||
})
|
||||
expect(result.current.isDragActive).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle file drop', () => {
|
||||
const file = new File(['content'], 'dropped.txt', { type: 'text/plain' })
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
const event = {
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
dataTransfer: { files: [file] },
|
||||
} as unknown as React.DragEvent<HTMLElement>
|
||||
|
||||
act(() => {
|
||||
result.current.handleDropFile(event)
|
||||
})
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalled()
|
||||
expect(result.current.isDragActive).toBe(false)
|
||||
})
|
||||
|
||||
it('should not upload when no file is dropped', () => {
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
const event = {
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
dataTransfer: { files: [] },
|
||||
} as unknown as React.DragEvent<HTMLElement>
|
||||
|
||||
act(() => {
|
||||
result.current.handleDropFile(event)
|
||||
})
|
||||
|
||||
// No file upload should be triggered
|
||||
expect(mockSetFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('noop handlers', () => {
|
||||
it('should have handleLoadFileFromLinkSuccess as noop', () => {
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
expect(() => result.current.handleLoadFileFromLinkSuccess()).not.toThrow()
|
||||
})
|
||||
|
||||
it('should have handleLoadFileFromLinkError as noop', () => {
|
||||
const { result } = renderHook(() => useFile(defaultFileConfig))
|
||||
|
||||
expect(() => result.current.handleLoadFileFromLinkError()).not.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { PdfHighlighter, PdfLoader } from 'react-pdf-highlighter'
|
||||
import 'react-pdf-highlighter/dist/style.css'
|
||||
|
||||
export {
|
||||
PdfHighlighter,
|
||||
PdfLoader,
|
||||
}
|
||||
142
web/app/components/base/file-uploader/pdf-preview.spec.tsx
Normal file
142
web/app/components/base/file-uploader/pdf-preview.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
168
web/app/components/base/file-uploader/store.spec.tsx
Normal file
168
web/app/components/base/file-uploader/store.spec.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { FileEntity } from './types'
|
||||
import { render, renderHook, screen } from '@testing-library/react'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { createFileStore, FileContext, FileContextProvider, useFileStore, useStore } from './store'
|
||||
|
||||
const createMockFile = (overrides: Partial<FileEntity> = {}): FileEntity => ({
|
||||
id: 'file-1',
|
||||
name: 'test.txt',
|
||||
size: 1024,
|
||||
type: 'text/plain',
|
||||
progress: 100,
|
||||
transferMethod: TransferMethod.local_file,
|
||||
supportFileType: 'document',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('createFileStore', () => {
|
||||
it('should create a store with empty files by default', () => {
|
||||
const store = createFileStore()
|
||||
expect(store.getState().files).toEqual([])
|
||||
})
|
||||
|
||||
it('should create a store with empty array when value is falsy', () => {
|
||||
const store = createFileStore(undefined)
|
||||
expect(store.getState().files).toEqual([])
|
||||
})
|
||||
|
||||
it('should create a store with initial files', () => {
|
||||
const files = [createMockFile()]
|
||||
const store = createFileStore(files)
|
||||
expect(store.getState().files).toEqual(files)
|
||||
})
|
||||
|
||||
it('should spread initial value to create a new array', () => {
|
||||
const files = [createMockFile()]
|
||||
const store = createFileStore(files)
|
||||
expect(store.getState().files).not.toBe(files)
|
||||
expect(store.getState().files).toEqual(files)
|
||||
})
|
||||
|
||||
it('should update files via setFiles', () => {
|
||||
const store = createFileStore()
|
||||
const newFiles = [createMockFile()]
|
||||
store.getState().setFiles(newFiles)
|
||||
expect(store.getState().files).toEqual(newFiles)
|
||||
})
|
||||
|
||||
it('should call onChange when setFiles is called', () => {
|
||||
const onChange = vi.fn()
|
||||
const store = createFileStore([], onChange)
|
||||
const newFiles = [createMockFile()]
|
||||
store.getState().setFiles(newFiles)
|
||||
expect(onChange).toHaveBeenCalledWith(newFiles)
|
||||
})
|
||||
|
||||
it('should not throw when onChange is not provided', () => {
|
||||
const store = createFileStore()
|
||||
expect(() => store.getState().setFiles([])).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useStore', () => {
|
||||
it('should return selected state from the store', () => {
|
||||
const files = [createMockFile()]
|
||||
const store = createFileStore(files)
|
||||
|
||||
const { result } = renderHook(() => useStore(s => s.files), {
|
||||
wrapper: ({ children }) => (
|
||||
<FileContext.Provider value={store}>{children}</FileContext.Provider>
|
||||
),
|
||||
})
|
||||
|
||||
expect(result.current).toEqual(files)
|
||||
})
|
||||
|
||||
it('should throw when used without FileContext.Provider', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
expect(() => {
|
||||
renderHook(() => useStore(s => s.files))
|
||||
}).toThrow('Missing FileContext.Provider in the tree')
|
||||
|
||||
consoleError.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useFileStore', () => {
|
||||
it('should return the store from context', () => {
|
||||
const store = createFileStore()
|
||||
|
||||
const { result } = renderHook(() => useFileStore(), {
|
||||
wrapper: ({ children }) => (
|
||||
<FileContext.Provider value={store}>{children}</FileContext.Provider>
|
||||
),
|
||||
})
|
||||
|
||||
expect(result.current).toBe(store)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FileContextProvider', () => {
|
||||
it('should render children', () => {
|
||||
render(
|
||||
<FileContextProvider>
|
||||
<div data-testid="child">Hello</div>
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('child')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should provide a store to children', () => {
|
||||
const TestChild = () => {
|
||||
const files = useStore(s => s.files)
|
||||
return <div data-testid="files">{files.length}</div>
|
||||
}
|
||||
|
||||
render(
|
||||
<FileContextProvider>
|
||||
<TestChild />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('files')).toHaveTextContent('0')
|
||||
})
|
||||
|
||||
it('should initialize store with value prop', () => {
|
||||
const files = [createMockFile()]
|
||||
const TestChild = () => {
|
||||
const storeFiles = useStore(s => s.files)
|
||||
return <div data-testid="files">{storeFiles.length}</div>
|
||||
}
|
||||
|
||||
render(
|
||||
<FileContextProvider value={files}>
|
||||
<TestChild />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('files')).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('should reuse store on re-render instead of creating a new one', () => {
|
||||
const TestChild = () => {
|
||||
const storeFiles = useStore(s => s.files)
|
||||
return <div data-testid="files">{storeFiles.length}</div>
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<FileContextProvider>
|
||||
<TestChild />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('files')).toHaveTextContent('0')
|
||||
|
||||
// Re-render with new value prop - store should be reused (storeRef.current exists)
|
||||
rerender(
|
||||
<FileContextProvider value={[createMockFile()]}>
|
||||
<TestChild />
|
||||
</FileContextProvider>,
|
||||
)
|
||||
|
||||
// Store was created once on first render, so the value prop change won't create a new store
|
||||
// The files count should still be 0 since storeRef.current is already set
|
||||
expect(screen.getByTestId('files')).toHaveTextContent('0')
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
69
web/app/components/base/file-uploader/video-preview.spec.tsx
Normal file
69
web/app/components/base/file-uploader/video-preview.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user