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
+103
View File
@@ -0,0 +1,103 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import {
listFiles,
isImageMime,
getThumbnailUrl,
type FileResponse,
} from '@/lib/files';
import { FilePreview } from './FilePreview';
interface FileGalleryProps {
vehicleId: string;
}
export function FileGallery({ vehicleId }: FileGalleryProps) {
const [images, setImages] = useState<FileResponse[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedImage, setSelectedImage] = useState<FileResponse | null>(null);
const fetchImages = useCallback(async () => {
setLoading(true);
setError(null);
try {
// Fetch all files and filter for images
const data = await listFiles(vehicleId, 1, 100);
const imageFiles = data.items.filter((f) => isImageMime(f.mime_type));
setImages(imageFiles);
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
setError(apiErr?.error?.message || 'Failed to load images');
} finally {
setLoading(false);
}
}, [vehicleId]);
useEffect(() => {
fetchImages();
}, [fetchImages]);
if (loading && images.length === 0) {
return (
<div className="text-center py-8 text-text-muted" data-testid="gallery-loading">
Loading gallery...
</div>
);
}
if (error) {
return (
<div className="text-center py-8 text-error" data-testid="gallery-error">
{error}
</div>
);
}
if (images.length === 0) {
return (
<div className="text-center py-8 text-text-muted" data-testid="gallery-empty">
No images available for this vehicle.
</div>
);
}
return (
<div data-testid="file-gallery">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{images.map((image) => {
const thumbUrl = getThumbnailUrl(image);
return (
<div
key={image.id}
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer group"
onClick={() => setSelectedImage(image)}
data-testid={`gallery-item-${image.id}`}
>
{thumbUrl && (
<img
src={thumbUrl}
alt={image.original_filename}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
/>
)}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-end p-2">
<span className="text-white text-xs opacity-0 group-hover:opacity-100 transition-opacity truncate">
{image.original_filename}
</span>
</div>
</div>
);
})}
</div>
{/* Full preview modal */}
<FilePreview
file={selectedImage}
open={!!selectedImage}
onClose={() => setSelectedImage(null)}
/>
</div>
);
}
+231
View File
@@ -0,0 +1,231 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal';
import {
listFiles,
deleteFile,
isImageMime,
formatFileSize,
getThumbnailUrl,
type FileResponse,
} from '@/lib/files';
interface FileListProps {
vehicleId: string;
onFileDeleted?: () => void;
}
export function FileList({ vehicleId, onFileDeleted }: FileListProps) {
const [files, setFiles] = useState<FileResponse[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<FileResponse | null>(null);
const [deleting, setDeleting] = useState(false);
const fetchFiles = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await listFiles(vehicleId, page, pageSize);
setFiles(data.items);
setTotal(data.total);
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
setError(apiErr?.error?.message || 'Failed to load files');
} finally {
setLoading(false);
}
}, [vehicleId, page, pageSize]);
useEffect(() => {
fetchFiles();
}, [fetchFiles]);
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
await deleteFile(vehicleId, deleteTarget.id);
setDeleteTarget(null);
await fetchFiles();
onFileDeleted?.();
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
setError(apiErr?.error?.message || 'Failed to delete file');
} finally {
setDeleting(false);
}
};
const handleDownload = (file: FileResponse) => {
const token = typeof window !== 'undefined'
? localStorage.getItem('access_token')
: null;
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1';
const url = `${API_BASE_URL}/vehicles/${vehicleId}/files/${file.id}`;
// Use fetch with auth header to download as blob
fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((res) => res.blob())
.then((blob) => {
const downloadUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = file.original_filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(downloadUrl);
})
.catch(() => setError('Failed to download file'));
};
if (loading && files.length === 0) {
return (
<div className="text-center py-8 text-text-muted" data-testid="file-list-loading">
Loading files...
</div>
);
}
if (error) {
return (
<div className="text-center py-8 text-error" data-testid="file-list-error">
{error}
</div>
);
}
if (files.length === 0) {
return (
<div className="text-center py-8 text-text-muted" data-testid="file-list-empty">
No files uploaded yet.
</div>
);
}
return (
<div data-testid="file-list">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{files.map((file) => {
const thumbUrl = getThumbnailUrl(file);
const isImg = isImageMime(file.mime_type);
return (
<div
key={file.id}
className="border border-border rounded-lg overflow-hidden bg-surface"
data-testid={`file-item-${file.id}`}
>
{/* Thumbnail or file icon */}
<div className="aspect-square bg-background flex items-center justify-center">
{isImg && thumbUrl ? (
<img
src={thumbUrl}
alt={file.original_filename}
className="w-full h-full object-cover"
data-testid={`file-thumb-${file.id}`}
/>
) : (
<div className="flex flex-col items-center gap-2 text-text-muted">
<svg className="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span className="text-xs uppercase">{file.mime_type.split('/')[1]}</span>
</div>
)}
</div>
{/* File info */}
<div className="p-3">
<p className="text-sm font-medium text-text truncate" title={file.original_filename}>
{file.original_filename}
</p>
<p className="text-xs text-text-muted mt-1">
{formatFileSize(file.file_size)}
</p>
{/* Actions */}
<div className="flex gap-2 mt-2">
<Button
variant="ghost"
className="text-xs px-2 py-1"
onClick={() => handleDownload(file)}
data-testid={`download-btn-${file.id}`}
>
Download
</Button>
<Button
variant="danger"
className="text-xs px-2 py-1"
onClick={() => setDeleteTarget(file)}
data-testid={`delete-btn-${file.id}`}
>
Delete
</Button>
</div>
</div>
</div>
);
})}
</div>
{/* Pagination */}
{total > pageSize && (
<div className="flex items-center justify-center gap-4 mt-6">
<Button
variant="secondary"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
Previous
</Button>
<span className="text-text-muted">
Page {page} of {Math.ceil(total / pageSize)}
</span>
<Button
variant="secondary"
disabled={page * pageSize >= total}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
)}
{/* Delete confirmation modal */}
<Modal
open={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
title="Delete File"
>
<div data-testid="delete-confirm">
<p className="text-text mb-4">
Are you sure you want to delete <strong>{deleteTarget?.original_filename}</strong>?
This action cannot be undone.
</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
variant="danger"
loading={deleting}
onClick={handleDelete}
data-testid="confirm-delete-btn"
>
Delete
</Button>
</div>
</div>
</Modal>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
'use client';
import { Modal } from '@/components/ui/Modal';
import { isImageMime, formatFileSize, type FileResponse } from '@/lib/files';
interface FilePreviewProps {
file: FileResponse | null;
open: boolean;
onClose: () => void;
}
export function FilePreview({ file, open, onClose }: FilePreviewProps) {
if (!file) return null;
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1';
const downloadUrl = `${API_BASE_URL}/vehicles/${file.vehicle_id}/files/${file.id}`;
const isImg = isImageMime(file.mime_type);
return (
<Modal open={open} onClose={onClose} title={file.original_filename}>
<div data-testid="file-preview" className="space-y-4">
{/* Preview content */}
{isImg ? (
<div className="flex justify-center">
<img
src={downloadUrl}
alt={file.original_filename}
className="max-w-full max-h-[60vh] rounded-lg"
data-testid="preview-image"
/>
</div>
) : (
<div className="flex flex-col items-center gap-4 py-8">
<svg className="h-16 w-16 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p className="text-text-muted">Preview not available for this file type</p>
</div>
)}
{/* File metadata */}
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-text-muted">File type:</span>
<span className="text-text">{file.mime_type}</span>
</div>
<div className="flex justify-between">
<span className="text-text-muted">Size:</span>
<span className="text-text">{formatFileSize(file.file_size)}</span>
</div>
{file.created_at && (
<div className="flex justify-between">
<span className="text-text-muted">Uploaded:</span>
<span className="text-text">
{new Date(file.created_at).toLocaleDateString()}
</span>
</div>
)}
</div>
{/* Download button */}
<div className="flex justify-end">
<a
href={downloadUrl}
download={file.original_filename}
className="inline-flex items-center px-4 py-2 rounded-lg bg-primary text-white hover:bg-primary-hover transition-colors"
data-testid="preview-download-btn"
>
Download
</a>
</div>
</div>
</Modal>
);
}
+214
View File
@@ -0,0 +1,214 @@
'use client';
import { useState, useCallback, useRef, type DragEvent } from 'react';
import { Button } from '@/components/ui/Button';
import {
uploadFile,
ALLOWED_MIME_TYPES,
MAX_FILE_SIZE_MB,
MAX_FILE_SIZE_BYTES,
type FileResponse,
} from '@/lib/files';
interface FileUploadProps {
vehicleId: string;
onUploadComplete?: (file: FileResponse) => void;
onUploadError?: (error: string) => void;
}
interface UploadStatus {
filename: string;
status: 'pending' | 'uploading' | 'success' | 'error';
error?: string;
}
export function FileUpload({ vehicleId, onUploadComplete, onUploadError }: FileUploadProps) {
const [isDragging, setIsDragging] = useState(false);
const [uploadStatuses, setUploadStatuses] = useState<UploadStatus[]>([]);
const [isUploading, setIsUploading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const validateFile = (file: File): string | null => {
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
return `Unsupported file type: ${file.type || 'unknown'}`;
}
if (file.size > MAX_FILE_SIZE_BYTES) {
return `File size exceeds ${MAX_FILE_SIZE_MB}MB limit`;
}
return null;
};
const handleFiles = useCallback(async (files: FileList) => {
setIsUploading(true);
const fileArray = Array.from(files);
const statuses: UploadStatus[] = fileArray.map((f) => ({
filename: f.name,
status: 'pending' as const,
}));
setUploadStatuses(statuses);
for (let i = 0; i < fileArray.length; i++) {
const file = fileArray[i];
const validationError = validateFile(file);
if (validationError) {
setUploadStatuses((prev) =>
prev.map((s, idx) =>
idx === i ? { ...s, status: 'error', error: validationError } : s
)
);
onUploadError?.(validationError);
continue;
}
setUploadStatuses((prev) =>
prev.map((s, idx) =>
idx === i ? { ...s, status: 'uploading' } : s
)
);
try {
const result = await uploadFile(vehicleId, file);
setUploadStatuses((prev) =>
prev.map((s, idx) =>
idx === i ? { ...s, status: 'success' } : s
)
);
onUploadComplete?.(result);
} catch (err: unknown) {
const apiErr = err as { error?: { message?: string } };
const errorMsg = apiErr?.error?.message || 'Upload failed';
setUploadStatuses((prev) =>
prev.map((s, idx) =>
idx === i ? { ...s, status: 'error', error: errorMsg } : s
)
);
onUploadError?.(errorMsg);
}
}
setIsUploading(false);
// Clear statuses after 3 seconds
setTimeout(() => setUploadStatuses([]), 3000);
}, [vehicleId, onUploadComplete, onUploadError]);
const handleDragEnter = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback((e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleFiles(e.dataTransfer.files);
}
}, [handleFiles]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
handleFiles(e.target.files);
// Reset input so same file can be selected again
e.target.value = '';
}
};
return (
<div className="w-full" data-testid="file-upload">
<div
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
className={`
border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors
${isDragging
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}
`}
data-testid="dropzone"
>
<input
ref={inputRef}
type="file"
multiple
accept=".jpg,.jpeg,.png,.webp,.pdf,.doc,.docx"
onChange={handleInputChange}
className="hidden"
data-testid="file-input"
/>
<div className="flex flex-col items-center gap-2">
<svg
className="h-12 w-12 text-text-muted"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<p className="text-text font-medium">
{isDragging ? 'Drop files here' : 'Drag & drop files or click to browse'}
</p>
<p className="text-text-muted text-sm">
Supports: JPG, PNG, WebP, PDF, DOC, DOCX (max {MAX_FILE_SIZE_MB}MB)
</p>
</div>
</div>
{uploadStatuses.length > 0 && (
<div className="mt-4 space-y-2" data-testid="upload-statuses">
{uploadStatuses.map((status, idx) => (
<div
key={idx}
className={`flex items-center justify-between p-2 rounded ${
status.status === 'success'
? 'bg-success/10 text-success'
: status.status === 'error'
? 'bg-error/10 text-error'
: 'bg-surface text-text'
}`}
data-testid={`upload-status-${idx}`}
>
<span className="text-sm">{status.filename}</span>
<span className="text-sm font-medium">
{status.status === 'pending' && 'Pending...'}
{status.status === 'uploading' && 'Uploading...'}
{status.status === 'success' && '✓ Uploaded'}
{status.status === 'error' && `${status.error}`}
</span>
</div>
))}
</div>
)}
{isUploading && (
<div className="mt-2">
<Button variant="secondary" disabled loading>
Uploading...
</Button>
</div>
)}
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { apiFetch, type PaginatedResponse } from './api';
export interface FileResponse {
id: string;
vehicle_id: string;
original_filename: string;
stored_filename: string;
file_path: string;
mime_type: string;
file_size: number;
thumbnail_path: string | null;
created_at?: string;
updated_at?: string;
}
export interface FileListResponse extends PaginatedResponse<FileResponse> {}
export interface FileDeleteResponse {
message: string;
id: string;
}
export const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
export const MAX_FILE_SIZE_MB = 20;
export const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
export function isImageMime(mime: string): boolean {
return mime.startsWith('image/');
}
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export async function listFiles(
vehicleId: string,
page = 1,
pageSize = 20
): Promise<FileListResponse> {
return apiFetch<FileListResponse>(
`/vehicles/${vehicleId}/files?page=${page}&page_size=${pageSize}`
);
}
export async function uploadFile(
vehicleId: string,
file: File
): Promise<FileResponse> {
const token = typeof window !== 'undefined'
? localStorage.getItem('access_token')
: null;
const formData = new FormData();
formData.append('file', file);
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1';
const response = await fetch(`${API_BASE_URL}/vehicles/${vehicleId}/files`, {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: formData,
});
if (response.status === 401 && token) {
const refreshToken = typeof window !== 'undefined'
? localStorage.getItem('refresh_token')
: null;
if (refreshToken) {
const refreshResult = await apiFetch<{ access_token: string; refresh_token: string }>(
'/auth/refresh',
{ method: 'POST', body: JSON.stringify({ refresh_token: refreshToken }) }
);
localStorage.setItem('access_token', refreshResult.access_token);
localStorage.setItem('refresh_token', refreshResult.refresh_token);
const retryResponse = await fetch(`${API_BASE_URL}/vehicles/${vehicleId}/files`, {
method: 'POST',
headers: { Authorization: `Bearer ${refreshResult.access_token}` },
body: formData,
});
if (!retryResponse.ok) {
const err = await retryResponse.json().catch(() => ({ error: { code: 'UNKNOWN', message: 'Upload failed' } }));
throw err;
}
return retryResponse.json();
}
}
if (!response.ok) {
const err = await response.json().catch(() => ({ error: { code: 'UNKNOWN', message: 'Upload failed' } }));
throw err;
}
return response.json();
}
export async function deleteFile(
vehicleId: string,
fileId: string
): Promise<FileDeleteResponse> {
return apiFetch<FileDeleteResponse>(
`/vehicles/${vehicleId}/files/${fileId}`,
{ method: 'DELETE' }
);
}
export function getFileDownloadUrl(vehicleId: string, fileId: string): string {
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1';
return `${API_BASE_URL}/vehicles/${vehicleId}/files/${fileId}`;
}
export function getThumbnailUrl(file: FileResponse): string | null {
if (!file.thumbnail_path) return null;
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1';
return `${API_BASE_URL}/vehicles/${file.vehicle_id}/files/${file.id}`;
}
+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);
});
});