feat(T02): vehicle management + mobile.de push + vehicle UI
- Vehicle model: 25+ fields, 5 vehicle types, soft-delete - Vehicle CRUD: 7 API endpoints with JWT auth, filter/sort/paginate - mobile.de: push/update/delete listings, field mapping, retry logic - MobileDeListing model for sync status tracking - Frontend: VehicleList, VehicleForm, VehicleDetail, MobileDeStatus - 73 backend tests (82% coverage), 16 frontend tests
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { ToastProvider } from '@/components/ui/Toast';
|
||||
import { I18nProvider } from '@/lib/i18n';
|
||||
import { VehicleList } from '@/components/vehicles/VehicleList';
|
||||
import { VehicleForm } from '@/components/vehicles/VehicleForm';
|
||||
import { VehicleDetail } from '@/components/vehicles/VehicleDetail';
|
||||
import { MobileDeStatus } from '@/components/vehicles/MobileDeStatus';
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
}),
|
||||
redirect: vi.fn(),
|
||||
usePathname: () => '/de/fahrzeuge',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
// Mock the vehicles API module
|
||||
vi.mock('@/lib/vehicles', () => ({
|
||||
listVehicles: vi.fn(),
|
||||
getVehicle: vi.fn(),
|
||||
createVehicle: vi.fn(),
|
||||
updateVehicle: vi.fn(),
|
||||
deleteVehicle: vi.fn(),
|
||||
pushToMobileDe: vi.fn(),
|
||||
getMobileDeStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
listVehicles as mockListVehicles,
|
||||
getVehicle as mockGetVehicle,
|
||||
createVehicle as mockCreateVehicle,
|
||||
deleteVehicle as mockDeleteVehicle,
|
||||
pushToMobileDe as mockPushToMobileDe,
|
||||
getMobileDeStatus as mockGetMobileDeStatus,
|
||||
} from '@/lib/vehicles';
|
||||
|
||||
const mockVehicle = {
|
||||
id: 'vehicle-123',
|
||||
make: 'Mercedes-Benz',
|
||||
model: 'Actros',
|
||||
fin: 'WDB9066351L123456',
|
||||
year: 2020,
|
||||
first_registration: '2020-03-15',
|
||||
power_kw: 300,
|
||||
power_hp: 408,
|
||||
fuel_type: 'Diesel',
|
||||
transmission: 'Manual',
|
||||
color: 'White',
|
||||
condition: 'used',
|
||||
location: 'Berlin',
|
||||
availability: 'available',
|
||||
price: 45000.00,
|
||||
vehicle_type: 'lkw',
|
||||
lkw_type: 'sattelzugmaschine',
|
||||
machine_type: null,
|
||||
body_type: null,
|
||||
operating_hours: null,
|
||||
operating_hours_unit: null,
|
||||
mileage_km: 120000,
|
||||
description: 'Well maintained truck',
|
||||
created_at: '2025-01-01T12:00:00Z',
|
||||
updated_at: '2025-01-01T12:00:00Z',
|
||||
deleted_at: null,
|
||||
};
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement) {
|
||||
return render(
|
||||
<I18nProvider initialLocale="de">
|
||||
<ToastProvider>
|
||||
{ui}
|
||||
</ToastProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('VehicleList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the vehicle list with filters and table', async () => {
|
||||
(mockListVehicles as any).mockResolvedValue({
|
||||
items: [mockVehicle],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
});
|
||||
|
||||
renderWithProviders(<VehicleList />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('vehicle-list')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('vehicle-filters')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('filter-search')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('filter-type')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('filter-availability')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('filter-sort')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays vehicles in the table after loading', async () => {
|
||||
(mockListVehicles as any).mockResolvedValue({
|
||||
items: [mockVehicle],
|
||||
total: 1,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
});
|
||||
|
||||
renderWithProviders(<VehicleList />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Mercedes-Benz')).toBeInTheDocument();
|
||||
expect(screen.getByText('Actros')).toBeInTheDocument();
|
||||
expect(screen.getByText('WDB9066351L123456')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows pagination when total > page_size', async () => {
|
||||
(mockListVehicles as any).mockResolvedValue({
|
||||
items: [mockVehicle],
|
||||
total: 40,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
});
|
||||
|
||||
renderWithProviders(<VehicleList />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('vehicle-pagination')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Page 1 of 2/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error message on API failure', async () => {
|
||||
(mockListVehicles as any).mockRejectedValue({
|
||||
error: { code: 'UNKNOWN', message: 'Failed to load' },
|
||||
});
|
||||
|
||||
renderWithProviders(<VehicleList />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('vehicle-error')).toBeInTheDocument();
|
||||
expect(screen.getByText('Failed to load')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls listVehicles with type filter when changed', async () => {
|
||||
(mockListVehicles as any).mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
});
|
||||
|
||||
renderWithProviders(<VehicleList />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockListVehicles).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const typeSelect = screen.getByTestId('filter-type');
|
||||
fireEvent.change(typeSelect, { target: { value: 'lkw' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockListVehicles).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'lkw', page: 1 })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('VehicleForm', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the form with all required fields', () => {
|
||||
renderWithProviders(<VehicleForm mode="create" />);
|
||||
|
||||
expect(screen.getByTestId('vehicle-form')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('input-make')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('input-model')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('input-fin')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('input-price')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('input-vehicle_type')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('submit-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows validation errors for empty required fields', async () => {
|
||||
renderWithProviders(<VehicleForm mode="create" />);
|
||||
|
||||
const submitButton = screen.getByTestId('submit-button');
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Make is required')).toBeInTheDocument();
|
||||
expect(screen.getByText('Model is required')).toBeInTheDocument();
|
||||
expect(screen.getByText('FIN is required')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error for FIN not 17 characters', async () => {
|
||||
renderWithProviders(<VehicleForm mode="create" />);
|
||||
|
||||
const finInput = screen.getByTestId('input-fin');
|
||||
fireEvent.change(finInput, { target: { value: 'SHORT' } });
|
||||
|
||||
const submitButton = screen.getByTestId('submit-button');
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('FIN must be exactly 17 characters')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls createVehicle on submit with valid data', async () => {
|
||||
(mockCreateVehicle as any).mockResolvedValue(mockVehicle);
|
||||
|
||||
renderWithProviders(<VehicleForm mode="create" />);
|
||||
|
||||
fireEvent.change(screen.getByTestId('input-make'), { target: { value: 'Volvo' } });
|
||||
fireEvent.change(screen.getByTestId('input-model'), { target: { value: 'FH16' } });
|
||||
fireEvent.change(screen.getByTestId('input-fin'), { target: { value: 'WDB9066351L123456' } });
|
||||
fireEvent.change(screen.getByTestId('input-price'), { target: { value: '85000' } });
|
||||
|
||||
fireEvent.click(screen.getByTestId('submit-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateVehicle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
make: 'Volvo',
|
||||
model: 'FH16',
|
||||
fin: 'WDB9066351L123456',
|
||||
price: 85000,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('VehicleDetail', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders vehicle details after loading', async () => {
|
||||
(mockGetVehicle as any).mockResolvedValue(mockVehicle);
|
||||
(mockGetMobileDeStatus as any).mockResolvedValue({
|
||||
synced: false,
|
||||
sync_status: 'pending',
|
||||
});
|
||||
|
||||
renderWithProviders(<VehicleDetail vehicleId="vehicle-123" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('vehicle-detail')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('vehicle-detail-title')).toHaveTextContent('Mercedes-Benz Actros');
|
||||
const fieldsContainer = screen.getByTestId('vehicle-detail-fields');
|
||||
expect(fieldsContainer).toHaveTextContent('WDB9066351L123456');
|
||||
expect(fieldsContainer).toHaveTextContent('lkw');
|
||||
expect(fieldsContainer).toHaveTextContent('available');
|
||||
});
|
||||
|
||||
it('shows mobile.de status section', async () => {
|
||||
(mockGetVehicle as any).mockResolvedValue(mockVehicle);
|
||||
(mockGetMobileDeStatus as any).mockResolvedValue({
|
||||
synced: true,
|
||||
ad_id: 'ad-123',
|
||||
synced_at: '2025-01-01T12:00:00Z',
|
||||
sync_status: 'synced',
|
||||
});
|
||||
|
||||
renderWithProviders(<VehicleDetail vehicleId="vehicle-123" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mobile-de-status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('mobile-de-sync-status')).toHaveTextContent('Synced');
|
||||
});
|
||||
|
||||
it('shows error message on API failure', async () => {
|
||||
(mockGetVehicle as any).mockRejectedValue({
|
||||
error: { code: 'VEHICLE_NOT_FOUND', message: 'Vehicle not found' },
|
||||
});
|
||||
|
||||
renderWithProviders(<VehicleDetail vehicleId="nonexistent" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('vehicle-detail-error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MobileDeStatus', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders with pending status when no listing exists', async () => {
|
||||
(mockGetMobileDeStatus as any).mockResolvedValue({
|
||||
synced: false,
|
||||
sync_status: 'pending',
|
||||
});
|
||||
|
||||
renderWithProviders(<MobileDeStatus vehicleId="vehicle-123" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mobile-de-sync-status')).toHaveTextContent('Pending');
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('mobile-de-synced')).toHaveTextContent('No');
|
||||
});
|
||||
|
||||
it('shows synced status after successful push', async () => {
|
||||
(mockGetMobileDeStatus as any).mockResolvedValue({
|
||||
synced: true,
|
||||
ad_id: 'ad-456',
|
||||
synced_at: '2025-06-01T10:00:00Z',
|
||||
sync_status: 'synced',
|
||||
});
|
||||
|
||||
renderWithProviders(<MobileDeStatus vehicleId="vehicle-123" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mobile-de-sync-status')).toHaveTextContent('Synced');
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('mobile-de-synced')).toHaveTextContent('Yes');
|
||||
expect(screen.getByText('ad-456')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error log when sync failed', async () => {
|
||||
(mockGetMobileDeStatus as any).mockResolvedValue({
|
||||
synced: false,
|
||||
sync_status: 'fehler',
|
||||
error_log: 'HTTP 500: Internal Server Error',
|
||||
});
|
||||
|
||||
renderWithProviders(<MobileDeStatus vehicleId="vehicle-123" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mobile-de-sync-status')).toHaveTextContent('Error');
|
||||
expect(screen.getByTestId('mobile-de-error-log')).toBeInTheDocument();
|
||||
expect(screen.getByText(/HTTP 500/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls pushToMobileDe when push button is clicked', async () => {
|
||||
(mockGetMobileDeStatus as any).mockResolvedValue({
|
||||
synced: false,
|
||||
sync_status: 'pending',
|
||||
});
|
||||
(mockPushToMobileDe as any).mockResolvedValue({
|
||||
message: 'Push queued',
|
||||
vehicle_id: 'vehicle-123',
|
||||
});
|
||||
|
||||
renderWithProviders(<MobileDeStatus vehicleId="vehicle-123" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mobile-de-push-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('mobile-de-push-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPushToMobileDe).toHaveBeenCalledWith('vehicle-123');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user