568 lines
23 KiB
Python
568 lines
23 KiB
Python
"""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 ASGITransport, AsyncClient
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.database import Base, get_db
|
|
from app.main import app
|
|
from app.models.retouch import RetouchResult, 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(l.price for l 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
|