feat(T05): Dateiablage pro Fahrzeug + File UI with thumbnails and gallery
This commit is contained in:
@@ -0,0 +1,443 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { FileUpload } from '@/components/files/FileUpload';
|
||||
import { FileList } from '@/components/files/FileList';
|
||||
import { FileGallery } from '@/components/files/FileGallery';
|
||||
import { FilePreview } from '@/components/files/FilePreview';
|
||||
import {
|
||||
formatFileSize,
|
||||
isImageMime,
|
||||
MAX_FILE_SIZE_MB,
|
||||
} from '@/lib/files';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
|
||||
// Mock the api module
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiFetch: vi.fn(),
|
||||
API_BASE_URL: 'http://localhost:8000/api/v1',
|
||||
}));
|
||||
|
||||
const mockedApiFetch = vi.mocked(apiFetch);
|
||||
|
||||
// Mock fetch for file operations
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as any;
|
||||
|
||||
// Mock URL.createObjectURL and URL.revokeObjectURL
|
||||
global.URL.createObjectURL = vi.fn(() => 'blob:http://test/blob123');
|
||||
global.URL.revokeObjectURL = vi.fn();
|
||||
|
||||
describe('FileUpload', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 201,
|
||||
json: async () => ({
|
||||
id: 'file-1',
|
||||
vehicle_id: 'veh-1',
|
||||
original_filename: 'test.jpg',
|
||||
stored_filename: 'abc123.jpg',
|
||||
file_path: '/tmp/uploads/veh-1/abc123.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
file_size: 1024,
|
||||
thumbnail_path: '/tmp/uploads/veh-1/thumbnails/thumb_abc123.jpg',
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('renders drag-and-drop zone', () => {
|
||||
render(<FileUpload vehicleId="veh-1" />);
|
||||
expect(screen.getByTestId('dropzone')).toBeDefined();
|
||||
expect(screen.getByTestId('file-input')).toBeDefined();
|
||||
expect(screen.getByText(/Drag & drop files or click to browse/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows supported file types and size limit', () => {
|
||||
render(<FileUpload vehicleId="veh-1" />);
|
||||
expect(screen.getByText(new RegExp(`max ${MAX_FILE_SIZE_MB}MB`, 'i'))).toBeDefined();
|
||||
});
|
||||
|
||||
it('accepts file selection via input', async () => {
|
||||
render(<FileUpload vehicleId="veh-1" />);
|
||||
const input = screen.getByTestId('file-input') as HTMLInputElement;
|
||||
|
||||
const file = new File(['test image data'], 'test.jpg', { type: 'image/jpeg' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows upload status after file selection', async () => {
|
||||
render(<FileUpload vehicleId="veh-1" />);
|
||||
const input = screen.getByTestId('file-input') as HTMLInputElement;
|
||||
|
||||
const file = new File(['test image data'], 'test.jpg', { type: 'image/jpeg' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('upload-statuses')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unsupported file types', async () => {
|
||||
render(<FileUpload vehicleId="veh-1" />);
|
||||
const input = screen.getByTestId('file-input') as HTMLInputElement;
|
||||
|
||||
const file = new File(['malware'], 'malware.exe', { type: 'application/x-msdownload' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Unsupported file type/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects oversized files', async () => {
|
||||
render(<FileUpload vehicleId="veh-1" />);
|
||||
const input = screen.getByTestId('file-input') as HTMLInputElement;
|
||||
|
||||
// Create a file larger than 20MB
|
||||
const largeContent = new Array(21 * 1024 * 1024).fill('x').join('');
|
||||
const file = new File([largeContent], 'large.jpg', { type: 'image/jpeg' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/exceeds.*MB limit/i)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onUploadComplete on successful upload', async () => {
|
||||
const onUploadComplete = vi.fn();
|
||||
render(<FileUpload vehicleId="veh-1" onUploadComplete={onUploadComplete} />);
|
||||
const input = screen.getByTestId('file-input') as HTMLInputElement;
|
||||
|
||||
const file = new File(['test image data'], 'test.jpg', { type: 'image/jpeg' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUploadComplete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedApiFetch.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 'file-1',
|
||||
vehicle_id: 'veh-1',
|
||||
original_filename: 'photo.jpg',
|
||||
stored_filename: 'abc123.jpg',
|
||||
file_path: '/tmp/uploads/veh-1/abc123.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
file_size: 102400,
|
||||
thumbnail_path: '/tmp/uploads/veh-1/thumbnails/thumb_abc123.jpg',
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'file-2',
|
||||
vehicle_id: 'veh-1',
|
||||
original_filename: 'document.pdf',
|
||||
stored_filename: 'def456.pdf',
|
||||
file_path: '/tmp/uploads/veh-1/def456.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
file_size: 204800,
|
||||
thumbnail_path: null,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('renders file list with items', async () => {
|
||||
render(<FileList vehicleId="veh-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('file-list')).toBeDefined();
|
||||
});
|
||||
expect(screen.getByText('photo.jpg')).toBeDefined();
|
||||
expect(screen.getByText('document.pdf')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows thumbnails for image files', async () => {
|
||||
render(<FileList vehicleId="veh-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('file-thumb-file-1')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows file icon for non-image files', async () => {
|
||||
render(<FileList vehicleId="veh-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('file-item-file-2')).toBeDefined();
|
||||
});
|
||||
// Non-image file should not have a thumbnail img
|
||||
expect(screen.queryByTestId('file-thumb-file-2')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows delete button for each file', async () => {
|
||||
render(<FileList vehicleId="veh-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('delete-btn-file-1')).toBeDefined();
|
||||
expect(screen.getByTestId('delete-btn-file-2')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows download button for each file', async () => {
|
||||
render(<FileList vehicleId="veh-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('download-btn-file-1')).toBeDefined();
|
||||
expect(screen.getByTestId('download-btn-file-2')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens delete confirmation dialog', async () => {
|
||||
render(<FileList vehicleId="veh-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('delete-btn-file-1')).toBeDefined();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('delete-btn-file-1'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('delete-confirm')).toBeDefined();
|
||||
expect(screen.getByTestId('confirm-delete-btn')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows empty state when no files', async () => {
|
||||
mockedApiFetch.mockResolvedValueOnce({
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
} as any);
|
||||
|
||||
render(<FileList vehicleId="veh-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('file-list-empty')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error state on API failure', async () => {
|
||||
mockedApiFetch.mockRejectedValueOnce({
|
||||
error: { code: 'UNKNOWN', message: 'Network error' },
|
||||
});
|
||||
|
||||
render(<FileList vehicleId="veh-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('file-list-error')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileGallery', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedApiFetch.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 'img-1',
|
||||
vehicle_id: 'veh-1',
|
||||
original_filename: 'photo1.jpg',
|
||||
stored_filename: 'abc123.jpg',
|
||||
file_path: '/tmp/uploads/veh-1/abc123.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
file_size: 102400,
|
||||
thumbnail_path: '/tmp/uploads/veh-1/thumbnails/thumb_abc123.jpg',
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'img-2',
|
||||
vehicle_id: 'veh-1',
|
||||
original_filename: 'photo2.png',
|
||||
stored_filename: 'def456.png',
|
||||
file_path: '/tmp/uploads/veh-1/def456.png',
|
||||
mime_type: 'image/png',
|
||||
file_size: 204800,
|
||||
thumbnail_path: '/tmp/uploads/veh-1/thumbnails/thumb_def456.png',
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('renders gallery with images', async () => {
|
||||
render(<FileGallery vehicleId="veh-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('file-gallery')).toBeDefined();
|
||||
});
|
||||
expect(screen.getByTestId('gallery-item-img-1')).toBeDefined();
|
||||
expect(screen.getByTestId('gallery-item-img-2')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows empty state when no images', async () => {
|
||||
mockedApiFetch.mockResolvedValueOnce({
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
} as any);
|
||||
|
||||
render(<FileGallery vehicleId="veh-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gallery-empty')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('filters out non-image files', async () => {
|
||||
mockedApiFetch.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
id: 'img-1',
|
||||
vehicle_id: 'veh-1',
|
||||
original_filename: 'photo.jpg',
|
||||
stored_filename: 'abc.jpg',
|
||||
file_path: '/tmp/abc.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
file_size: 1024,
|
||||
thumbnail_path: '/tmp/thumb.jpg',
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'doc-1',
|
||||
vehicle_id: 'veh-1',
|
||||
original_filename: 'doc.pdf',
|
||||
stored_filename: 'def.pdf',
|
||||
file_path: '/tmp/def.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
file_size: 2048,
|
||||
thumbnail_path: null,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
} as any);
|
||||
|
||||
render(<FileGallery vehicleId="veh-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('gallery-item-img-1')).toBeDefined();
|
||||
});
|
||||
expect(screen.queryByTestId('gallery-item-doc-1')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FilePreview', () => {
|
||||
it('renders image preview for image files', () => {
|
||||
const file = {
|
||||
id: 'file-1',
|
||||
vehicle_id: 'veh-1',
|
||||
original_filename: 'photo.jpg',
|
||||
stored_filename: 'abc.jpg',
|
||||
file_path: '/tmp/abc.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
file_size: 102400,
|
||||
thumbnail_path: '/tmp/thumb.jpg',
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
render(<FilePreview file={file} open={true} onClose={() => {}} />);
|
||||
expect(screen.getByTestId('file-preview')).toBeDefined();
|
||||
expect(screen.getByTestId('preview-image')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders placeholder for non-image files', () => {
|
||||
const file = {
|
||||
id: 'file-2',
|
||||
vehicle_id: 'veh-1',
|
||||
original_filename: 'doc.pdf',
|
||||
stored_filename: 'def.pdf',
|
||||
file_path: '/tmp/def.pdf',
|
||||
mime_type: 'application/pdf',
|
||||
file_size: 204800,
|
||||
thumbnail_path: null,
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
render(<FilePreview file={file} open={true} onClose={() => {}} />);
|
||||
expect(screen.getByTestId('file-preview')).toBeDefined();
|
||||
expect(screen.queryByTestId('preview-image')).toBeNull();
|
||||
expect(screen.getByText(/Preview not available/i)).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows file metadata', () => {
|
||||
const file = {
|
||||
id: 'file-1',
|
||||
vehicle_id: 'veh-1',
|
||||
original_filename: 'photo.jpg',
|
||||
stored_filename: 'abc.jpg',
|
||||
file_path: '/tmp/abc.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
file_size: 102400,
|
||||
thumbnail_path: '/tmp/thumb.jpg',
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
render(<FilePreview file={file} open={true} onClose={() => {}} />);
|
||||
expect(screen.getByText('image/jpeg')).toBeDefined();
|
||||
expect(screen.getByText('100.0 KB')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows download button', () => {
|
||||
const file = {
|
||||
id: 'file-1',
|
||||
vehicle_id: 'veh-1',
|
||||
original_filename: 'photo.jpg',
|
||||
stored_filename: 'abc.jpg',
|
||||
file_path: '/tmp/abc.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
file_size: 1024,
|
||||
thumbnail_path: '/tmp/thumb.jpg',
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
updated_at: '2025-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
render(<FilePreview file={file} open={true} onClose={() => {}} />);
|
||||
expect(screen.getByTestId('preview-download-btn')).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns null when file is null', () => {
|
||||
const { container } = render(<FilePreview file={null} open={false} onClose={() => {}} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Utility functions', () => {
|
||||
it('formatFileSize formats bytes correctly', () => {
|
||||
expect(formatFileSize(0)).toBe('0 B');
|
||||
expect(formatFileSize(512)).toBe('512 B');
|
||||
expect(formatFileSize(1024)).toBe('1.0 KB');
|
||||
expect(formatFileSize(1024 * 1024)).toBe('1.0 MB');
|
||||
expect(formatFileSize(1024 * 1024 * 5)).toBe('5.0 MB');
|
||||
});
|
||||
|
||||
it('isImageMime detects image MIME types', () => {
|
||||
expect(isImageMime('image/jpeg')).toBe(true);
|
||||
expect(isImageMime('image/png')).toBe(true);
|
||||
expect(isImageMime('image/webp')).toBe(true);
|
||||
expect(isImageMime('application/pdf')).toBe(false);
|
||||
expect(isImageMime('text/plain')).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user