import asyncio from decimal import Decimal import httpx import pytest from app.controllers.v1 import delivery as delivery_controller from app.controllers.v1.delivery import get_aggregator_service from app.main import create_app from app.schemas.request import DeliveryCalculationRequest, DeliveryEntity, ParcelType from app.schemas.response import DeliveryPrice from app.services.aggregator import ( AggregatorService, AggregatorServiceError, InvalidDeliveryRequestError, ) class StubAggregatorService: def __init__(self, *, response: object, error: Exception | None = None) -> None: self._response = response self._error = error self.calls: list[DeliveryCalculationRequest] = [] async def get_all_prices(self, request: DeliveryCalculationRequest) -> object: self.calls.append(request) if self._error is not None: raise self._error return self._response class StubPriceProvider: def __init__(self, response: list[DeliveryPrice]) -> None: self.name = "stub-provider" self.cache_ttl_seconds = 900 self._response = response self.calls: list[DeliveryCalculationRequest] = [] async def get_prices( self, request: DeliveryCalculationRequest ) -> list[DeliveryPrice]: self.calls.append(request) return self._response def _install_service_override(app, service: StubAggregatorService) -> None: async def override_service() -> StubAggregatorService: return service app.dependency_overrides[get_aggregator_service] = override_service def _valid_payload() -> dict[str, object]: return { "entity": "individual", "from_city": 1, "to_city": 2, "weight_kg": 2.5, "length_cm": 30.0, "width_cm": 20.0, "height_cm": 10.0, } def _make_price( *, service_name: str, price: str, provider: str = "cdek", ) -> DeliveryPrice: return DeliveryPrice( provider=provider, service_name=service_name, price=Decimal(price), currency="RUB", delivery_days_min=1, delivery_days_max=3, ) def test_post_delivery_price_uses_registered_provider_in_default_dependency( monkeypatch: pytest.MonkeyPatch, ) -> None: class StubHttpClient: def __init__(self) -> None: self.closed = False async def aclose(self) -> None: self.closed = True class StubProvider: name = "stub-provider" cache_ttl_seconds = 900 def __init__(self) -> None: self.calls: list[DeliveryCalculationRequest] = [] async def get_prices( self, request: DeliveryCalculationRequest ) -> list[DeliveryPrice]: self.calls.append(request) return [ DeliveryPrice( provider=self.name, service_name="slow", price=Decimal("223.50"), currency="RUB", delivery_days_min=4, delivery_days_max=5, ), DeliveryPrice( provider=self.name, service_name="fast", price=Decimal("123.50"), currency="RUB", delivery_days_min=2, delivery_days_max=3, ), ] class StubCDEKProvider: @classmethod def from_adapter_config(cls, *, http_client, adapter_config) -> StubProvider: assert http_client is stub_http_client _ = adapter_config return stub_provider class StubCache: def __init__(self) -> None: self.storage: dict[str, object] = {} async def get(self, key: str) -> object | None: return self.storage.get(key) async def set(self, key: str, value: object, ttl: int | None = None) -> None: _ = ttl self.storage[key] = value class StubPriceCache: @classmethod def from_repository_config( cls, repository_config, *, metrics ) -> StubCache: _ = repository_config assert metrics is delivery_controller.get_cache_metrics() return stub_cache stub_http_client = StubHttpClient() stub_provider = StubProvider() stub_cache = StubCache() http_client_timeouts: list[float] = [] def fake_build_controller_http_client(timeout_seconds: float) -> StubHttpClient: http_client_timeouts.append(timeout_seconds) return stub_http_client monkeypatch.setattr( delivery_controller, "build_controller_http_client", fake_build_controller_http_client, ) monkeypatch.setattr(delivery_controller, "CDEKProvider", StubCDEKProvider) monkeypatch.setattr(delivery_controller, "PriceCache", StubPriceCache) app = create_app() async def run_requests() -> tuple[httpx.Response, httpx.Response]: transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient( transport=transport, base_url="http://testserver", ) as client: first = await client.post("/api/v1/delivery/price", json=_valid_payload()) second = await client.post("/api/v1/delivery/price", json=_valid_payload()) return first, second first_response, second_response = asyncio.run(run_requests()) assert first_response.status_code == 200 assert second_response.status_code == 200 assert first_response.json() == [ { "provider": "stub-provider", "service_name": "fast", "price": "124", "currency": "RUB", "delivery_days_min": 2, "delivery_days_max": 3, "tariff_code": None, }, { "provider": "stub-provider", "service_name": "slow", "price": "224", "currency": "RUB", "delivery_days_min": 4, "delivery_days_max": 5, "tariff_code": None, } ] assert second_response.json() == first_response.json() assert len(http_client_timeouts) == 1 assert len(stub_provider.calls) == 1 def test_post_delivery_price_returns_prices_and_delegates_to_service() -> None: expected_prices = [ DeliveryPrice( provider="cdek", service_name="economy", price=Decimal("150.00"), currency="RUB", delivery_days_min=3, delivery_days_max=5, ), DeliveryPrice( provider="cdek", service_name="express", price=Decimal("100.50"), currency="RUB", delivery_days_min=1, delivery_days_max=2, ), ] service = StubAggregatorService(response=expected_prices) app = create_app() _install_service_override(app, service) async def run_request() -> httpx.Response: transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient( transport=transport, base_url="http://testserver", ) as client: return await client.post("/api/v1/delivery/price", json=_valid_payload()) response = asyncio.run(run_request()) assert response.status_code == 200 assert response.json() == [price.model_dump(mode="json") for price in expected_prices] assert len(service.calls) == 1 assert service.calls[0] == DeliveryCalculationRequest( entity=DeliveryEntity.INDIVIDUAL, from_city=1, to_city=2, weight_kg=2.5, length_cm=30.0, width_cm=20.0, height_cm=10.0, parcel_type=None, ) @pytest.mark.parametrize(("field_name", "field_value"), [("from_city", "1"), ("to_city", "2")]) def test_post_delivery_price_rejects_non_integer_city_identifier( field_name: str, field_value: str, ) -> None: service = StubAggregatorService(response=[]) app = create_app() _install_service_override(app, service) payload = _valid_payload() payload[field_name] = field_value async def run_request() -> httpx.Response: transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient( transport=transport, base_url="http://testserver", ) as client: return await client.post("/api/v1/delivery/price", json=payload) response = asyncio.run(run_request()) assert response.status_code == 422 assert response.json()["detail"][0]["loc"] == ["body", field_name] assert service.calls == [] def test_post_delivery_price_accepts_optional_parcel_type() -> None: service = StubAggregatorService(response=[]) app = create_app() _install_service_override(app, service) payload = _valid_payload() payload["parcel_type"] = "doc" async def run_request() -> httpx.Response: transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient( transport=transport, base_url="http://testserver", ) as client: return await client.post("/api/v1/delivery/price", json=payload) response = asyncio.run(run_request()) assert response.status_code == 200 assert len(service.calls) == 1 assert service.calls[0].parcel_type == ParcelType.DOC @pytest.mark.parametrize( ("parcel_type", "expected_service_names"), [ ("doc", ["Срочный документ", "DOCUMENT EXPRESS"]), ("parcel", ["Parcel locker"]), (None, ["Parcel locker", "Срочный документ", "DOCUMENT EXPRESS"]), ], ) def test_post_delivery_price_filters_response_by_optional_parcel_type( parcel_type: str | None, expected_service_names: list[str], ) -> None: provider = StubPriceProvider( response=[ _make_price(service_name="Parcel locker", price="90.00", provider="other"), _make_price(service_name="Срочный документ", price="150.00", provider="other"), _make_price(service_name="DOCUMENT EXPRESS", price="200.00", provider="other"), ] ) service = AggregatorService(providers=[provider]) app = create_app() async def override_service() -> AggregatorService: return service app.dependency_overrides[get_aggregator_service] = override_service payload = _valid_payload() if parcel_type is not None: payload["parcel_type"] = parcel_type async def run_request() -> httpx.Response: transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient( transport=transport, base_url="http://testserver", ) as client: return await client.post("/api/v1/delivery/price", json=payload) response = asyncio.run(run_request()) assert response.status_code == 200 assert [item["service_name"] for item in response.json()] == expected_service_names def test_post_delivery_price_rejects_invalid_payload() -> None: service = StubAggregatorService(response=[]) app = create_app() _install_service_override(app, service) invalid_payload = _valid_payload() invalid_payload["weight_kg"] = 0 async def run_request() -> httpx.Response: transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient( transport=transport, base_url="http://testserver", ) as client: return await client.post("/api/v1/delivery/price", json=invalid_payload) response = asyncio.run(run_request()) assert response.status_code == 422 assert service.calls == [] def test_post_delivery_price_rejects_invalid_parcel_type() -> None: service = StubAggregatorService(response=[]) app = create_app() _install_service_override(app, service) invalid_payload = _valid_payload() invalid_payload["parcel_type"] = "letters" async def run_request() -> httpx.Response: transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient( transport=transport, base_url="http://testserver", ) as client: return await client.post("/api/v1/delivery/price", json=invalid_payload) response = asyncio.run(run_request()) assert response.status_code == 422 assert response.json()["detail"][0]["loc"] == ["body", "parcel_type"] assert service.calls == [] def test_post_delivery_price_maps_service_exception_to_503() -> None: service = StubAggregatorService( response=[], error=AggregatorServiceError("service unavailable"), ) app = create_app() _install_service_override(app, service) async def run_request() -> httpx.Response: transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) async with httpx.AsyncClient( transport=transport, base_url="http://testserver", ) as client: return await client.post("/api/v1/delivery/price", json=_valid_payload()) response = asyncio.run(run_request()) assert response.status_code == 503 assert response.json() == { "detail": { "code": "aggregator_service_error", "message": "Delivery price aggregation is temporarily unavailable.", } } def test_post_delivery_price_maps_invalid_request_to_400() -> None: service = StubAggregatorService( response=[], error=InvalidDeliveryRequestError("city not found"), ) app = create_app() _install_service_override(app, service) async def run_request() -> httpx.Response: transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) async with httpx.AsyncClient( transport=transport, base_url="http://testserver", ) as client: return await client.post("/api/v1/delivery/price", json=_valid_payload()) response = asyncio.run(run_request()) assert response.status_code == 400 assert response.json() == { "detail": { "code": "invalid_delivery_request", "message": "Delivery request contains unknown or unsupported location.", } } def test_delivery_price_endpoint_accepts_only_post_method() -> None: service = StubAggregatorService(response=[]) app = create_app() _install_service_override(app, service) async def run_request() -> httpx.Response: transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient( transport=transport, base_url="http://testserver", ) as client: return await client.get("/api/v1/delivery/price") response = asyncio.run(run_request()) assert response.status_code == 405 def test_post_delivery_price_enforces_response_schema() -> None: service = StubAggregatorService( response=[ { "provider": "cdek", "service_name": "express", "price": "100.50", "currency": "R", "delivery_days_min": 1, "delivery_days_max": 2, } ] ) app = create_app() _install_service_override(app, service) async def run_request() -> httpx.Response: transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) async with httpx.AsyncClient( transport=transport, base_url="http://testserver", ) as client: return await client.post("/api/v1/delivery/price", json=_valid_payload()) response = asyncio.run(run_request()) assert response.status_code == 500