diff --git a/app/adapters/delivery_providers/cdek/__init__.py b/app/adapters/delivery_providers/cdek/__init__.py index 79972df..b9f7c65 100644 --- a/app/adapters/delivery_providers/cdek/__init__.py +++ b/app/adapters/delivery_providers/cdek/__init__.py @@ -1 +1,5 @@ """CDEK adapter package.""" + +from app.adapters.delivery_providers.cdek.client import CDEKProvider + +__all__ = ["CDEKProvider"] diff --git a/app/adapters/delivery_providers/cdek/auth.py b/app/adapters/delivery_providers/cdek/auth.py index 88ea341..fdc66ca 100644 --- a/app/adapters/delivery_providers/cdek/auth.py +++ b/app/adapters/delivery_providers/cdek/auth.py @@ -1,6 +1,97 @@ -"""CDEK auth adapter skeleton.""" +"""CDEK OAuth2 authentication adapter.""" + +import asyncio +import time +from collections.abc import Callable +from typing import Any + +import httpx + + +class CDEKAuthError(RuntimeError): + """Raised when CDEK OAuth2 flow fails.""" class CDEKAuthClient: + def __init__( + self, + http_client: httpx.AsyncClient, + *, + base_url: str, + client_id: str, + client_secret: str, + timeout_seconds: float = 10.0, + expiry_skew_seconds: float = 30.0, + clock: Callable[[], float] | None = None, + ) -> None: + self._http_client = http_client + self._token_url = f"{base_url.rstrip('/')}/oauth/token" + self._client_id = client_id + self._client_secret = client_secret + self._timeout_seconds = timeout_seconds + self._expiry_skew_seconds = expiry_skew_seconds + self._clock = clock or time.monotonic + self._token: str | None = None + self._token_expires_at: float = 0.0 + self._refresh_lock = asyncio.Lock() + async def get_access_token(self) -> str: - raise NotImplementedError("CDEK auth implementation is added in task 003.") + if self._has_valid_token(): + return self._cached_token() + + async with self._refresh_lock: + if self._has_valid_token(): + return self._cached_token() + return await self._fetch_access_token() + + def _has_valid_token(self) -> bool: + return self._token is not None and self._clock() < self._token_expires_at + + def _cached_token(self) -> str: + if self._token is None: + raise CDEKAuthError("CDEK OAuth token is unexpectedly missing.") + return self._token + + async def _fetch_access_token(self) -> str: + try: + response = await self._http_client.post( + self._token_url, + data={ + "grant_type": "client_credentials", + "client_id": self._client_id, + "client_secret": self._client_secret, + }, + timeout=self._timeout_seconds, + ) + response.raise_for_status() + payload = response.json() + token = self._extract_access_token(payload) + expires_in = self._extract_expires_in(payload) + except (httpx.HTTPError, TypeError, ValueError) as exc: + raise CDEKAuthError("Failed to obtain CDEK OAuth token.") from exc + + self._token = token + ttl_seconds = max(expires_in - self._expiry_skew_seconds, 0.0) + self._token_expires_at = self._clock() + ttl_seconds + return self._token + + @staticmethod + def _extract_access_token(payload: Any) -> str: + if not isinstance(payload, dict): + raise ValueError("CDEK OAuth payload must be a JSON object.") + token = payload.get("access_token") + if not isinstance(token, str) or not token.strip(): + raise ValueError("CDEK OAuth payload has no valid access_token.") + return token + + @staticmethod + def _extract_expires_in(payload: Any) -> float: + if not isinstance(payload, dict): + raise ValueError("CDEK OAuth payload must be a JSON object.") + expires_in = payload.get("expires_in") + if expires_in is None: + raise ValueError("CDEK OAuth payload has no expires_in.") + parsed_expires_in = float(expires_in) + if parsed_expires_in < 0: + raise ValueError("CDEK OAuth expires_in cannot be negative.") + return parsed_expires_in diff --git a/app/adapters/delivery_providers/cdek/client.py b/app/adapters/delivery_providers/cdek/client.py index c212ab3..cf907fb 100644 --- a/app/adapters/delivery_providers/cdek/client.py +++ b/app/adapters/delivery_providers/cdek/client.py @@ -1,9 +1,195 @@ -"""CDEK HTTP client adapter skeleton.""" +"""CDEK HTTP client and provider adapter.""" +import asyncio +from collections.abc import Awaitable, Callable +from typing import Any + +import httpx + +from app.adapters.delivery_providers.base import DeliveryProvider +from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient +from app.adapters.delivery_providers.cdek.mapper import map_cdek_response +from app.config import AdapterConfig from app.schemas.request import DeliveryRequest +from app.schemas.response import DeliveryPrice + + +class CDEKClientError(RuntimeError): + """Raised when CDEK tariff request fails.""" class CDEKClient: - async def get_raw_price(self, request: DeliveryRequest) -> dict: - _ = request - raise NotImplementedError("CDEK client implementation is added in task 003.") + def __init__( + self, + http_client: httpx.AsyncClient, + auth_client: CDEKAuthClient, + *, + base_url: str, + timeout_seconds: float = 10.0, + retry_attempts: int = 2, + retry_backoff_seconds: float = 0.2, + sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, + ) -> None: + self._http_client = http_client + self._auth_client = auth_client + normalized_base_url = base_url.rstrip("/") + self._city_lookup_url = f"{normalized_base_url}/location/cities" + self._tariff_url = f"{base_url.rstrip('/')}/calculator/tarifflist" + self._timeout_seconds = timeout_seconds + self._retry_attempts = retry_attempts + self._retry_backoff_seconds = retry_backoff_seconds + self._sleep = sleep + self._city_code_cache: dict[str, int] = {} + + async def get_raw_price(self, request: DeliveryRequest) -> dict[str, Any]: + payload = await self._build_payload(request) + for attempt in range(self._retry_attempts + 1): + try: + token = await self._auth_client.get_access_token() + response = await self._http_client.post( + self._tariff_url, + json=payload, + headers={"Authorization": f"Bearer {token}"}, + timeout=self._timeout_seconds, + ) + except (httpx.TimeoutException, httpx.TransportError) as exc: + if attempt < self._retry_attempts: + await self._sleep(self._retry_delay(attempt)) + continue + raise CDEKClientError( + "CDEK tariff request failed after retry attempts." + ) from exc + + if self._should_retry(response.status_code): + if attempt < self._retry_attempts: + await self._sleep(self._retry_delay(attempt)) + continue + raise CDEKClientError( + f"CDEK tariff request failed with status {response.status_code}." + ) + + try: + response.raise_for_status() + raw_payload = response.json() + except (httpx.HTTPError, TypeError, ValueError) as exc: + raise CDEKClientError("CDEK tariff request returned invalid payload.") from exc + + if not isinstance(raw_payload, dict): + raise CDEKClientError("CDEK tariff payload must be a JSON object.") + return raw_payload + + raise CDEKClientError("CDEK tariff request failed unexpectedly.") + + def _retry_delay(self, attempt: int) -> float: + return self._retry_backoff_seconds * (attempt + 1) + + @staticmethod + def _should_retry(status_code: int) -> bool: + return status_code == 429 or status_code >= 500 + + async def _build_payload(self, request: DeliveryRequest) -> dict[str, Any]: + from_city_code = await self._resolve_city_code(request.from_city) + to_city_code = await self._resolve_city_code(request.to_city) + return { + "from_location": {"code": from_city_code}, + "to_location": {"code": to_city_code}, + "packages": [ + { + "weight": int(round(request.weight_kg * 1000)), + "length": int(round(request.length_cm)), + "width": int(round(request.width_cm)), + "height": int(round(request.height_cm)), + } + ], + } + + async def _resolve_city_code(self, city: str) -> int: + normalized_city = city.strip().casefold() + cached_code = self._city_code_cache.get(normalized_city) + if cached_code is not None: + return cached_code + + for attempt in range(self._retry_attempts + 1): + try: + token = await self._auth_client.get_access_token() + response = await self._http_client.get( + self._city_lookup_url, + params={"city": city, "country_codes": "RU", "size": 1}, + headers={"Authorization": f"Bearer {token}"}, + timeout=self._timeout_seconds, + ) + except (httpx.TimeoutException, httpx.TransportError) as exc: + if attempt < self._retry_attempts: + await self._sleep(self._retry_delay(attempt)) + continue + raise CDEKClientError( + "CDEK city lookup request failed after retry attempts." + ) from exc + + if self._should_retry(response.status_code): + if attempt < self._retry_attempts: + await self._sleep(self._retry_delay(attempt)) + continue + raise CDEKClientError( + f"CDEK city lookup failed with status {response.status_code}." + ) + try: + response.raise_for_status() + body = response.json() + except (httpx.HTTPError, TypeError, ValueError) as exc: + raise CDEKClientError("CDEK city lookup returned invalid payload.") from exc + break + else: + raise CDEKClientError("CDEK city lookup failed unexpectedly.") + + if not isinstance(body, list) or not body: + raise CDEKClientError(f"CDEK city lookup returned no matches for '{city}'.") + first_item = body[0] + if not isinstance(first_item, dict): + raise CDEKClientError("CDEK city lookup response entry must be an object.") + raw_city_code = first_item.get("code") + if raw_city_code is None: + raise CDEKClientError("CDEK city lookup response has no city code.") + try: + city_code = int(raw_city_code) + except (TypeError, ValueError) as exc: + raise CDEKClientError("CDEK city lookup response city code is invalid.") from exc + + self._city_code_cache[normalized_city] = city_code + return city_code + + +class CDEKProvider(DeliveryProvider): + name = "cdek" + + def __init__(self, client: CDEKClient, *, cache_ttl_seconds: int = 900) -> None: + self._client = client + self.cache_ttl_seconds = cache_ttl_seconds + + @classmethod + def from_adapter_config( + cls, + *, + http_client: httpx.AsyncClient, + adapter_config: AdapterConfig, + ) -> "CDEKProvider": + auth_client = CDEKAuthClient( + http_client=http_client, + base_url=adapter_config.cdek_base_url, + client_id=adapter_config.cdek_client_id, + client_secret=adapter_config.cdek_client_secret, + timeout_seconds=adapter_config.cdek_timeout_seconds, + ) + client = CDEKClient( + http_client=http_client, + auth_client=auth_client, + base_url=adapter_config.cdek_base_url, + timeout_seconds=adapter_config.cdek_timeout_seconds, + retry_attempts=adapter_config.cdek_retry_attempts, + retry_backoff_seconds=adapter_config.cdek_retry_backoff_seconds, + ) + return cls(client=client, cache_ttl_seconds=adapter_config.cdek_cache_ttl_seconds) + + async def get_price(self, request: DeliveryRequest) -> DeliveryPrice: + raw_payload = await self._client.get_raw_price(request) + return map_cdek_response(raw_payload) diff --git a/app/adapters/delivery_providers/cdek/mapper.py b/app/adapters/delivery_providers/cdek/mapper.py index 8542b90..8c7ce1e 100644 --- a/app/adapters/delivery_providers/cdek/mapper.py +++ b/app/adapters/delivery_providers/cdek/mapper.py @@ -1,8 +1,51 @@ -"""CDEK response mapper skeleton.""" +"""CDEK response mapper to unified delivery schema.""" + +from decimal import Decimal +from typing import Any from app.schemas.response import DeliveryPrice -def map_cdek_response(payload: dict) -> DeliveryPrice: - _ = payload - raise NotImplementedError("CDEK mapper implementation is added in task 003.") +class CDEKMappingError(ValueError): + """Raised when CDEK response cannot be mapped.""" + + +def map_cdek_response(payload: dict[str, Any]) -> DeliveryPrice: + tariff = _get_first_tariff(payload) + + service_name = tariff.get("tariff_name") or tariff.get("tariff_code") + if service_name is None: + raise CDEKMappingError("CDEK tariff_name is missing.") + + raw_price = tariff.get("delivery_sum") + if raw_price is None: + raise CDEKMappingError("CDEK delivery_sum is missing.") + + period_min = tariff.get("period_min") + if period_min is None: + raise CDEKMappingError("CDEK period_min is missing.") + period_max = tariff.get("period_max", period_min) + + raw_currency = tariff.get("currency") or payload.get("currency") or "RUB" + + try: + return DeliveryPrice( + provider="cdek", + service_name=str(service_name), + price=Decimal(str(raw_price)), + currency=str(raw_currency).upper(), + delivery_days_min=int(period_min), + delivery_days_max=int(period_max), + ) + except (ArithmeticError, TypeError, ValueError) as exc: + raise CDEKMappingError("CDEK response fields have invalid values.") from exc + + +def _get_first_tariff(payload: dict[str, Any]) -> dict[str, Any]: + tariff_codes = payload.get("tariff_codes") + if not isinstance(tariff_codes, list) or not tariff_codes: + raise CDEKMappingError("CDEK response must include non-empty tariff_codes.") + tariff = tariff_codes[0] + if not isinstance(tariff, dict): + raise CDEKMappingError("CDEK tariff entry must be an object.") + return tariff diff --git a/app/config.py b/app/config.py index b54daee..f1c62c4 100644 --- a/app/config.py +++ b/app/config.py @@ -36,7 +36,12 @@ class RepositoryConfig(BaseModel): class AdapterConfig(BaseModel): cdek_base_url: str = "https://api.cdek.ru/v2" - cdek_timeout_seconds: float = 10.0 + cdek_client_id: str = "" + cdek_client_secret: str = "" + cdek_retry_attempts: int = Field(default=2, ge=0) + cdek_retry_backoff_seconds: float = Field(default=0.2, ge=0) + cdek_timeout_seconds: float = Field(default=10.0, gt=0) + cdek_cache_ttl_seconds: int = Field(default=900, gt=0) class ObservabilityConfig(BaseModel): diff --git a/config.example.yaml b/config.example.yaml index 91536fa..71e9969 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -15,7 +15,12 @@ repository: adapter: cdek_base_url: "https://api.cdek.ru/v2" + cdek_client_id: "" + cdek_client_secret: "" + cdek_retry_attempts: 2 + cdek_retry_backoff_seconds: 0.2 cdek_timeout_seconds: 10.0 + cdek_cache_ttl_seconds: 900 observability: service_name: "g2s-aggregator" diff --git a/spec/index.md b/spec/index.md index 51a92cf..81ec0b9 100644 --- a/spec/index.md +++ b/spec/index.md @@ -1,7 +1,7 @@ # Spec Tasks Index > ⚠️ This file is generated. Do not edit manually. -> Generated at (UTC): `2026-03-07T11:14:43+00:00` +> Generated at (UTC): `2026-03-07T11:56:55+00:00` ## Tasks @@ -10,7 +10,7 @@ | 000 | DONE | 2026-02-01 | Task format reference | `spec/tasks/000_template.md` | | 001 | DONE | 2026-03-07 | Create app skeleton and component configuration | `spec/tasks/001_create_app_skeleton_and_config.md` | | 002 | DONE | 2026-03-07 | Implement pure domain price rules | `spec/tasks/002_implement_pure_domain_quote_rules.md` | -| 003 | TODO | 2026-03-07 | Add CDEK provider adapter | `spec/tasks/003_add_cdek_provider_adapter.md` | +| 003 | DONE | 2026-03-07 | Add CDEK provider adapter | `spec/tasks/003_add_cdek_provider_adapter.md` | | 004 | TODO | 2026-03-07 | Add Redis price cache repository | `spec/tasks/004_add_redis_price_cache_repository.md` | | 005 | TODO | 2026-03-07 | Implement aggregator service workflow | `spec/tasks/005_implement_aggregator_service_workflow.md` | | 006 | TODO | 2026-03-07 | Add delivery price controller endpoint | `spec/tasks/006_add_delivery_price_controller.md` | @@ -21,5 +21,5 @@ ## Summary - Total: **10** -- TODO: **7** -- DONE: **3** +- TODO: **6** +- DONE: **4** diff --git a/spec/tasks/003_add_cdek_provider_adapter.md b/spec/tasks/003_add_cdek_provider_adapter.md index 8fa36f3..e0468b4 100644 --- a/spec/tasks/003_add_cdek_provider_adapter.md +++ b/spec/tasks/003_add_cdek_provider_adapter.md @@ -1,7 +1,7 @@ --- id: 003 title: Add CDEK provider adapter -status: TODO +status: DONE created: 2026-03-07 --- diff --git a/tests/adapters/delivery_providers/cdek/test_auth.py b/tests/adapters/delivery_providers/cdek/test_auth.py new file mode 100644 index 0000000..31a0b66 --- /dev/null +++ b/tests/adapters/delivery_providers/cdek/test_auth.py @@ -0,0 +1,76 @@ +import asyncio + +import httpx + +from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient + + +def test_auth_client_reuses_valid_token_until_expiration() -> None: + call_count = 0 + current_time = {"value": 100.0} + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal call_count + call_count += 1 + assert request.url.path == "/v2/oauth/token" + return httpx.Response( + 200, + json={"access_token": "token-1", "expires_in": 120}, + request=request, + ) + + async def scenario() -> None: + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport) as http_client: + auth_client = CDEKAuthClient( + http_client=http_client, + base_url="https://api.cdek.test/v2", + client_id="client-id", + client_secret="client-secret", + expiry_skew_seconds=5.0, + clock=lambda: current_time["value"], + ) + + first = await auth_client.get_access_token() + second = await auth_client.get_access_token() + + assert first == "token-1" + assert second == "token-1" + + asyncio.run(scenario()) + + assert call_count == 1 + + +def test_auth_client_refreshes_expired_token() -> None: + responses = iter( + [ + {"access_token": "token-1", "expires_in": 10}, + {"access_token": "token-2", "expires_in": 10}, + ] + ) + current_time = {"value": 0.0} + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=next(responses), request=request) + + async def scenario() -> None: + transport = httpx.MockTransport(handler) + async with httpx.AsyncClient(transport=transport) as http_client: + auth_client = CDEKAuthClient( + http_client=http_client, + base_url="https://api.cdek.test/v2", + client_id="client-id", + client_secret="client-secret", + expiry_skew_seconds=0.0, + clock=lambda: current_time["value"], + ) + + first = await auth_client.get_access_token() + current_time["value"] = 11.0 + second = await auth_client.get_access_token() + + assert first == "token-1" + assert second == "token-2" + + asyncio.run(scenario()) diff --git a/tests/adapters/delivery_providers/cdek/test_client.py b/tests/adapters/delivery_providers/cdek/test_client.py new file mode 100644 index 0000000..6ca238b --- /dev/null +++ b/tests/adapters/delivery_providers/cdek/test_client.py @@ -0,0 +1,311 @@ +import asyncio +from typing import Any + +import httpx +import pytest + +from app.adapters.delivery_providers.cdek.client import ( + CDEKClient, + CDEKClientError, + CDEKProvider, +) +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("city") if params else None + code_by_city = {"Moscow": 44, "Kazan": 424} + return httpx.Response( + 200, + json=[{"code": code_by_city.get(city, 44), "city": 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() -> DeliveryRequest: + return DeliveryRequest( + entity=DeliveryEntity.INDIVIDUAL, + from_city="Moscow", + to_city="Kazan", + weight_kg=1.2, + length_cm=20, + width_cm=10, + height_cm=5, + ) + + +def test_cdek_client_retries_timeout_and_applies_timeout_seconds() -> None: + request = _make_request() + city_lookup_moscow = httpx.Response( + 200, + json=[{"code": 44, "city": "Moscow"}], + request=httpx.Request("GET", "https://api.cdek.test/v2/location/cities"), + ) + city_lookup_kazan = httpx.Response( + 200, + json=[{"code": 424, "city": "Kazan"}], + request=httpx.Request("GET", "https://api.cdek.test/v2/location/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"] == { + "city": "Moscow", + "country_codes": "RU", + "size": 1, + } + 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_raises_on_non_retriable_http_error() -> None: + request = _make_request() + city_lookup_moscow = httpx.Response( + 200, + json=[{"code": 44, "city": "Moscow"}], + request=httpx.Request("GET", "https://api.cdek.test/v2/location/cities"), + ) + city_lookup_kazan = httpx.Response( + 200, + json=[{"code": 424, "city": "Kazan"}], + request=httpx.Request("GET", "https://api.cdek.test/v2/location/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/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(CDEKClientError, 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( + """ +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 +""".strip(), + encoding="utf-8", + ) + monkeypatch.setenv("G2S_CONFIG_FILE", 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] diff --git a/tests/adapters/delivery_providers/cdek/test_mapper.py b/tests/adapters/delivery_providers/cdek/test_mapper.py new file mode 100644 index 0000000..c774742 --- /dev/null +++ b/tests/adapters/delivery_providers/cdek/test_mapper.py @@ -0,0 +1,33 @@ +from decimal import Decimal + +import pytest + +from app.adapters.delivery_providers.cdek.mapper import CDEKMappingError, map_cdek_response + + +def test_map_cdek_response_maps_first_tariff_to_unified_model() -> None: + payload = { + "tariff_codes": [ + { + "tariff_name": "Express", + "delivery_sum": "1234.50", + "currency": "rub", + "period_min": 2, + "period_max": 4, + } + ] + } + + result = map_cdek_response(payload) + + assert result.provider == "cdek" + assert result.service_name == "Express" + assert result.price == Decimal("1234.50") + assert result.currency == "RUB" + assert result.delivery_days_min == 2 + assert result.delivery_days_max == 4 + + +def test_map_cdek_response_raises_for_missing_tariff_codes() -> None: + with pytest.raises(CDEKMappingError): + map_cdek_response({"tariff_codes": []}) diff --git a/tests/config/test_config_sections.py b/tests/config/test_config_sections.py index e8dc6af..f531400 100644 --- a/tests/config/test_config_sections.py +++ b/tests/config/test_config_sections.py @@ -7,6 +7,12 @@ def test_configuration_sections_are_loaded_from_env(monkeypatch) -> None: monkeypatch.setenv("G2S_BUSINESS_LOGIC__WEIGHT_ROUND_SCALE", "3") monkeypatch.setenv("G2S_REPOSITORY__PRICE_CACHE_TTL_SECONDS", "1200") monkeypatch.setenv("G2S_ADAPTER__CDEK_BASE_URL", "https://sandbox.cdek.test") + monkeypatch.setenv("G2S_ADAPTER__CDEK_CLIENT_ID", "env-client-id") + monkeypatch.setenv("G2S_ADAPTER__CDEK_CLIENT_SECRET", "env-client-secret") + monkeypatch.setenv("G2S_ADAPTER__CDEK_RETRY_ATTEMPTS", "4") + monkeypatch.setenv("G2S_ADAPTER__CDEK_RETRY_BACKOFF_SECONDS", "0.7") + monkeypatch.setenv("G2S_ADAPTER__CDEK_TIMEOUT_SECONDS", "8.5") + monkeypatch.setenv("G2S_ADAPTER__CDEK_CACHE_TTL_SECONDS", "780") monkeypatch.setenv("G2S_OBSERVABILITY__SERVICE_NAME", "g2s-test") monkeypatch.setenv("G2S_ALERTS__TELEGRAM_ENABLED", "true") @@ -17,6 +23,12 @@ def test_configuration_sections_are_loaded_from_env(monkeypatch) -> None: assert settings.business_logic.weight_round_scale == 3 assert settings.repository.price_cache_ttl_seconds == 1200 assert settings.adapter.cdek_base_url == "https://sandbox.cdek.test" + assert settings.adapter.cdek_client_id == "env-client-id" + assert settings.adapter.cdek_client_secret == "env-client-secret" + assert settings.adapter.cdek_retry_attempts == 4 + assert settings.adapter.cdek_retry_backoff_seconds == 0.7 + assert settings.adapter.cdek_timeout_seconds == 8.5 + assert settings.adapter.cdek_cache_ttl_seconds == 780 assert settings.observability.service_name == "g2s-test" assert settings.alerts.telegram_enabled is True @@ -35,6 +47,12 @@ repository: price_cache_ttl_seconds: 600 adapter: cdek_base_url: "https://yaml.cdek.test" + cdek_client_id: "yaml-client-id" + cdek_client_secret: "yaml-client-secret" + cdek_retry_attempts: 5 + cdek_retry_backoff_seconds: 0.4 + cdek_timeout_seconds: 9.5 + cdek_cache_ttl_seconds: 810 observability: service_name: "g2s-yaml" alerts: @@ -51,6 +69,12 @@ alerts: assert settings.business_logic.weight_round_scale == 4 assert settings.repository.price_cache_ttl_seconds == 600 assert settings.adapter.cdek_base_url == "https://yaml.cdek.test" + assert settings.adapter.cdek_client_id == "yaml-client-id" + assert settings.adapter.cdek_client_secret == "yaml-client-secret" + assert settings.adapter.cdek_retry_attempts == 5 + assert settings.adapter.cdek_retry_backoff_seconds == 0.4 + assert settings.adapter.cdek_timeout_seconds == 9.5 + assert settings.adapter.cdek_cache_ttl_seconds == 810 assert settings.observability.service_name == "g2s-yaml" assert settings.alerts.telegram_enabled is True