430 lines
15 KiB
Python
430 lines
15 KiB
Python
|
|
"""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
|