"""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 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() 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