"""Additional tests for vehicle_service and router to reach 80% coverage.""" import uuid from datetime import date from decimal import Decimal from unittest.mock import AsyncMock, MagicMock, patch import pytest import pytest_asyncio from app.models.vehicle import 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