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:
2026-07-14 12:03:19 +02:00
parent d89304845a
commit 74b5e6afb8
24 changed files with 3847 additions and 97 deletions
BIN
View File
Binary file not shown.
+2
View File
@@ -31,6 +31,8 @@ class Settings(BaseSettings):
UPLOAD_DIR: str = "/tmp/uploads"
APP_NAME: str = "ERP Nutzfahrzeuge"
APP_ENV: str = "development"
MOBILE_DE_API_KEY: str = ""
MOBILE_DE_SELLER_ID: str = ""
@property
def cors_origins_list(self) -> list[str]:
+2 -1
View File
@@ -9,7 +9,7 @@ from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, users
from app.routers import auth, users, vehicles
@asynccontextmanager
@@ -40,6 +40,7 @@ app.add_middleware(
api_v1_router = APIRouter(prefix="/api/v1")
api_v1_router.include_router(auth.router)
api_v1_router.include_router(users.router)
api_v1_router.include_router(vehicles.router)
# Health endpoint (no auth required)
@api_v1_router.get("/health", tags=["health"])
+239
View File
@@ -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
),
}
+199
View File
@@ -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,
)
+147
View File
@@ -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
+244
View File
@@ -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)
+209
View File
@@ -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
+145
View File
@@ -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
+429
View File
@@ -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
+346
View File
@@ -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
+391
View File
@@ -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