295 lines
9.6 KiB
TypeScript
295 lines
9.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import { OCRUpload } from '@/components/ocr/OCRUpload';
|
|
import { OCRResults } from '@/components/ocr/OCRResults';
|
|
import { OCRDetail } from '@/components/ocr/OCRDetail';
|
|
import type { OCRResultResponse } from '@/lib/ocr';
|
|
|
|
// Mock fetch globally
|
|
const mockFetch = vi.fn();
|
|
global.fetch = mockFetch as unknown as typeof fetch;
|
|
|
|
// Mock localStorage
|
|
const localStorageMock = (() => {
|
|
let store: Record<string, string> = {};
|
|
return {
|
|
getItem: (key: string) => store[key] || null,
|
|
setItem: (key: string, value: string) => { store[key] = value; },
|
|
removeItem: (key: string) => { delete store[key]; },
|
|
clear: () => { store = {}; },
|
|
};
|
|
})();
|
|
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
|
|
|
|
// Mock apiFetch to avoid auth header complexity for list/get tests
|
|
vi.mock('@/lib/api', () => ({
|
|
apiFetch: vi.fn(),
|
|
API_BASE_URL: 'http://localhost:8000/api/v1',
|
|
}));
|
|
|
|
const { apiFetch } = await import('@/lib/api');
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorageMock.clear();
|
|
});
|
|
|
|
const mockOCRResult: OCRResultResponse = {
|
|
id: 'result-123',
|
|
vehicle_id: 'vehicle-456',
|
|
file_path: '/tmp/uploads/scan.png',
|
|
file_name: 'scan.png',
|
|
mime_type: 'image/png',
|
|
status: 'completed',
|
|
raw_text: '{"brand": "Mercedes"}',
|
|
structured_data: {
|
|
brand: 'Mercedes',
|
|
model: 'Actros',
|
|
vin: 'WDB9066351L123456',
|
|
first_registration: '15.03.2020',
|
|
mileage: 120000,
|
|
power_kw: 350,
|
|
fuel_type: 'Diesel',
|
|
},
|
|
confidence_score: 0.92,
|
|
error_message: null,
|
|
created_at: '2026-07-16T10:00:00Z',
|
|
updated_at: '2026-07-16T10:01:00Z',
|
|
};
|
|
|
|
describe('OCRUpload', () => {
|
|
it('renders drag-and-drop zone', () => {
|
|
render(<OCRUpload />);
|
|
expect(screen.getByTestId('ocr-dropzone')).toBeInTheDocument();
|
|
expect(screen.getByText('Drag and drop scan image here')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows file input on click', () => {
|
|
render(<OCRUpload />);
|
|
const input = screen.getByTestId('ocr-file-input');
|
|
expect(input).toHaveAttribute('type', 'file');
|
|
expect(input).toHaveAttribute('accept', 'image/*');
|
|
});
|
|
|
|
it('shows error for non-image file', async () => {
|
|
render(<OCRUpload />);
|
|
const input = screen.getByTestId('ocr-file-input') as HTMLInputElement;
|
|
|
|
const file = new File(['data'], 'doc.pdf', { type: 'application/pdf' });
|
|
Object.defineProperty(input, 'files', { value: [file], writable: false });
|
|
fireEvent.change(input);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('ocr-upload-error')).toBeInTheDocument();
|
|
expect(screen.getByText('Only image files are allowed')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('uploads image file successfully', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({
|
|
message: 'OCR processing queued',
|
|
ocr_result_id: 'new-result-id',
|
|
status: 'pending',
|
|
}),
|
|
});
|
|
|
|
let uploadedId = '';
|
|
render(<OCRUpload onUploadComplete={(id) => { uploadedId = id; }} />);
|
|
const input = screen.getByTestId('ocr-file-input') as HTMLInputElement;
|
|
|
|
const file = new File(['image-data'], 'scan.png', { type: 'image/png' });
|
|
Object.defineProperty(input, 'files', { value: [file], writable: false });
|
|
fireEvent.change(input);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('ocr-upload-success')).toBeInTheDocument();
|
|
expect(uploadedId).toBe('new-result-id');
|
|
});
|
|
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
const call = mockFetch.mock.calls[0];
|
|
expect(call[0]).toContain('/ocr/upload');
|
|
expect(call[1].method).toBe('POST');
|
|
});
|
|
|
|
it('shows error on upload failure', async () => {
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
json: async () => ({
|
|
error: { code: 'INVALID_MIME_TYPE', message: 'Invalid MIME type' },
|
|
}),
|
|
});
|
|
|
|
render(<OCRUpload />);
|
|
const input = screen.getByTestId('ocr-file-input') as HTMLInputElement;
|
|
|
|
const file = new File(['image-data'], 'scan.png', { type: 'image/png' });
|
|
Object.defineProperty(input, 'files', { value: [file], writable: false });
|
|
fireEvent.change(input);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('ocr-upload-error')).toBeInTheDocument();
|
|
expect(screen.getByText('Invalid MIME type')).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('OCRResults', () => {
|
|
it('renders results list', async () => {
|
|
vi.mocked(apiFetch).mockResolvedValueOnce({
|
|
items: [mockOCRResult],
|
|
total: 1,
|
|
page: 1,
|
|
page_size: 20,
|
|
});
|
|
|
|
render(<OCRResults />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('scan.png')).toBeInTheDocument();
|
|
expect(screen.getByText('completed')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows loading state', async () => {
|
|
vi.mocked(apiFetch).mockImplementationOnce(
|
|
() => new Promise(resolve => setTimeout(() => resolve({
|
|
items: [],
|
|
total: 0,
|
|
page: 1,
|
|
page_size: 20,
|
|
}), 100))
|
|
);
|
|
|
|
render(<OCRResults />);
|
|
expect(screen.getByTestId('ocr-results-loading')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows error on fetch failure', async () => {
|
|
vi.mocked(apiFetch).mockRejectedValueOnce({
|
|
error: { code: 'UNKNOWN', message: 'Network error' },
|
|
});
|
|
|
|
render(<OCRResults />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('ocr-results-error')).toBeInTheDocument();
|
|
expect(screen.getByText('Network error')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('calls onSelectResult when row is clicked', async () => {
|
|
vi.mocked(apiFetch).mockResolvedValueOnce({
|
|
items: [mockOCRResult],
|
|
total: 1,
|
|
page: 1,
|
|
page_size: 20,
|
|
});
|
|
|
|
const onSelect = vi.fn();
|
|
render(<OCRResults onSelectResult={onSelect} />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('ocr-row-result-123')).toBeInTheDocument();
|
|
});
|
|
|
|
fireEvent.click(screen.getByTestId('ocr-row-result-123'));
|
|
expect(onSelect).toHaveBeenCalledWith(mockOCRResult);
|
|
});
|
|
});
|
|
|
|
describe('OCRDetail', () => {
|
|
it('renders side-by-side original and extracted data', () => {
|
|
render(<OCRDetail result={mockOCRResult} />);
|
|
|
|
expect(screen.getByTestId('ocr-original-scan')).toBeInTheDocument();
|
|
expect(screen.getByTestId('ocr-extracted-data')).toBeInTheDocument();
|
|
expect(screen.getByTestId('ocr-field-brand')).toHaveTextContent('Mercedes');
|
|
expect(screen.getByTestId('ocr-field-model')).toHaveTextContent('Actros');
|
|
expect(screen.getByTestId('ocr-field-vin')).toHaveTextContent('WDB9066351L123456');
|
|
expect(screen.getByTestId('ocr-field-mileage')).toHaveTextContent('120000 km');
|
|
expect(screen.getByTestId('ocr-field-power_kw')).toHaveTextContent('350 kW');
|
|
expect(screen.getByTestId('ocr-field-fuel_type')).toHaveTextContent('Diesel');
|
|
});
|
|
|
|
it('shows apply button when status is completed and vehicle linked', () => {
|
|
render(<OCRDetail result={mockOCRResult} />);
|
|
expect(screen.getByTestId('ocr-apply-button')).toBeInTheDocument();
|
|
});
|
|
|
|
it('hides apply button when no vehicle linked', () => {
|
|
const noVehicleResult = { ...mockOCRResult, vehicle_id: null };
|
|
render(<OCRDetail result={noVehicleResult} />);
|
|
expect(screen.queryByTestId('ocr-apply-button')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('hides apply button when status is pending', () => {
|
|
const pendingResult = { ...mockOCRResult, status: 'pending' as const };
|
|
render(<OCRDetail result={pendingResult} />);
|
|
expect(screen.queryByTestId('ocr-apply-button')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('sends POST apply request on button click', async () => {
|
|
vi.mocked(apiFetch).mockResolvedValueOnce({
|
|
message: 'OCR data applied to vehicle',
|
|
ocr_result_id: 'result-123',
|
|
vehicle_id: 'vehicle-456',
|
|
updated_fields: ['make', 'model', 'mileage_km'],
|
|
});
|
|
|
|
render(<OCRDetail result={mockOCRResult} />);
|
|
fireEvent.click(screen.getByTestId('ocr-apply-button'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('ocr-apply-success')).toBeInTheDocument();
|
|
expect(screen.getByText(/Applied 3 fields/)).toBeInTheDocument();
|
|
});
|
|
|
|
expect(apiFetch).toHaveBeenCalledWith('/ocr/results/result-123/apply', {
|
|
method: 'POST',
|
|
});
|
|
});
|
|
|
|
it('shows error on apply failure', async () => {
|
|
vi.mocked(apiFetch).mockRejectedValueOnce({
|
|
error: { code: 'APPLY_FAILED', message: 'Vehicle not found' },
|
|
});
|
|
|
|
render(<OCRDetail result={mockOCRResult} />);
|
|
fireEvent.click(screen.getByTestId('ocr-apply-button'));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('ocr-apply-error')).toBeInTheDocument();
|
|
expect(screen.getByText('Vehicle not found')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it('shows raw text when available', () => {
|
|
render(<OCRDetail result={mockOCRResult} />);
|
|
expect(screen.getByTestId('ocr-raw-text')).toBeInTheDocument();
|
|
expect(screen.getByTestId('ocr-raw-text')).toHaveTextContent('Mercedes');
|
|
});
|
|
|
|
it('shows error message when status is failed', () => {
|
|
const failedResult: OCRResultResponse = {
|
|
...mockOCRResult,
|
|
status: 'failed',
|
|
error_message: 'OpenRouter API unavailable',
|
|
structured_data: null,
|
|
};
|
|
render(<OCRDetail result={failedResult} />);
|
|
expect(screen.getByText(/OpenRouter API unavailable/)).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows no data message when structured_data is null', () => {
|
|
const noDataResult: OCRResultResponse = {
|
|
...mockOCRResult,
|
|
structured_data: null,
|
|
};
|
|
render(<OCRDetail result={noDataResult} />);
|
|
expect(screen.getByText('No structured data available yet.')).toBeInTheDocument();
|
|
});
|
|
});
|