feat(T05): Dateiablage pro Fahrzeug + File UI with thumbnails and gallery

This commit is contained in:
2026-07-17 01:38:48 +02:00
parent 149ac04dc1
commit a38d340ddc
18 changed files with 2649 additions and 69 deletions
+443
View File
@@ -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);
});
});