249 lines
8.3 KiB
TypeScript
249 lines
8.3 KiB
TypeScript
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||
|
|
import { RetouchUpload } from '@/components/retouch/RetouchUpload';
|
||
|
|
import { BeforeAfterSlider } from '@/components/retouch/BeforeAfterSlider';
|
||
|
|
import { PriceComparison } from '@/components/retouch/PriceComparison';
|
||
|
|
|
||
|
|
// 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
|
||
|
|
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();
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('RetouchUpload', () => {
|
||
|
|
it('renders drag-and-drop zone', () => {
|
||
|
|
render(<RetouchUpload />);
|
||
|
|
expect(screen.getByTestId('retouch-dropzone')).toBeInTheDocument();
|
||
|
|
expect(screen.getByText('Drag and drop vehicle image here')).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('shows file input with image accept', () => {
|
||
|
|
render(<RetouchUpload />);
|
||
|
|
const input = screen.getByTestId('retouch-file-input');
|
||
|
|
expect(input).toHaveAttribute('type', 'file');
|
||
|
|
expect(input).toHaveAttribute('accept', 'image/*');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('shows error for non-image file', async () => {
|
||
|
|
render(<RetouchUpload />);
|
||
|
|
const input = screen.getByTestId('retouch-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('retouch-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: 'Retouch processing queued',
|
||
|
|
retouch_id: 'new-retouch-id',
|
||
|
|
status: 'pending',
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
let uploadedId = '';
|
||
|
|
render(<RetouchUpload onUploadComplete={(id) => { uploadedId = id; }} />);
|
||
|
|
const input = screen.getByTestId('retouch-file-input') as HTMLInputElement;
|
||
|
|
|
||
|
|
const file = new File(['image-data'], 'truck.png', { type: 'image/png' });
|
||
|
|
Object.defineProperty(input, 'files', { value: [file], writable: false });
|
||
|
|
fireEvent.change(input);
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByTestId('retouch-upload-success')).toBeInTheDocument();
|
||
|
|
expect(uploadedId).toBe('new-retouch-id');
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||
|
|
const call = mockFetch.mock.calls[0];
|
||
|
|
expect(call[0]).toContain('/retouch/process');
|
||
|
|
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(<RetouchUpload />);
|
||
|
|
const input = screen.getByTestId('retouch-file-input') as HTMLInputElement;
|
||
|
|
|
||
|
|
const file = new File(['image-data'], 'truck.png', { type: 'image/png' });
|
||
|
|
Object.defineProperty(input, 'files', { value: [file], writable: false });
|
||
|
|
fireEvent.change(input);
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByTestId('retouch-upload-error')).toBeInTheDocument();
|
||
|
|
expect(screen.getByText('Invalid MIME type')).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('BeforeAfterSlider', () => {
|
||
|
|
it('renders both images', () => {
|
||
|
|
render(
|
||
|
|
<BeforeAfterSlider
|
||
|
|
beforeSrc="https://example.com/before.png"
|
||
|
|
afterSrc="https://example.com/after.png"
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
expect(screen.getByTestId('before-after-slider')).toBeInTheDocument();
|
||
|
|
expect(screen.getByTestId('before-image')).toHaveAttribute('src', 'https://example.com/before.png');
|
||
|
|
expect(screen.getByTestId('after-image')).toHaveAttribute('src', 'https://example.com/after.png');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('renders labels', () => {
|
||
|
|
render(
|
||
|
|
<BeforeAfterSlider
|
||
|
|
beforeSrc="https://example.com/before.png"
|
||
|
|
afterSrc="https://example.com/after.png"
|
||
|
|
beforeLabel="Original"
|
||
|
|
afterLabel="Retuschiert"
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
expect(screen.getByText('Original')).toBeInTheDocument();
|
||
|
|
expect(screen.getByText('Retuschiert')).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('renders slider handle', () => {
|
||
|
|
render(
|
||
|
|
<BeforeAfterSlider
|
||
|
|
beforeSrc="https://example.com/before.png"
|
||
|
|
afterSrc="https://example.com/after.png"
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
expect(screen.getByTestId('slider-handle')).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe('PriceComparison', () => {
|
||
|
|
it('renders vehicle ID input and compare button', () => {
|
||
|
|
render(<PriceComparison />);
|
||
|
|
expect(screen.getByTestId('price-compare-vehicle-input')).toBeInTheDocument();
|
||
|
|
expect(screen.getByTestId('price-compare-button')).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('shows error when comparing without vehicle ID', async () => {
|
||
|
|
render(<PriceComparison />);
|
||
|
|
const button = screen.getByTestId('price-compare-button');
|
||
|
|
fireEvent.click(button);
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByTestId('price-compare-error')).toBeInTheDocument();
|
||
|
|
expect(screen.getByText('Please enter a vehicle ID')).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('fetches and displays comparable listings', async () => {
|
||
|
|
vi.mocked(apiFetch).mockResolvedValueOnce({
|
||
|
|
vehicle_id: 'veh-123',
|
||
|
|
comparable_listings: [
|
||
|
|
{
|
||
|
|
title: 'Mercedes Actros 2020',
|
||
|
|
make: 'Mercedes',
|
||
|
|
model: 'Actros',
|
||
|
|
year: 2020,
|
||
|
|
price: 42000,
|
||
|
|
mileage_km: 110000,
|
||
|
|
location: 'Berlin',
|
||
|
|
url: 'https://mobile.de/listing/123',
|
||
|
|
source: 'mobile.de',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: 'Mercedes Actros 2020',
|
||
|
|
make: 'Mercedes',
|
||
|
|
model: 'Actros',
|
||
|
|
year: 2020,
|
||
|
|
price: 48000,
|
||
|
|
mileage_km: 95000,
|
||
|
|
location: 'Hamburg',
|
||
|
|
url: 'https://mobile.de/listing/456',
|
||
|
|
source: 'mobile.de',
|
||
|
|
},
|
||
|
|
],
|
||
|
|
average_price: 45000,
|
||
|
|
listing_count: 2,
|
||
|
|
});
|
||
|
|
|
||
|
|
render(<PriceComparison />);
|
||
|
|
const input = screen.getByTestId('price-compare-vehicle-input') as HTMLInputElement;
|
||
|
|
fireEvent.change(input, { target: { value: 'veh-123' } });
|
||
|
|
fireEvent.click(screen.getByTestId('price-compare-button'));
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByTestId('price-compare-results')).toBeInTheDocument();
|
||
|
|
expect(screen.getAllByText('Mercedes Actros 2020').length).toBe(2);
|
||
|
|
expect(screen.getByTestId('price-compare-row-0')).toBeInTheDocument();
|
||
|
|
expect(screen.getByTestId('price-compare-row-1')).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('shows empty state when no listings found', async () => {
|
||
|
|
vi.mocked(apiFetch).mockResolvedValueOnce({
|
||
|
|
vehicle_id: 'veh-456',
|
||
|
|
comparable_listings: [],
|
||
|
|
average_price: null,
|
||
|
|
listing_count: 0,
|
||
|
|
});
|
||
|
|
|
||
|
|
render(<PriceComparison />);
|
||
|
|
const input = screen.getByTestId('price-compare-vehicle-input') as HTMLInputElement;
|
||
|
|
fireEvent.change(input, { target: { value: 'veh-456' } });
|
||
|
|
fireEvent.click(screen.getByTestId('price-compare-button'));
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByTestId('price-compare-empty')).toBeInTheDocument();
|
||
|
|
expect(screen.getByText('No comparable listings found.')).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('shows error on fetch failure', async () => {
|
||
|
|
vi.mocked(apiFetch).mockRejectedValueOnce({
|
||
|
|
error: { code: 'VEHICLE_NOT_FOUND', message: 'Vehicle not found' },
|
||
|
|
});
|
||
|
|
|
||
|
|
render(<PriceComparison />);
|
||
|
|
const input = screen.getByTestId('price-compare-vehicle-input') as HTMLInputElement;
|
||
|
|
fireEvent.change(input, { target: { value: 'bad-id' } });
|
||
|
|
fireEvent.click(screen.getByTestId('price-compare-button'));
|
||
|
|
|
||
|
|
await waitFor(() => {
|
||
|
|
expect(screen.getByTestId('price-compare-error')).toBeInTheDocument();
|
||
|
|
expect(screen.getByText('Vehicle not found')).toBeInTheDocument();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|