diff --git a/.a0/current_status.md b/.a0/current_status.md index 401e372..3018f0e 100644 --- a/.a0/current_status.md +++ b/.a0/current_status.md @@ -1,27 +1,31 @@ # Current Status -**Task**: T01 – Auth + User Management + RBAC + Base Frontend Layout + i18n Setup +**Task**: T02 – Vehicle Management + mobile.de Push + Vehicle UI **Status**: COMPLETED **Date**: 2026-07-14 ## 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) -- 10 Python-Dateien erstellt (config, database, models, schemas, utils, services, dependencies, routers, main) -- 4 Test-Dateien mit 50 Tests – alle bestanden -- Coverage: 88% total, auth_service 95%, routers 79-100% -- PostgreSQL 18 als Test-DB (kein SQLite) -- bcrypt Password-Hashing, JWT HS256, RBAC require_role +- 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) +- MobileDeListing model: vehicle_id FK, ad_id, sync_status, synced_at, error_log +- 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 +- mobile.de service: push_listing, update_listing, delete_listing, get_listing_status, retry_failed_listing (max 3 retries) +- 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) -- 6 UI-Komponenten (Button, Input, Card, Table, Modal, Toast) -- Login-Page mit Form-Validation und Toast-Error-Handling -- i18n mit DE/EN (31 Keys je Sprache) -- 12 Vitest-Tests – alle bestanden -- Next.js Production Build erfolgreich +- VehicleList: Table with filters (search, type, availability, sort), pagination, error handling +- VehicleForm: Create/edit form with validation (make, model, fin=17 chars, price>0, vehicle_type), conditional fields +- VehicleDetail: All vehicle fields display, delete button, MobileDeStatus embedded +- MobileDeStatus: Sync status badge, push button, ad_id, synced_at, error_log display +- 3 pages: fahrzeuge list, fahrzeuge/neu create, fahrzeuge/[id] detail ## Test Evidence -- Backend: 50/50 pytest passed, 88% coverage -- Frontend: 12/12 vitest passed, Next.js build success -- test_report.md erstellt mit vollständigen Ergebnissen +- Backend: 73/73 pytest passed, 82% total coverage (vehicle_service 99%, mobilede_service 80%, router 60%) +- Frontend: 16/16 vitest passed +- 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 diff --git a/.a0/worklog.md b/.a0/worklog.md index b4d1010..cf83c63 100644 --- a/.a0/worklog.md +++ b/.a0/worklog.md @@ -45,3 +45,26 @@ - Backend: 50/50 passed, 88% total coverage - Frontend: 12/12 passed, Next.js build success - 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 diff --git a/backend/.coverage b/backend/.coverage index f6e1d74..c42fcfa 100644 Binary files a/backend/.coverage and b/backend/.coverage differ diff --git a/backend/app/config.py b/backend/app/config.py index a85d474..f81fc42 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -31,6 +31,8 @@ class Settings(BaseSettings): UPLOAD_DIR: str = "/tmp/uploads" APP_NAME: str = "ERP Nutzfahrzeuge" APP_ENV: str = "development" + MOBILE_DE_API_KEY: str = "" + MOBILE_DE_SELLER_ID: str = "" @property def cors_origins_list(self) -> list[str]: diff --git a/backend/app/main.py b/backend/app/main.py index b253cf2..a63c12d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,7 +9,7 @@ from fastapi import APIRouter, FastAPI from fastapi.middleware.cors import CORSMiddleware from app.config import settings -from app.routers import auth, users +from app.routers import auth, users, vehicles @asynccontextmanager @@ -40,6 +40,7 @@ app.add_middleware( api_v1_router = APIRouter(prefix="/api/v1") api_v1_router.include_router(auth.router) api_v1_router.include_router(users.router) +api_v1_router.include_router(vehicles.router) # Health endpoint (no auth required) @api_v1_router.get("/health", tags=["health"]) diff --git a/backend/app/models/vehicle.py b/backend/app/models/vehicle.py new file mode 100644 index 0000000..7632215 --- /dev/null +++ b/backend/app/models/vehicle.py @@ -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"" + + 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"" + + 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 + ), + } diff --git a/backend/app/routers/vehicles.py b/backend/app/routers/vehicles.py new file mode 100644 index 0000000..f921fac --- /dev/null +++ b/backend/app/routers/vehicles.py @@ -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, + ) diff --git a/backend/app/schemas/vehicle.py b/backend/app/schemas/vehicle.py new file mode 100644 index 0000000..85aceff --- /dev/null +++ b/backend/app/schemas/vehicle.py @@ -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 diff --git a/backend/app/services/mobilede_service.py b/backend/app/services/mobilede_service.py new file mode 100644 index 0000000..51d541f --- /dev/null +++ b/backend/app/services/mobilede_service.py @@ -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) diff --git a/backend/app/services/vehicle_service.py b/backend/app/services/vehicle_service.py new file mode 100644 index 0000000..3d77385 --- /dev/null +++ b/backend/app/services/vehicle_service.py @@ -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 diff --git a/backend/app/utils/mobilede_mapping.py b/backend/app/utils/mobilede_mapping.py new file mode 100644 index 0000000..3d4ae3e --- /dev/null +++ b/backend/app/utils/mobilede_mapping.py @@ -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 diff --git a/backend/tests/test_mobilede.py b/backend/tests/test_mobilede.py new file mode 100644 index 0000000..9f76dc6 --- /dev/null +++ b/backend/tests/test_mobilede.py @@ -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 diff --git a/backend/tests/test_vehicles.py b/backend/tests/test_vehicles.py new file mode 100644 index 0000000..f2d283a --- /dev/null +++ b/backend/tests/test_vehicles.py @@ -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 diff --git a/backend/tests/test_vehicles_extra.py b/backend/tests/test_vehicles_extra.py new file mode 100644 index 0000000..ff5f30e --- /dev/null +++ b/backend/tests/test_vehicles_extra.py @@ -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 diff --git a/frontend/app/[locale]/fahrzeuge/[id]/page.tsx b/frontend/app/[locale]/fahrzeuge/[id]/page.tsx new file mode 100644 index 0000000..aa7ec2f --- /dev/null +++ b/frontend/app/[locale]/fahrzeuge/[id]/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/frontend/app/[locale]/fahrzeuge/neu/page.tsx b/frontend/app/[locale]/fahrzeuge/neu/page.tsx new file mode 100644 index 0000000..18e40ea --- /dev/null +++ b/frontend/app/[locale]/fahrzeuge/neu/page.tsx @@ -0,0 +1,10 @@ +import { VehicleForm } from '@/components/vehicles/VehicleForm'; + +export default function NeuesFahrzeugPage() { + return ( +
+

