feat(T02): vehicle management + mobile.de push + vehicle UI
- Vehicle model: 25+ fields, 5 vehicle types, soft-delete - Vehicle CRUD: 7 API endpoints with JWT auth, filter/sort/paginate - mobile.de: push/update/delete listings, field mapping, retry logic - MobileDeListing model for sync status tracking - Frontend: VehicleList, VehicleForm, VehicleDetail, MobileDeStatus - 73 backend tests (82% coverage), 16 frontend tests
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
"""Tests for mobile.de service: field mapping, push, update, delete, status, retry."""
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.vehicle import MobileDeListing, Vehicle
|
||||
from app.services import mobilede_service
|
||||
from app.utils.mobilede_mapping import map_fields
|
||||
|
||||
|
||||
def _make_vehicle(**overrides) -> Vehicle:
|
||||
"""Create a Vehicle instance with defaults and optional overrides."""
|
||||
defaults = {
|
||||
"make": "Mercedes-Benz",
|
||||
"model": "Actros",
|
||||
"fin": "WDB9066351L123456",
|
||||
"year": 2020,
|
||||
"first_registration": date(2020, 3, 15),
|
||||
"power_kw": 300,
|
||||
"power_hp": 408,
|
||||
"fuel_type": "Diesel",
|
||||
"transmission": "Manual",
|
||||
"color": "White",
|
||||
"condition": "used",
|
||||
"location": "Berlin",
|
||||
"availability": "available",
|
||||
"price": Decimal("45000.00"),
|
||||
"vehicle_type": "lkw",
|
||||
"lkw_type": "sattelzugmaschine",
|
||||
"mileage_km": 120000,
|
||||
"description": "Well maintained truck",
|
||||
}
|
||||
defaults.update(overrides)
|
||||
vehicle = Vehicle(**defaults)
|
||||
vehicle.id = uuid.uuid4()
|
||||
return vehicle
|
||||
|
||||
|
||||
class TestFieldMapping:
|
||||
"""Tests for mobile.de field mapping (map_fields)."""
|
||||
|
||||
def test_map_fields_basic_lkw(self):
|
||||
"""map_fields produces correct ad format for LKW."""
|
||||
vehicle = _make_vehicle()
|
||||
ad = map_fields(vehicle)
|
||||
|
||||
assert ad["vin"] == "WDB9066351L123456"
|
||||
assert ad["make"] == "Mercedes-Benz"
|
||||
assert ad["model"] == "Actros"
|
||||
assert ad["category"] == "SemiTractor"
|
||||
assert ad["price"] == {"amount": 45000.0, "currency": "EUR"}
|
||||
assert ad["availabilityStatus"] == "available"
|
||||
assert ad["condition"] == "used"
|
||||
assert ad["firstRegistration"] == "2020-03"
|
||||
assert ad["mileage"] == {"value": 120000, "unit": "km"}
|
||||
assert ad["power"] == {"powerKw": 300, "powerHp": 408}
|
||||
assert ad["fuelType"] == "Diesel"
|
||||
assert ad["transmission"] == "Manual"
|
||||
assert ad["color"] == "White"
|
||||
assert ad["sellerLocation"] == "Berlin"
|
||||
assert ad["description"] == "Well maintained truck"
|
||||
|
||||
def test_map_fields_baumaschine_with_operating_hours(self):
|
||||
"""map_fields maps operating_hours to mileage for baumaschine."""
|
||||
vehicle = _make_vehicle(
|
||||
vehicle_type="baumaschine",
|
||||
machine_type="Bagger",
|
||||
operating_hours=Decimal("3500.5"),
|
||||
operating_hours_unit="h",
|
||||
mileage_km=None,
|
||||
lkw_type=None,
|
||||
)
|
||||
ad = map_fields(vehicle)
|
||||
|
||||
assert ad["category"] == "ConstructionMachine"
|
||||
assert ad["mileage"] == {"value": 3500.5, "unit": "h"}
|
||||
assert ad["bodyType"] == "Bagger"
|
||||
|
||||
def test_map_fields_pkw(self):
|
||||
"""map_fields maps PKW correctly."""
|
||||
vehicle = _make_vehicle(
|
||||
vehicle_type="pkw",
|
||||
lkw_type=None,
|
||||
body_type="Limousine",
|
||||
)
|
||||
ad = map_fields(vehicle)
|
||||
assert ad["category"] == "Car"
|
||||
assert ad["bodyType"] == "Limousine"
|
||||
|
||||
def test_map_fields_stapler(self):
|
||||
"""map_fields maps Stapler correctly."""
|
||||
vehicle = _make_vehicle(
|
||||
vehicle_type="stapler",
|
||||
lkw_type=None,
|
||||
operating_hours=Decimal("12000"),
|
||||
operating_hours_unit="h",
|
||||
mileage_km=None,
|
||||
)
|
||||
ad = map_fields(vehicle)
|
||||
assert ad["category"] == "ForkliftTruck"
|
||||
assert ad["mileage"] == {"value": 12000.0, "unit": "h"}
|
||||
|
||||
def test_map_fields_transporter(self):
|
||||
"""map_fields maps Transporter correctly."""
|
||||
vehicle = _make_vehicle(
|
||||
vehicle_type="transporter",
|
||||
lkw_type=None,
|
||||
)
|
||||
ad = map_fields(vehicle)
|
||||
assert ad["category"] == "Van"
|
||||
|
||||
def test_map_fields_no_optional_fields(self):
|
||||
"""map_fields handles vehicle with no optional fields."""
|
||||
vehicle = Vehicle(
|
||||
make="Test",
|
||||
model="Model",
|
||||
fin="WDB9066351L123456",
|
||||
condition="new",
|
||||
availability="available",
|
||||
price=Decimal("10000.00"),
|
||||
vehicle_type="pkw",
|
||||
)
|
||||
ad = map_fields(vehicle)
|
||||
assert ad["vin"] == "WDB9066351L123456"
|
||||
assert ad["make"] == "Test"
|
||||
assert "firstRegistration" not in ad
|
||||
assert "mileage" not in ad
|
||||
assert "power" not in ad
|
||||
assert "fuelType" not in ad
|
||||
|
||||
def test_map_fields_lkw_type_with_prefix(self):
|
||||
"""map_fields strips lkw_ prefix for category lookup."""
|
||||
vehicle = _make_vehicle(lkw_type="lkw_kipper")
|
||||
ad = map_fields(vehicle)
|
||||
assert ad["category"] == "Tipper"
|
||||
|
||||
def test_map_fields_lkw_type_unknown_falls_back(self):
|
||||
"""map_fields falls back to base category for unknown lkw_type."""
|
||||
vehicle = _make_vehicle(lkw_type="unknown_type")
|
||||
ad = map_fields(vehicle)
|
||||
assert ad["category"] == "Truck"
|
||||
|
||||
|
||||
class TestPushListing:
|
||||
"""Tests for mobilede_service.push_listing."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_listing_success(self, db_session):
|
||||
"""push_listing creates listing with synced status on success."""
|
||||
vehicle = _make_vehicle()
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
mock_response.json.return_value = {"id": "ad-123"}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
listing = await mobilede_service.push_listing(db_session, vehicle)
|
||||
|
||||
assert listing.sync_status == "synced"
|
||||
assert listing.ad_id == "ad-123"
|
||||
assert listing.synced_at is not None
|
||||
assert listing.error_log is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_listing_http_error(self, db_session):
|
||||
"""push_listing sets fehler status on HTTP error."""
|
||||
import httpx
|
||||
|
||||
vehicle = _make_vehicle()
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 400
|
||||
mock_response.text = "Bad Request"
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"Bad Request", request=MagicMock(), response=mock_response
|
||||
)
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
listing = await mobilede_service.push_listing(db_session, vehicle)
|
||||
|
||||
assert listing.sync_status == "fehler"
|
||||
assert listing.error_log is not None
|
||||
assert "400" in listing.error_log
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_listing_request_error(self, db_session):
|
||||
"""push_listing sets fehler status on request error."""
|
||||
import httpx
|
||||
|
||||
vehicle = _make_vehicle()
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
listing = await mobilede_service.push_listing(db_session, vehicle)
|
||||
|
||||
assert listing.sync_status == "fehler"
|
||||
assert "Connection refused" in listing.error_log
|
||||
|
||||
|
||||
class TestUpdateListing:
|
||||
"""Tests for mobilede_service.update_listing."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_listing_success(self, db_session):
|
||||
"""update_listing updates synced status on success."""
|
||||
vehicle = _make_vehicle()
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
listing = MobileDeListing(
|
||||
vehicle_id=vehicle.id,
|
||||
ad_id="ad-123",
|
||||
sync_status="synced",
|
||||
)
|
||||
db_session.add(listing)
|
||||
await db_session.flush()
|
||||
|
||||
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.put = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = await mobilede_service.update_listing(db_session, vehicle, listing)
|
||||
|
||||
assert result.sync_status == "synced"
|
||||
assert result.synced_at is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_listing_no_ad_id(self, db_session):
|
||||
"""update_listing sets fehler when listing has no ad_id."""
|
||||
vehicle = _make_vehicle()
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
listing = MobileDeListing(
|
||||
vehicle_id=vehicle.id,
|
||||
sync_status="pending",
|
||||
)
|
||||
db_session.add(listing)
|
||||
await db_session.flush()
|
||||
|
||||
result = await mobilede_service.update_listing(db_session, vehicle, listing)
|
||||
|
||||
assert result.sync_status == "fehler"
|
||||
assert "ad_id" in result.error_log
|
||||
|
||||
|
||||
class TestDeleteListing:
|
||||
"""Tests for mobilede_service.delete_listing."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_listing_success(self, db_session):
|
||||
"""delete_listing sets deleted status on success."""
|
||||
vehicle = _make_vehicle()
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
listing = MobileDeListing(
|
||||
vehicle_id=vehicle.id,
|
||||
ad_id="ad-123",
|
||||
sync_status="synced",
|
||||
)
|
||||
db_session.add(listing)
|
||||
await db_session.flush()
|
||||
|
||||
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 204
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.delete = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = await mobilede_service.delete_listing(db_session, listing)
|
||||
|
||||
assert result.sync_status == "deleted"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_listing_no_ad_id(self, db_session):
|
||||
"""delete_listing sets fehler when listing has no ad_id."""
|
||||
vehicle = _make_vehicle()
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
listing = MobileDeListing(
|
||||
vehicle_id=vehicle.id,
|
||||
sync_status="pending",
|
||||
)
|
||||
db_session.add(listing)
|
||||
await db_session.flush()
|
||||
|
||||
result = await mobilede_service.delete_listing(db_session, listing)
|
||||
|
||||
assert result.sync_status == "fehler"
|
||||
assert "ad_id" in result.error_log
|
||||
|
||||
|
||||
class TestGetListingStatus:
|
||||
"""Tests for mobilede_service.get_listing_status."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_listing_status_returns_latest(self, db_session):
|
||||
"""get_listing_status returns the most recent listing."""
|
||||
vehicle = _make_vehicle()
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
listing1 = MobileDeListing(
|
||||
vehicle_id=vehicle.id,
|
||||
sync_status="fehler",
|
||||
error_log="First attempt failed",
|
||||
created_at=datetime(2025, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
db_session.add(listing1)
|
||||
await db_session.flush()
|
||||
|
||||
listing2 = MobileDeListing(
|
||||
vehicle_id=vehicle.id,
|
||||
ad_id="ad-456",
|
||||
sync_status="synced",
|
||||
created_at=datetime(2025, 1, 2, 12, 0, 0, tzinfo=timezone.utc),
|
||||
)
|
||||
db_session.add(listing2)
|
||||
await db_session.flush()
|
||||
|
||||
result = await mobilede_service.get_listing_status(db_session, vehicle.id)
|
||||
|
||||
assert result is not None
|
||||
assert result.sync_status == "synced"
|
||||
assert result.ad_id == "ad-456"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_listing_status_returns_none_when_no_listing(self, db_session):
|
||||
"""get_listing_status returns None when no listing exists."""
|
||||
vehicle_id = uuid.uuid4()
|
||||
result = await mobilede_service.get_listing_status(db_session, vehicle_id)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestRetryFailedListing:
|
||||
"""Tests for mobilede_service.retry_failed_listing."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_succeeds_within_max_retries(self, db_session):
|
||||
"""retry_failed_listing re-attempts push when under max retries."""
|
||||
vehicle = _make_vehicle()
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
listing = MobileDeListing(
|
||||
vehicle_id=vehicle.id,
|
||||
sync_status="fehler",
|
||||
error_log="[retry 1] HTTP 500: Internal Server Error",
|
||||
)
|
||||
db_session.add(listing)
|
||||
await db_session.flush()
|
||||
|
||||
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
mock_response.json.return_value = {"id": "ad-789"}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
result = await mobilede_service.retry_failed_listing(db_session, listing, vehicle)
|
||||
|
||||
assert result.sync_status == "synced"
|
||||
assert result.ad_id == "ad-789"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_exceeds_max_retries(self, db_session):
|
||||
"""retry_failed_listing marks as permanently failed after max retries."""
|
||||
vehicle = _make_vehicle()
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
listing = MobileDeListing(
|
||||
vehicle_id=vehicle.id,
|
||||
sync_status="fehler",
|
||||
error_log=f"[retry {mobilede_service.MAX_RETRIES}] Last error",
|
||||
)
|
||||
db_session.add(listing)
|
||||
await db_session.flush()
|
||||
|
||||
result = await mobilede_service.retry_failed_listing(db_session, listing, vehicle)
|
||||
|
||||
assert result.sync_status == "fehler"
|
||||
assert "Max retries" in result.error_log
|
||||
@@ -0,0 +1,346 @@
|
||||
"""Tests for vehicle CRUD endpoints and mobile.de integration."""
|
||||
|
||||
import uuid
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.database import Base, get_db
|
||||
from app.main import app
|
||||
from app.models.vehicle import MobileDeListing, Vehicle
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sample_vehicle_data():
|
||||
"""Valid vehicle data for creation."""
|
||||
return {
|
||||
"make": "Mercedes-Benz",
|
||||
"model": "Actros",
|
||||
"fin": "WDB9066351L123456",
|
||||
"year": 2020,
|
||||
"first_registration": "2020-03-15",
|
||||
"power_kw": 300,
|
||||
"fuel_type": "Diesel",
|
||||
"transmission": "Manual",
|
||||
"color": "White",
|
||||
"condition": "used",
|
||||
"location": "Berlin",
|
||||
"availability": "available",
|
||||
"price": 45000.00,
|
||||
"vehicle_type": "lkw",
|
||||
"lkw_type": "sattelzugmaschine",
|
||||
"mileage_km": 120000,
|
||||
"description": "Well maintained truck",
|
||||
}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def created_vehicle(admin_client, sample_vehicle_data):
|
||||
"""Create a vehicle via API and return the response."""
|
||||
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
class TestVehicleList:
|
||||
"""GET /api/v1/vehicles tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_returns_200_with_pagination(self, admin_client, created_vehicle):
|
||||
"""GET /api/v1/vehicles returns 200 with paginated list."""
|
||||
response = await admin_client.get("/api/v1/vehicles/?page=1&page_size=20")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
assert "page" in data
|
||||
assert "page_size" in data
|
||||
assert data["page"] == 1
|
||||
assert data["page_size"] == 20
|
||||
assert data["total"] >= 1
|
||||
assert len(data["items"]) >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_filter_by_type(self, admin_client, created_vehicle):
|
||||
"""GET /api/v1/vehicles?type=lkw returns filtered results."""
|
||||
response = await admin_client.get("/api/v1/vehicles/?type=lkw")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
for item in data["items"]:
|
||||
assert item["vehicle_type"] == "lkw"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_filter_by_availability(self, admin_client, created_vehicle):
|
||||
"""GET /api/v1/vehicles?availability=available returns filtered results."""
|
||||
response = await admin_client.get("/api/v1/vehicles/?availability=available")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
for item in data["items"]:
|
||||
assert item["availability"] == "available"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_sort_descending(self, admin_client, created_vehicle):
|
||||
"""GET /api/v1/vehicles?sort=-created_at returns sorted results."""
|
||||
response = await admin_client.get("/api/v1/vehicles/?sort=-created_at")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
if len(data["items"]) >= 2:
|
||||
assert data["items"][0]["created_at"] >= data["items"][1]["created_at"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_filter_by_price_range(self, admin_client, created_vehicle):
|
||||
"""GET /api/v1/vehicles?min_price=40000&max_price=50000 returns filtered results."""
|
||||
response = await admin_client.get("/api/v1/vehicles/?min_price=40000&max_price=50000")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
for item in data["items"]:
|
||||
assert float(item["price"]) >= 40000
|
||||
assert float(item["price"]) <= 50000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_search(self, admin_client, created_vehicle):
|
||||
"""GET /api/v1/vehicles?search=Mercedes returns matching results."""
|
||||
response = await admin_client.get("/api/v1/vehicles/?search=Mercedes")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
for item in data["items"]:
|
||||
assert "Mercedes" in item["make"] or "Mercedes" in item["model"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_requires_auth(self, client):
|
||||
"""GET /api/v1/vehicles without auth returns 401."""
|
||||
response = await client.get("/api/v1/vehicles/")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestVehicleCreate:
|
||||
"""POST /api/v1/vehicles tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vehicle_returns_201(self, admin_client, sample_vehicle_data):
|
||||
"""POST /api/v1/vehicles with valid data returns 201."""
|
||||
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["make"] == sample_vehicle_data["make"]
|
||||
assert data["model"] == sample_vehicle_data["model"]
|
||||
assert data["fin"] == sample_vehicle_data["fin"]
|
||||
assert data["vehicle_type"] == "lkw"
|
||||
assert data["id"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vehicle_missing_make_returns_422(self, admin_client, sample_vehicle_data):
|
||||
"""POST /api/v1/vehicles without make returns 422."""
|
||||
del sample_vehicle_data["make"]
|
||||
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vehicle_missing_fin_returns_422(self, admin_client, sample_vehicle_data):
|
||||
"""POST /api/v1/vehicles without fin returns 422."""
|
||||
del sample_vehicle_data["fin"]
|
||||
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vehicle_short_fin_returns_422(self, admin_client, sample_vehicle_data):
|
||||
"""POST /api/v1/vehicles with short FIN returns 422."""
|
||||
sample_vehicle_data["fin"] = "SHORT"
|
||||
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vehicle_duplicate_fin_returns_409(self, admin_client, sample_vehicle_data, created_vehicle):
|
||||
"""POST /api/v1/vehicles with duplicate FIN returns 409."""
|
||||
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||
assert response.status_code == 409
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vehicle_auto_computes_power_hp(self, admin_client, sample_vehicle_data):
|
||||
"""POST /api/v1/vehicles auto-computes power_hp from power_kw."""
|
||||
sample_vehicle_data["power_kw"] = 100
|
||||
sample_vehicle_data.pop("power_hp", None)
|
||||
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["power_hp"] == 136 # 100 * 1.35962 ≈ 136
|
||||
|
||||
|
||||
class TestVehicleDetail:
|
||||
"""GET /api/v1/vehicles/:id tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_vehicle_returns_200(self, admin_client, created_vehicle):
|
||||
"""GET /api/v1/vehicles/:id returns 200 with detail."""
|
||||
vehicle_id = created_vehicle["id"]
|
||||
response = await admin_client.get(f"/api/v1/vehicles/{vehicle_id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == vehicle_id
|
||||
assert data["make"] == created_vehicle["make"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_vehicle_nonexistent_returns_404(self, admin_client):
|
||||
"""GET /api/v1/vehicles/:id with nonexistent ID returns 404."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
response = await admin_client.get(f"/api/v1/vehicles/{fake_id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestVehicleUpdate:
|
||||
"""PUT /api/v1/vehicles/:id tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_vehicle_returns_200(self, admin_client, created_vehicle):
|
||||
"""PUT /api/v1/vehicles/:id returns 200 with updated data."""
|
||||
vehicle_id = created_vehicle["id"]
|
||||
response = await admin_client.put(
|
||||
f"/api/v1/vehicles/{vehicle_id}",
|
||||
json={"price": "42000.00", "availability": "reserved"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert float(data["price"]) == 42000.00
|
||||
assert data["availability"] == "reserved"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_vehicle_nonexistent_returns_404(self, admin_client):
|
||||
"""PUT /api/v1/vehicles/:id with nonexistent ID returns 404."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
response = await admin_client.put(
|
||||
f"/api/v1/vehicles/{fake_id}",
|
||||
json={"price": "42000.00"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_vehicle_no_fields_returns_400(self, admin_client, created_vehicle):
|
||||
"""PUT /api/v1/vehicles/:id with no fields returns 400."""
|
||||
vehicle_id = created_vehicle["id"]
|
||||
response = await admin_client.put(
|
||||
f"/api/v1/vehicles/{vehicle_id}",
|
||||
json={},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
class TestVehicleDelete:
|
||||
"""DELETE /api/v1/vehicles/:id tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_vehicle_returns_200_with_deleted_at(self, admin_client, created_vehicle):
|
||||
"""DELETE /api/v1/vehicles/:id returns 200 and sets deleted_at."""
|
||||
vehicle_id = created_vehicle["id"]
|
||||
response = await admin_client.delete(f"/api/v1/vehicles/{vehicle_id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["deleted_at"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_vehicle_nonexistent_returns_404(self, admin_client):
|
||||
"""DELETE /api/v1/vehicles/:id with nonexistent ID returns 404."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
response = await admin_client.delete(f"/api/v1/vehicles/{fake_id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deleted_vehicle_not_in_list(self, admin_client, created_vehicle):
|
||||
"""After soft-delete, vehicle does not appear in list."""
|
||||
vehicle_id = created_vehicle["id"]
|
||||
await admin_client.delete(f"/api/v1/vehicles/{vehicle_id}")
|
||||
response = await admin_client.get("/api/v1/vehicles/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
for item in data["items"]:
|
||||
assert item["id"] != vehicle_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deleted_vehicle_returns_404_on_detail(self, admin_client, created_vehicle):
|
||||
"""After soft-delete, GET /api/v1/vehicles/:id returns 404."""
|
||||
vehicle_id = created_vehicle["id"]
|
||||
await admin_client.delete(f"/api/v1/vehicles/{vehicle_id}")
|
||||
response = await admin_client.get(f"/api/v1/vehicles/{vehicle_id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestMobileDePush:
|
||||
"""POST /api/v1/vehicles/:id/mobile-de/push tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_returns_202(self, admin_client, created_vehicle):
|
||||
"""POST /api/v1/vehicles/:id/mobile-de/push returns 202."""
|
||||
vehicle_id = created_vehicle["id"]
|
||||
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
mock_response.json.return_value = {"id": "mobile-de-ad-123"}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
response = await admin_client.post(f"/api/v1/vehicles/{vehicle_id}/mobile-de/push")
|
||||
|
||||
assert response.status_code == 202
|
||||
data = response.json()
|
||||
assert data["message"] == "Push queued"
|
||||
assert data["vehicle_id"] == vehicle_id
|
||||
assert data["listing_id"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_nonexistent_vehicle_returns_404(self, admin_client):
|
||||
"""POST /api/v1/vehicles/:id/mobile-de/push with nonexistent ID returns 404."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
response = await admin_client.post(f"/api/v1/vehicles/{fake_id}/mobile-de/push")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestMobileDeStatus:
|
||||
"""GET /api/v1/vehicles/:id/mobile-de/status tests."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_returns_200_with_no_listing(self, admin_client, created_vehicle):
|
||||
"""GET /api/v1/vehicles/:id/mobile-de/status returns 200 with pending status when no listing exists."""
|
||||
vehicle_id = created_vehicle["id"]
|
||||
response = await admin_client.get(f"/api/v1/vehicles/{vehicle_id}/mobile-de/status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["synced"] is False
|
||||
assert data["sync_status"] == "pending"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_returns_200_with_synced_listing(self, admin_client, created_vehicle):
|
||||
"""GET /api/v1/vehicles/:id/mobile-de/status returns 200 with sync info after push."""
|
||||
vehicle_id = created_vehicle["id"]
|
||||
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 201
|
||||
mock_response.json.return_value = {"id": "mobile-de-ad-456"}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
await admin_client.post(f"/api/v1/vehicles/{vehicle_id}/mobile-de/push")
|
||||
|
||||
response = await admin_client.get(f"/api/v1/vehicles/{vehicle_id}/mobile-de/status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["synced"] is True
|
||||
assert data["ad_id"] == "mobile-de-ad-456"
|
||||
assert data["synced_at"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_nonexistent_vehicle_returns_404(self, admin_client):
|
||||
"""GET /api/v1/vehicles/:id/mobile-de/status with nonexistent ID returns 404."""
|
||||
fake_id = str(uuid.uuid4())
|
||||
response = await admin_client.get(f"/api/v1/vehicles/{fake_id}/mobile-de/status")
|
||||
assert response.status_code == 404
|
||||
@@ -0,0 +1,391 @@
|
||||
"""Additional tests for vehicle_service and router to reach 80% coverage."""
|
||||
|
||||
import uuid
|
||||
from datetime import date, datetime, timezone
|
||||
from decimal import Decimal
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.vehicle import MobileDeListing, Vehicle
|
||||
from app.services import vehicle_service
|
||||
|
||||
|
||||
def _make_vehicle_data(**overrides) -> dict:
|
||||
"""Return valid vehicle creation data with optional overrides."""
|
||||
defaults = {
|
||||
"make": "Volvo",
|
||||
"model": "FH16",
|
||||
"fin": "WDB9066351L123456",
|
||||
"year": 2021,
|
||||
"first_registration": date(2021, 6, 1),
|
||||
"power_kw": 500,
|
||||
"power_hp": 680,
|
||||
"fuel_type": "Diesel",
|
||||
"transmission": "Automatic",
|
||||
"color": "Red",
|
||||
"condition": "used",
|
||||
"location": "Hamburg",
|
||||
"availability": "available",
|
||||
"price": Decimal("85000.00"),
|
||||
"vehicle_type": "lkw",
|
||||
"lkw_type": "sattelzugmaschine",
|
||||
"mileage_km": 80000,
|
||||
"description": "Heavy duty truck",
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return defaults
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def sample_vehicle_data():
|
||||
"""Valid vehicle data for creation."""
|
||||
return {
|
||||
"make": "Mercedes-Benz",
|
||||
"model": "Actros",
|
||||
"fin": "WDB9066351L123456",
|
||||
"year": 2020,
|
||||
"first_registration": "2020-03-15",
|
||||
"power_kw": 300,
|
||||
"fuel_type": "Diesel",
|
||||
"transmission": "Manual",
|
||||
"color": "White",
|
||||
"condition": "used",
|
||||
"location": "Berlin",
|
||||
"availability": "available",
|
||||
"price": 45000.00,
|
||||
"vehicle_type": "lkw",
|
||||
"lkw_type": "sattelzugmaschine",
|
||||
"mileage_km": 120000,
|
||||
"description": "Well maintained truck",
|
||||
}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def created_vehicle(admin_client, sample_vehicle_data):
|
||||
"""Create a vehicle via API and return the response."""
|
||||
response = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
class TestVehicleServiceDirect:
|
||||
"""Direct service-layer tests for vehicle_service."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_empty(self, db_session):
|
||||
"""list_vehicles returns empty list when no vehicles exist."""
|
||||
vehicles, total = await vehicle_service.list_vehicles(db_session)
|
||||
assert vehicles == []
|
||||
assert total == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_pagination(self, db_session):
|
||||
"""list_vehicles respects page and page_size."""
|
||||
fins = ["WDB9066351L123450", "WDB9066351L123451", "WDB9066351L123452",
|
||||
"WDB9066351L123453", "WDB9066351L123454"]
|
||||
for fin in fins:
|
||||
data = _make_vehicle_data(fin=fin)
|
||||
vehicle = Vehicle(**data)
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
vehicles, total = await vehicle_service.list_vehicles(db_session, page=1, page_size=2)
|
||||
assert len(vehicles) == 2
|
||||
assert total == 5
|
||||
|
||||
vehicles_page2, _ = await vehicle_service.list_vehicles(db_session, page=2, page_size=2)
|
||||
assert len(vehicles_page2) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_sort_ascending(self, db_session):
|
||||
"""list_vehicles sorts ascending by make."""
|
||||
makes_fins = [("Zebra", "WDB9066351L000001"), ("Alpha", "WDB9066351L000002"), ("Mike", "WDB9066351L000003")]
|
||||
for make, fin in makes_fins:
|
||||
data = _make_vehicle_data(make=make, fin=fin)
|
||||
vehicle = Vehicle(**data)
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
vehicles, _ = await vehicle_service.list_vehicles(db_session, sort="make")
|
||||
makes = [v.make for v in vehicles]
|
||||
assert makes == sorted(makes)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_sort_invalid_field_defaults_to_created_at(self, db_session):
|
||||
"""list_vehicles falls back to created_at sort for invalid field."""
|
||||
data = _make_vehicle_data()
|
||||
vehicle = Vehicle(**data)
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
vehicles, total = await vehicle_service.list_vehicles(db_session, sort="invalid_field")
|
||||
assert total == 1
|
||||
assert len(vehicles) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_filter_by_min_price_only(self, db_session):
|
||||
"""list_vehicles filters by min_price only."""
|
||||
data1 = _make_vehicle_data(fin="WDB9066351L00000A", price=Decimal("30000.00"))
|
||||
data2 = _make_vehicle_data(fin="WDB9066351L00000B", price=Decimal("60000.00"))
|
||||
db_session.add(Vehicle(**data1))
|
||||
db_session.add(Vehicle(**data2))
|
||||
await db_session.flush()
|
||||
|
||||
vehicles, total = await vehicle_service.list_vehicles(db_session, min_price=50000)
|
||||
assert total == 1
|
||||
assert float(vehicles[0].price) >= 50000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_filter_by_max_price_only(self, db_session):
|
||||
"""list_vehicles filters by max_price only."""
|
||||
data1 = _make_vehicle_data(fin="WDB9066351L00000A", price=Decimal("30000.00"))
|
||||
data2 = _make_vehicle_data(fin="WDB9066351L00000B", price=Decimal("60000.00"))
|
||||
db_session.add(Vehicle(**data1))
|
||||
db_session.add(Vehicle(**data2))
|
||||
await db_session.flush()
|
||||
|
||||
vehicles, total = await vehicle_service.list_vehicles(db_session, max_price=40000)
|
||||
assert total == 1
|
||||
assert float(vehicles[0].price) <= 40000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_search_by_fin(self, db_session):
|
||||
"""list_vehicles search matches FIN."""
|
||||
data = _make_vehicle_data(fin="WDB9066351L123456")
|
||||
db_session.add(Vehicle(**data))
|
||||
await db_session.flush()
|
||||
|
||||
vehicles, total = await vehicle_service.list_vehicles(db_session, search="123456")
|
||||
assert total == 1
|
||||
assert "123456" in vehicles[0].fin
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_search_by_location(self, db_session):
|
||||
"""list_vehicles search matches location."""
|
||||
data = _make_vehicle_data(location="Munich")
|
||||
db_session.add(Vehicle(**data))
|
||||
await db_session.flush()
|
||||
|
||||
vehicles, total = await vehicle_service.list_vehicles(db_session, search="Munich")
|
||||
assert total == 1
|
||||
assert vehicles[0].location == "Munich"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_vehicle_by_fin(self, db_session):
|
||||
"""get_vehicle_by_fin returns vehicle by FIN."""
|
||||
data = _make_vehicle_data(fin="WDB9066351L999999")
|
||||
vehicle = Vehicle(**data)
|
||||
db_session.add(vehicle)
|
||||
await db_session.flush()
|
||||
|
||||
result = await vehicle_service.get_vehicle_by_fin(db_session, "WDB9066351L999999")
|
||||
assert result is not None
|
||||
assert result.fin == "WDB9066351L999999"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_vehicle_by_fin_not_found(self, db_session):
|
||||
"""get_vehicle_by_fin returns None for nonexistent FIN."""
|
||||
result = await vehicle_service.get_vehicle_by_fin(db_session, "NONEXISTENT1234567")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vehicle_success(self, db_session):
|
||||
"""create_vehicle creates and returns a vehicle."""
|
||||
data = _make_vehicle_data()
|
||||
vehicle = await vehicle_service.create_vehicle(db_session, data)
|
||||
assert vehicle.id is not None
|
||||
assert vehicle.make == "Volvo"
|
||||
assert vehicle.fin == "WDB9066351L123456"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vehicle_duplicate_fin_raises(self, db_session):
|
||||
"""create_vehicle raises ValueError for duplicate FIN."""
|
||||
data = _make_vehicle_data()
|
||||
await vehicle_service.create_vehicle(db_session, data)
|
||||
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
await vehicle_service.create_vehicle(db_session, data)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_vehicle_success(self, db_session):
|
||||
"""update_vehicle updates fields and returns updated vehicle."""
|
||||
data = _make_vehicle_data()
|
||||
vehicle = await vehicle_service.create_vehicle(db_session, data)
|
||||
|
||||
updated = await vehicle_service.update_vehicle(
|
||||
db_session, vehicle.id, {"make": "Scania", "price": Decimal("90000.00")}
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated.make == "Scania"
|
||||
assert float(updated.price) == 90000.00
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_vehicle_not_found(self, db_session):
|
||||
"""update_vehicle returns None for nonexistent ID."""
|
||||
result = await vehicle_service.update_vehicle(
|
||||
db_session, uuid.uuid4(), {"make": "Test"}
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_vehicle_duplicate_fin_raises(self, db_session):
|
||||
"""update_vehicle raises ValueError when updating to existing FIN."""
|
||||
data1 = _make_vehicle_data(fin="WDB9066351L111111")
|
||||
data2 = _make_vehicle_data(fin="WDB9066351L222222")
|
||||
v1 = await vehicle_service.create_vehicle(db_session, data1)
|
||||
await vehicle_service.create_vehicle(db_session, data2)
|
||||
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
await vehicle_service.update_vehicle(
|
||||
db_session, v1.id, {"fin": "WDB9066351L222222"}
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_vehicle_same_fin_no_error(self, db_session):
|
||||
"""update_vehicle allows setting same FIN (no change)."""
|
||||
data = _make_vehicle_data(fin="WDB9066351L333333")
|
||||
vehicle = await vehicle_service.create_vehicle(db_session, data)
|
||||
|
||||
updated = await vehicle_service.update_vehicle(
|
||||
db_session, vehicle.id, {"fin": "WDB9066351L333333"}
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated.fin == "WDB9066351L333333"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soft_delete_vehicle_success(self, db_session):
|
||||
"""soft_delete_vehicle sets deleted_at."""
|
||||
data = _make_vehicle_data()
|
||||
vehicle = await vehicle_service.create_vehicle(db_session, data)
|
||||
|
||||
deleted = await vehicle_service.soft_delete_vehicle(db_session, vehicle.id)
|
||||
assert deleted is not None
|
||||
assert deleted.deleted_at is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_soft_delete_vehicle_not_found(self, db_session):
|
||||
"""soft_delete_vehicle returns None for nonexistent ID."""
|
||||
result = await vehicle_service.soft_delete_vehicle(db_session, uuid.uuid4())
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_vehicle_by_id_not_found(self, db_session):
|
||||
"""get_vehicle_by_id returns None for nonexistent ID."""
|
||||
result = await vehicle_service.get_vehicle_by_id(db_session, uuid.uuid4())
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_vehicle_by_id_excludes_deleted(self, db_session):
|
||||
"""get_vehicle_by_id returns None for soft-deleted vehicle."""
|
||||
data = _make_vehicle_data()
|
||||
vehicle = await vehicle_service.create_vehicle(db_session, data)
|
||||
await vehicle_service.soft_delete_vehicle(db_session, vehicle.id)
|
||||
|
||||
result = await vehicle_service.get_vehicle_by_id(db_session, vehicle.id)
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestRouterAdditionalPaths:
|
||||
"""Additional router tests for error paths and edge cases."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_with_all_filters(self, admin_client, created_vehicle):
|
||||
"""GET /api/v1/vehicles with all filters combined."""
|
||||
response = await admin_client.get(
|
||||
"/api/v1/vehicles/?type=lkw&availability=available&min_price=40000&max_price=50000&search=Mercedes&sort=-price"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_vehicle_verkaeufer_allowed(self, verkaeufer_client, sample_vehicle_data):
|
||||
"""POST /api/v1/vehicles works for verkaeufer role (not admin-only)."""
|
||||
sample_vehicle_data["fin"] = "WDB9066351L654321"
|
||||
response = await verkaeufer_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_vehicle_fin_duplicate_returns_409(self, admin_client, sample_vehicle_data):
|
||||
"""PUT /api/v1/vehicles/:id with duplicate FIN returns 409."""
|
||||
sample_vehicle_data["fin"] = "WDB9066351L111111"
|
||||
resp1 = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||
assert resp1.status_code == 201
|
||||
vehicle1_id = resp1.json()["id"]
|
||||
|
||||
sample_vehicle_data["fin"] = "WDB9066351L222222"
|
||||
resp2 = await admin_client.post("/api/v1/vehicles/", json=sample_vehicle_data)
|
||||
assert resp2.status_code == 201
|
||||
|
||||
response = await admin_client.put(
|
||||
f"/api/v1/vehicles/{vehicle1_id}",
|
||||
json={"fin": "WDB9066351L222222"},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_vehicles_empty_result(self, admin_client):
|
||||
"""GET /api/v1/vehicles with filters that match nothing returns empty list."""
|
||||
response = await admin_client.get("/api/v1/vehicles/?type=baumaschine&min_price=999999")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] == 0
|
||||
assert data["items"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_vehicle_invalid_uuid_returns_422(self, admin_client):
|
||||
"""GET /api/v1/vehicles/invalid-uuid returns 422."""
|
||||
response = await admin_client.get("/api/v1/vehicles/not-a-uuid")
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_push_to_mobile_de_failure_still_returns_202(self, admin_client, created_vehicle):
|
||||
"""POST /api/v1/vehicles/:id/mobile-de/push returns 202 even when mobile.de API fails."""
|
||||
import httpx
|
||||
vehicle_id = created_vehicle["id"]
|
||||
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.text = "Internal Server Error"
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"Server Error", request=MagicMock(), response=mock_response
|
||||
)
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
response = await admin_client.post(f"/api/v1/vehicles/{vehicle_id}/mobile-de/push")
|
||||
|
||||
assert response.status_code == 202
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mobile_de_status_after_failed_push(self, admin_client, created_vehicle):
|
||||
"""GET /api/v1/vehicles/:id/mobile-de/status shows fehler after failed push."""
|
||||
import httpx
|
||||
vehicle_id = created_vehicle["id"]
|
||||
with patch("app.services.mobilede_service.httpx.AsyncClient") as mock_client_cls:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.text = "Internal Server Error"
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
"Server Error", request=MagicMock(), response=mock_response
|
||||
)
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_response)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
await admin_client.post(f"/api/v1/vehicles/{vehicle_id}/mobile-de/push")
|
||||
|
||||
response = await admin_client.get(f"/api/v1/vehicles/{vehicle_id}/mobile-de/status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["synced"] is False
|
||||
assert data["sync_status"] == "fehler"
|
||||
assert data["error_log"] is not None
|
||||
Reference in New Issue
Block a user