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:
+19
-15
@@ -1,27 +1,31 @@
|
|||||||
# Current Status
|
# Current Status
|
||||||
|
|
||||||
**Task**: T01 – Auth + User Management + RBAC + Base Frontend Layout + i18n Setup
|
**Task**: T02 – Vehicle Management + mobile.de Push + Vehicle UI
|
||||||
**Status**: COMPLETED
|
**Status**: COMPLETED
|
||||||
**Date**: 2026-07-14
|
**Date**: 2026-07-14
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
Backend (FastAPI) und Frontend (Next.js 14) vollständig implementiert.
|
Vehicle CRUD API with pagination/filtering/sorting, mobile.de push integration, and full frontend UI implemented.
|
||||||
|
|
||||||
## Backend (COMPLETED)
|
## Backend (COMPLETED)
|
||||||
- 10 Python-Dateien erstellt (config, database, models, schemas, utils, services, dependencies, routers, main)
|
- Vehicle model: UUID PK, all fields per spec (make, model, fin CHECK len=17, year, first_registration, power_kw, power_hp computed, fuel_type, transmission, color, condition, location, availability, price, vehicle_type, lkw_type, machine_type, body_type, operating_hours, operating_hours_unit, mileage_km, description, timestamps, deleted_at soft-delete)
|
||||||
- 4 Test-Dateien mit 50 Tests – alle bestanden
|
- MobileDeListing model: vehicle_id FK, ad_id, sync_status, synced_at, error_log
|
||||||
- Coverage: 88% total, auth_service 95%, routers 79-100%
|
- 7 API endpoints: GET /vehicles (list+filter+sort+paginate), POST /vehicles (create), GET /vehicles/:id (detail), PUT /vehicles/:id (update), DELETE /vehicles/:id (soft-delete), POST /vehicles/:id/mobile-de/push (202 async), GET /vehicles/:id/mobile-de/status
|
||||||
- PostgreSQL 18 als Test-DB (kein SQLite)
|
- mobile.de service: push_listing, update_listing, delete_listing, get_listing_status, retry_failed_listing (max 3 retries)
|
||||||
- bcrypt Password-Hashing, JWT HS256, RBAC require_role
|
- Field mapping: map_fields() converts Vehicle to mobile.de Ad format (vin, firstRegistration YYYY-MM, mileage, price EUR, power, category, bodyType, sellerLocation)
|
||||||
|
- All endpoints require JWT Bearer auth
|
||||||
|
- Config: MOBILE_DE_API_KEY, MOBILE_DE_SELLER_ID env vars added
|
||||||
|
|
||||||
## Frontend (COMPLETED)
|
## Frontend (COMPLETED)
|
||||||
- 6 UI-Komponenten (Button, Input, Card, Table, Modal, Toast)
|
- VehicleList: Table with filters (search, type, availability, sort), pagination, error handling
|
||||||
- Login-Page mit Form-Validation und Toast-Error-Handling
|
- VehicleForm: Create/edit form with validation (make, model, fin=17 chars, price>0, vehicle_type), conditional fields
|
||||||
- i18n mit DE/EN (31 Keys je Sprache)
|
- VehicleDetail: All vehicle fields display, delete button, MobileDeStatus embedded
|
||||||
- 12 Vitest-Tests – alle bestanden
|
- MobileDeStatus: Sync status badge, push button, ad_id, synced_at, error_log display
|
||||||
- Next.js Production Build erfolgreich
|
- 3 pages: fahrzeuge list, fahrzeuge/neu create, fahrzeuge/[id] detail
|
||||||
|
|
||||||
## Test Evidence
|
## Test Evidence
|
||||||
- Backend: 50/50 pytest passed, 88% coverage
|
- Backend: 73/73 pytest passed, 82% total coverage (vehicle_service 99%, mobilede_service 80%, router 60%)
|
||||||
- Frontend: 12/12 vitest passed, Next.js build success
|
- Frontend: 16/16 vitest passed
|
||||||
- test_report.md erstellt mit vollständigen Ergebnissen
|
- test_report.md updated with full results
|
||||||
|
- All mobile.de HTTP calls mocked in tests, no real API calls
|
||||||
|
- Soft-delete verified: deleted_at set, vehicle excluded from list and detail queries
|
||||||
|
|||||||
@@ -45,3 +45,26 @@
|
|||||||
- Backend: 50/50 passed, 88% total coverage
|
- Backend: 50/50 passed, 88% total coverage
|
||||||
- Frontend: 12/12 passed, Next.js build success
|
- Frontend: 12/12 passed, Next.js build success
|
||||||
- test_report.md erstellt
|
- test_report.md erstellt
|
||||||
|
|
||||||
|
## T02 – Vehicle Management + mobile.de Push + Vehicle UI (2026-07-14)
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- models/vehicle.py: Vehicle + MobileDeListing models with UUID PK, soft-delete, all fields per spec
|
||||||
|
- schemas/vehicle.py: VehicleCreate/Update/Response/ListResponse, MobileDeStatusResponse, MobileDePushResponse with auto-compute power_hp
|
||||||
|
- utils/mobilede_mapping.py: map_fields() converts Vehicle to mobile.de Ad format
|
||||||
|
- services/vehicle_service.py: CRUD with pagination, filtering, sorting, soft-delete
|
||||||
|
- services/mobilede_service.py: push/update/delete listing, get status, retry (max 3)
|
||||||
|
- routers/vehicles.py: 7 endpoints (list, create, detail, update, delete, mobile-de push, mobile-de status)
|
||||||
|
- config.py: Added MOBILE_DE_API_KEY, MOBILE_DE_SELLER_ID
|
||||||
|
- main.py: Registered vehicles router
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- lib/vehicles.ts: Full API client with typed interfaces
|
||||||
|
- components/vehicles/: VehicleList, VehicleForm, VehicleDetail, MobileDeStatus
|
||||||
|
- app/[locale]/fahrzeuge/: list page, neu (create) page, [id] detail page
|
||||||
|
- tests/vehicles.test.tsx: 16 tests
|
||||||
|
|
||||||
|
### Test Results
|
||||||
|
- Backend: 73/73 pytest passed, 82% total coverage
|
||||||
|
- Frontend: 16/16 vitest passed
|
||||||
|
- test_report.md updated
|
||||||
|
|||||||
Binary file not shown.
@@ -31,6 +31,8 @@ class Settings(BaseSettings):
|
|||||||
UPLOAD_DIR: str = "/tmp/uploads"
|
UPLOAD_DIR: str = "/tmp/uploads"
|
||||||
APP_NAME: str = "ERP Nutzfahrzeuge"
|
APP_NAME: str = "ERP Nutzfahrzeuge"
|
||||||
APP_ENV: str = "development"
|
APP_ENV: str = "development"
|
||||||
|
MOBILE_DE_API_KEY: str = ""
|
||||||
|
MOBILE_DE_SELLER_ID: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cors_origins_list(self) -> list[str]:
|
def cors_origins_list(self) -> list[str]:
|
||||||
|
|||||||
+2
-1
@@ -9,7 +9,7 @@ from fastapi import APIRouter, FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.routers import auth, users
|
from app.routers import auth, users, vehicles
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -40,6 +40,7 @@ app.add_middleware(
|
|||||||
api_v1_router = APIRouter(prefix="/api/v1")
|
api_v1_router = APIRouter(prefix="/api/v1")
|
||||||
api_v1_router.include_router(auth.router)
|
api_v1_router.include_router(auth.router)
|
||||||
api_v1_router.include_router(users.router)
|
api_v1_router.include_router(users.router)
|
||||||
|
api_v1_router.include_router(vehicles.router)
|
||||||
|
|
||||||
# Health endpoint (no auth required)
|
# Health endpoint (no auth required)
|
||||||
@api_v1_router.get("/health", tags=["health"])
|
@api_v1_router.get("/health", tags=["health"])
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"""SQLAlchemy models for vehicles and mobile.de listings."""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
CheckConstraint,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
Enum,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleCondition(str, enum.Enum):
|
||||||
|
new = "new"
|
||||||
|
used = "used"
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleAvailability(str, enum.Enum):
|
||||||
|
available = "available"
|
||||||
|
reserved = "reserved"
|
||||||
|
sold = "sold"
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleType(str, enum.Enum):
|
||||||
|
lkw = "lkw"
|
||||||
|
pkw = "pkw"
|
||||||
|
baumaschine = "baumaschine"
|
||||||
|
stapler = "stapler"
|
||||||
|
transporter = "transporter"
|
||||||
|
|
||||||
|
|
||||||
|
class SyncStatus(str, enum.Enum):
|
||||||
|
pending = "pending"
|
||||||
|
synced = "synced"
|
||||||
|
fehler = "fehler"
|
||||||
|
|
||||||
|
|
||||||
|
class Vehicle(Base):
|
||||||
|
"""Vehicle entity for the ERP inventory."""
|
||||||
|
|
||||||
|
__tablename__ = "vehicles"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"char_length(fin) = 17", name="ck_vehicles_fin_length"
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"condition IN ('new', 'used')", name="ck_vehicles_condition"
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"availability IN ('available', 'reserved', 'sold')",
|
||||||
|
name="ck_vehicles_availability",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"vehicle_type IN ('lkw', 'pkw', 'baumaschine', 'stapler', 'transporter')",
|
||||||
|
name="ck_vehicles_vehicle_type",
|
||||||
|
),
|
||||||
|
CheckConstraint(
|
||||||
|
"operating_hours_unit IS NULL OR operating_hours_unit IN ('h', 'min')",
|
||||||
|
name="ck_vehicles_operating_hours_unit",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
make: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
model: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
fin: Mapped[str] = mapped_column(
|
||||||
|
String(17), unique=True, nullable=False, index=True
|
||||||
|
)
|
||||||
|
year: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
first_registration: Mapped[date | None] = mapped_column(
|
||||||
|
Date, nullable=True
|
||||||
|
)
|
||||||
|
power_kw: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
power_hp: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
fuel_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
transmission: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
color: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
condition: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="used"
|
||||||
|
)
|
||||||
|
location: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
availability: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="available"
|
||||||
|
)
|
||||||
|
price: Mapped[Decimal] = mapped_column(
|
||||||
|
Numeric(12, 2), nullable=False
|
||||||
|
)
|
||||||
|
vehicle_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
lkw_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
machine_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
body_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
operating_hours: Mapped[Decimal | None] = mapped_column(
|
||||||
|
Numeric(12, 1), nullable=True
|
||||||
|
)
|
||||||
|
operating_hours_unit: Mapped[str | None] = mapped_column(
|
||||||
|
String(5), nullable=True
|
||||||
|
)
|
||||||
|
mileage_km: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
mobile_de_listings: Mapped[list["MobileDeListing"]] = relationship(
|
||||||
|
back_populates="vehicle", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Vehicle id={self.id} fin={self.fin} make={self.make}>"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Serialize vehicle for API responses."""
|
||||||
|
return {
|
||||||
|
"id": str(self.id),
|
||||||
|
"make": self.make,
|
||||||
|
"model": self.model,
|
||||||
|
"fin": self.fin,
|
||||||
|
"year": self.year,
|
||||||
|
"first_registration": (
|
||||||
|
self.first_registration.isoformat()
|
||||||
|
if self.first_registration
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"power_kw": self.power_kw,
|
||||||
|
"power_hp": self.power_hp,
|
||||||
|
"fuel_type": self.fuel_type,
|
||||||
|
"transmission": self.transmission,
|
||||||
|
"color": self.color,
|
||||||
|
"condition": self.condition,
|
||||||
|
"location": self.location,
|
||||||
|
"availability": self.availability,
|
||||||
|
"price": float(self.price) if self.price is not None else None,
|
||||||
|
"vehicle_type": self.vehicle_type,
|
||||||
|
"lkw_type": self.lkw_type,
|
||||||
|
"machine_type": self.machine_type,
|
||||||
|
"body_type": self.body_type,
|
||||||
|
"operating_hours": (
|
||||||
|
float(self.operating_hours)
|
||||||
|
if self.operating_hours is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"operating_hours_unit": self.operating_hours_unit,
|
||||||
|
"mileage_km": self.mileage_km,
|
||||||
|
"description": self.description,
|
||||||
|
"created_at": (
|
||||||
|
self.created_at.isoformat() if self.created_at else None
|
||||||
|
),
|
||||||
|
"updated_at": (
|
||||||
|
self.updated_at.isoformat() if self.updated_at else None
|
||||||
|
),
|
||||||
|
"deleted_at": (
|
||||||
|
self.deleted_at.isoformat() if self.deleted_at else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MobileDeListing(Base):
|
||||||
|
"""mobile.de listing sync tracking for a vehicle."""
|
||||||
|
|
||||||
|
__tablename__ = "mobile_de_listings"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
vehicle_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("vehicles.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
ad_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
sync_status: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="pending"
|
||||||
|
)
|
||||||
|
synced_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
error_log: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
vehicle: Mapped["Vehicle"] = relationship(back_populates="mobile_de_listings")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<MobileDeListing id={self.id} vehicle_id={self.vehicle_id} status={self.sync_status}>"
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Serialize listing for API responses."""
|
||||||
|
return {
|
||||||
|
"id": str(self.id),
|
||||||
|
"vehicle_id": str(self.vehicle_id),
|
||||||
|
"ad_id": self.ad_id,
|
||||||
|
"sync_status": self.sync_status,
|
||||||
|
"synced_at": (
|
||||||
|
self.synced_at.isoformat() if self.synced_at else None
|
||||||
|
),
|
||||||
|
"error_log": self.error_log,
|
||||||
|
"created_at": (
|
||||||
|
self.created_at.isoformat() if self.created_at else None
|
||||||
|
),
|
||||||
|
"updated_at": (
|
||||||
|
self.updated_at.isoformat() if self.updated_at else None
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""Vehicles router: CRUD, filtering, sorting, and mobile.de integration endpoints."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import get_current_user, get_pagination
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
from app.schemas.vehicle import (
|
||||||
|
MobileDePushResponse,
|
||||||
|
MobileDeStatusResponse,
|
||||||
|
VehicleCreate,
|
||||||
|
VehicleListResponse,
|
||||||
|
VehicleResponse,
|
||||||
|
VehicleUpdate,
|
||||||
|
)
|
||||||
|
from app.services import vehicle_service
|
||||||
|
from app.services import mobilede_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/vehicles", tags=["vehicles"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=VehicleListResponse, status_code=status.HTTP_200_OK)
|
||||||
|
async def list_vehicles(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
pagination: dict = Depends(get_pagination),
|
||||||
|
vehicle_type: str | None = Query(None, description="Filter by vehicle type"),
|
||||||
|
availability: str | None = Query(None, description="Filter by availability"),
|
||||||
|
min_price: float | None = Query(None, ge=0, description="Minimum price"),
|
||||||
|
max_price: float | None = Query(None, ge=0, description="Maximum price"),
|
||||||
|
search: str | None = Query(None, description="Search in make, model, fin, location"),
|
||||||
|
sort: str | None = Query(None, description="Sort field (prefix - for descending)"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List vehicles with pagination, filtering, and sorting."""
|
||||||
|
vehicles, total = await vehicle_service.list_vehicles(
|
||||||
|
db,
|
||||||
|
page=pagination["page"],
|
||||||
|
page_size=pagination["page_size"],
|
||||||
|
vehicle_type=vehicle_type,
|
||||||
|
availability=availability,
|
||||||
|
min_price=min_price,
|
||||||
|
max_price=max_price,
|
||||||
|
search=search,
|
||||||
|
sort=sort,
|
||||||
|
)
|
||||||
|
return VehicleListResponse(
|
||||||
|
items=[VehicleResponse.model_validate(v) for v in vehicles],
|
||||||
|
total=total,
|
||||||
|
page=pagination["page"],
|
||||||
|
page_size=pagination["page_size"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=VehicleResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_vehicle(
|
||||||
|
body: VehicleCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Create a new vehicle."""
|
||||||
|
data = body.model_dump(exclude_unset=False)
|
||||||
|
try:
|
||||||
|
vehicle = await vehicle_service.create_vehicle(db, data)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail={"error": {"code": "DUPLICATE_FIN", "message": str(exc)}},
|
||||||
|
)
|
||||||
|
return VehicleResponse.model_validate(vehicle)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK)
|
||||||
|
async def get_vehicle(
|
||||||
|
vehicle_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get a single vehicle by ID."""
|
||||||
|
vehicle = await vehicle_service.get_vehicle_by_id(db, vehicle_id)
|
||||||
|
if vehicle is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||||
|
)
|
||||||
|
return VehicleResponse.model_validate(vehicle)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK)
|
||||||
|
async def update_vehicle(
|
||||||
|
vehicle_id: uuid.UUID,
|
||||||
|
body: VehicleUpdate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Update a vehicle's fields."""
|
||||||
|
updates = body.model_dump(exclude_unset=True)
|
||||||
|
if not updates:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={"error": {"code": "NO_FIELDS", "message": "No fields to update"}},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
vehicle = await vehicle_service.update_vehicle(db, vehicle_id, updates)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail={"error": {"code": "DUPLICATE_FIN", "message": str(exc)}},
|
||||||
|
)
|
||||||
|
if vehicle is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||||
|
)
|
||||||
|
return VehicleResponse.model_validate(vehicle)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{vehicle_id}", response_model=VehicleResponse, status_code=status.HTTP_200_OK)
|
||||||
|
async def delete_vehicle(
|
||||||
|
vehicle_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Soft-delete a vehicle (sets deleted_at)."""
|
||||||
|
vehicle = await vehicle_service.soft_delete_vehicle(db, vehicle_id)
|
||||||
|
if vehicle is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||||
|
)
|
||||||
|
return VehicleResponse.model_validate(vehicle)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{vehicle_id}/mobile-de/push",
|
||||||
|
response_model=MobileDePushResponse,
|
||||||
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
|
)
|
||||||
|
async def push_to_mobile_de(
|
||||||
|
vehicle_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Push a vehicle listing to mobile.de (async, returns 202)."""
|
||||||
|
vehicle = await vehicle_service.get_vehicle_by_id(db, vehicle_id)
|
||||||
|
if vehicle is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
listing = await mobilede_service.push_listing(db, vehicle)
|
||||||
|
|
||||||
|
return MobileDePushResponse(
|
||||||
|
message="Push queued",
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
listing_id=listing.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{vehicle_id}/mobile-de/status",
|
||||||
|
response_model=MobileDeStatusResponse,
|
||||||
|
status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
async def get_mobile_de_status(
|
||||||
|
vehicle_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""Get the mobile.de sync status for a vehicle."""
|
||||||
|
# Verify vehicle exists
|
||||||
|
vehicle = await vehicle_service.get_vehicle_by_id(db, vehicle_id)
|
||||||
|
if vehicle is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": "Vehicle not found"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
listing = await mobilede_service.get_listing_status(db, vehicle_id)
|
||||||
|
if listing is None:
|
||||||
|
return MobileDeStatusResponse(
|
||||||
|
synced=False,
|
||||||
|
ad_id=None,
|
||||||
|
synced_at=None,
|
||||||
|
sync_status="pending",
|
||||||
|
error_log=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
return MobileDeStatusResponse(
|
||||||
|
synced=(listing.sync_status == "synced"),
|
||||||
|
ad_id=listing.ad_id,
|
||||||
|
synced_at=listing.synced_at,
|
||||||
|
sync_status=listing.sync_status,
|
||||||
|
error_log=listing.error_log,
|
||||||
|
)
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""Pydantic schemas for vehicle-related request and response bodies."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
VEHICLE_TYPES = Literal["lkw", "pkw", "baumaschine", "stapler", "transporter"]
|
||||||
|
CONDITIONS = Literal["new", "used"]
|
||||||
|
AVAILABILITY = Literal["available", "reserved", "sold"]
|
||||||
|
HOURS_UNITS = Literal["h", "min"]
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleBase(BaseModel):
|
||||||
|
"""Base vehicle fields shared across schemas."""
|
||||||
|
|
||||||
|
make: str = Field(..., min_length=1, max_length=100)
|
||||||
|
model: str = Field(..., min_length=1, max_length=100)
|
||||||
|
fin: str = Field(..., min_length=17, max_length=17)
|
||||||
|
year: Optional[int] = Field(None, ge=1900, le=2100)
|
||||||
|
first_registration: Optional[date] = None
|
||||||
|
power_kw: Optional[int] = Field(None, ge=0)
|
||||||
|
power_hp: Optional[int] = Field(None, ge=0)
|
||||||
|
fuel_type: Optional[str] = Field(None, max_length=50)
|
||||||
|
transmission: Optional[str] = Field(None, max_length=20)
|
||||||
|
color: Optional[str] = Field(None, max_length=50)
|
||||||
|
condition: CONDITIONS = "used"
|
||||||
|
location: Optional[str] = Field(None, max_length=255)
|
||||||
|
availability: AVAILABILITY = "available"
|
||||||
|
price: Decimal = Field(..., ge=0)
|
||||||
|
vehicle_type: VEHICLE_TYPES
|
||||||
|
lkw_type: Optional[str] = Field(None, max_length=50)
|
||||||
|
machine_type: Optional[str] = Field(None, max_length=50)
|
||||||
|
body_type: Optional[str] = Field(None, max_length=100)
|
||||||
|
operating_hours: Optional[Decimal] = Field(None, ge=0)
|
||||||
|
operating_hours_unit: Optional[HOURS_UNITS] = None
|
||||||
|
mileage_km: Optional[int] = Field(None, ge=0)
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def compute_power_hp(self) -> "VehicleBase":
|
||||||
|
"""Auto-compute power_hp from power_kw if not provided."""
|
||||||
|
if self.power_kw is not None and self.power_hp is None:
|
||||||
|
self.power_hp = round(self.power_kw * 1.35962)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleCreate(VehicleBase):
|
||||||
|
"""POST /api/v1/vehicles request body."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleUpdate(BaseModel):
|
||||||
|
"""PUT /api/v1/vehicles/:id request body (all fields optional)."""
|
||||||
|
|
||||||
|
make: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||||
|
model: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||||
|
fin: Optional[str] = Field(None, min_length=17, max_length=17)
|
||||||
|
year: Optional[int] = Field(None, ge=1900, le=2100)
|
||||||
|
first_registration: Optional[date] = None
|
||||||
|
power_kw: Optional[int] = Field(None, ge=0)
|
||||||
|
power_hp: Optional[int] = Field(None, ge=0)
|
||||||
|
fuel_type: Optional[str] = Field(None, max_length=50)
|
||||||
|
transmission: Optional[str] = Field(None, max_length=20)
|
||||||
|
color: Optional[str] = Field(None, max_length=50)
|
||||||
|
condition: Optional[CONDITIONS] = None
|
||||||
|
location: Optional[str] = Field(None, max_length=255)
|
||||||
|
availability: Optional[AVAILABILITY] = None
|
||||||
|
price: Optional[Decimal] = Field(None, ge=0)
|
||||||
|
vehicle_type: Optional[VEHICLE_TYPES] = None
|
||||||
|
lkw_type: Optional[str] = Field(None, max_length=50)
|
||||||
|
machine_type: Optional[str] = Field(None, max_length=50)
|
||||||
|
body_type: Optional[str] = Field(None, max_length=100)
|
||||||
|
operating_hours: Optional[Decimal] = Field(None, ge=0)
|
||||||
|
operating_hours_unit: Optional[HOURS_UNITS] = None
|
||||||
|
mileage_km: Optional[int] = Field(None, ge=0)
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def compute_power_hp(self) -> "VehicleUpdate":
|
||||||
|
"""Auto-compute power_hp from power_kw if not provided."""
|
||||||
|
if self.power_kw is not None and self.power_hp is None:
|
||||||
|
self.power_hp = round(self.power_kw * 1.35962)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleResponse(BaseModel):
|
||||||
|
"""Vehicle response schema."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
make: str
|
||||||
|
model: str
|
||||||
|
fin: str
|
||||||
|
year: Optional[int] = None
|
||||||
|
first_registration: Optional[date] = None
|
||||||
|
power_kw: Optional[int] = None
|
||||||
|
power_hp: Optional[int] = None
|
||||||
|
fuel_type: Optional[str] = None
|
||||||
|
transmission: Optional[str] = None
|
||||||
|
color: Optional[str] = None
|
||||||
|
condition: str
|
||||||
|
location: Optional[str] = None
|
||||||
|
availability: str
|
||||||
|
price: Decimal
|
||||||
|
vehicle_type: str
|
||||||
|
lkw_type: Optional[str] = None
|
||||||
|
machine_type: Optional[str] = None
|
||||||
|
body_type: Optional[str] = None
|
||||||
|
operating_hours: Optional[Decimal] = None
|
||||||
|
operating_hours_unit: Optional[str] = None
|
||||||
|
mileage_km: Optional[int] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
deleted_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleListResponse(BaseModel):
|
||||||
|
"""Paginated vehicle list response."""
|
||||||
|
|
||||||
|
items: list[VehicleResponse]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class MobileDeStatusResponse(BaseModel):
|
||||||
|
"""mobile.de sync status for a vehicle."""
|
||||||
|
|
||||||
|
synced: bool
|
||||||
|
ad_id: Optional[str] = None
|
||||||
|
synced_at: Optional[datetime] = None
|
||||||
|
sync_status: str = "pending"
|
||||||
|
error_log: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MobileDePushResponse(BaseModel):
|
||||||
|
"""Response for mobile.de push request."""
|
||||||
|
|
||||||
|
message: str = "Push queued"
|
||||||
|
vehicle_id: uuid.UUID
|
||||||
|
listing_id: Optional[uuid.UUID] = None
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
"""mobile.de integration service: push, update, delete listings and check status.
|
||||||
|
|
||||||
|
Uses httpx for async HTTP calls to the mobile.de seller listings API.
|
||||||
|
API key and seller ID are loaded from environment variables.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from sqlalchemy import and_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.models.vehicle import MobileDeListing, Vehicle
|
||||||
|
from app.utils.mobilede_mapping import map_fields
|
||||||
|
|
||||||
|
# mobile.de API base URL
|
||||||
|
_MOBILE_DE_API_BASE = "https://api.mobile.de"
|
||||||
|
|
||||||
|
# Maximum retry attempts for failed pushes
|
||||||
|
MAX_RETRIES = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _get_api_headers() -> dict[str, str]:
|
||||||
|
"""Build authorization headers for mobile.de API."""
|
||||||
|
api_key = settings.MOBILE_DE_API_KEY
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"X-Mobile-DE-Seller-ID": settings.MOBILE_DE_SELLER_ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_listing_url(listing_id: str | None = None) -> str:
|
||||||
|
"""Build the mobile.de listings URL."""
|
||||||
|
base = f"{_MOBILE_DE_API_BASE}/api/seller/listings"
|
||||||
|
if listing_id:
|
||||||
|
return f"{base}/{listing_id}"
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _get_status_url(listing_id: str) -> str:
|
||||||
|
"""Build the mobile.de listing status URL."""
|
||||||
|
return f"{_MOBILE_DE_API_BASE}/api/seller/listings/{listing_id}/status"
|
||||||
|
|
||||||
|
|
||||||
|
async def push_listing(
|
||||||
|
db: AsyncSession, vehicle: Vehicle
|
||||||
|
) -> MobileDeListing:
|
||||||
|
"""Push a vehicle listing to mobile.de.
|
||||||
|
|
||||||
|
Creates a MobileDeListing record with status 'pending',
|
||||||
|
sends the mapped ad data to mobile.de POST /api/seller/listings,
|
||||||
|
and updates the listing with the returned ad_id and 'synced' status.
|
||||||
|
|
||||||
|
On failure, sets sync_status to 'fehler' with error_log.
|
||||||
|
"""
|
||||||
|
# Create listing record
|
||||||
|
listing = MobileDeListing(
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
sync_status="pending",
|
||||||
|
)
|
||||||
|
db.add(listing)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Map vehicle fields to mobile.de ad format
|
||||||
|
ad_data = map_fields(vehicle)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.post(
|
||||||
|
_get_listing_url(),
|
||||||
|
json=ad_data,
|
||||||
|
headers=_get_api_headers(),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
ad_id = result.get("id") or result.get("listingId")
|
||||||
|
|
||||||
|
listing.ad_id = str(ad_id) if ad_id else None
|
||||||
|
listing.sync_status = "synced"
|
||||||
|
listing.synced_at = datetime.now(timezone.utc)
|
||||||
|
listing.error_log = None
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = (
|
||||||
|
f"HTTP {exc.response.status_code}: {exc.response.text[:500]}"
|
||||||
|
)
|
||||||
|
except (httpx.RequestError, httpx.HTTPError) as exc:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = f"Request error: {str(exc)[:500]}"
|
||||||
|
except Exception as exc:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = f"Unexpected error: {str(exc)[:500]}"
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(listing)
|
||||||
|
return listing
|
||||||
|
|
||||||
|
|
||||||
|
async def update_listing(
|
||||||
|
db: AsyncSession, vehicle: Vehicle, listing: MobileDeListing
|
||||||
|
) -> MobileDeListing:
|
||||||
|
"""Update an existing mobile.de listing.
|
||||||
|
|
||||||
|
Sends PUT /api/seller/listings/{id} with updated ad data.
|
||||||
|
"""
|
||||||
|
if not listing.ad_id:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = "Cannot update listing without ad_id"
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(listing)
|
||||||
|
return listing
|
||||||
|
|
||||||
|
ad_data = map_fields(vehicle)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.put(
|
||||||
|
_get_listing_url(listing.ad_id),
|
||||||
|
json=ad_data,
|
||||||
|
headers=_get_api_headers(),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
listing.sync_status = "synced"
|
||||||
|
listing.synced_at = datetime.now(timezone.utc)
|
||||||
|
listing.error_log = None
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = (
|
||||||
|
f"HTTP {exc.response.status_code}: {exc.response.text[:500]}"
|
||||||
|
)
|
||||||
|
except (httpx.RequestError, httpx.HTTPError) as exc:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = f"Request error: {str(exc)[:500]}"
|
||||||
|
except Exception as exc:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = f"Unexpected error: {str(exc)[:500]}"
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(listing)
|
||||||
|
return listing
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_listing(
|
||||||
|
db: AsyncSession, listing: MobileDeListing
|
||||||
|
) -> MobileDeListing:
|
||||||
|
"""Delete a listing from mobile.de.
|
||||||
|
|
||||||
|
Sends DELETE /api/seller/listings/{id}.
|
||||||
|
"""
|
||||||
|
if not listing.ad_id:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = "Cannot delete listing without ad_id"
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(listing)
|
||||||
|
return listing
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.delete(
|
||||||
|
_get_listing_url(listing.ad_id),
|
||||||
|
headers=_get_api_headers(),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
listing.sync_status = "deleted"
|
||||||
|
listing.error_log = None
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = (
|
||||||
|
f"HTTP {exc.response.status_code}: {exc.response.text[:500]}"
|
||||||
|
)
|
||||||
|
except (httpx.RequestError, httpx.HTTPError) as exc:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = f"Request error: {str(exc)[:500]}"
|
||||||
|
except Exception as exc:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = f"Unexpected error: {str(exc)[:500]}"
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(listing)
|
||||||
|
return listing
|
||||||
|
|
||||||
|
|
||||||
|
async def get_listing_status(
|
||||||
|
db: AsyncSession, vehicle_id: uuid.UUID
|
||||||
|
) -> MobileDeListing | None:
|
||||||
|
"""Get the latest mobile.de listing status for a vehicle.
|
||||||
|
|
||||||
|
Returns the most recent MobileDeListing record, or None if no listing exists.
|
||||||
|
"""
|
||||||
|
stmt = (
|
||||||
|
select(MobileDeListing)
|
||||||
|
.where(MobileDeListing.vehicle_id == vehicle_id)
|
||||||
|
.order_by(MobileDeListing.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def retry_failed_listing(
|
||||||
|
db: AsyncSession, listing: MobileDeListing, vehicle: Vehicle
|
||||||
|
) -> MobileDeListing:
|
||||||
|
"""Retry a failed listing push.
|
||||||
|
|
||||||
|
Increments retry count (tracked via error_log prefix) and re-attempts push.
|
||||||
|
After MAX_RETRIES, marks as permanently failed.
|
||||||
|
"""
|
||||||
|
# Count existing retries from error_log
|
||||||
|
retry_count = 0
|
||||||
|
if listing.error_log and listing.error_log.startswith("[retry"):
|
||||||
|
try:
|
||||||
|
retry_count = int(listing.error_log.split("]")[0].split("retry ")[1])
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
retry_count = 0
|
||||||
|
|
||||||
|
if retry_count >= MAX_RETRIES:
|
||||||
|
listing.sync_status = "fehler"
|
||||||
|
listing.error_log = (
|
||||||
|
f"Max retries ({MAX_RETRIES}) exceeded. "
|
||||||
|
f"Last error: {listing.error_log}"
|
||||||
|
)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(listing)
|
||||||
|
return listing
|
||||||
|
|
||||||
|
# Attempt re-push
|
||||||
|
listing.sync_status = "pending"
|
||||||
|
listing.error_log = f"[retry {retry_count + 1}] Retrying push"
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return await push_listing(db, vehicle)
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"""Vehicle service: CRUD, filtering, sorting, and soft-delete operations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import and_, func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
|
||||||
|
|
||||||
|
# Fields that are safe to sort by
|
||||||
|
_SORTABLE_FIELDS: set[str] = {
|
||||||
|
"make",
|
||||||
|
"model",
|
||||||
|
"fin",
|
||||||
|
"year",
|
||||||
|
"price",
|
||||||
|
"vehicle_type",
|
||||||
|
"availability",
|
||||||
|
"condition",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"power_kw",
|
||||||
|
"mileage_km",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_filters(
|
||||||
|
stmt: select,
|
||||||
|
vehicle_type: str | None = None,
|
||||||
|
availability: str | None = None,
|
||||||
|
min_price: float | None = None,
|
||||||
|
max_price: float | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> select:
|
||||||
|
"""Apply WHERE filters to a select statement (always excludes soft-deleted)."""
|
||||||
|
conditions = [Vehicle.deleted_at.is_(None)]
|
||||||
|
|
||||||
|
if vehicle_type:
|
||||||
|
conditions.append(Vehicle.vehicle_type == vehicle_type)
|
||||||
|
if availability:
|
||||||
|
conditions.append(Vehicle.availability == availability)
|
||||||
|
if min_price is not None:
|
||||||
|
conditions.append(Vehicle.price >= min_price)
|
||||||
|
if max_price is not None:
|
||||||
|
conditions.append(Vehicle.price <= max_price)
|
||||||
|
if search:
|
||||||
|
search_pattern = f"%{search}%"
|
||||||
|
conditions.append(
|
||||||
|
or_(
|
||||||
|
Vehicle.make.ilike(search_pattern),
|
||||||
|
Vehicle.model.ilike(search_pattern),
|
||||||
|
Vehicle.fin.ilike(search_pattern),
|
||||||
|
Vehicle.location.ilike(search_pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return stmt.where(and_(*conditions))
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_sort(stmt: select, sort: str | None = None) -> select:
|
||||||
|
"""Apply ORDER BY to a select statement based on sort param.
|
||||||
|
|
||||||
|
Format: 'field' for ascending, '-field' for descending.
|
||||||
|
"""
|
||||||
|
if not sort:
|
||||||
|
return stmt.order_by(Vehicle.created_at.desc())
|
||||||
|
|
||||||
|
descending = sort.startswith("-")
|
||||||
|
field_name = sort.lstrip("-")
|
||||||
|
|
||||||
|
if field_name not in _SORTABLE_FIELDS:
|
||||||
|
return stmt.order_by(Vehicle.created_at.desc())
|
||||||
|
|
||||||
|
column = getattr(Vehicle, field_name)
|
||||||
|
if descending:
|
||||||
|
return stmt.order_by(column.desc())
|
||||||
|
return stmt.order_by(column.asc())
|
||||||
|
|
||||||
|
|
||||||
|
async def list_vehicles(
|
||||||
|
db: AsyncSession,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
vehicle_type: str | None = None,
|
||||||
|
availability: str | None = None,
|
||||||
|
min_price: float | None = None,
|
||||||
|
max_price: float | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
sort: str | None = None,
|
||||||
|
) -> tuple[list[Vehicle], int]:
|
||||||
|
"""List vehicles with pagination, filtering, and sorting.
|
||||||
|
|
||||||
|
Returns (vehicles, total_count).
|
||||||
|
"""
|
||||||
|
# Build count query
|
||||||
|
count_stmt = select(func.count(Vehicle.id))
|
||||||
|
count_stmt = _apply_filters(
|
||||||
|
count_stmt,
|
||||||
|
vehicle_type=vehicle_type,
|
||||||
|
availability=availability,
|
||||||
|
min_price=min_price,
|
||||||
|
max_price=max_price,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
total_result = await db.execute(count_stmt)
|
||||||
|
total = total_result.scalar_one()
|
||||||
|
|
||||||
|
# Build data query
|
||||||
|
data_stmt = select(Vehicle)
|
||||||
|
data_stmt = _apply_filters(
|
||||||
|
data_stmt,
|
||||||
|
vehicle_type=vehicle_type,
|
||||||
|
availability=availability,
|
||||||
|
min_price=min_price,
|
||||||
|
max_price=max_price,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
data_stmt = _apply_sort(data_stmt, sort)
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
data_stmt = data_stmt.offset(offset).limit(page_size)
|
||||||
|
|
||||||
|
result = await db.execute(data_stmt)
|
||||||
|
vehicles = list(result.scalars().all())
|
||||||
|
|
||||||
|
return vehicles, total
|
||||||
|
|
||||||
|
|
||||||
|
async def get_vehicle_by_id(
|
||||||
|
db: AsyncSession, vehicle_id: uuid.UUID
|
||||||
|
) -> Vehicle | None:
|
||||||
|
"""Get a single vehicle by ID, excluding soft-deleted."""
|
||||||
|
stmt = select(Vehicle).where(
|
||||||
|
and_(Vehicle.id == vehicle_id, Vehicle.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_vehicle_by_fin(
|
||||||
|
db: AsyncSession, fin: str
|
||||||
|
) -> Vehicle | None:
|
||||||
|
"""Get a single vehicle by FIN, excluding soft-deleted."""
|
||||||
|
stmt = select(Vehicle).where(
|
||||||
|
and_(Vehicle.fin == fin, Vehicle.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_vehicle(
|
||||||
|
db: AsyncSession, data: dict[str, Any]
|
||||||
|
) -> Vehicle:
|
||||||
|
"""Create a new vehicle.
|
||||||
|
|
||||||
|
Raises ValueError if FIN already exists.
|
||||||
|
"""
|
||||||
|
existing = await get_vehicle_by_fin(db, data["fin"])
|
||||||
|
if existing is not None:
|
||||||
|
raise ValueError(f"Vehicle with FIN '{data['fin']}' already exists")
|
||||||
|
|
||||||
|
vehicle = Vehicle(**data)
|
||||||
|
db.add(vehicle)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(vehicle)
|
||||||
|
return vehicle
|
||||||
|
|
||||||
|
|
||||||
|
async def update_vehicle(
|
||||||
|
db: AsyncSession, vehicle_id: uuid.UUID, updates: dict[str, Any]
|
||||||
|
) -> Vehicle | None:
|
||||||
|
"""Update a vehicle's fields. Returns None if not found or deleted."""
|
||||||
|
vehicle = await get_vehicle_by_id(db, vehicle_id)
|
||||||
|
if vehicle is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# If FIN is being updated, check for duplicates
|
||||||
|
if "fin" in updates and updates["fin"] != vehicle.fin:
|
||||||
|
existing = await get_vehicle_by_fin(db, updates["fin"])
|
||||||
|
if existing is not None:
|
||||||
|
raise ValueError(f"Vehicle with FIN '{updates['fin']}' already exists")
|
||||||
|
|
||||||
|
for key, value in updates.items():
|
||||||
|
if hasattr(vehicle, key):
|
||||||
|
setattr(vehicle, key, value)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(vehicle)
|
||||||
|
return vehicle
|
||||||
|
|
||||||
|
|
||||||
|
async def soft_delete_vehicle(
|
||||||
|
db: AsyncSession, vehicle_id: uuid.UUID
|
||||||
|
) -> Vehicle | None:
|
||||||
|
"""Soft-delete a vehicle by setting deleted_at. Returns None if not found."""
|
||||||
|
vehicle = await get_vehicle_by_id(db, vehicle_id)
|
||||||
|
if vehicle is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
vehicle.deleted_at = datetime.now(timezone.utc)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(vehicle)
|
||||||
|
return vehicle
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Field mapping utilities for mobile.de listing format conversion."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
|
||||||
|
|
||||||
|
# mobile.de category mapping based on vehicle_type
|
||||||
|
_CATEGORY_MAP: dict[str, str] = {
|
||||||
|
"lkw": "Truck",
|
||||||
|
"pkw": "Car",
|
||||||
|
"baumaschine": "ConstructionMachine",
|
||||||
|
"stapler": "ForkliftTruck",
|
||||||
|
"transporter": "Van",
|
||||||
|
}
|
||||||
|
|
||||||
|
# LKW sub-type mapping for mobile.de category refinement
|
||||||
|
_LKW_TYPE_MAP: dict[str, str] = {
|
||||||
|
"sattelzugmaschine": "SemiTractor",
|
||||||
|
"sattelauflieger": "SemiTrailer",
|
||||||
|
"kipper": "Tipper",
|
||||||
|
"kuehlmobil": "RefrigeratedVehicle",
|
||||||
|
"tieflader": "LowLoader",
|
||||||
|
"silofahrzeug": "SiloVehicle",
|
||||||
|
"tankwagen": "TankVehicle",
|
||||||
|
"kranwagen": "CraneVehicle",
|
||||||
|
"muldenkipper": "DumperTruck",
|
||||||
|
"sattelkipper": "SemiTipper",
|
||||||
|
"schwertransporter": "HeavyTransporter",
|
||||||
|
"sonderkonstruktion": "SpecialConstruction",
|
||||||
|
"pritsche": "Flatbed",
|
||||||
|
"planenlkw": "TarpaulinTruck",
|
||||||
|
"boxenlkw": "BoxBodyTruck",
|
||||||
|
"iso_lkw": "IsoTruck",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_first_registration(reg_date: date | None) -> str | None:
|
||||||
|
"""Convert date to YYYY-MM format for mobile.de."""
|
||||||
|
if reg_date is None:
|
||||||
|
return None
|
||||||
|
return reg_date.strftime("%Y-%m")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_mileage(vehicle: Vehicle) -> dict[str, Any] | None:
|
||||||
|
"""Map mileage or operating hours to mobile.de mileage field."""
|
||||||
|
if vehicle.mileage_km is not None:
|
||||||
|
return {"value": vehicle.mileage_km, "unit": "km"}
|
||||||
|
if vehicle.operating_hours is not None:
|
||||||
|
unit = vehicle.operating_hours_unit or "h"
|
||||||
|
return {"value": float(vehicle.operating_hours), "unit": unit}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _format_price(price: Decimal) -> dict[str, Any]:
|
||||||
|
"""Map price to mobile.de price format."""
|
||||||
|
return {
|
||||||
|
"amount": float(price),
|
||||||
|
"currency": "EUR",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_power(power_kw: int | None, power_hp: int | None) -> dict[str, Any] | None:
|
||||||
|
"""Map power to mobile.de power format."""
|
||||||
|
if power_kw is None and power_hp is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"powerKw": power_kw,
|
||||||
|
"powerHp": power_hp,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_category(vehicle: Vehicle) -> str:
|
||||||
|
"""Determine mobile.de category from vehicle type and lkw_type."""
|
||||||
|
base = _CATEGORY_MAP.get(vehicle.vehicle_type, "Other")
|
||||||
|
if vehicle.vehicle_type == "lkw" and vehicle.lkw_type:
|
||||||
|
# Try direct match, then strip lkw_ prefix for lookup
|
||||||
|
lkw_key = vehicle.lkw_type
|
||||||
|
if lkw_key in _LKW_TYPE_MAP:
|
||||||
|
return _LKW_TYPE_MAP[lkw_key]
|
||||||
|
stripped = lkw_key.removeprefix("lkw_")
|
||||||
|
if stripped in _LKW_TYPE_MAP:
|
||||||
|
return _LKW_TYPE_MAP[stripped]
|
||||||
|
return base
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def map_fields(vehicle: Vehicle) -> dict[str, Any]:
|
||||||
|
"""Map a Vehicle model to the mobile.de listing Ad format.
|
||||||
|
|
||||||
|
Converts internal vehicle fields to the mobile.de REST API listing format.
|
||||||
|
See: https://developer.mobile.de/api/seller-listings
|
||||||
|
"""
|
||||||
|
mileage = _format_mileage(vehicle)
|
||||||
|
power = _format_power(vehicle.power_kw, vehicle.power_hp)
|
||||||
|
|
||||||
|
ad: dict[str, Any] = {
|
||||||
|
"vin": vehicle.fin,
|
||||||
|
"make": vehicle.make,
|
||||||
|
"model": vehicle.model,
|
||||||
|
"category": _format_category(vehicle),
|
||||||
|
"price": _format_price(vehicle.price),
|
||||||
|
"availabilityStatus": vehicle.availability,
|
||||||
|
"condition": vehicle.condition,
|
||||||
|
}
|
||||||
|
|
||||||
|
if vehicle.first_registration is not None:
|
||||||
|
ad["firstRegistration"] = _format_first_registration(
|
||||||
|
vehicle.first_registration
|
||||||
|
)
|
||||||
|
|
||||||
|
if mileage is not None:
|
||||||
|
ad["mileage"] = mileage
|
||||||
|
|
||||||
|
if power is not None:
|
||||||
|
ad["power"] = power
|
||||||
|
|
||||||
|
if vehicle.fuel_type is not None:
|
||||||
|
ad["fuelType"] = vehicle.fuel_type
|
||||||
|
|
||||||
|
if vehicle.transmission is not None:
|
||||||
|
ad["transmission"] = vehicle.transmission
|
||||||
|
|
||||||
|
if vehicle.color is not None:
|
||||||
|
ad["color"] = vehicle.color
|
||||||
|
|
||||||
|
if vehicle.year is not None:
|
||||||
|
ad["year"] = vehicle.year
|
||||||
|
|
||||||
|
if vehicle.location is not None:
|
||||||
|
ad["sellerLocation"] = vehicle.location
|
||||||
|
|
||||||
|
if vehicle.machine_type is not None:
|
||||||
|
ad["bodyType"] = vehicle.machine_type
|
||||||
|
elif vehicle.body_type is not None:
|
||||||
|
ad["bodyType"] = vehicle.body_type
|
||||||
|
|
||||||
|
if vehicle.description is not None:
|
||||||
|
ad["description"] = vehicle.description
|
||||||
|
|
||||||
|
return ad
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
"""Tests for mobile.de service: field mapping, push, update, delete, status, retry."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.vehicle import MobileDeListing, Vehicle
|
||||||
|
from app.services import mobilede_service
|
||||||
|
from app.utils.mobilede_mapping import map_fields
|
||||||
|
|
||||||
|
|
||||||
|
def _make_vehicle(**overrides) -> Vehicle:
|
||||||
|
"""Create a Vehicle instance with defaults and optional overrides."""
|
||||||
|
defaults = {
|
||||||
|
"make": "Mercedes-Benz",
|
||||||
|
"model": "Actros",
|
||||||
|
"fin": "WDB9066351L123456",
|
||||||
|
"year": 2020,
|
||||||
|
"first_registration": date(2020, 3, 15),
|
||||||
|
"power_kw": 300,
|
||||||
|
"power_hp": 408,
|
||||||
|
"fuel_type": "Diesel",
|
||||||
|
"transmission": "Manual",
|
||||||
|
"color": "White",
|
||||||
|
"condition": "used",
|
||||||
|
"location": "Berlin",
|
||||||
|
"availability": "available",
|
||||||
|
"price": Decimal("45000.00"),
|
||||||
|
"vehicle_type": "lkw",
|
||||||
|
"lkw_type": "sattelzugmaschine",
|
||||||
|
"mileage_km": 120000,
|
||||||
|
"description": "Well maintained truck",
|
||||||
|
}
|
||||||
|
defaults.update(overrides)
|
||||||
|
vehicle = Vehicle(**defaults)
|
||||||
|
vehicle.id = uuid.uuid4()
|
||||||
|
return vehicle
|
||||||
|
|
||||||
|
|
||||||
|
class TestFieldMapping:
|
||||||
|
"""Tests for mobile.de field mapping (map_fields)."""
|
||||||
|
|
||||||
|
def test_map_fields_basic_lkw(self):
|
||||||
|
"""map_fields produces correct ad format for LKW."""
|
||||||
|
vehicle = _make_vehicle()
|
||||||
|
ad = map_fields(vehicle)
|
||||||
|
|
||||||
|
assert ad["vin"] == "WDB9066351L123456"
|
||||||
|
assert ad["make"] == "Mercedes-Benz"
|
||||||
|
assert ad["model"] == "Actros"
|
||||||
|
assert ad["category"] == "SemiTractor"
|
||||||
|
assert ad["price"] == {"amount": 45000.0, "currency": "EUR"}
|
||||||
|
assert ad["availabilityStatus"] == "available"
|
||||||
|
assert ad["condition"] == "used"
|
||||||
|
assert ad["firstRegistration"] == "2020-03"
|
||||||
|
assert ad["mileage"] == {"value": 120000, "unit": "km"}
|
||||||
|
assert ad["power"] == {"powerKw": 300, "powerHp": 408}
|
||||||
|
assert ad["fuelType"] == "Diesel"
|
||||||
|
assert ad["transmission"] == "Manual"
|
||||||
|
assert ad["color"] == "White"
|
||||||
|
assert ad["sellerLocation"] == "Berlin"
|
||||||
|
assert ad["description"] == "Well maintained truck"
|
||||||
|
|
||||||
|
def test_map_fields_baumaschine_with_operating_hours(self):
|
||||||
|
"""map_fields maps operating_hours to mileage for baumaschine."""
|
||||||
|
vehicle = _make_vehicle(
|
||||||
|
vehicle_type="baumaschine",
|
||||||
|
machine_type="Bagger",
|
||||||
|
operating_hours=Decimal("3500.5"),
|
||||||
|
operating_hours_unit="h",
|
||||||
|
mileage_km=None,
|
||||||
|
lkw_type=None,
|
||||||
|
)
|
||||||
|
ad = map_fields(vehicle)
|
||||||
|
|
||||||
|
assert ad["category"] == "ConstructionMachine"
|
||||||
|
assert ad["mileage"] == {"value": 3500.5, "unit": "h"}
|
||||||
|
assert ad["bodyType"] == "Bagger"
|
||||||
|
|
||||||
|
def test_map_fields_pkw(self):
|
||||||
|
"""map_fields maps PKW correctly."""
|
||||||
|
vehicle = _make_vehicle(
|
||||||
|
vehicle_type="pkw",
|
||||||
|
lkw_type=None,
|
||||||
|
body_type="Limousine",
|
||||||
|
)
|
||||||
|
ad = map_fields(vehicle)
|
||||||
|
assert ad["category"] == "Car"
|
||||||
|
assert ad["bodyType"] == "Limousine"
|
||||||
|
|
||||||
|
def test_map_fields_stapler(self):
|
||||||
|
"""map_fields maps Stapler correctly."""
|
||||||
|
vehicle = _make_vehicle(
|
||||||
|
vehicle_type="stapler",
|
||||||
|
lkw_type=None,
|
||||||
|
operating_hours=Decimal("12000"),
|
||||||
|
operating_hours_unit="h",
|
||||||
|
mileage_km=None,
|
||||||
|
)
|
||||||
|
ad = map_fields(vehicle)
|
||||||
|
assert ad["category"] == "ForkliftTruck"
|
||||||
|
assert ad["mileage"] == {"value": 12000.0, "unit": "h"}
|
||||||
|
|
||||||
|
def test_map_fields_transporter(self):
|
||||||
|
"""map_fields maps Transporter correctly."""
|
||||||
|
vehicle = _make_vehicle(
|
||||||
|
vehicle_type="transporter",
|
||||||
|
lkw_type=None,
|
||||||
|
)
|
||||||
|
ad = map_fields(vehicle)
|
||||||
|
assert ad["category"] == "Van"
|
||||||
|
|
||||||
|
def test_map_fields_no_optional_fields(self):
|
||||||
|
"""map_fields handles vehicle with no optional fields."""
|
||||||
|
vehicle = Vehicle(
|
||||||
|
make="Test",
|
||||||
|
model="Model",
|
||||||
|
fin="WDB9066351L123456",
|
||||||
|
condition="new",
|
||||||
|
availability="available",
|
||||||
|
price=Decimal("10000.00"),
|
||||||
|
vehicle_type="pkw",
|
||||||
|
)
|
||||||
|
ad = map_fields(vehicle)
|
||||||
|
assert ad["vin"] == "WDB9066351L123456"
|
||||||
|
assert ad["make"] == "Test"
|
||||||
|
assert "firstRegistration" not in ad
|
||||||
|
assert "mileage" not in ad
|
||||||
|
assert "power" not in ad
|
||||||
|
assert "fuelType" not in ad
|
||||||
|
|
||||||
|
def test_map_fields_lkw_type_with_prefix(self):
|
||||||
|
"""map_fields strips lkw_ prefix for category lookup."""
|
||||||
|
vehicle = _make_vehicle(lkw_type="lkw_kipper")
|
||||||
|
ad = map_fields(vehicle)
|
||||||
|
assert ad["category"] == "Tipper"
|
||||||
|
|
||||||
|
def test_map_fields_lkw_type_unknown_falls_back(self):
|
||||||
|
"""map_fields falls back to base category for unknown lkw_type."""
|
||||||
|
vehicle = _make_vehicle(lkw_type="unknown_type")
|
||||||
|
ad = map_fields(vehicle)
|
||||||
|
assert ad["category"] == "Truck"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPushListing:
|
||||||
|
"""Tests for mobilede_service.push_listing."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_push_listing_success(self, db_session):
|
||||||
|
"""push_listing creates listing with synced status on success."""
|
||||||
|
vehicle = _make_vehicle()
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 201
|
||||||
|
mock_response.json.return_value = {"id": "ad-123"}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
listing = await mobilede_service.push_listing(db_session, vehicle)
|
||||||
|
|
||||||
|
assert listing.sync_status == "synced"
|
||||||
|
assert listing.ad_id == "ad-123"
|
||||||
|
assert listing.synced_at is not None
|
||||||
|
assert listing.error_log is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_push_listing_http_error(self, db_session):
|
||||||
|
"""push_listing sets fehler status on HTTP error."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
vehicle = _make_vehicle()
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 400
|
||||||
|
mock_response.text = "Bad Request"
|
||||||
|
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||||
|
"Bad Request", request=MagicMock(), response=mock_response
|
||||||
|
)
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
listing = await mobilede_service.push_listing(db_session, vehicle)
|
||||||
|
|
||||||
|
assert listing.sync_status == "fehler"
|
||||||
|
assert listing.error_log is not None
|
||||||
|
assert "400" in listing.error_log
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_push_listing_request_error(self, db_session):
|
||||||
|
"""push_listing sets fehler status on request error."""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
vehicle = _make_vehicle()
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
listing = await mobilede_service.push_listing(db_session, vehicle)
|
||||||
|
|
||||||
|
assert listing.sync_status == "fehler"
|
||||||
|
assert "Connection refused" in listing.error_log
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateListing:
|
||||||
|
"""Tests for mobilede_service.update_listing."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_listing_success(self, db_session):
|
||||||
|
"""update_listing updates synced status on success."""
|
||||||
|
vehicle = _make_vehicle()
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
listing = MobileDeListing(
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
ad_id="ad-123",
|
||||||
|
sync_status="synced",
|
||||||
|
)
|
||||||
|
db_session.add(listing)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.put = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
result = await mobilede_service.update_listing(db_session, vehicle, listing)
|
||||||
|
|
||||||
|
assert result.sync_status == "synced"
|
||||||
|
assert result.synced_at is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_listing_no_ad_id(self, db_session):
|
||||||
|
"""update_listing sets fehler when listing has no ad_id."""
|
||||||
|
vehicle = _make_vehicle()
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
listing = MobileDeListing(
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
sync_status="pending",
|
||||||
|
)
|
||||||
|
db_session.add(listing)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
result = await mobilede_service.update_listing(db_session, vehicle, listing)
|
||||||
|
|
||||||
|
assert result.sync_status == "fehler"
|
||||||
|
assert "ad_id" in result.error_log
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteListing:
|
||||||
|
"""Tests for mobilede_service.delete_listing."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_listing_success(self, db_session):
|
||||||
|
"""delete_listing sets deleted status on success."""
|
||||||
|
vehicle = _make_vehicle()
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
listing = MobileDeListing(
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
ad_id="ad-123",
|
||||||
|
sync_status="synced",
|
||||||
|
)
|
||||||
|
db_session.add(listing)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 204
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.delete = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
result = await mobilede_service.delete_listing(db_session, listing)
|
||||||
|
|
||||||
|
assert result.sync_status == "deleted"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_listing_no_ad_id(self, db_session):
|
||||||
|
"""delete_listing sets fehler when listing has no ad_id."""
|
||||||
|
vehicle = _make_vehicle()
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
listing = MobileDeListing(
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
sync_status="pending",
|
||||||
|
)
|
||||||
|
db_session.add(listing)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
result = await mobilede_service.delete_listing(db_session, listing)
|
||||||
|
|
||||||
|
assert result.sync_status == "fehler"
|
||||||
|
assert "ad_id" in result.error_log
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetListingStatus:
|
||||||
|
"""Tests for mobilede_service.get_listing_status."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_listing_status_returns_latest(self, db_session):
|
||||||
|
"""get_listing_status returns the most recent listing."""
|
||||||
|
vehicle = _make_vehicle()
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
listing1 = MobileDeListing(
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
sync_status="fehler",
|
||||||
|
error_log="First attempt failed",
|
||||||
|
created_at=datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
db_session.add(listing1)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
listing2 = MobileDeListing(
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
ad_id="ad-456",
|
||||||
|
sync_status="synced",
|
||||||
|
created_at=datetime(2025, 1, 2, 12, 0, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
db_session.add(listing2)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
result = await mobilede_service.get_listing_status(db_session, vehicle.id)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.sync_status == "synced"
|
||||||
|
assert result.ad_id == "ad-456"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_listing_status_returns_none_when_no_listing(self, db_session):
|
||||||
|
"""get_listing_status returns None when no listing exists."""
|
||||||
|
vehicle_id = uuid.uuid4()
|
||||||
|
result = await mobilede_service.get_listing_status(db_session, vehicle_id)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestRetryFailedListing:
|
||||||
|
"""Tests for mobilede_service.retry_failed_listing."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_succeeds_within_max_retries(self, db_session):
|
||||||
|
"""retry_failed_listing re-attempts push when under max retries."""
|
||||||
|
vehicle = _make_vehicle()
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
listing = MobileDeListing(
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
sync_status="fehler",
|
||||||
|
error_log="[retry 1] HTTP 500: Internal Server Error",
|
||||||
|
)
|
||||||
|
db_session.add(listing)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 201
|
||||||
|
mock_response.json.return_value = {"id": "ad-789"}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
result = await mobilede_service.retry_failed_listing(db_session, listing, vehicle)
|
||||||
|
|
||||||
|
assert result.sync_status == "synced"
|
||||||
|
assert result.ad_id == "ad-789"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retry_exceeds_max_retries(self, db_session):
|
||||||
|
"""retry_failed_listing marks as permanently failed after max retries."""
|
||||||
|
vehicle = _make_vehicle()
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
listing = MobileDeListing(
|
||||||
|
vehicle_id=vehicle.id,
|
||||||
|
sync_status="fehler",
|
||||||
|
error_log=f"[retry {mobilede_service.MAX_RETRIES}] Last error",
|
||||||
|
)
|
||||||
|
db_session.add(listing)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
result = await mobilede_service.retry_failed_listing(db_session, listing, vehicle)
|
||||||
|
|
||||||
|
assert result.sync_status == "fehler"
|
||||||
|
assert "Max retries" in result.error_log
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
"""Tests for vehicle CRUD endpoints and mobile.de integration."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from app.database import Base, get_db
|
||||||
|
from app.main import app
|
||||||
|
from app.models.vehicle import MobileDeListing, Vehicle
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def sample_vehicle_data():
|
||||||
|
"""Valid vehicle data for creation."""
|
||||||
|
return {
|
||||||
|
"make": "Mercedes-Benz",
|
||||||
|
"model": "Actros",
|
||||||
|
"fin": "WDB9066351L123456",
|
||||||
|
"year": 2020,
|
||||||
|
"first_registration": "2020-03-15",
|
||||||
|
"power_kw": 300,
|
||||||
|
"fuel_type": "Diesel",
|
||||||
|
"transmission": "Manual",
|
||||||
|
"color": "White",
|
||||||
|
"condition": "used",
|
||||||
|
"location": "Berlin",
|
||||||
|
"availability": "available",
|
||||||
|
"price": 45000.00,
|
||||||
|
"vehicle_type": "lkw",
|
||||||
|
"lkw_type": "sattelzugmaschine",
|
||||||
|
"mileage_km": 120000,
|
||||||
|
"description": "Well maintained truck",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def created_vehicle(admin_client, sample_vehicle_data):
|
||||||
|
"""Create a vehicle via API and return the response."""
|
||||||
|
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
class TestVehicleList:
|
||||||
|
"""GET /api/v1/vehicles tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_returns_200_with_pagination(self, admin_client, created_vehicle):
|
||||||
|
"""GET /api/v1/vehicles returns 200 with paginated list."""
|
||||||
|
response = await admin_client.get("/api/v1/vehicles/?page=1&page_size=20")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "items" in data
|
||||||
|
assert "total" in data
|
||||||
|
assert "page" in data
|
||||||
|
assert "page_size" in data
|
||||||
|
assert data["page"] == 1
|
||||||
|
assert data["page_size"] == 20
|
||||||
|
assert data["total"] >= 1
|
||||||
|
assert len(data["items"]) >= 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_filter_by_type(self, admin_client, created_vehicle):
|
||||||
|
"""GET /api/v1/vehicles?type=lkw returns filtered results."""
|
||||||
|
response = await admin_client.get("/api/v1/vehicles/?type=lkw")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
for item in data["items"]:
|
||||||
|
assert item["vehicle_type"] == "lkw"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_filter_by_availability(self, admin_client, created_vehicle):
|
||||||
|
"""GET /api/v1/vehicles?availability=available returns filtered results."""
|
||||||
|
response = await admin_client.get("/api/v1/vehicles/?availability=available")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
for item in data["items"]:
|
||||||
|
assert item["availability"] == "available"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_sort_descending(self, admin_client, created_vehicle):
|
||||||
|
"""GET /api/v1/vehicles?sort=-created_at returns sorted results."""
|
||||||
|
response = await admin_client.get("/api/v1/vehicles/?sort=-created_at")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
if len(data["items"]) >= 2:
|
||||||
|
assert data["items"][0]["created_at"] >= data["items"][1]["created_at"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_filter_by_price_range(self, admin_client, created_vehicle):
|
||||||
|
"""GET /api/v1/vehicles?min_price=40000&max_price=50000 returns filtered results."""
|
||||||
|
response = await admin_client.get("/api/v1/vehicles/?min_price=40000&max_price=50000")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
for item in data["items"]:
|
||||||
|
assert float(item["price"]) >= 40000
|
||||||
|
assert float(item["price"]) <= 50000
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_search(self, admin_client, created_vehicle):
|
||||||
|
"""GET /api/v1/vehicles?search=Mercedes returns matching results."""
|
||||||
|
response = await admin_client.get("/api/v1/vehicles/?search=Mercedes")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
for item in data["items"]:
|
||||||
|
assert "Mercedes" in item["make"] or "Mercedes" in item["model"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_requires_auth(self, client):
|
||||||
|
"""GET /api/v1/vehicles without auth returns 401."""
|
||||||
|
response = await client.get("/api/v1/vehicles/")
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
class TestVehicleCreate:
|
||||||
|
"""POST /api/v1/vehicles tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_vehicle_returns_201(self, admin_client, sample_vehicle_data):
|
||||||
|
"""POST /api/v1/vehicles with valid data returns 201."""
|
||||||
|
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["make"] == sample_vehicle_data["make"]
|
||||||
|
assert data["model"] == sample_vehicle_data["model"]
|
||||||
|
assert data["fin"] == sample_vehicle_data["fin"]
|
||||||
|
assert data["vehicle_type"] == "lkw"
|
||||||
|
assert data["id"] is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_vehicle_missing_make_returns_422(self, admin_client, sample_vehicle_data):
|
||||||
|
"""POST /api/v1/vehicles without make returns 422."""
|
||||||
|
del sample_vehicle_data["make"]
|
||||||
|
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_vehicle_missing_fin_returns_422(self, admin_client, sample_vehicle_data):
|
||||||
|
"""POST /api/v1/vehicles without fin returns 422."""
|
||||||
|
del sample_vehicle_data["fin"]
|
||||||
|
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_vehicle_short_fin_returns_422(self, admin_client, sample_vehicle_data):
|
||||||
|
"""POST /api/v1/vehicles with short FIN returns 422."""
|
||||||
|
sample_vehicle_data["fin"] = "SHORT"
|
||||||
|
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_vehicle_duplicate_fin_returns_409(self, admin_client, sample_vehicle_data, created_vehicle):
|
||||||
|
"""POST /api/v1/vehicles with duplicate FIN returns 409."""
|
||||||
|
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_vehicle_auto_computes_power_hp(self, admin_client, sample_vehicle_data):
|
||||||
|
"""POST /api/v1/vehicles auto-computes power_hp from power_kw."""
|
||||||
|
sample_vehicle_data["power_kw"] = 100
|
||||||
|
sample_vehicle_data.pop("power_hp", None)
|
||||||
|
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["power_hp"] == 136 # 100 * 1.35962 ≈ 136
|
||||||
|
|
||||||
|
|
||||||
|
class TestVehicleDetail:
|
||||||
|
"""GET /api/v1/vehicles/:id tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_vehicle_returns_200(self, admin_client, created_vehicle):
|
||||||
|
"""GET /api/v1/vehicles/:id returns 200 with detail."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
response = await admin_client.get(f"/api/v1/vehicles/{vehicle_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["id"] == vehicle_id
|
||||||
|
assert data["make"] == created_vehicle["make"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_vehicle_nonexistent_returns_404(self, admin_client):
|
||||||
|
"""GET /api/v1/vehicles/:id with nonexistent ID returns 404."""
|
||||||
|
fake_id = str(uuid.uuid4())
|
||||||
|
response = await admin_client.get(f"/api/v1/vehicles/{fake_id}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestVehicleUpdate:
|
||||||
|
"""PUT /api/v1/vehicles/:id tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_vehicle_returns_200(self, admin_client, created_vehicle):
|
||||||
|
"""PUT /api/v1/vehicles/:id returns 200 with updated data."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
response = await admin_client.put(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}",
|
||||||
|
json={"price": "42000.00", "availability": "reserved"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert float(data["price"]) == 42000.00
|
||||||
|
assert data["availability"] == "reserved"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_vehicle_nonexistent_returns_404(self, admin_client):
|
||||||
|
"""PUT /api/v1/vehicles/:id with nonexistent ID returns 404."""
|
||||||
|
fake_id = str(uuid.uuid4())
|
||||||
|
response = await admin_client.put(
|
||||||
|
f"/api/v1/vehicles/{fake_id}",
|
||||||
|
json={"price": "42000.00"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_vehicle_no_fields_returns_400(self, admin_client, created_vehicle):
|
||||||
|
"""PUT /api/v1/vehicles/:id with no fields returns 400."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
response = await admin_client.put(
|
||||||
|
f"/api/v1/vehicles/{vehicle_id}",
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
class TestVehicleDelete:
|
||||||
|
"""DELETE /api/v1/vehicles/:id tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_vehicle_returns_200_with_deleted_at(self, admin_client, created_vehicle):
|
||||||
|
"""DELETE /api/v1/vehicles/:id returns 200 and sets deleted_at."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
response = await admin_client.delete(f"/api/v1/vehicles/{vehicle_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["deleted_at"] is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_vehicle_nonexistent_returns_404(self, admin_client):
|
||||||
|
"""DELETE /api/v1/vehicles/:id with nonexistent ID returns 404."""
|
||||||
|
fake_id = str(uuid.uuid4())
|
||||||
|
response = await admin_client.delete(f"/api/v1/vehicles/{fake_id}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deleted_vehicle_not_in_list(self, admin_client, created_vehicle):
|
||||||
|
"""After soft-delete, vehicle does not appear in list."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
await admin_client.delete(f"/api/v1/vehicles/{vehicle_id}")
|
||||||
|
response = await admin_client.get("/api/v1/vehicles/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
for item in data["items"]:
|
||||||
|
assert item["id"] != vehicle_id
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deleted_vehicle_returns_404_on_detail(self, admin_client, created_vehicle):
|
||||||
|
"""After soft-delete, GET /api/v1/vehicles/:id returns 404."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
await admin_client.delete(f"/api/v1/vehicles/{vehicle_id}")
|
||||||
|
response = await admin_client.get(f"/api/v1/vehicles/{vehicle_id}")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestMobileDePush:
|
||||||
|
"""POST /api/v1/vehicles/:id/mobile-de/push tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_push_returns_202(self, admin_client, created_vehicle):
|
||||||
|
"""POST /api/v1/vehicles/:id/mobile-de/push returns 202."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 201
|
||||||
|
mock_response.json.return_value = {"id": "mobile-de-ad-123"}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
response = await admin_client.post(f"/api/v1/vehicles/{vehicle_id}/mobile-de/push")
|
||||||
|
|
||||||
|
assert response.status_code == 202
|
||||||
|
data = response.json()
|
||||||
|
assert data["message"] == "Push queued"
|
||||||
|
assert data["vehicle_id"] == vehicle_id
|
||||||
|
assert data["listing_id"] is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_push_nonexistent_vehicle_returns_404(self, admin_client):
|
||||||
|
"""POST /api/v1/vehicles/:id/mobile-de/push with nonexistent ID returns 404."""
|
||||||
|
fake_id = str(uuid.uuid4())
|
||||||
|
response = await admin_client.post(f"/api/v1/vehicles/{fake_id}/mobile-de/push")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestMobileDeStatus:
|
||||||
|
"""GET /api/v1/vehicles/:id/mobile-de/status tests."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_status_returns_200_with_no_listing(self, admin_client, created_vehicle):
|
||||||
|
"""GET /api/v1/vehicles/:id/mobile-de/status returns 200 with pending status when no listing exists."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
response = await admin_client.get(f"/api/v1/vehicles/{vehicle_id}/mobile-de/status")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["synced"] is False
|
||||||
|
assert data["sync_status"] == "pending"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_status_returns_200_with_synced_listing(self, admin_client, created_vehicle):
|
||||||
|
"""GET /api/v1/vehicles/:id/mobile-de/status returns 200 with sync info after push."""
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 201
|
||||||
|
mock_response.json.return_value = {"id": "mobile-de-ad-456"}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
await admin_client.post(f"/api/v1/vehicles/{vehicle_id}/mobile-de/push")
|
||||||
|
|
||||||
|
response = await admin_client.get(f"/api/v1/vehicles/{vehicle_id}/mobile-de/status")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["synced"] is True
|
||||||
|
assert data["ad_id"] == "mobile-de-ad-456"
|
||||||
|
assert data["synced_at"] is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_status_nonexistent_vehicle_returns_404(self, admin_client):
|
||||||
|
"""GET /api/v1/vehicles/:id/mobile-de/status with nonexistent ID returns 404."""
|
||||||
|
fake_id = str(uuid.uuid4())
|
||||||
|
response = await admin_client.get(f"/api/v1/vehicles/{fake_id}/mobile-de/status")
|
||||||
|
assert response.status_code == 404
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
"""Additional tests for vehicle_service and router to reach 80% coverage."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.vehicle import MobileDeListing, Vehicle
|
||||||
|
from app.services import vehicle_service
|
||||||
|
|
||||||
|
|
||||||
|
def _make_vehicle_data(**overrides) -> dict:
|
||||||
|
"""Return valid vehicle creation data with optional overrides."""
|
||||||
|
defaults = {
|
||||||
|
"make": "Volvo",
|
||||||
|
"model": "FH16",
|
||||||
|
"fin": "WDB9066351L123456",
|
||||||
|
"year": 2021,
|
||||||
|
"first_registration": date(2021, 6, 1),
|
||||||
|
"power_kw": 500,
|
||||||
|
"power_hp": 680,
|
||||||
|
"fuel_type": "Diesel",
|
||||||
|
"transmission": "Automatic",
|
||||||
|
"color": "Red",
|
||||||
|
"condition": "used",
|
||||||
|
"location": "Hamburg",
|
||||||
|
"availability": "available",
|
||||||
|
"price": Decimal("85000.00"),
|
||||||
|
"vehicle_type": "lkw",
|
||||||
|
"lkw_type": "sattelzugmaschine",
|
||||||
|
"mileage_km": 80000,
|
||||||
|
"description": "Heavy duty truck",
|
||||||
|
}
|
||||||
|
defaults.update(overrides)
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def sample_vehicle_data():
|
||||||
|
"""Valid vehicle data for creation."""
|
||||||
|
return {
|
||||||
|
"make": "Mercedes-Benz",
|
||||||
|
"model": "Actros",
|
||||||
|
"fin": "WDB9066351L123456",
|
||||||
|
"year": 2020,
|
||||||
|
"first_registration": "2020-03-15",
|
||||||
|
"power_kw": 300,
|
||||||
|
"fuel_type": "Diesel",
|
||||||
|
"transmission": "Manual",
|
||||||
|
"color": "White",
|
||||||
|
"condition": "used",
|
||||||
|
"location": "Berlin",
|
||||||
|
"availability": "available",
|
||||||
|
"price": 45000.00,
|
||||||
|
"vehicle_type": "lkw",
|
||||||
|
"lkw_type": "sattelzugmaschine",
|
||||||
|
"mileage_km": 120000,
|
||||||
|
"description": "Well maintained truck",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def created_vehicle(admin_client, sample_vehicle_data):
|
||||||
|
"""Create a vehicle via API and return the response."""
|
||||||
|
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||||
|
assert response.status_code == 201, response.text
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
class TestVehicleServiceDirect:
|
||||||
|
"""Direct service-layer tests for vehicle_service."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_empty(self, db_session):
|
||||||
|
"""list_vehicles returns empty list when no vehicles exist."""
|
||||||
|
vehicles, total = await vehicle_service.list_vehicles(db_session)
|
||||||
|
assert vehicles == []
|
||||||
|
assert total == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_pagination(self, db_session):
|
||||||
|
"""list_vehicles respects page and page_size."""
|
||||||
|
fins = ["WDB9066351L123450", "WDB9066351L123451", "WDB9066351L123452",
|
||||||
|
"WDB9066351L123453", "WDB9066351L123454"]
|
||||||
|
for fin in fins:
|
||||||
|
data = _make_vehicle_data(fin=fin)
|
||||||
|
vehicle = Vehicle(**data)
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
vehicles, total = await vehicle_service.list_vehicles(db_session, page=1, page_size=2)
|
||||||
|
assert len(vehicles) == 2
|
||||||
|
assert total == 5
|
||||||
|
|
||||||
|
vehicles_page2, _ = await vehicle_service.list_vehicles(db_session, page=2, page_size=2)
|
||||||
|
assert len(vehicles_page2) == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_sort_ascending(self, db_session):
|
||||||
|
"""list_vehicles sorts ascending by make."""
|
||||||
|
makes_fins = [("Zebra", "WDB9066351L000001"), ("Alpha", "WDB9066351L000002"), ("Mike", "WDB9066351L000003")]
|
||||||
|
for make, fin in makes_fins:
|
||||||
|
data = _make_vehicle_data(make=make, fin=fin)
|
||||||
|
vehicle = Vehicle(**data)
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
vehicles, _ = await vehicle_service.list_vehicles(db_session, sort="make")
|
||||||
|
makes = [v.make for v in vehicles]
|
||||||
|
assert makes == sorted(makes)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_sort_invalid_field_defaults_to_created_at(self, db_session):
|
||||||
|
"""list_vehicles falls back to created_at sort for invalid field."""
|
||||||
|
data = _make_vehicle_data()
|
||||||
|
vehicle = Vehicle(**data)
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
vehicles, total = await vehicle_service.list_vehicles(db_session, sort="invalid_field")
|
||||||
|
assert total == 1
|
||||||
|
assert len(vehicles) == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_filter_by_min_price_only(self, db_session):
|
||||||
|
"""list_vehicles filters by min_price only."""
|
||||||
|
data1 = _make_vehicle_data(fin="WDB9066351L00000A", price=Decimal("30000.00"))
|
||||||
|
data2 = _make_vehicle_data(fin="WDB9066351L00000B", price=Decimal("60000.00"))
|
||||||
|
db_session.add(Vehicle(**data1))
|
||||||
|
db_session.add(Vehicle(**data2))
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
vehicles, total = await vehicle_service.list_vehicles(db_session, min_price=50000)
|
||||||
|
assert total == 1
|
||||||
|
assert float(vehicles[0].price) >= 50000
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_filter_by_max_price_only(self, db_session):
|
||||||
|
"""list_vehicles filters by max_price only."""
|
||||||
|
data1 = _make_vehicle_data(fin="WDB9066351L00000A", price=Decimal("30000.00"))
|
||||||
|
data2 = _make_vehicle_data(fin="WDB9066351L00000B", price=Decimal("60000.00"))
|
||||||
|
db_session.add(Vehicle(**data1))
|
||||||
|
db_session.add(Vehicle(**data2))
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
vehicles, total = await vehicle_service.list_vehicles(db_session, max_price=40000)
|
||||||
|
assert total == 1
|
||||||
|
assert float(vehicles[0].price) <= 40000
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_search_by_fin(self, db_session):
|
||||||
|
"""list_vehicles search matches FIN."""
|
||||||
|
data = _make_vehicle_data(fin="WDB9066351L123456")
|
||||||
|
db_session.add(Vehicle(**data))
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
vehicles, total = await vehicle_service.list_vehicles(db_session, search="123456")
|
||||||
|
assert total == 1
|
||||||
|
assert "123456" in vehicles[0].fin
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_search_by_location(self, db_session):
|
||||||
|
"""list_vehicles search matches location."""
|
||||||
|
data = _make_vehicle_data(location="Munich")
|
||||||
|
db_session.add(Vehicle(**data))
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
vehicles, total = await vehicle_service.list_vehicles(db_session, search="Munich")
|
||||||
|
assert total == 1
|
||||||
|
assert vehicles[0].location == "Munich"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_vehicle_by_fin(self, db_session):
|
||||||
|
"""get_vehicle_by_fin returns vehicle by FIN."""
|
||||||
|
data = _make_vehicle_data(fin="WDB9066351L999999")
|
||||||
|
vehicle = Vehicle(**data)
|
||||||
|
db_session.add(vehicle)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
result = await vehicle_service.get_vehicle_by_fin(db_session, "WDB9066351L999999")
|
||||||
|
assert result is not None
|
||||||
|
assert result.fin == "WDB9066351L999999"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_vehicle_by_fin_not_found(self, db_session):
|
||||||
|
"""get_vehicle_by_fin returns None for nonexistent FIN."""
|
||||||
|
result = await vehicle_service.get_vehicle_by_fin(db_session, "NONEXISTENT1234567")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_vehicle_success(self, db_session):
|
||||||
|
"""create_vehicle creates and returns a vehicle."""
|
||||||
|
data = _make_vehicle_data()
|
||||||
|
vehicle = await vehicle_service.create_vehicle(db_session, data)
|
||||||
|
assert vehicle.id is not None
|
||||||
|
assert vehicle.make == "Volvo"
|
||||||
|
assert vehicle.fin == "WDB9066351L123456"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_vehicle_duplicate_fin_raises(self, db_session):
|
||||||
|
"""create_vehicle raises ValueError for duplicate FIN."""
|
||||||
|
data = _make_vehicle_data()
|
||||||
|
await vehicle_service.create_vehicle(db_session, data)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="already exists"):
|
||||||
|
await vehicle_service.create_vehicle(db_session, data)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_vehicle_success(self, db_session):
|
||||||
|
"""update_vehicle updates fields and returns updated vehicle."""
|
||||||
|
data = _make_vehicle_data()
|
||||||
|
vehicle = await vehicle_service.create_vehicle(db_session, data)
|
||||||
|
|
||||||
|
updated = await vehicle_service.update_vehicle(
|
||||||
|
db_session, vehicle.id, {"make": "Scania", "price": Decimal("90000.00")}
|
||||||
|
)
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.make == "Scania"
|
||||||
|
assert float(updated.price) == 90000.00
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_vehicle_not_found(self, db_session):
|
||||||
|
"""update_vehicle returns None for nonexistent ID."""
|
||||||
|
result = await vehicle_service.update_vehicle(
|
||||||
|
db_session, uuid.uuid4(), {"make": "Test"}
|
||||||
|
)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_vehicle_duplicate_fin_raises(self, db_session):
|
||||||
|
"""update_vehicle raises ValueError when updating to existing FIN."""
|
||||||
|
data1 = _make_vehicle_data(fin="WDB9066351L111111")
|
||||||
|
data2 = _make_vehicle_data(fin="WDB9066351L222222")
|
||||||
|
v1 = await vehicle_service.create_vehicle(db_session, data1)
|
||||||
|
await vehicle_service.create_vehicle(db_session, data2)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="already exists"):
|
||||||
|
await vehicle_service.update_vehicle(
|
||||||
|
db_session, v1.id, {"fin": "WDB9066351L222222"}
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_vehicle_same_fin_no_error(self, db_session):
|
||||||
|
"""update_vehicle allows setting same FIN (no change)."""
|
||||||
|
data = _make_vehicle_data(fin="WDB9066351L333333")
|
||||||
|
vehicle = await vehicle_service.create_vehicle(db_session, data)
|
||||||
|
|
||||||
|
updated = await vehicle_service.update_vehicle(
|
||||||
|
db_session, vehicle.id, {"fin": "WDB9066351L333333"}
|
||||||
|
)
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.fin == "WDB9066351L333333"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_soft_delete_vehicle_success(self, db_session):
|
||||||
|
"""soft_delete_vehicle sets deleted_at."""
|
||||||
|
data = _make_vehicle_data()
|
||||||
|
vehicle = await vehicle_service.create_vehicle(db_session, data)
|
||||||
|
|
||||||
|
deleted = await vehicle_service.soft_delete_vehicle(db_session, vehicle.id)
|
||||||
|
assert deleted is not None
|
||||||
|
assert deleted.deleted_at is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_soft_delete_vehicle_not_found(self, db_session):
|
||||||
|
"""soft_delete_vehicle returns None for nonexistent ID."""
|
||||||
|
result = await vehicle_service.soft_delete_vehicle(db_session, uuid.uuid4())
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_vehicle_by_id_not_found(self, db_session):
|
||||||
|
"""get_vehicle_by_id returns None for nonexistent ID."""
|
||||||
|
result = await vehicle_service.get_vehicle_by_id(db_session, uuid.uuid4())
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_vehicle_by_id_excludes_deleted(self, db_session):
|
||||||
|
"""get_vehicle_by_id returns None for soft-deleted vehicle."""
|
||||||
|
data = _make_vehicle_data()
|
||||||
|
vehicle = await vehicle_service.create_vehicle(db_session, data)
|
||||||
|
await vehicle_service.soft_delete_vehicle(db_session, vehicle.id)
|
||||||
|
|
||||||
|
result = await vehicle_service.get_vehicle_by_id(db_session, vehicle.id)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestRouterAdditionalPaths:
|
||||||
|
"""Additional router tests for error paths and edge cases."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_with_all_filters(self, admin_client, created_vehicle):
|
||||||
|
"""GET /api/v1/vehicles with all filters combined."""
|
||||||
|
response = await admin_client.get(
|
||||||
|
"/api/v1/vehicles/?type=lkw&availability=available&min_price=40000&max_price=50000&search=Mercedes&sort=-price"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] >= 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_vehicle_verkaeufer_allowed(self, verkaeufer_client, sample_vehicle_data):
|
||||||
|
"""POST /api/v1/vehicles works for verkaeufer role (not admin-only)."""
|
||||||
|
sample_vehicle_data["fin"] = "WDB9066351L654321"
|
||||||
|
response = await verkaeufer_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_vehicle_fin_duplicate_returns_409(self, admin_client, sample_vehicle_data):
|
||||||
|
"""PUT /api/v1/vehicles/:id with duplicate FIN returns 409."""
|
||||||
|
sample_vehicle_data["fin"] = "WDB9066351L111111"
|
||||||
|
resp1 = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||||
|
assert resp1.status_code == 201
|
||||||
|
vehicle1_id = resp1.json()["id"]
|
||||||
|
|
||||||
|
sample_vehicle_data["fin"] = "WDB9066351L222222"
|
||||||
|
resp2 = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||||
|
assert resp2.status_code == 201
|
||||||
|
|
||||||
|
response = await admin_client.put(
|
||||||
|
f"/api/v1/vehicles/{vehicle1_id}",
|
||||||
|
json={"fin": "WDB9066351L222222"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_vehicles_empty_result(self, admin_client):
|
||||||
|
"""GET /api/v1/vehicles with filters that match nothing returns empty list."""
|
||||||
|
response = await admin_client.get("/api/v1/vehicles/?type=baumaschine&min_price=999999")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["total"] == 0
|
||||||
|
assert data["items"] == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_vehicle_invalid_uuid_returns_422(self, admin_client):
|
||||||
|
"""GET /api/v1/vehicles/invalid-uuid returns 422."""
|
||||||
|
response = await admin_client.get("/api/v1/vehicles/not-a-uuid")
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_push_to_mobile_de_failure_still_returns_202(self, admin_client, created_vehicle):
|
||||||
|
"""POST /api/v1/vehicles/:id/mobile-de/push returns 202 even when mobile.de API fails."""
|
||||||
|
import httpx
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 500
|
||||||
|
mock_response.text = "Internal Server Error"
|
||||||
|
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||||
|
"Server Error", request=MagicMock(), response=mock_response
|
||||||
|
)
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
response = await admin_client.post(f"/api/v1/vehicles/{vehicle_id}/mobile-de/push")
|
||||||
|
|
||||||
|
assert response.status_code == 202
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mobile_de_status_after_failed_push(self, admin_client, created_vehicle):
|
||||||
|
"""GET /api/v1/vehicles/:id/mobile-de/status shows fehler after failed push."""
|
||||||
|
import httpx
|
||||||
|
vehicle_id = created_vehicle["id"]
|
||||||
|
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 500
|
||||||
|
mock_response.text = "Internal Server Error"
|
||||||
|
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||||
|
"Server Error", request=MagicMock(), response=mock_response
|
||||||
|
)
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
await admin_client.post(f"/api/v1/vehicles/{vehicle_id}/mobile-de/push")
|
||||||
|
|
||||||
|
response = await admin_client.get(f"/api/v1/vehicles/{vehicle_id}/mobile-de/status")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["synced"] is False
|
||||||
|
assert data["sync_status"] == "fehler"
|
||||||
|
assert data["error_log"] is not None
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { VehicleDetail } from '@/components/vehicles/VehicleDetail';
|
||||||
|
|
||||||
|
export default async function FahrzeugDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ locale: string; id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
|
<VehicleDetail vehicleId={id} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { VehicleForm } from '@/components/vehicles/VehicleForm';
|
||||||
|
|
||||||
|
export default function NeuesFahrzeugPage() {
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto p-6">
|
||||||
|
<h1 className="text-2xl font-bold text-text mb-6">Neues Fahrzeug</h1>
|
||||||
|
<VehicleForm mode="create" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { VehicleList } from '@/components/vehicles/VehicleList';
|
||||||
|
|
||||||
|
export default function FahrzeugePage() {
|
||||||
|
return <VehicleList />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import {
|
||||||
|
getMobileDeStatus,
|
||||||
|
pushToMobileDe,
|
||||||
|
type MobileDeStatusResponse,
|
||||||
|
} from '@/lib/vehicles';
|
||||||
|
|
||||||
|
interface MobileDeStatusProps {
|
||||||
|
vehicleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<string, string> = {
|
||||||
|
synced: 'bg-green-100 text-green-800',
|
||||||
|
pending: 'bg-yellow-100 text-yellow-800',
|
||||||
|
fehler: 'bg-red-100 text-red-800',
|
||||||
|
deleted: 'bg-gray-100 text-gray-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
synced: 'Synced',
|
||||||
|
pending: 'Pending',
|
||||||
|
fehler: 'Error',
|
||||||
|
deleted: 'Deleted',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MobileDeStatus({ vehicleId }: MobileDeStatusProps) {
|
||||||
|
const [status, setStatus] = useState<MobileDeStatusResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [pushing, setPushing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchStatus = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await getMobileDeStatus(vehicleId);
|
||||||
|
setStatus(data);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const apiErr = err as { error?: { message?: string } };
|
||||||
|
setError(apiErr?.error?.message || 'Failed to load mobile.de status');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [vehicleId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStatus();
|
||||||
|
}, [fetchStatus]);
|
||||||
|
|
||||||
|
const handlePush = async () => {
|
||||||
|
setPushing(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await pushToMobileDe(vehicleId);
|
||||||
|
await fetchStatus();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const apiErr = err as { error?: { message?: string } };
|
||||||
|
setError(apiErr?.error?.message || 'Failed to push to mobile.de');
|
||||||
|
} finally {
|
||||||
|
setPushing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncStatus = status?.sync_status || 'pending';
|
||||||
|
const statusStyle = STATUS_STYLES[syncStatus] || 'bg-gray-100 text-gray-800';
|
||||||
|
const statusLabel = STATUS_LABELS[syncStatus] || syncStatus;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="mobile-de-status" className="p-6 bg-surface rounded-lg border border-border space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-text">mobile.de Status</h2>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
loading={pushing}
|
||||||
|
onClick={handlePush}
|
||||||
|
data-testid="mobile-de-push-button"
|
||||||
|
>
|
||||||
|
Push to mobile.de
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div data-testid="mobile-de-loading" className="text-text-muted">
|
||||||
|
Loading status...
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div data-testid="mobile-de-error" className="text-error">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : status ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm font-medium text-text-muted">Sync Status:</span>
|
||||||
|
<span
|
||||||
|
data-testid="mobile-de-sync-status"
|
||||||
|
className={`px-3 py-1 rounded-full text-sm font-medium ${statusStyle}`}
|
||||||
|
>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-testid="mobile-de-synced" className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-text-muted">Synced</dt>
|
||||||
|
<dd className="text-text">{status.synced ? 'Yes' : 'No'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-text-muted">Ad ID</dt>
|
||||||
|
<dd className="text-text">{status.ad_id || '—'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-sm font-medium text-text-muted">Synced At</dt>
|
||||||
|
<dd className="text-text">
|
||||||
|
{status.synced_at ? new Date(status.synced_at).toLocaleString('de-DE') : '—'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status.error_log && (
|
||||||
|
<div data-testid="mobile-de-error-log" className="p-3 bg-error/10 text-error rounded-lg text-sm">
|
||||||
|
<strong>Error:</strong> {status.error_log}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import {
|
||||||
|
getVehicle,
|
||||||
|
deleteVehicle,
|
||||||
|
type VehicleResponse,
|
||||||
|
} from '@/lib/vehicles';
|
||||||
|
import { MobileDeStatus } from './MobileDeStatus';
|
||||||
|
|
||||||
|
interface VehicleDetailProps {
|
||||||
|
vehicleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(price: number): string {
|
||||||
|
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(price);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr?: string): string {
|
||||||
|
if (!dateStr) return '—';
|
||||||
|
return new Date(dateStr).toLocaleDateString('de-DE');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VehicleDetail({ vehicleId }: VehicleDetailProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [vehicle, setVehicle] = useState<VehicleResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchVehicle = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await getVehicle(vehicleId);
|
||||||
|
setVehicle(data);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const apiErr = err as { error?: { message?: string } };
|
||||||
|
setError(apiErr?.error?.message || 'Failed to load vehicle');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [vehicleId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchVehicle();
|
||||||
|
}, [fetchVehicle]);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!vehicle) return;
|
||||||
|
if (!confirm('Delete this vehicle?')) return;
|
||||||
|
try {
|
||||||
|
await deleteVehicle(vehicle.id);
|
||||||
|
router.push('/de/fahrzeuge');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const apiErr = err as { error?: { message?: string } };
|
||||||
|
setError(apiErr?.error?.message || 'Failed to delete vehicle');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div data-testid="vehicle-detail-loading" className="text-center py-8 text-text-muted">
|
||||||
|
Loading vehicle...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div data-testid="vehicle-detail-error" className="p-4 bg-error/10 text-error rounded-lg">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!vehicle) {
|
||||||
|
return (
|
||||||
|
<div data-testid="vehicle-detail-not-found" className="text-center py-8 text-text-muted">
|
||||||
|
Vehicle not found
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields: { label: string; value: string | number | undefined | null }[] = [
|
||||||
|
{ label: 'Make', value: vehicle.make },
|
||||||
|
{ label: 'Model', value: vehicle.model },
|
||||||
|
{ label: 'FIN', value: vehicle.fin },
|
||||||
|
{ label: 'Year', value: vehicle.year },
|
||||||
|
{ label: 'First Registration', value: formatDate(vehicle.first_registration) },
|
||||||
|
{ label: 'Power (kW)', value: vehicle.power_kw },
|
||||||
|
{ label: 'Power (HP)', value: vehicle.power_hp },
|
||||||
|
{ label: 'Fuel Type', value: vehicle.fuel_type },
|
||||||
|
{ label: 'Transmission', value: vehicle.transmission },
|
||||||
|
{ label: 'Color', value: vehicle.color },
|
||||||
|
{ label: 'Condition', value: vehicle.condition },
|
||||||
|
{ label: 'Location', value: vehicle.location },
|
||||||
|
{ label: 'Availability', value: vehicle.availability },
|
||||||
|
{ label: 'Price', value: formatPrice(vehicle.price) },
|
||||||
|
{ label: 'Vehicle Type', value: vehicle.vehicle_type },
|
||||||
|
{ label: 'LKW Type', value: vehicle.lkw_type },
|
||||||
|
{ label: 'Machine Type', value: vehicle.machine_type },
|
||||||
|
{ label: 'Body Type', value: vehicle.body_type },
|
||||||
|
{ label: 'Operating Hours', value: vehicle.operating_hours },
|
||||||
|
{ label: 'Operating Hours Unit', value: vehicle.operating_hours_unit },
|
||||||
|
{ label: 'Mileage (km)', value: vehicle.mileage_km },
|
||||||
|
{ label: 'Description', value: vehicle.description },
|
||||||
|
{ label: 'Created At', value: formatDate(vehicle.created_at) },
|
||||||
|
{ label: 'Updated At', value: formatDate(vehicle.updated_at) },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="vehicle-detail" className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 data-testid="vehicle-detail-title" className="text-2xl font-bold text-text">
|
||||||
|
{vehicle.make} {vehicle.model}
|
||||||
|
</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" onClick={() => router.push(`/de/fahrzeuge/${vehicle.id}/bearbeiten`)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={handleDelete} data-testid="delete-button">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-testid="vehicle-detail-fields" className="grid grid-cols-2 md:grid-cols-3 gap-4 p-6 bg-surface rounded-lg border border-border">
|
||||||
|
{fields.map(field => (
|
||||||
|
<div key={field.label} className="space-y-1">
|
||||||
|
<dt className="text-sm font-medium text-text-muted">{field.label}</dt>
|
||||||
|
<dd data-testid={`field-${field.label.toLowerCase().replace(/\s+/g, '_')}`} className="text-text">
|
||||||
|
{field.value !== null && field.value !== undefined && field.value !== '' ? field.value : '—'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MobileDeStatus vehicleId={vehicle.id} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import {
|
||||||
|
createVehicle,
|
||||||
|
updateVehicle,
|
||||||
|
type VehicleResponse,
|
||||||
|
type VehicleCreateData,
|
||||||
|
} from '@/lib/vehicles';
|
||||||
|
|
||||||
|
const VEHICLE_TYPES = ['lkw', 'pkw', 'baumaschine', 'stapler', 'transporter'];
|
||||||
|
const CONDITIONS = ['new', 'used'];
|
||||||
|
const AVAILABILITY = ['available', 'reserved', 'sold'];
|
||||||
|
const FUEL_TYPES = ['Diesel', 'Petrol', 'Electric', 'Hybrid', 'Gas'];
|
||||||
|
const TRANSMISSIONS = ['Manual', 'Automatic', 'Semi-Automatic'];
|
||||||
|
const HOURS_UNITS = ['h', 'min'];
|
||||||
|
|
||||||
|
interface VehicleFormProps {
|
||||||
|
vehicle?: VehicleResponse;
|
||||||
|
mode?: 'create' | 'edit';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormErrors {
|
||||||
|
[key: string]: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VehicleForm({ vehicle, mode = 'create' }: VehicleFormProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState<VehicleCreateData>({
|
||||||
|
make: vehicle?.make || '',
|
||||||
|
model: vehicle?.model || '',
|
||||||
|
fin: vehicle?.fin || '',
|
||||||
|
year: vehicle?.year,
|
||||||
|
first_registration: vehicle?.first_registration,
|
||||||
|
power_kw: vehicle?.power_kw,
|
||||||
|
fuel_type: vehicle?.fuel_type,
|
||||||
|
transmission: vehicle?.transmission,
|
||||||
|
color: vehicle?.color,
|
||||||
|
condition: vehicle?.condition || 'used',
|
||||||
|
location: vehicle?.location,
|
||||||
|
availability: vehicle?.availability || 'available',
|
||||||
|
price: vehicle?.price || 0,
|
||||||
|
vehicle_type: vehicle?.vehicle_type || 'lkw',
|
||||||
|
lkw_type: vehicle?.lkw_type,
|
||||||
|
machine_type: vehicle?.machine_type,
|
||||||
|
body_type: vehicle?.body_type,
|
||||||
|
operating_hours: vehicle?.operating_hours,
|
||||||
|
operating_hours_unit: vehicle?.operating_hours_unit,
|
||||||
|
mileage_km: vehicle?.mileage_km,
|
||||||
|
description: vehicle?.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
const validate = (): boolean => {
|
||||||
|
const newErrors: FormErrors = {};
|
||||||
|
|
||||||
|
if (!formData.make || formData.make.trim().length === 0) {
|
||||||
|
newErrors.make = 'Make is required';
|
||||||
|
}
|
||||||
|
if (!formData.model || formData.model.trim().length === 0) {
|
||||||
|
newErrors.model = 'Model is required';
|
||||||
|
}
|
||||||
|
if (!formData.fin) {
|
||||||
|
newErrors.fin = 'FIN is required';
|
||||||
|
} else if (formData.fin.length !== 17) {
|
||||||
|
newErrors.fin = 'FIN must be exactly 17 characters';
|
||||||
|
}
|
||||||
|
if (!formData.price || formData.price <= 0) {
|
||||||
|
newErrors.price = 'Price must be greater than 0';
|
||||||
|
}
|
||||||
|
if (!formData.vehicle_type) {
|
||||||
|
newErrors.vehicle_type = 'Vehicle type is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (field: keyof VehicleCreateData, value: string | number | undefined) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
if (errors[field]) {
|
||||||
|
setErrors(prev => { const next = { ...prev }; delete next[field]; return next; });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!validate()) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setSubmitError(null);
|
||||||
|
try {
|
||||||
|
if (mode === 'edit' && vehicle) {
|
||||||
|
await updateVehicle(vehicle.id, formData);
|
||||||
|
router.push(`/de/fahrzeuge/${vehicle.id}`);
|
||||||
|
} else {
|
||||||
|
const created = await createVehicle(formData);
|
||||||
|
router.push(`/de/fahrzeuge/${created.id}`);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const apiErr = err as { error?: { message?: string } };
|
||||||
|
setSubmitError(apiErr?.error?.message || 'Failed to save vehicle');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputClass = 'w-full px-3 py-2 border rounded-lg bg-surface text-text border-border focus:outline-none focus:ring-2 focus:ring-primary';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form data-testid="vehicle-form" onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{submitError && (
|
||||||
|
<div data-testid="form-submit-error" className="p-4 bg-error/10 text-error rounded-lg">
|
||||||
|
{submitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Make *"
|
||||||
|
data-testid="input-make"
|
||||||
|
value={formData.make}
|
||||||
|
onChange={e => handleChange('make', e.target.value)}
|
||||||
|
error={errors.make}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Model *"
|
||||||
|
data-testid="input-model"
|
||||||
|
value={formData.model}
|
||||||
|
onChange={e => handleChange('model', e.target.value)}
|
||||||
|
error={errors.model}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="FIN * (17 characters)"
|
||||||
|
data-testid="input-fin"
|
||||||
|
value={formData.fin}
|
||||||
|
maxLength={17}
|
||||||
|
onChange={e => handleChange('fin', e.target.value.toUpperCase())}
|
||||||
|
error={errors.fin}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-1">Vehicle Type *</label>
|
||||||
|
<select
|
||||||
|
data-testid="input-vehicle_type"
|
||||||
|
className={inputClass}
|
||||||
|
value={formData.vehicle_type}
|
||||||
|
onChange={e => handleChange('vehicle_type', e.target.value)}
|
||||||
|
>
|
||||||
|
{VEHICLE_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
{errors.vehicle_type && <p className="mt-1 text-sm text-error">{errors.vehicle_type}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Price (EUR) *"
|
||||||
|
data-testid="input-price"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
value={formData.price || ''}
|
||||||
|
onChange={e => handleChange('price', parseFloat(e.target.value) || 0)}
|
||||||
|
error={errors.price}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-1">Availability</label>
|
||||||
|
<select
|
||||||
|
data-testid="input-availability"
|
||||||
|
className={inputClass}
|
||||||
|
value={formData.availability}
|
||||||
|
onChange={e => handleChange('availability', e.target.value)}
|
||||||
|
>
|
||||||
|
{AVAILABILITY.map(a => <option key={a} value={a}>{a}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Year"
|
||||||
|
data-testid="input-year"
|
||||||
|
type="number"
|
||||||
|
min="1900"
|
||||||
|
max="2100"
|
||||||
|
value={formData.year || ''}
|
||||||
|
onChange={e => handleChange('year', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="First Registration"
|
||||||
|
data-testid="input-first_registration"
|
||||||
|
type="date"
|
||||||
|
value={formData.first_registration || ''}
|
||||||
|
onChange={e => handleChange('first_registration', e.target.value || undefined)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Power (kW)"
|
||||||
|
data-testid="input-power_kw"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={formData.power_kw || ''}
|
||||||
|
onChange={e => handleChange('power_kw', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-1">Fuel Type</label>
|
||||||
|
<select
|
||||||
|
data-testid="input-fuel_type"
|
||||||
|
className={inputClass}
|
||||||
|
value={formData.fuel_type || ''}
|
||||||
|
onChange={e => handleChange('fuel_type', e.target.value || undefined)}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{FUEL_TYPES.map(f => <option key={f} value={f}>{f}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-1">Transmission</label>
|
||||||
|
<select
|
||||||
|
data-testid="input-transmission"
|
||||||
|
className={inputClass}
|
||||||
|
value={formData.transmission || ''}
|
||||||
|
onChange={e => handleChange('transmission', e.target.value || undefined)}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{TRANSMISSIONS.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-1">Condition</label>
|
||||||
|
<select
|
||||||
|
data-testid="input-condition"
|
||||||
|
className={inputClass}
|
||||||
|
value={formData.condition}
|
||||||
|
onChange={e => handleChange('condition', e.target.value)}
|
||||||
|
>
|
||||||
|
{CONDITIONS.map(c => <option key={c} value={c}>{c}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Color"
|
||||||
|
data-testid="input-color"
|
||||||
|
value={formData.color || ''}
|
||||||
|
onChange={e => handleChange('color', e.target.value || undefined)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Location"
|
||||||
|
data-testid="input-location"
|
||||||
|
value={formData.location || ''}
|
||||||
|
onChange={e => handleChange('location', e.target.value || undefined)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.vehicle_type === 'lkw' && (
|
||||||
|
<Input
|
||||||
|
label="LKW Type"
|
||||||
|
data-testid="input-lkw_type"
|
||||||
|
value={formData.lkw_type || ''}
|
||||||
|
onChange={e => handleChange('lkw_type', e.target.value || undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.vehicle_type === 'baumaschine' && (
|
||||||
|
<Input
|
||||||
|
label="Machine Type"
|
||||||
|
data-testid="input-machine_type"
|
||||||
|
value={formData.machine_type || ''}
|
||||||
|
onChange={e => handleChange('machine_type', e.target.value || undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
label="Mileage (km)"
|
||||||
|
data-testid="input-mileage_km"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={formData.mileage_km || ''}
|
||||||
|
onChange={e => handleChange('mileage_km', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-1">Operating Hours</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
data-testid="input-operating_hours"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
value={formData.operating_hours || ''}
|
||||||
|
onChange={e => handleChange('operating_hours', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
data-testid="input-operating_hours_unit"
|
||||||
|
className={inputClass}
|
||||||
|
style={{ maxWidth: '80px' }}
|
||||||
|
value={formData.operating_hours_unit || 'h'}
|
||||||
|
onChange={e => handleChange('operating_hours_unit', e.target.value || undefined)}
|
||||||
|
>
|
||||||
|
{HOURS_UNITS.map(u => <option key={u} value={u}>{u}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
data-testid="input-description"
|
||||||
|
className={inputClass}
|
||||||
|
rows={4}
|
||||||
|
value={formData.description || ''}
|
||||||
|
onChange={e => handleChange('description', e.target.value || undefined)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button type="submit" loading={loading} data-testid="submit-button">
|
||||||
|
{mode === 'edit' ? 'Update Vehicle' : 'Create Vehicle'}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" onClick={() => router.back()}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Table } from '@/components/ui/Table';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import {
|
||||||
|
listVehicles,
|
||||||
|
type VehicleResponse,
|
||||||
|
type VehicleListParams,
|
||||||
|
} from '@/lib/vehicles';
|
||||||
|
import type { PaginatedResponse } from '@/lib/api';
|
||||||
|
|
||||||
|
const VEHICLE_TYPES = ['lkw', 'pkw', 'baumaschine', 'stapler', 'transporter'];
|
||||||
|
const AVAILABILITY_OPTIONS = ['available', 'reserved', 'sold'];
|
||||||
|
const SORT_OPTIONS = [
|
||||||
|
{ value: '-created_at', label: 'Newest First' },
|
||||||
|
{ value: 'created_at', label: 'Oldest First' },
|
||||||
|
{ value: 'make', label: 'Make A-Z' },
|
||||||
|
{ value: '-make', label: 'Make Z-A' },
|
||||||
|
{ value: '-price', label: 'Price High-Low' },
|
||||||
|
{ value: 'price', label: 'Price Low-High' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function VehicleList() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [vehicles, setVehicles] = useState<VehicleResponse[]>([]);
|
||||||
|
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 [filters, setFilters] = useState<VehicleListParams>({
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchVehicles = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data: PaginatedResponse<VehicleResponse> = await listVehicles(filters);
|
||||||
|
setVehicles(data.items);
|
||||||
|
setTotal(data.total);
|
||||||
|
setPage(data.page);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const apiErr = err as { error?: { message?: string } };
|
||||||
|
setError(apiErr?.error?.message || 'Failed to load vehicles');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchVehicles();
|
||||||
|
}, [fetchVehicles]);
|
||||||
|
|
||||||
|
const handleFilterChange = (key: keyof VehicleListParams, value: string) => {
|
||||||
|
setFilters(prev => ({
|
||||||
|
...prev,
|
||||||
|
page: 1,
|
||||||
|
[key]: value || undefined,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
setFilters(prev => ({ ...prev, page: newPage }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
key: 'make',
|
||||||
|
label: 'Make',
|
||||||
|
render: (row: VehicleResponse) => (
|
||||||
|
<button
|
||||||
|
data-testid={`vehicle-row-${row.id}`}
|
||||||
|
onClick={() => router.push(`/de/fahrzeuge/${row.id}`)}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{row.make}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'model', label: 'Model' },
|
||||||
|
{ key: 'fin', label: 'FIN' },
|
||||||
|
{ key: 'vehicle_type', label: 'Type' },
|
||||||
|
{ key: 'availability', label: 'Availability' },
|
||||||
|
{
|
||||||
|
key: 'price',
|
||||||
|
label: 'Price',
|
||||||
|
render: (row: VehicleResponse) =>
|
||||||
|
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(row.price),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / pageSize);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="vehicle-list" className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-text">Fahrzeuge</h1>
|
||||||
|
<Button onClick={() => router.push('/de/fahrzeuge/neu')}>
|
||||||
|
+ Neues Fahrzeug
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-testid="vehicle-filters" className="flex flex-wrap gap-3 p-4 bg-surface rounded-lg border border-border">
|
||||||
|
<div className="w-48">
|
||||||
|
<label className="block text-sm font-medium text-text mb-1">Search</label>
|
||||||
|
<Input
|
||||||
|
data-testid="filter-search"
|
||||||
|
type="text"
|
||||||
|
placeholder="Make, model, FIN..."
|
||||||
|
value={filters.search || ''}
|
||||||
|
onChange={e => handleFilterChange('search', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-40">
|
||||||
|
<label className="block text-sm font-medium text-text mb-1">Type</label>
|
||||||
|
<select
|
||||||
|
data-testid="filter-type"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg bg-surface text-text border-border"
|
||||||
|
value={filters.type || ''}
|
||||||
|
onChange={e => handleFilterChange('type', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All</option>
|
||||||
|
{VEHICLE_TYPES.map(t => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="w-40">
|
||||||
|
<label className="block text-sm font-medium text-text mb-1">Availability</label>
|
||||||
|
<select
|
||||||
|
data-testid="filter-availability"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg bg-surface text-text border-border"
|
||||||
|
value={filters.availability || ''}
|
||||||
|
onChange={e => handleFilterChange('availability', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All</option>
|
||||||
|
{AVAILABILITY_OPTIONS.map(a => (
|
||||||
|
<option key={a} value={a}>{a}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="w-40">
|
||||||
|
<label className="block text-sm font-medium text-text mb-1">Sort</label>
|
||||||
|
<select
|
||||||
|
data-testid="filter-sort"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg bg-surface text-text border-border"
|
||||||
|
value={filters.sort || ''}
|
||||||
|
onChange={e => handleFilterChange('sort', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Default</option>
|
||||||
|
{SORT_OPTIONS.map(s => (
|
||||||
|
<option key={s.value} value={s.value}>{s.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div data-testid="vehicle-error" className="p-4 bg-error/10 text-error rounded-lg">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div data-testid="vehicle-loading" className="text-center py-8 text-text-muted">
|
||||||
|
Loading vehicles...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table columns={columns} data={vehicles} rowKey={row => row.id} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div data-testid="vehicle-pagination" className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-text-muted">
|
||||||
|
Page {page} of {totalPages} ({total} total)
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => handlePageChange(page - 1)}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => handlePageChange(page + 1)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { apiFetch, type PaginatedResponse } from './api';
|
||||||
|
|
||||||
|
export interface VehicleResponse {
|
||||||
|
id: string;
|
||||||
|
make: string;
|
||||||
|
model: string;
|
||||||
|
fin: string;
|
||||||
|
year?: number;
|
||||||
|
first_registration?: string;
|
||||||
|
power_kw?: number;
|
||||||
|
power_hp?: number;
|
||||||
|
fuel_type?: string;
|
||||||
|
transmission?: string;
|
||||||
|
color?: string;
|
||||||
|
condition: string;
|
||||||
|
location?: string;
|
||||||
|
availability: string;
|
||||||
|
price: number;
|
||||||
|
vehicle_type: string;
|
||||||
|
lkw_type?: string;
|
||||||
|
machine_type?: string;
|
||||||
|
body_type?: string;
|
||||||
|
operating_hours?: number;
|
||||||
|
operating_hours_unit?: string;
|
||||||
|
mileage_km?: number;
|
||||||
|
description?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
deleted_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VehicleCreateData {
|
||||||
|
make: string;
|
||||||
|
model: string;
|
||||||
|
fin: string;
|
||||||
|
year?: number;
|
||||||
|
first_registration?: string;
|
||||||
|
power_kw?: number;
|
||||||
|
fuel_type?: string;
|
||||||
|
transmission?: string;
|
||||||
|
color?: string;
|
||||||
|
condition?: string;
|
||||||
|
location?: string;
|
||||||
|
availability?: string;
|
||||||
|
price: number;
|
||||||
|
vehicle_type: string;
|
||||||
|
lkw_type?: string;
|
||||||
|
machine_type?: string;
|
||||||
|
body_type?: string;
|
||||||
|
operating_hours?: number;
|
||||||
|
operating_hours_unit?: string;
|
||||||
|
mileage_km?: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VehicleUpdateData {
|
||||||
|
make?: string;
|
||||||
|
model?: string;
|
||||||
|
fin?: string;
|
||||||
|
year?: number;
|
||||||
|
first_registration?: string;
|
||||||
|
power_kw?: number;
|
||||||
|
fuel_type?: string;
|
||||||
|
transmission?: string;
|
||||||
|
color?: string;
|
||||||
|
condition?: string;
|
||||||
|
location?: string;
|
||||||
|
availability?: string;
|
||||||
|
price?: number;
|
||||||
|
vehicle_type?: string;
|
||||||
|
lkw_type?: string;
|
||||||
|
machine_type?: string;
|
||||||
|
body_type?: string;
|
||||||
|
operating_hours?: number;
|
||||||
|
operating_hours_unit?: string;
|
||||||
|
mileage_km?: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VehicleListParams {
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
type?: string;
|
||||||
|
availability?: string;
|
||||||
|
min_price?: number;
|
||||||
|
max_price?: number;
|
||||||
|
search?: string;
|
||||||
|
sort?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MobileDeStatusResponse {
|
||||||
|
synced: boolean;
|
||||||
|
ad_id?: string;
|
||||||
|
synced_at?: string;
|
||||||
|
sync_status: string;
|
||||||
|
error_log?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MobileDePushResponse {
|
||||||
|
message: string;
|
||||||
|
vehicle_id: string;
|
||||||
|
listing_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listVehicles(params: VehicleListParams = {}): Promise<PaginatedResponse<VehicleResponse>> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.page) query.set('page', String(params.page));
|
||||||
|
if (params.page_size) query.set('page_size', String(params.page_size));
|
||||||
|
if (params.type) query.set('type', params.type);
|
||||||
|
if (params.availability) query.set('availability', params.availability);
|
||||||
|
if (params.min_price !== undefined) query.set('min_price', String(params.min_price));
|
||||||
|
if (params.max_price !== undefined) query.set('max_price', String(params.max_price));
|
||||||
|
if (params.search) query.set('search', params.search);
|
||||||
|
if (params.sort) query.set('sort', params.sort);
|
||||||
|
const qs = query.toString();
|
||||||
|
return apiFetch<PaginatedResponse<VehicleResponse>>(`/vehicles/${qs ? `?${qs}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVehicle(id: string): Promise<VehicleResponse> {
|
||||||
|
return apiFetch<VehicleResponse>(`/vehicles/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createVehicle(data: VehicleCreateData): Promise<VehicleResponse> {
|
||||||
|
return apiFetch<VehicleResponse>('/vehicles/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateVehicle(id: string, data: VehicleUpdateData): Promise<VehicleResponse> {
|
||||||
|
return apiFetch<VehicleResponse>(`/vehicles/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteVehicle(id: string): Promise<VehicleResponse> {
|
||||||
|
return apiFetch<VehicleResponse>(`/vehicles/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pushToMobileDe(vehicleId: string): Promise<MobileDePushResponse> {
|
||||||
|
return apiFetch<MobileDePushResponse>(`/vehicles/${vehicleId}/mobile-de/push`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMobileDeStatus(vehicleId: string): Promise<MobileDeStatusResponse> {
|
||||||
|
return apiFetch<MobileDeStatusResponse>(`/vehicles/${vehicleId}/mobile-de/status`);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+77
-81
@@ -1,108 +1,104 @@
|
|||||||
# T01 Test Report – Auth + User Management + RBAC + Frontend + i18n
|
# Test Report – T02: Vehicle Management + mobile.de Push + Vehicle UI
|
||||||
|
|
||||||
**Date**: 2026-07-14
|
**Date**: 2026-07-14
|
||||||
**Task**: T01
|
**Task**: T02
|
||||||
**Status**: PASSED
|
**Status**: PASSED
|
||||||
|
|
||||||
---
|
## Backend Tests
|
||||||
|
|
||||||
## Backend Tests (pytest)
|
**Command**: `cd backend && python -m pytest tests/test_vehicles.py tests/test_mobilede.py tests/test_vehicles_extra.py --cov=app.services.vehicle_service --cov=app.services.mobilede_service --cov=app.routers.vehicles --cov-report=term-missing -v`
|
||||||
|
|
||||||
**Command**: `cd backend && python -m pytest tests/ --cov=app --cov-report=term-missing -v`
|
**Result**: 73 passed in 18.84s
|
||||||
|
|
||||||
**Result**: 50 passed in 19.00s
|
### Coverage Report
|
||||||
|
|
||||||
### Coverage
|
|
||||||
|
|
||||||
| Module | Stmts | Miss | Cover |
|
| Module | Stmts | Miss | Cover |
|
||||||
|---|---|---|---|
|
|--------|-------|------|-------|
|
||||||
| app/config.py | 27 | 0 | 100% |
|
| app/routers/vehicles.py | 63 | 25 | 60% |
|
||||||
| app/database.py | 22 | 12 | 45% |
|
| app/services/mobilede_service.py | 122 | 24 | 80% |
|
||||||
| app/dependencies.py | 39 | 8 | 79% |
|
| app/services/vehicle_service.py | 86 | 1 | 99% |
|
||||||
| app/main.py | 20 | 1 | 95% |
|
| **TOTAL** | **271** | **50** | **82%** |
|
||||||
| app/models/user.py | 26 | 2 | 92% |
|
|
||||||
| app/routers/auth.py | 24 | 5 | 79% |
|
|
||||||
| app/routers/users.py | 35 | 11 | 69% |
|
|
||||||
| app/schemas/user.py | 46 | 0 | 100% |
|
|
||||||
| app/services/auth_service.py | 86 | 4 | 95% |
|
|
||||||
| app/utils/jwt.py | 34 | 0 | 100% |
|
|
||||||
| **TOTAL** | **359** | **43** | **88%** |
|
|
||||||
|
|
||||||
### Test Files
|
**Coverage target**: >= 80% → **MET** (82% total)
|
||||||
- `tests/test_auth.py` – 12 tests (login valid/invalid/inactive, refresh valid/invalid/access-rejected, me with/without/invalid/refresh-token)
|
|
||||||
- `tests/test_users.py` – 14 tests (list admin/non-admin/no-auth, pagination, create admin/non-admin/duplicate/short-pw, update, delete soft-deactivate, password-hash exclusion)
|
|
||||||
- `tests/test_health.py` – 3 tests (health check, no-auth, root endpoint)
|
|
||||||
- `tests/test_auth_service.py` – 21 tests (hash/verify, get-by-email/id, authenticate valid/wrong-pw/inactive/nonexistent, token pair, refresh valid/invalid/nonexistent, create success/duplicate, list pagination, update success/not-found/role, deactivate success/not-found)
|
|
||||||
|
|
||||||
### Key Acceptance Criteria Verified
|
### Test Categories
|
||||||
- POST /api/v1/auth/login valid -> 200 + JWT (test_login_valid_credentials)
|
|
||||||
- POST /api/v1/auth/login invalid -> 401 (test_login_invalid_password, test_login_nonexistent_user)
|
|
||||||
- POST /api/v1/auth/refresh valid -> 200 + new token (test_refresh_valid_token)
|
|
||||||
- POST /api/v1/auth/refresh invalid -> 401 (test_refresh_invalid_token)
|
|
||||||
- GET /api/v1/auth/me with JWT -> 200 + user object (test_get_me_with_valid_token)
|
|
||||||
- GET /api/v1/auth/me without JWT -> 401 (test_get_me_without_token)
|
|
||||||
- GET /api/v1/users admin -> 200 + paginated (test_list_users_as_admin)
|
|
||||||
- GET /api/v1/users non-admin -> 403 (test_list_users_as_non_admin)
|
|
||||||
- POST /api/v1/users -> 201 (test_create_user_as_admin)
|
|
||||||
- DELETE /api/v1/users/:id -> 200 + is_active=false (test_delete_user_soft_deactivate)
|
|
||||||
- GET /api/v1/health -> 200 + {status:'ok'} (test_health_check)
|
|
||||||
- Password hash never exposed in responses (test_user_response_excludes_password_hash)
|
|
||||||
- Auth module coverage >= 80% (auth_service: 95%, routers: 79-100%)
|
|
||||||
|
|
||||||
---
|
#### test_vehicles.py (27 tests)
|
||||||
|
- TestVehicleList: 7 tests (pagination, filter by type/availability/price, sort, search, auth required)
|
||||||
|
- TestVehicleCreate: 6 tests (201 on valid, 422 on missing make/fin/short fin, 409 on duplicate FIN, auto-compute power_hp)
|
||||||
|
- TestVehicleDetail: 2 tests (200 on found, 404 on nonexistent)
|
||||||
|
- TestVehicleUpdate: 3 tests (200 on update, 404 on nonexistent, 400 on no fields)
|
||||||
|
- TestVehicleDelete: 4 tests (200 with deleted_at, 404 on nonexistent, not in list after delete, 404 on detail after delete)
|
||||||
|
- TestMobileDePush: 2 tests (202 on push, 404 on nonexistent vehicle)
|
||||||
|
- TestMobileDeStatus: 3 tests (200 with no listing, 200 with synced listing, 404 on nonexistent)
|
||||||
|
|
||||||
## Frontend Tests (vitest)
|
#### test_mobilede.py (18 tests)
|
||||||
|
- TestFieldMapping: 8 tests (LKW, Baumaschine, PKW, Stapler, Transporter, no optional fields, lkw_type with prefix, unknown lkw_type fallback)
|
||||||
|
- TestPushListing: 3 tests (success, HTTP error, request error)
|
||||||
|
- TestUpdateListing: 2 tests (success, no ad_id)
|
||||||
|
- TestDeleteListing: 2 tests (success, no ad_id)
|
||||||
|
- TestGetListingStatus: 2 tests (returns latest, returns none)
|
||||||
|
- TestRetryFailedListing: 2 tests (succeeds within max retries, exceeds max retries)
|
||||||
|
|
||||||
**Command**: `cd frontend && npx vitest run`
|
#### test_vehicles_extra.py (28 tests)
|
||||||
|
- TestVehicleServiceDirect: 21 tests (list empty, pagination, sort asc, invalid sort, min/max price, search by fin/location, get by fin, create, duplicate fin, update, not found, duplicate fin update, same fin, soft delete, not found, get by id not found, excludes deleted)
|
||||||
|
- TestRouterAdditionalPaths: 7 tests (all filters combined, verkaeufer allowed, fin duplicate 409, empty result, invalid UUID 422, push failure 202, status after failed push)
|
||||||
|
|
||||||
**Result**: 12 passed in 1.87s
|
## Frontend Tests
|
||||||
|
|
||||||
### Test Files
|
**Command**: `cd frontend && npx vitest run tests/vehicles.test.tsx`
|
||||||
- `tests/auth.test.tsx` – 5 tests (login form renders, validation errors, API call on submit, error toast on failure, English locale rendering)
|
|
||||||
- `tests/i18n.test.tsx` – 7 tests (German defaults, English defaults, DE->EN live switch, EN->DE live switch, de.json >= 20 keys, en.json >= 20 keys, fallback for missing keys)
|
|
||||||
|
|
||||||
### Key Acceptance Criteria Verified
|
**Result**: 16 passed in 2.21s
|
||||||
- Frontend login renders with email/password fields (test_auth.test.tsx)
|
|
||||||
- Login form submits and calls API (test_auth.test.tsx)
|
|
||||||
- Toast on error (test_auth.test.tsx)
|
|
||||||
- i18n DE/EN switch works live (test_i18n.test.tsx)
|
|
||||||
- de.json + en.json >= 20 keys (31 keys each)
|
|
||||||
|
|
||||||
---
|
### Test Categories
|
||||||
|
|
||||||
## Frontend Build (Next.js)
|
#### VehicleList (5 tests)
|
||||||
|
- Renders vehicle list with filters and table
|
||||||
|
- Displays vehicles in table after loading
|
||||||
|
- Shows pagination when total > page_size
|
||||||
|
- Shows error message on API failure
|
||||||
|
- Calls listVehicles with type filter when changed
|
||||||
|
|
||||||
**Command**: `cd frontend && npm run build`
|
#### VehicleForm (4 tests)
|
||||||
|
- Renders form with all required fields
|
||||||
|
- Shows validation errors for empty required fields
|
||||||
|
- Shows error for FIN not 17 characters
|
||||||
|
- Calls createVehicle on submit with valid data
|
||||||
|
|
||||||
**Result**: Compiled successfully, all static pages generated
|
#### VehicleDetail (3 tests)
|
||||||
|
- Renders vehicle details after loading
|
||||||
|
- Shows mobile.de status section
|
||||||
|
- Shows error message on API failure
|
||||||
|
|
||||||
```
|
#### MobileDeStatus (4 tests)
|
||||||
Route (app) Size First Load JS
|
- Renders with pending status when no listing exists
|
||||||
┌ ○ / 136 B 87.2 kB
|
- Shows synced status after successful push
|
||||||
├ ○ /_not-found 875 B 87.9 kB
|
- Shows error log when sync failed
|
||||||
└ ○ /login 3.51 kB 90.6 kB
|
- Calls pushToMobileDe when push button is clicked
|
||||||
|
|
||||||
○ (Static) prerendered as static content
|
|
||||||
```
|
|
||||||
|
|
||||||
TypeScript type checking passed (no type errors).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Smoke Test
|
## Smoke Test
|
||||||
|
|
||||||
- Backend: PostgreSQL 18 running, test database `erp_test` active, all 50 tests pass against real PostgreSQL (no SQLite, no mocks for DB layer)
|
- Backend: All API endpoints tested via HTTPX ASGI transport with real PostgreSQL test DB
|
||||||
- Frontend: Next.js 14.2.5 production build succeeds, login page renders at `/login`, i18n provider wraps auth routes
|
- Frontend: All components tested with React Testing Library and mocked API calls
|
||||||
- bcrypt password hashing verified (bcrypt 4.3.0, passlib 1.7.4)
|
- mobile.de: All HTTP calls mocked with httpx.AsyncClient patches, no real API calls made
|
||||||
- JWT tokens created and verified with HS256
|
- Auth: All vehicle endpoints require JWT Bearer token (verified with 401 test)
|
||||||
- CORS configured from env var CORS_ORIGINS
|
- Soft-delete: Verified deleted_at is set and vehicle excluded from subsequent queries
|
||||||
|
|
||||||
---
|
## Acceptance Criteria Verification
|
||||||
|
|
||||||
## Forbidden Patterns Check
|
| Criterion | Status | Evidence |
|
||||||
|
|-----------|--------|----------|
|
||||||
- No hardcoded secrets: all from env vars via Pydantic BaseSettings
|
| GET /api/v1/vehicles → 200 + paginated list | ✅ | test_list_vehicles_returns_200_with_pagination |
|
||||||
- No SQLite for testing: PostgreSQL 18 used exclusively
|
| GET /api/v1/vehicles?type=lkw&availability=available → 200 + filtered | ✅ | test_list_vehicles_filter_by_type, test_list_vehicles_filter_by_availability |
|
||||||
- No synchronous DB calls: all async (asyncpg, AsyncSession)
|
| GET /api/v1/vehicles?sort=-created_at → 200 + sorted | ✅ | test_list_vehicles_sort_descending |
|
||||||
- No plain text passwords: bcrypt hashing via passlib
|
| GET /api/v1/vehicles/:id → 200 + detail, nonexistent → 404 | ✅ | test_get_vehicle_returns_200, test_get_vehicle_nonexistent_returns_404 |
|
||||||
- CORS configured: CORS_ORIGINS env var with comma-separated origins
|
| POST /api/v1/vehicles valid → 201, missing make → 422 | ✅ | test_create_vehicle_returns_201, test_create_vehicle_missing_make_returns_422 |
|
||||||
|
| PUT /api/v1/vehicles/:id → 200 + updated | ✅ | test_update_vehicle_returns_200 |
|
||||||
|
| DELETE /api/v1/vehicles/:id → 200 + deleted_at set | ✅ | test_delete_vehicle_returns_200_with_deleted_at |
|
||||||
|
| POST /api/v1/vehicles/:id/mobile-de/push → 202 (async) | ✅ | test_push_returns_202 |
|
||||||
|
| GET /api/v1/vehicles/:id/mobile-de/status → 200 + sync info | ✅ | test_status_returns_200_with_synced_listing |
|
||||||
|
| mobile.de push sends correct Ad format (mocked) | ✅ | test_map_fields_basic_lkw, test_push_listing_success |
|
||||||
|
| Frontend Vehicle List renders table with filter+pagination | ✅ | VehicleList test suite (5 tests) |
|
||||||
|
| Frontend Vehicle Detail shows all fields + mobile.de status | ✅ | VehicleDetail test suite (3 tests) |
|
||||||
|
| Frontend Create Form validates required fields | ✅ | VehicleForm test suite (4 tests) |
|
||||||
|
| pytest coverage >= 80% vehicle module | ✅ | 82% total coverage |
|
||||||
|
|||||||
Reference in New Issue
Block a user