Files

591 lines
24 KiB
Python
Raw Permalink Normal View History

"""Tests for retouch module: upload, process, results, and price comparison with mocked OpenRouter."""
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.retouch import RetouchStatus
from app.models.vehicle import Vehicle
from app.services import retouch_service, price_compare_service
# Ensure all models are registered with Base.metadata
from app.models import user, vehicle, retouch # noqa: F401
@pytest_asyncio.fixture
async def test_vehicle(db_session: AsyncSession) -> Vehicle:
"""Create a test vehicle for retouch linking."""
v = Vehicle(
make="Mercedes",
model="Actros",
fin="WDB9066351L123456",
year=2020,
power_kw=350,
fuel_type="Diesel",
condition="used",
availability="available",
price=45000,
vehicle_type="lkw",
mileage_km=120000,
)
db_session.add(v)
await db_session.commit()
await db_session.refresh(v)
return v
def _fake_image(size: int = 1024) -> bytes:
"""Generate fake image bytes for testing."""
return b"\x89PNG\r\n\x1a\n" + b"\x00" * (size - 8)
class TestRetouchService:
"""Unit tests for retouch_service functions."""
@pytest.mark.asyncio
async def test_validate_mime_type_valid(self):
assert retouch_service.validate_mime_type("image/png") is True
assert retouch_service.validate_mime_type("image/jpeg") is True
assert retouch_service.validate_mime_type("image/webp") is True
@pytest.mark.asyncio
async def test_validate_mime_type_invalid(self):
assert retouch_service.validate_mime_type("application/pdf") is False
assert retouch_service.validate_mime_type("text/plain") is False
assert retouch_service.validate_mime_type("") is False
@pytest.mark.asyncio
async def test_validate_file_size_valid(self):
assert retouch_service.validate_file_size(1024 * 1024) is True
@pytest.mark.asyncio
async def test_validate_file_size_too_large(self):
assert retouch_service.validate_file_size(51 * 1024 * 1024) is False
@pytest.mark.asyncio
async def test_generate_retouch_prompt_default(self):
prompt = retouch_service.generate_retouch_prompt()
assert "background" in prompt.lower()
assert "color" in prompt.lower()
@pytest.mark.asyncio
async def test_generate_retouch_prompt_with_vehicle_info(self):
info = {"make": "Mercedes", "model": "Actros", "color": "red"}
prompt = retouch_service.generate_retouch_prompt(info)
assert "Mercedes" in prompt
assert "Actros" in prompt
assert "red" in prompt
@pytest.mark.asyncio
async def test_upload_retouch_file_success(
self, db_session: AsyncSession, tmp_path
):
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
result = await retouch_service.upload_retouch_file(
db=db_session,
file_bytes=_fake_image(),
file_name="truck.png",
mime_type="image/png",
)
assert result.id is not None
assert result.status == RetouchStatus.pending.value
assert result.original_file_name == "truck.png"
assert result.original_file_path.endswith(".png")
@pytest.mark.asyncio
async def test_upload_retouch_file_invalid_mime(self, db_session: AsyncSession):
with pytest.raises(ValueError, match="Invalid MIME type"):
await retouch_service.upload_retouch_file(
db=db_session,
file_bytes=b"data",
file_name="doc.pdf",
mime_type="application/pdf",
)
@pytest.mark.asyncio
async def test_upload_retouch_file_too_large(self, db_session: AsyncSession):
large = b"x" * (51 * 1024 * 1024)
with pytest.raises(ValueError, match="File size exceeds"):
await retouch_service.upload_retouch_file(
db=db_session,
file_bytes=large,
file_name="big.png",
mime_type="image/png",
)
@pytest.mark.asyncio
async def test_get_result_not_found(self, db_session: AsyncSession):
result = await retouch_service.get_result(db_session, uuid.uuid4())
assert result is None
@pytest.mark.asyncio
async def test_list_results_empty(self, db_session: AsyncSession):
items, total = await retouch_service.list_results(db_session)
assert total == 0
assert items == []
@pytest.mark.asyncio
async def test_list_results_with_data(self, db_session: AsyncSession, tmp_path):
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
await retouch_service.upload_retouch_file(
db=db_session,
file_bytes=_fake_image(),
file_name="img1.png",
mime_type="image/png",
)
await retouch_service.upload_retouch_file(
db=db_session,
file_bytes=_fake_image(),
file_name="img2.png",
mime_type="image/png",
)
await db_session.commit()
items, total = await retouch_service.list_results(db_session)
assert total == 2
assert len(items) == 2
@pytest.mark.asyncio
async def test_list_results_filter_by_vehicle(
self, db_session: AsyncSession, test_vehicle: Vehicle, tmp_path
):
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
await retouch_service.upload_retouch_file(
db=db_session,
file_bytes=_fake_image(),
file_name="img1.png",
mime_type="image/png",
vehicle_id=test_vehicle.id,
)
await retouch_service.upload_retouch_file(
db=db_session,
file_bytes=_fake_image(),
file_name="img2.png",
mime_type="image/png",
)
await db_session.commit()
items, total = await retouch_service.list_results(
db_session, vehicle_id=test_vehicle.id
)
assert total == 1
assert len(items) == 1
assert items[0].vehicle_id == test_vehicle.id
@pytest.mark.asyncio
async def test_process_retouch_success(self, db_session: AsyncSession, tmp_path):
"""Test process_retouch with mocked Flux.1-Pro call."""
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
result = await retouch_service.upload_retouch_file(
db=db_session,
file_bytes=_fake_image(),
file_name="truck.png",
mime_type="image/png",
)
await db_session.commit()
# Mock the _call_flux_pro function to return fake retouched bytes
retouched_bytes = b"\x89PNG\r\n\x1a\n" + b"\xff" * 1016
with patch.object(
retouch_service,
"_call_flux_pro",
new_callable=AsyncMock,
return_value=retouched_bytes,
):
processed = await retouch_service.process_retouch(db_session, result.id)
await db_session.commit()
assert processed.status == RetouchStatus.completed.value
assert processed.retouched_file_path is not None
assert processed.error_message is None
assert processed.retouched_file_path.endswith(".png")
@pytest.mark.asyncio
async def test_process_retouch_failure(self, db_session: AsyncSession, tmp_path):
"""Test process_retouch sets failed status on OpenRouter error."""
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
result = await retouch_service.upload_retouch_file(
db=db_session,
file_bytes=_fake_image(),
file_name="truck.png",
mime_type="image/png",
)
await db_session.commit()
with patch.object(
retouch_service,
"_call_flux_pro",
new_callable=AsyncMock,
side_effect=Exception("OpenRouter unavailable"),
):
processed = await retouch_service.process_retouch(db_session, result.id)
await db_session.commit()
assert processed.status == RetouchStatus.failed.value
assert processed.error_message is not None
assert "OpenRouter unavailable" in processed.error_message
assert processed.retouched_file_path is None
@pytest.mark.asyncio
async def test_process_retouch_not_found(self, db_session: AsyncSession):
"""Test process_retouch raises ValueError for non-existent result."""
with pytest.raises(ValueError, match="not found"):
await retouch_service.process_retouch(db_session, uuid.uuid4())
@pytest.mark.asyncio
async def test_call_flux_pro_no_api_key(self):
"""Test _call_flux_pro raises ValueError when no API key configured."""
with patch.object(retouch_service.settings, "OPENROUTER_API_KEY", ""):
with pytest.raises(
ValueError, match="OPENROUTER_API_KEY is not configured"
):
await retouch_service._call_flux_pro(
image_bytes=b"fake-image",
mime_type="image/png",
prompt="test prompt",
)
@pytest.mark.asyncio
async def test_call_flux_pro_success_data_uri(
self, db_session: AsyncSession, tmp_path
):
"""Test _call_flux_pro with mocked httpx returning base64 data URI."""
import base64 as b64mod
retouched = b"\x89PNG\r\n\x1a\nretouched-data"
b64_retouched = b64mod.b64encode(retouched).decode("utf-8")
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock()
mock_response.json.return_value = {
"choices": [
{"message": {"content": f"data:image/png;base64,{b64_retouched}"}}
]
}
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)
with patch.object(retouch_service.settings, "OPENROUTER_API_KEY", "test-key"):
with patch(
"app.services.retouch_service.httpx.AsyncClient",
return_value=mock_client,
):
result = await retouch_service._call_flux_pro(
image_bytes=b"fake-image",
mime_type="image/png",
prompt="test prompt",
)
assert result == retouched
@pytest.mark.asyncio
async def test_call_flux_pro_success_list_content(
self, db_session: AsyncSession, tmp_path
):
"""Test _call_flux_pro with content as list of parts."""
import base64 as b64mod
retouched = b"\x89PNG\r\n\x1a\nlist-content"
b64_retouched = b64mod.b64encode(retouched).decode("utf-8")
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock()
mock_response.json.return_value = {
"choices": [
{
"message": {
"content": [
{"type": "text", "text": "Here is the image"},
{
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{b64_retouched}"
},
},
]
}
}
]
}
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)
with patch.object(retouch_service.settings, "OPENROUTER_API_KEY", "test-key"):
with patch(
"app.services.retouch_service.httpx.AsyncClient",
return_value=mock_client,
):
result = await retouch_service._call_flux_pro(
image_bytes=b"fake-image",
mime_type="image/png",
prompt="test prompt",
)
assert result == retouched
@pytest.mark.asyncio
async def test_call_flux_pro_fallback_original_bytes(self):
"""Test _call_flux_pro returns original bytes when no image in response."""
mock_response = MagicMock()
mock_response.raise_for_status = MagicMock()
mock_response.json.return_value = {
"choices": [{"message": {"content": "Sorry, I cannot process this image."}}]
}
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)
original = b"original-image-bytes"
with patch.object(retouch_service.settings, "OPENROUTER_API_KEY", "test-key"):
with patch(
"app.services.retouch_service.httpx.AsyncClient",
return_value=mock_client,
):
result = await retouch_service._call_flux_pro(
image_bytes=original,
mime_type="image/png",
prompt="test prompt",
)
assert result == original
class TestPriceCompareService:
"""Unit tests for price_compare_service functions."""
@pytest.mark.asyncio
async def test_compare_prices_success(
self, db_session: AsyncSession, test_vehicle: Vehicle
):
result = await price_compare_service.compare_prices(db_session, test_vehicle.id)
assert result.vehicle_id == test_vehicle.id
assert len(result.comparable_listings) > 0
assert result.average_price is not None
assert result.listing_count > 0
# Average should be within reasonable range of vehicle price
base = float(test_vehicle.price)
assert base * 0.7 < result.average_price < base * 1.3
@pytest.mark.asyncio
async def test_compare_prices_vehicle_not_found(self, db_session: AsyncSession):
with pytest.raises(ValueError, match="not found"):
await price_compare_service.compare_prices(db_session, uuid.uuid4())
@pytest.mark.asyncio
async def test_compare_prices_listings_have_correct_make_model(
self, db_session: AsyncSession, test_vehicle: Vehicle
):
result = await price_compare_service.compare_prices(db_session, test_vehicle.id)
for listing in result.comparable_listings:
assert listing.make == test_vehicle.make
assert listing.model == test_vehicle.model
assert listing.source == "mobile.de"
@pytest.mark.asyncio
async def test_compare_prices_average_calculation(
self, db_session: AsyncSession, test_vehicle: Vehicle
):
result = await price_compare_service.compare_prices(db_session, test_vehicle.id)
expected_avg = sum(
listing.price for listing in result.comparable_listings
) / len(result.comparable_listings)
assert abs(result.average_price - round(expected_avg, 2)) < 0.01
class TestRetouchRouter:
"""Integration tests for retouch router endpoints."""
@pytest.mark.asyncio
async def test_process_image_success(self, admin_client: AsyncClient, tmp_path):
"""POST /retouch/process with valid image returns 202 + retouch_id."""
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
with patch(
"app.routers.image_retouch.run_retouch_processing",
new_callable=AsyncMock,
):
fake_image = _fake_image()
response = await admin_client.post(
"/api/v1/retouch/process",
files={"file": ("truck.png", fake_image, "image/png")},
)
assert response.status_code == 202
data = response.json()
assert "retouch_id" in data
assert data["status"] == "pending"
assert data["message"] == "Retouch processing queued"
@pytest.mark.asyncio
async def test_process_image_no_file(self, admin_client: AsyncClient):
"""POST /retouch/process without file returns 422."""
response = await admin_client.post("/api/v1/retouch/process")
assert response.status_code == 422
@pytest.mark.asyncio
async def test_process_image_invalid_mime(self, admin_client: AsyncClient):
"""POST /retouch/process with invalid MIME returns 422."""
response = await admin_client.post(
"/api/v1/retouch/process",
files={"file": ("doc.pdf", b"fake-pdf-data", "application/pdf")},
)
assert response.status_code == 422
data = response.json()
assert data["detail"]["error"]["code"] == "INVALID_MIME_TYPE"
@pytest.mark.asyncio
async def test_process_image_with_vehicle_id(
self, admin_client: AsyncClient, test_vehicle: Vehicle, tmp_path
):
"""POST /retouch/process with vehicle_id links the result."""
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
with patch(
"app.routers.image_retouch.run_retouch_processing",
new_callable=AsyncMock,
):
response = await admin_client.post(
"/api/v1/retouch/process",
files={"file": ("truck.png", _fake_image(), "image/png")},
data={"vehicle_id": str(test_vehicle.id)},
)
assert response.status_code == 202
data = response.json()
assert "retouch_id" in data
@pytest.mark.asyncio
async def test_get_retouch_result_completed(
self, admin_client: AsyncClient, db_session: AsyncSession, tmp_path
):
"""GET /retouch/results/:id returns 200 with completed status."""
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
result = await retouch_service.upload_retouch_file(
db=db_session,
file_bytes=_fake_image(),
file_name="truck.png",
mime_type="image/png",
)
result.status = RetouchStatus.completed.value
result.retouched_file_path = str(tmp_path / "retouched.png")
await db_session.commit()
await db_session.refresh(result)
response = await admin_client.get(f"/api/v1/retouch/results/{result.id}")
assert response.status_code == 200
data = response.json()
assert data["status"] == "completed"
assert data["retouched_file_path"] is not None
@pytest.mark.asyncio
async def test_get_retouch_result_processing(
self, admin_client: AsyncClient, db_session: AsyncSession, tmp_path
):
"""GET /retouch/results/:id before completion returns 200 with processing status."""
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
result = await retouch_service.upload_retouch_file(
db=db_session,
file_bytes=_fake_image(),
file_name="truck.png",
mime_type="image/png",
)
result.status = RetouchStatus.processing.value
await db_session.commit()
await db_session.refresh(result)
response = await admin_client.get(f"/api/v1/retouch/results/{result.id}")
assert response.status_code == 200
data = response.json()
assert data["status"] == "processing"
@pytest.mark.asyncio
async def test_get_retouch_result_failed(
self, admin_client: AsyncClient, db_session: AsyncSession, tmp_path
):
"""GET /retouch/results/:id with failed status returns 200 + error."""
with patch.object(retouch_service.settings, "UPLOAD_DIR", str(tmp_path)):
result = await retouch_service.upload_retouch_file(
db=db_session,
file_bytes=_fake_image(),
file_name="truck.png",
mime_type="image/png",
)
result.status = RetouchStatus.failed.value
result.error_message = "OpenRouter unavailable"
await db_session.commit()
await db_session.refresh(result)
response = await admin_client.get(f"/api/v1/retouch/results/{result.id}")
assert response.status_code == 200
data = response.json()
assert data["status"] == "failed"
assert data["error_message"] == "OpenRouter unavailable"
@pytest.mark.asyncio
async def test_get_retouch_result_not_found(self, admin_client: AsyncClient):
"""GET /retouch/results/:id with non-existent ID returns 404."""
response = await admin_client.get(f"/api/v1/retouch/results/{uuid.uuid4()}")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_price_compare_success(
self, admin_client: AsyncClient, test_vehicle: Vehicle
):
"""POST /retouch/price-compare with vehicle_id returns 200 + listings."""
response = await admin_client.post(
"/api/v1/retouch/price-compare",
json={"vehicle_id": str(test_vehicle.id)},
)
assert response.status_code == 200
data = response.json()
assert data["vehicle_id"] == str(test_vehicle.id)
assert isinstance(data["comparable_listings"], list)
assert len(data["comparable_listings"]) > 0
assert data["average_price"] is not None
assert data["listing_count"] > 0
@pytest.mark.asyncio
async def test_price_compare_no_vehicle_id(self, admin_client: AsyncClient):
"""POST /retouch/price-compare without vehicle_id returns 422."""
response = await admin_client.post(
"/api/v1/retouch/price-compare",
json={},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_price_compare_vehicle_not_found(self, admin_client: AsyncClient):
"""POST /retouch/price-compare with non-existent vehicle returns 404."""
response = await admin_client.post(
"/api/v1/retouch/price-compare",
json={"vehicle_id": str(uuid.uuid4())},
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_process_image_unauthorized(self, client: AsyncClient):
"""POST /retouch/process without auth returns 401."""
response = await client.post(
"/api/v1/retouch/process",
files={"file": ("truck.png", _fake_image(), "image/png")},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_get_result_unauthorized(self, client: AsyncClient):
"""GET /retouch/results/:id without auth returns 401."""
response = await client.get(f"/api/v1/retouch/results/{uuid.uuid4()}")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_price_compare_unauthorized(self, client: AsyncClient):
"""POST /retouch/price-compare without auth returns 401."""
response = await client.post(
"/api/v1/retouch/price-compare",
json={"vehicle_id": str(uuid.uuid4())},
)
assert response.status_code == 401