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
+19 -15
View File
@@ -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
+23
View File
@@ -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
BIN
View File
Binary file not shown.
+2
View File
@@ -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
View File
@@ -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"])
+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
@@ -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>
);
}
+5
View File
@@ -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>
);
}
+149
View File
@@ -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`);
}
+380
View File
@@ -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
View File
@@ -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 |