Neues Fahrzeug

+ +
+ ); +} diff --git a/frontend/app/[locale]/fahrzeuge/page.tsx b/frontend/app/[locale]/fahrzeuge/page.tsx new file mode 100644 index 0000000..ad48a88 --- /dev/null +++ b/frontend/app/[locale]/fahrzeuge/page.tsx @@ -0,0 +1,5 @@ +import { VehicleList } from '@/components/vehicles/VehicleList'; + +export default function FahrzeugePage() { + return ; +} diff --git a/frontend/components/vehicles/MobileDeStatus.tsx b/frontend/components/vehicles/MobileDeStatus.tsx new file mode 100644 index 0000000..6f660f0 --- /dev/null +++ b/frontend/components/vehicles/MobileDeStatus.tsx @@ -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 = { + 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 = { + synced: 'Synced', + pending: 'Pending', + fehler: 'Error', + deleted: 'Deleted', +}; + +export function MobileDeStatus({ vehicleId }: MobileDeStatusProps) { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [pushing, setPushing] = useState(false); + const [error, setError] = useState(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 ( +
+
+

mobile.de Status

+ +
+ + {loading ? ( +
+ Loading status... +
+ ) : error ? ( +
+ {error} +
+ ) : status ? ( +
+
+ Sync Status: + + {statusLabel} + +
+ +
+
+
Synced
+
{status.synced ? 'Yes' : 'No'}
+
+
+
Ad ID
+
{status.ad_id || '—'}
+
+
+
Synced At
+
+ {status.synced_at ? new Date(status.synced_at).toLocaleString('de-DE') : '—'} +
+
+
+ + {status.error_log && ( +
+ Error: {status.error_log} +
+ )} +
+ ) : null} +
+ ); +} diff --git a/frontend/components/vehicles/VehicleDetail.tsx b/frontend/components/vehicles/VehicleDetail.tsx new file mode 100644 index 0000000..81930c2 --- /dev/null +++ b/frontend/components/vehicles/VehicleDetail.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+ Loading vehicle... +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!vehicle) { + return ( +
+ Vehicle not found +
+ ); + } + + 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 ( +
+
+

+ {vehicle.make} {vehicle.model} +

+
+ + +
+
+ +
+ {fields.map(field => ( +
+
{field.label}
+
+ {field.value !== null && field.value !== undefined && field.value !== '' ? field.value : '—'} +
+
+ ))} +
+ + +
+ ); +} diff --git a/frontend/components/vehicles/VehicleForm.tsx b/frontend/components/vehicles/VehicleForm.tsx new file mode 100644 index 0000000..32ba6b2 --- /dev/null +++ b/frontend/components/vehicles/VehicleForm.tsx @@ -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({}); + const [submitError, setSubmitError] = useState(null); + + const [formData, setFormData] = useState({ + 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 ( +
+ {submitError && ( +
+ {submitError} +
+ )} + +
+ handleChange('make', e.target.value)} + error={errors.make} + /> + handleChange('model', e.target.value)} + error={errors.model} + /> +
+ +
+ handleChange('fin', e.target.value.toUpperCase())} + error={errors.fin} + /> +
+ + + {errors.vehicle_type &&

{errors.vehicle_type}

} +
+
+ +
+ handleChange('price', parseFloat(e.target.value) || 0)} + error={errors.price} + /> +
+ + +
+
+ +
+ handleChange('year', e.target.value ? parseInt(e.target.value) : undefined)} + /> + handleChange('first_registration', e.target.value || undefined)} + /> + handleChange('power_kw', e.target.value ? parseInt(e.target.value) : undefined)} + /> +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ handleChange('color', e.target.value || undefined)} + /> + handleChange('location', e.target.value || undefined)} + /> +
+ + {formData.vehicle_type === 'lkw' && ( + handleChange('lkw_type', e.target.value || undefined)} + /> + )} + + {formData.vehicle_type === 'baumaschine' && ( + handleChange('machine_type', e.target.value || undefined)} + /> + )} + +
+ handleChange('mileage_km', e.target.value ? parseInt(e.target.value) : undefined)} + /> +
+ +
+ handleChange('operating_hours', e.target.value ? parseFloat(e.target.value) : undefined)} + /> + +
+
+
+ +
+ +