import asyncio from decimal import Decimal from typing import Any import httpx import pytest from app.adapters.delivery_providers.cdek.client import ( CDEKClient, CDEKClientError, CDEKProvider, CDEKRequestError, ) from app.cities import cities_map from app import config as config_module from app.config import AdapterConfig, Settings from app.schemas.request import DeliveryCalculationRequest, DeliveryEntity 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: raise AssertionError("CDEK city lookup API must not be called.") class RecordingHTTPClient: def __init__(self) -> None: self.auth_timeouts: list[float | None] = [] self.tariff_timeouts: list[float | None] = [] self.tariff_payloads: list[dict[str, Any]] = [] async def get( self, url: str, *, params: dict[str, Any] | None = None, headers: dict[str, str] | None = None, timeout: float | None = None, ) -> httpx.Response: raise AssertionError("CDEK city lookup API must not be called.") 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 self.tariff_payloads.append(json) return httpx.Response( 200, json={ "tariff_codes": [ { "tariff_name": "Express", "delivery_sum": "899.00", "currency": "RUB", "period_min": 1, "period_max": 2, }, { "tariff_name": "Economy", "delivery_sum": "499.00", "currency": "RUB", "period_min": 3, "period_max": 5, } ] }, request=request, ) def _make_request(**overrides: object) -> DeliveryCalculationRequest: payload: dict[str, object] = { "entity": DeliveryEntity.INDIVIDUAL, "from_city": 1, "to_city": 2, "weight_kg": 1.2, "length_cm": 20, "width_cm": 10, "height_cm": 5, } payload.update(overrides) return DeliveryCalculationRequest(**payload) def _cdek_code(city_id: str) -> int: city_entry = cities_map[city_id] cdek_data = city_entry["cdek"] assert isinstance(cdek_data, dict) return int(cdek_data["code"]) def test_cdek_client_retries_timeout_and_applies_timeout_seconds() -> None: request = _make_request() 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([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) == 2 assert http_client.calls[0]["method"] == "POST" assert http_client.calls[0]["json"]["from_location"] == {"code": _cdek_code("1")} assert http_client.calls[0]["json"]["to_location"] == {"code": _cdek_code("2")} assert http_client.calls[0]["timeout"] == 10.0 assert sleep_calls == [0.5] def test_cdek_client_builds_payload_from_cities_map_codes() -> None: request = _make_request() 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([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]["json"]["from_location"] == {"code": _cdek_code("1")} assert http_client.calls[0]["json"]["to_location"] == {"code": _cdek_code("2")} assert http_client.calls[0]["json"]["packages"] == [ {"weight": 1200, "length": 20, "width": 10, "height": 5} ] assert http_client.calls[0]["params"] is None def test_cdek_client_raises_when_city_mapping_is_missing() -> None: request = _make_request(from_city=999999) http_client = SequenceHTTPClient([]) 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="city id 999999"): asyncio.run(client.get_raw_price(request)) assert http_client.calls == [] def test_cdek_client_raises_when_cdek_code_is_not_configured() -> None: request = _make_request(to_city=3) http_client = SequenceHTTPClient([]) 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="city id 3"): asyncio.run(client.get_raw_price(request)) assert http_client.calls == [] def test_cdek_client_raises_on_non_retriable_http_error() -> None: request = _make_request() bad_response = httpx.Response( 400, json={"error": "bad request"}, request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"), ) http_client = SequenceHTTPClient([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) == 1 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 tbank_payment: init_url: "https://securepay.tinkoff.ru/v2/Init" notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications" success_url: "https://merchant.test/payment/success" auth: terminal_key: "test-terminal-key" password: "test-password" postgres: dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/cdek_adapter_test" observability: enabled: false service_name: "cdek-adapter-test-service" otlp_endpoint: "http://collector:4317" otlp_insecure: true email: smtp_host: "smtp.test" smtp_port: 587 from_address: "no-reply@test" """.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_prices(_make_request())) assert provider.cache_ttl_seconds == 777 assert http_client.auth_timeouts == [7.5] assert http_client.tariff_timeouts == [7.5] assert http_client.tariff_payloads == [ { "type": 2, "from_location": {"code": _cdek_code("1")}, "to_location": {"code": _cdek_code("2")}, "packages": [ {"weight": 1200, "length": 20, "width": 10, "height": 5} ], } ] assert [price.provider for price in result] == ["cdek", "cdek"] assert [price.service_name for price in result] == ["Express", "Economy"] assert [price.price for price in result] == [Decimal("899.00"), Decimal("499.00")] 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, ) result = asyncio.run(provider.get_prices(_make_request())) assert provider.cache_ttl_seconds == 900 assert http_client.auth_timeouts == [10.0] assert http_client.tariff_timeouts == [10.0] assert len(result) == 2