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