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,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
|
||||
Reference in New Issue
Block a user