import asyncio from typing import Any import httpx import pytest from app.adapters.delivery_providers.cdek.client import ( CDEKClient, CDEKClientError, CDEKProvider, CDEKRequestError, ) from app import config as config_module from app.config import AdapterConfig, Settings from app.schemas.request import DeliveryEntity, DeliveryRequest class StubAuthClient: async def get_access_token(self) -> str: return "test-token" class SequenceHTTPClient: def __init__(self, results: list[Any]) -> None: self._results = results self.calls: list[dict[str, Any]] = [] def _next_result(self) -> Any: return self._results[len(self.calls) - 1] async def post( self, url: str, *, json: dict[str, Any] | None = None, data: dict[str, Any] | None = None, headers: dict[str, str] | None = None, timeout: float | None = None, ) -> httpx.Response: self.calls.append( { "method": "POST", "url": url, "json": json, "data": data, "params": None, "headers": headers, "timeout": timeout, } ) result = self._next_result() if isinstance(result, Exception): raise result return result async def get( self, url: str, *, params: dict[str, Any] | None = None, headers: dict[str, str] | None = None, timeout: float | None = None, ) -> httpx.Response: self.calls.append( { "method": "GET", "url": url, "json": None, "data": None, "params": params, "headers": headers, "timeout": timeout, } ) result = self._next_result() if isinstance(result, Exception): raise result return result class RecordingHTTPClient: def __init__(self) -> None: self.auth_timeouts: list[float | None] = [] self.city_lookup_timeouts: list[float | None] = [] self.tariff_timeouts: list[float | None] = [] async def get( self, url: str, *, params: dict[str, Any] | None = None, headers: dict[str, str] | None = None, timeout: float | None = None, ) -> httpx.Response: request = httpx.Request("GET", url, params=params) self.city_lookup_timeouts.append(timeout) assert headers == {"Authorization": "Bearer provider-token"} city = params.get("name") if params else None code_by_city = {"Moscow": 44, "Kazan": 424} return httpx.Response( 200, json={"code": code_by_city.get(city, 44), "full_name": city}, request=request, ) async def post( self, url: str, *, json: dict[str, Any] | None = None, data: dict[str, Any] | None = None, headers: dict[str, str] | None = None, timeout: float | None = None, ) -> httpx.Response: request = httpx.Request("POST", url) if url.endswith("/oauth/token"): self.auth_timeouts.append(timeout) return httpx.Response( 200, json={"access_token": "provider-token", "expires_in": 3600}, request=request, ) self.tariff_timeouts.append(timeout) assert headers == {"Authorization": "Bearer provider-token"} assert data is None assert json is not None return httpx.Response( 200, json={ "tariff_codes": [ { "tariff_name": "Express", "delivery_sum": "899.00", "currency": "RUB", "period_min": 1, "period_max": 2, } ] }, request=request, ) def _make_request(**overrides: object) -> DeliveryRequest: payload: dict[str, object] = { "entity": DeliveryEntity.INDIVIDUAL, "from_city": "Moscow", "to_city": "Kazan", "country_code": None, "weight_kg": 1.2, "length_cm": 20, "width_cm": 10, "height_cm": 5, } payload.update(overrides) return DeliveryRequest(**payload) def test_cdek_client_retries_timeout_and_applies_timeout_seconds() -> None: request = _make_request() city_lookup_moscow = httpx.Response( 200, json={"code": 44, "full_name": "Moscow"}, request=httpx.Request("GET", "https://api.cdek.test/v2/location/suggest/cities"), ) city_lookup_kazan = httpx.Response( 200, json={"code": 424, "full_name": "Kazan"}, request=httpx.Request("GET", "https://api.cdek.test/v2/location/suggest/cities"), ) first_error = httpx.ReadTimeout( "read timeout", request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"), ) second_response = httpx.Response( 200, json={"tariff_codes": [{"delivery_sum": 500}]}, request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"), ) http_client = SequenceHTTPClient( [city_lookup_moscow, city_lookup_kazan, first_error, second_response] ) sleep_calls: list[float] = [] async def fake_sleep(seconds: float) -> None: sleep_calls.append(seconds) client = CDEKClient( http_client=http_client, # type: ignore[arg-type] auth_client=StubAuthClient(), # type: ignore[arg-type] base_url="https://api.cdek.test/v2", timeout_seconds=10.0, retry_attempts=1, retry_backoff_seconds=0.5, sleep=fake_sleep, ) result = asyncio.run(client.get_raw_price(request)) assert result == {"tariff_codes": [{"delivery_sum": 500}]} assert len(http_client.calls) == 4 assert http_client.calls[0]["method"] == "GET" assert http_client.calls[0]["params"] == { "name": "Moscow", } assert http_client.calls[1]["method"] == "GET" assert http_client.calls[2]["method"] == "POST" assert http_client.calls[2]["json"]["from_location"] == {"code": 44} assert http_client.calls[2]["json"]["to_location"] == {"code": 424} assert http_client.calls[2]["timeout"] == 10.0 assert sleep_calls == [0.5] def test_cdek_client_passes_country_code_when_provided() -> None: request = _make_request(country_code="KZ") city_lookup_from = httpx.Response( 200, json={"code": 88, "full_name": "Moscow"}, request=httpx.Request("GET", "https://api.cdek.test/v2/location/suggest/cities"), ) city_lookup_to = httpx.Response( 200, json={"code": 99, "full_name": "Kazan"}, request=httpx.Request("GET", "https://api.cdek.test/v2/location/suggest/cities"), ) tariff_response = httpx.Response( 200, json={"tariff_codes": [{"delivery_sum": 500}]}, request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"), ) http_client = SequenceHTTPClient([city_lookup_from, city_lookup_to, tariff_response]) client = CDEKClient( http_client=http_client, # type: ignore[arg-type] auth_client=StubAuthClient(), # type: ignore[arg-type] base_url="https://api.cdek.test/v2", retry_attempts=0, ) asyncio.run(client.get_raw_price(request)) assert http_client.calls[0]["params"] == { "name": "Moscow", "country_code": "KZ", } def test_cdek_client_raises_on_non_retriable_http_error() -> None: request = _make_request() city_lookup_moscow = httpx.Response( 200, json={"code": 44, "full_name": "Moscow"}, request=httpx.Request("GET", "https://api.cdek.test/v2/location/suggest/cities"), ) city_lookup_kazan = httpx.Response( 200, json={"code": 424, "full_name": "Kazan"}, request=httpx.Request("GET", "https://api.cdek.test/v2/location/suggest/cities"), ) bad_response = httpx.Response( 400, json={"error": "bad request"}, request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"), ) http_client = SequenceHTTPClient([city_lookup_moscow, city_lookup_kazan, bad_response]) client = CDEKClient( http_client=http_client, # type: ignore[arg-type] auth_client=StubAuthClient(), # type: ignore[arg-type] base_url="https://api.cdek.test/v2", retry_attempts=3, ) with pytest.raises(CDEKClientError): asyncio.run(client.get_raw_price(request)) assert len(http_client.calls) == 3 def test_cdek_client_raises_when_city_code_not_found() -> None: request = _make_request() city_lookup_empty = httpx.Response( 200, json=[], request=httpx.Request("GET", "https://api.cdek.test/v2/location/suggest/cities"), ) http_client = SequenceHTTPClient([city_lookup_empty]) client = CDEKClient( http_client=http_client, # type: ignore[arg-type] auth_client=StubAuthClient(), # type: ignore[arg-type] base_url="https://api.cdek.test/v2", retry_attempts=0, ) with pytest.raises(CDEKRequestError, match="no matches"): asyncio.run(client.get_raw_price(request)) def test_provider_uses_adapter_yaml_config_for_timeout_and_cache_ttl( tmp_path, monkeypatch ) -> None: config_file = tmp_path / "config.yaml" config_file.write_text( """ controller: api_prefix: "/api/v1" request_id_header: "X-Request-ID" service: provider_timeout_seconds: 10.0 max_parallel_providers: 8 business_logic: weight_round_scale: 2 provider_price_multiplier: 1.0 repository: redis_dsn: "redis://localhost:6379/0" price_cache_ttl_seconds: 900 adapter: cdek_base_url: "https://api.cdek.test/v2" cdek_client_id: "yaml-id" cdek_client_secret: "yaml-secret" cdek_retry_attempts: 0 cdek_retry_backoff_seconds: 0.1 cdek_timeout_seconds: 7.5 cdek_cache_ttl_seconds: 777 observability: enabled: false service_name: "cdek-adapter-test-service" otlp_endpoint: "http://collector:4317" otlp_insecure: true """.strip(), encoding="utf-8", ) monkeypatch.setattr(config_module, "_resolve_runtime_config_file", lambda: str(config_file)) settings = Settings() http_client = RecordingHTTPClient() provider = CDEKProvider.from_adapter_config( http_client=http_client, # type: ignore[arg-type] adapter_config=settings.adapter, ) result = asyncio.run(provider.get_price(_make_request())) assert provider.cache_ttl_seconds == 777 assert http_client.auth_timeouts == [7.5] assert http_client.city_lookup_timeouts == [7.5, 7.5] assert http_client.tariff_timeouts == [7.5] assert result.provider == "cdek" def test_provider_uses_default_adapter_timeout_and_cache_ttl() -> None: adapter_config = AdapterConfig( cdek_client_id="default-id", cdek_client_secret="default-secret", ) http_client = RecordingHTTPClient() provider = CDEKProvider.from_adapter_config( http_client=http_client, # type: ignore[arg-type] adapter_config=adapter_config, ) asyncio.run(provider.get_price(_make_request())) assert provider.cache_ttl_seconds == 900 assert http_client.auth_timeouts == [10.0] assert http_client.city_lookup_timeouts == [10.0, 10.0] assert http_client.tariff_timeouts == [10.0]