diff --git a/.gitignore b/.gitignore index fd9eb53..0f5b215 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ *.iml /config.yaml __pycache__ +http-client.private.env.json diff --git a/app/adapters/delivery_providers/cdek/client.py b/app/adapters/delivery_providers/cdek/client.py index c08e86e..bce0046 100644 --- a/app/adapters/delivery_providers/cdek/client.py +++ b/app/adapters/delivery_providers/cdek/client.py @@ -10,7 +10,13 @@ import httpx from app.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient from app.adapters.delivery_providers.cdek.mapper import map_cdek_response +from app.adapters.delivery_providers.cdek.order_mapper import ( + CDEKOrderMappingError, + map_cdek_order_request, + map_cdek_order_response, +) from app.config import AdapterConfig +from app.schemas.order import OrderCreateRequest, OrderCreateResponse from app.schemas.request import DeliveryRequest from app.schemas.response import DeliveryPrice log = logging.getLogger(__name__) @@ -41,6 +47,7 @@ class CDEKClient: normalized_base_url = base_url.rstrip("/") self._city_lookup_url = f"{normalized_base_url}/location/suggest/cities" self._tariff_url = f"{normalized_base_url}/calculator/tarifflist" + self._orders_url = f"{normalized_base_url}/orders" self._timeout_seconds = timeout_seconds self._retry_attempts = retry_attempts self._retry_backoff_seconds = retry_backoff_seconds @@ -86,6 +93,62 @@ class CDEKClient: raise CDEKClientError("CDEK tariff request failed unexpectedly.") + async def register_order(self, request: OrderCreateRequest) -> OrderCreateResponse: + payload = map_cdek_order_request(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._orders_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 order registration 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( + "CDEK order registration failed with retriable status " + f"{response.status_code}." + ) + + if 400 <= response.status_code < 500: + raise CDEKRequestError( + "CDEK order registration request was rejected with status " + f"{response.status_code}." + ) + + try: + response.raise_for_status() + raw_payload = response.json() + except (httpx.HTTPError, TypeError, ValueError) as exc: + raise CDEKClientError( + "CDEK order registration returned invalid payload." + ) from exc + + if not isinstance(raw_payload, dict): + raise CDEKClientError( + "CDEK order registration payload must be a JSON object." + ) + + try: + return map_cdek_order_response(raw_payload) + except CDEKOrderMappingError as exc: + raise CDEKClientError( + "CDEK order registration response payload is invalid." + ) from exc + + raise CDEKClientError("CDEK order registration failed unexpectedly.") + def _retry_delay(self, attempt: int) -> float: return self._retry_backoff_seconds * (attempt + 1) @@ -221,3 +284,6 @@ class CDEKProvider(DeliveryProvider): async def get_price(self, request: DeliveryRequest) -> DeliveryPrice: raw_payload = await self._client.get_raw_price(request) return map_cdek_response(raw_payload) + + async def register_order(self, request: OrderCreateRequest) -> OrderCreateResponse: + return await self._client.register_order(request) diff --git a/app/adapters/delivery_providers/cdek/order_mapper.py b/app/adapters/delivery_providers/cdek/order_mapper.py new file mode 100644 index 0000000..9ce7f1c --- /dev/null +++ b/app/adapters/delivery_providers/cdek/order_mapper.py @@ -0,0 +1,25 @@ +"""CDEK order registration payload mappers.""" + +from typing import Any + +from app.schemas.order import OrderCreateRequest, OrderCreateResponse + + +class CDEKOrderMappingError(ValueError): + """Raised when CDEK order payload cannot be mapped.""" + + +def map_cdek_order_request(request: OrderCreateRequest) -> dict[str, Any]: + return request.model_dump(mode="python", exclude_none=True) + + +def map_cdek_order_response(payload: dict[str, Any]) -> OrderCreateResponse: + entity = payload.get("entity") + if not isinstance(entity, dict): + raise CDEKOrderMappingError("CDEK order response must include entity object.") + + order_uuid = entity.get("uuid") + if not isinstance(order_uuid, str) or not order_uuid: + raise CDEKOrderMappingError("CDEK order response must include entity.uuid.") + + return OrderCreateResponse(provider="cdek", order_uuid=order_uuid) diff --git a/app/schemas/order.py b/app/schemas/order.py new file mode 100644 index 0000000..b8f629b --- /dev/null +++ b/app/schemas/order.py @@ -0,0 +1,53 @@ +"""Schemas for CDEK order registration.""" + +from typing import Literal + +from pydantic import BaseModel, Field + + +class OrderPhone(BaseModel): + number: str = Field(min_length=1) + + +class OrderParty(BaseModel): + company: str | None = None + name: str = Field(min_length=1) + email: str = Field(min_length=1) + phones: list[OrderPhone] = Field(min_length=1) + + +class OrderLocation(BaseModel): + address: str = Field(min_length=1) + city: str = Field(min_length=1) + country_code: str = Field(min_length=2, max_length=2) + + +class OrderService(BaseModel): + code: str = Field(min_length=1) + parameter: str = Field(min_length=1) + + +class OrderPackage(BaseModel): + number: str = Field(min_length=1) + weight: int = Field(gt=0) + length: int = Field(gt=0) + width: int = Field(gt=0) + height: int = Field(gt=0) + comment: str | None = None + + +class OrderCreateRequest(BaseModel): + type: Literal[2] + tariff_code: Literal[535] + comment: str | None = None + sender: OrderParty + recipient: OrderParty + from_location: OrderLocation + to_location: OrderLocation + services: list[OrderService] = Field(min_length=1) + packages: list[OrderPackage] = Field(min_length=1) + + +class OrderCreateResponse(BaseModel): + provider: str = Field(min_length=1) + order_uuid: str = Field(min_length=1) diff --git a/http-client.env.json b/http-client.env.json new file mode 100644 index 0000000..0ff2bb9 --- /dev/null +++ b/http-client.env.json @@ -0,0 +1,8 @@ +{ + "test": { + "base_url": "https://api.edu.cdek.ru" + }, + "prod": { + "base_url": "https://api.cdek.ru" + } +} diff --git a/http-client.http b/http-client.http new file mode 100644 index 0000000..4a0e90c --- /dev/null +++ b/http-client.http @@ -0,0 +1,73 @@ +### 1. Получение токена авторизации CDEK +POST {{base_url}}/v2/oauth/token +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials&client_id={{client_id}}&client_secret={{client_secret}} + +> {% + client.global.set("auth_token", response.body.access_token); +%} + +### 2. Получение информации о заказе по номеру СДЭК +GET {{base_url}}/v2/orders?cdek_number=10240410458 +Authorization: Bearer {{auth_token}} + +### 3. Регистрация заказа (тип "доставка", до двери) +POST {{base_url}}/v2/orders +Authorization: Bearer {{auth_token}} +Content-Type: application/json + +{ + "type": 2, + "tariff_code": 535, + "comment": "Тестовый заказ", + "sender": { + "company": "ООО Ромашка", + "name": "Петр Петров", + "email": "sender@example.com", + "phones": [ + { + "number": "+79009876543" + } + ] + }, + "recipient": { + "name": "Иван Иванов", + "email": "ivan@example.com", + "phones": [ + { + "number": "+79001234567" + } + ] + }, + "from_location": { + "address": "ул. Ленина, 1", + "city": "Москва", + "country_code": "RU" + }, + "to_location": { + "address": "ул. Пушкина, 10", + "city": "Новосибирск", + "country_code": "RU" + }, + "services": [ + { + "code": "INSURANCE", + "parameter": "1000" + } + ], + "packages": [ + { + "number": "1", + "weight": 1000, + "length": 20, + "width": 15, + "height": 10, + "comment": "Упаковка 1" + } + ] +} + +> {% + client.global.set("order_uuid", response.body.entity.uuid); +%} diff --git a/spec/index.md b/spec/index.md index 1d98bff..714794f 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-13T14:21:47+00:00` +> Generated at (UTC): `2026-03-14T00:55:12+00:00` ## Tasks @@ -22,10 +22,12 @@ | 012 | DONE | 2026-03-08 | Remove observability and SigNoz stack for phase 1 | `spec/tasks/012_remove_observability_and_signoz_for_phase1.md` | | 013 | DONE | 2026-03-09 | Add configurable provider price multiplier in domain logic | `spec/tasks/013_add_configurable_provider_price_multiplier.md` | | 014 | DONE | 2026-03-12 | Add minimal structlog JSON logging | `spec/tasks/014_add_minimal_structlog_json_logging.md` | -| 015 | TODO | 2026-03-13 | Add minimal OpenTelemetry tracing | `spec/tasks/015_add_minimal_opentelemetry_tracing.md` | +| 015 | DONE | 2026-03-13 | Add minimal OpenTelemetry tracing | `spec/tasks/015_add_minimal_opentelemetry_tracing.md` | +| 016 | DONE | 2026-03-14 | Add CDEK order registration adapter | `spec/tasks/016_add_cdek_order_registration_adapter.md` | +| 017 | TODO | 2026-03-14 | Add CDEK order creation endpoint | `spec/tasks/017_add_cdek_order_creation_endpoint.md` | ## Summary -- Total: **16** +- Total: **18** - TODO: **1** -- DONE: **15** +- DONE: **17** diff --git a/spec/overview.md b/spec/overview.md index 1226801..8c34570 100644 --- a/spec/overview.md +++ b/spec/overview.md @@ -16,6 +16,7 @@ ## Продуктовые требования - Принимать запрос на расчёт стоимости доставки (откуда, куда, вес, габариты) +- Принимать запрос на создание заказа CDEK по контракту из `http-client.http` для сценария "доставка, до двери" - Опрашивать всех зарегистрированных провайдеров параллельно - Возвращать унифицированный список тарифов, отсортированных по цене - Если провайдер вернул ошибку или не ответил вовремя — исключить его из результата, не падая целиком @@ -29,7 +30,7 @@ ### Фаза 1 - **CDEK** — https://apidoc.cdek.ru/ - Аутентификация: OAuth2 (client credentials) - - Операции: расчёт тарифа + - Операции: расчёт тарифа, регистрация заказа - Cache TTL: 15 минут ### Фаза 2+ @@ -41,16 +42,19 @@ ### Controller (`app/controllers/v1/delivery.py`) - `POST /api/v1/delivery/price` — принимает `DeliveryRequest`, возвращает `list[DeliveryPrice]` +- `POST /api/v1/delivery/order` — принимает `OrderCreateRequest`, возвращает `OrderCreateResponse` - Парсинг и валидация входных данных через Pydantic - Маппинг исключений сервиса в HTTP-ответы -- Вызывает ровно один метод Service: `AggregatorService.get_all_prices()` +- Каждый endpoint вызывает ровно один метод Service: `AggregatorService.get_all_prices()` или `AggregatorService.create_order()` ### Service (`app/services/aggregator.py`) - `AggregatorService.get_all_prices(request: DeliveryRequest) -> list[DeliveryPrice]` - Распределяет запросы по всем зарегистрированным провайдерам через `asyncio.gather(..., return_exceptions=True)` - Фильтрует упавшие результаты - Сортирует тарифы по цене -- Не содержит бизнес-логики и логики, специфичной для провайдеров +- `AggregatorService.create_order(request: OrderCreateRequest) -> OrderCreateResponse` +- `AggregatorService.create_order()` оркестрирует регистрацию заказа в CDEK через injected adapter dependency +- Service не содержит бизнес-логики и provider HTTP-деталей ### Business Logic (`app/domain/`) - Правила сортировки и фильтрации тарифов @@ -75,7 +79,10 @@ - `cdek/client.py` — HTTP-клиент (httpx AsyncClient), аутентификация, ретраи, таймаут (10с) - `cdek/auth.py` — управление OAuth2-токеном - `cdek/mapper.py` — ответ CDEK → `DeliveryPrice` -- Каждый адаптер владеет своей конфигурацией; наружу экспонирует только `get_price()` +- `cdek/order_mapper.py` — request/response mapping для регистрации заказа CDEK +- Каждый адаптер владеет своей конфигурацией; наружу экспонирует только service-facing methods, необходимые соответствующему use-case + +Для сценария создания заказа CDEK adapter принимает валидированную order model, отправляет контракт `Регистрация заказа (тип "доставка", до двери)` из `http-client.http` и возвращает внутреннюю response model без утечки HTTP-деталей в Service. --- @@ -102,6 +109,38 @@ delivery_days_min: int delivery_days_max: int ``` +### Входная: `OrderCreateRequest` +``` +type: Literal[2] +tariff_code: Literal[136] +comment: str | None +sender: + company: str | None + name: str + email: str + phones: list[{number: str}] +recipient: + name: str + email: str + phones: list[{number: str}] +from_location: + address: str + city: str + country_code: str +to_location: + address: str + city: str + country_code: str +services: list[{code: str, parameter: str}] +packages: list[{number: str, weight: int, length: int, width: int, height: int, comment: str | None}] +``` + +### Выходная: `OrderCreateResponse` +``` +provider: str +order_uuid: str +``` + --- ## Технологический стек @@ -126,7 +165,7 @@ app/ │ └── v1/ │ └── delivery.py # Controller ├── services/ -│ └── aggregator.py # Service +│ └── aggregator.py # Service ├── domain/ │ └── price.py # Business Logic ├── adapters/ @@ -135,13 +174,15 @@ app/ │ └── cdek/ │ ├── client.py │ ├── auth.py -│ └── mapper.py +│ ├── mapper.py +│ └── order_mapper.py ├── repositories/ │ └── cache/ │ └── redis_cache.py # Repository ├── schemas/ │ ├── request.py -│ └── response.py +│ ├── response.py +│ └── order.py └── config.py ``` diff --git a/spec/tasks/015_add_minimal_opentelemetry_tracing.md b/spec/tasks/015_add_minimal_opentelemetry_tracing.md index f56b1c8..9a3baa6 100644 --- a/spec/tasks/015_add_minimal_opentelemetry_tracing.md +++ b/spec/tasks/015_add_minimal_opentelemetry_tracing.md @@ -1,7 +1,7 @@ --- id: 015 title: Add minimal OpenTelemetry tracing -status: TODO +status: DONE created: 2026-03-13 --- diff --git a/spec/tasks/016_add_cdek_order_registration_adapter.md b/spec/tasks/016_add_cdek_order_registration_adapter.md new file mode 100644 index 0000000..eac06d3 --- /dev/null +++ b/spec/tasks/016_add_cdek_order_registration_adapter.md @@ -0,0 +1,43 @@ +--- +id: 016 +title: Add CDEK order registration adapter +status: DONE +created: 2026-03-14 +--- + +## Context +Сейчас CDEK adapter поддерживает только расчёт тарифа. Новый сценарий требует регистрацию заказа в CDEK по контракту из `http-client.http` в разделе `Регистрация заказа (тип "доставка", до двери)`. + +## Goal +Расширить существующий CDEK adapter регистрацией заказа: добавить в текущий adapter/provider сбор payload по контракту, вызов `POST /v2/orders`, детерминированную обработку provider errors и маппинг успешного ответа во внутреннюю response model. + +## Constraints +- Изменения ограничены существующим Adapter layer и schema/mapper моделями, необходимыми для стабильного adapter contract. +- Использовать существующий OAuth2 flow CDEK; не дублировать auth logic. +- Контракт запроса должен соответствовать разделу `Регистрация заказа (тип "доставка", до двери)` из `http-client.http`. +- В scope этой задачи входят только значения `type=2` и `tariff_code=535`; не расширять поддержку на другие типы заказа и тарифы. +- Не добавлять новые controller/service модули, controller routing, service orchestration, cache behavior и business logic. +- Не изменять файлы в `spec/`. + +## Acceptance criteria +- Существующий CDEK adapter/provider предоставляет стабильный метод регистрации заказа, принимающий валидированную internal model вместо raw dict. +- Adapter отправляет `POST /v2/orders` с bearer token и JSON payload, соответствующим контракту из `http-client.http`. +- Успешный ответ CDEK маппится во внутреннюю response model с `order_uuid`, полученным из `entity.uuid`. +- Provider validation errors класса 4xx маппятся в детерминированную provider request error, а transport/5xx ошибки — в adapter client error. +- Adapter tests покрывают success case, payload mapping, response mapping и error handling для order registration. + +## Definition of Done +- [ ] Расширен существующий adapter method set для регистрации заказа CDEK. +- [ ] Реализованы request/response mapper(s) для order registration. +- [ ] Переиспользуется существующий OAuth2 auth client. +- [ ] Добавлены adapter tests для order registration сценария. + +## Tests +- Добавить `tests/adapters/delivery_providers/cdek/test_order_client.py` для success/error сценариев регистрации заказа. +- Проверить, что payload содержит поля из контракта `http-client.http`. +- Проверить маппинг `entity.uuid` в внутреннюю response model. +- Для внешних HTTP взаимодействий использовать stubs/mocks. + +## Commands +- `poetry run pytest tests/adapters/delivery_providers/cdek/test_order_client.py -q` +- `python3 spec/gen_spec_index.py --check` diff --git a/spec/tasks/017_add_cdek_order_creation_endpoint.md b/spec/tasks/017_add_cdek_order_creation_endpoint.md new file mode 100644 index 0000000..f1c3657 --- /dev/null +++ b/spec/tasks/017_add_cdek_order_creation_endpoint.md @@ -0,0 +1,50 @@ +--- +id: 017 +title: Add CDEK order creation endpoint +status: TODO +created: 2026-03-14 +--- + +## Context +Сейчас API поддерживает только `POST /api/v1/delivery/price`. Для нового пользовательского сценария нужен отдельный endpoint `/order`, который создаёт заказ в CDEK по контракту из `http-client.http`. + +## Goal +Добавить `POST /api/v1/order` в существующий controller и существующий service с request/response schema и error mapping для регистрации заказа в CDEK через adapter contract из задачи `016`. + +## Constraints +- Controller отвечает только за DTO validation, routing и mapping service exceptions в HTTP responses. +- Endpoint должен вызывать ровно один метод Service: `AggregatorService.create_order()`. +- Новый endpoint должен быть добавлен в существующий controller модуль `app/controllers/v1/delivery.py`; не создавать отдельный controller модуль. +- Логика создания заказа должна быть добавлена в существующий service модуль `app/services/aggregator.py`; не создавать отдельный service модуль. +- Service оркестрирует только вызов injected CDEK order adapter и не содержит business logic или provider HTTP-деталей. +- Контракт входного запроса должен соответствовать разделу `Регистрация заказа (тип "доставка", до двери)` из `http-client.http`. +- Scope задачи не включает кеширование, агрегацию тарифов, расчёт стоимости, новые провайдеры и расширение order flow за пределы CDEK. +- Не изменять файлы в `spec/`. + +## Acceptance criteria +- Существует endpoint `POST /api/v1/order`, принимающий payload по контракту из `http-client.http`. +- Реализованы request/response schemas для создания заказа и минимального успешного ответа API. +- Endpoint реализован в существующем controller `app/controllers/v1/delivery.py`. +- Controller делегирует обработку только в `AggregatorService.create_order()`. +- Service вызывает injected adapter для регистрации заказа и возвращает response model с `order_uuid`. +- Логика orchestration размещена в существующем service `app/services/aggregator.py`. +- Ошибки валидации входного payload возвращают 422, provider request errors маппятся в 400, недоступность CDEK и transport failures — в 503. +- API и service tests покрывают success case и основные failure scenarios. + +## Definition of Done +- [ ] Добавлены order request/response schemas. +- [ ] Реализован метод `AggregatorService.create_order()` для оркестрации регистрации заказа. +- [ ] Реализован endpoint `POST /api/v1/order` в существующем controller. +- [ ] Добавлены service и controller/API tests для order creation flow. + +## Tests +- Добавить `tests/services/test_order.py` для проверки вызова adapter и маппинга ошибок сервиса. +- Добавить `tests/controllers/v1/test_order.py` для success case, schema validation и HTTP mapping ошибок. +- При необходимости обновить `tests/smoke/test_app_import.py` для проверки подключения нового endpoint и service wiring без новых модулей. +- Использовать test doubles для adapter dependency. + +## Commands +- `poetry run pytest tests/services/test_order.py -q` +- `poetry run pytest tests/controllers/v1/test_order.py -q` +- `poetry run pytest tests/smoke/test_app_import.py -q` +- `python3 spec/gen_spec_index.py --check` diff --git a/tests/adapters/delivery_providers/cdek/test_order_client.py b/tests/adapters/delivery_providers/cdek/test_order_client.py new file mode 100644 index 0000000..ee22cf0 --- /dev/null +++ b/tests/adapters/delivery_providers/cdek/test_order_client.py @@ -0,0 +1,233 @@ +import asyncio +from typing import Any + +import httpx +import pytest + +from app.adapters.delivery_providers.cdek.client import ( + CDEKClient, + CDEKClientError, + CDEKProvider, + CDEKRequestError, +) +from app.schemas.order import OrderCreateRequest + + +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, + "headers": headers, + "timeout": timeout, + } + ) + result = self._next_result() + if isinstance(result, Exception): + raise result + return result + + +def _make_order_request(**overrides: object) -> OrderCreateRequest: + payload: dict[str, object] = { + "type": 2, + "tariff_code": 535, + "comment": "Test order", + "sender": { + "company": "Romashka LLC", + "name": "Petr Petrov", + "email": "sender@example.com", + "phones": [{"number": "+79009876543"}], + }, + "recipient": { + "name": "Ivan Ivanov", + "email": "ivan@example.com", + "phones": [{"number": "+79001234567"}], + }, + "from_location": { + "address": "Lenina 1", + "city": "Moscow", + "country_code": "RU", + }, + "to_location": { + "address": "Pushkina 10", + "city": "Novosibirsk", + "country_code": "RU", + }, + "services": [{"code": "INSURANCE", "parameter": "1000"}], + "packages": [ + { + "number": "1", + "weight": 1000, + "length": 20, + "width": 15, + "height": 10, + "comment": "Package 1", + } + ], + } + payload.update(overrides) + return OrderCreateRequest(**payload) + + +def test_provider_register_order_posts_cdek_contract_payload_and_maps_response() -> None: + response = httpx.Response( + 200, + json={"entity": {"uuid": "cdek-order-uuid"}}, + request=httpx.Request("POST", "https://api.cdek.test/v2/orders"), + ) + http_client = SequenceHTTPClient([response]) + provider = CDEKProvider( + 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=7.5, + retry_attempts=0, + ) + ) + + result = asyncio.run(provider.register_order(_make_order_request())) + + assert result.provider == "cdek" + assert result.order_uuid == "cdek-order-uuid" + assert http_client.calls == [ + { + "method": "POST", + "url": "https://api.cdek.test/v2/orders", + "json": { + "type": 2, + "tariff_code": 535, + "comment": "Test order", + "sender": { + "company": "Romashka LLC", + "name": "Petr Petrov", + "email": "sender@example.com", + "phones": [{"number": "+79009876543"}], + }, + "recipient": { + "name": "Ivan Ivanov", + "email": "ivan@example.com", + "phones": [{"number": "+79001234567"}], + }, + "from_location": { + "address": "Lenina 1", + "city": "Moscow", + "country_code": "RU", + }, + "to_location": { + "address": "Pushkina 10", + "city": "Novosibirsk", + "country_code": "RU", + }, + "services": [{"code": "INSURANCE", "parameter": "1000"}], + "packages": [ + { + "number": "1", + "weight": 1000, + "length": 20, + "width": 15, + "height": 10, + "comment": "Package 1", + } + ], + }, + "data": None, + "headers": {"Authorization": "Bearer test-token"}, + "timeout": 7.5, + } + ] + + +def test_cdek_client_register_order_maps_4xx_to_request_error() -> None: + rejected_response = httpx.Response( + 422, + json={"errors": [{"code": "v1", "message": "bad request"}]}, + request=httpx.Request("POST", "https://api.cdek.test/v2/orders"), + ) + http_client = SequenceHTTPClient([rejected_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=2, + ) + + with pytest.raises(CDEKRequestError, match="status 422"): + asyncio.run(client.register_order(_make_order_request())) + + assert len(http_client.calls) == 1 + + +def test_cdek_client_register_order_retries_5xx_and_raises_client_error() -> None: + first_response = httpx.Response( + 503, + json={"message": "temporary failure"}, + request=httpx.Request("POST", "https://api.cdek.test/v2/orders"), + ) + second_response = httpx.Response( + 503, + json={"message": "temporary failure"}, + request=httpx.Request("POST", "https://api.cdek.test/v2/orders"), + ) + http_client = SequenceHTTPClient([first_response, 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", + retry_attempts=1, + retry_backoff_seconds=0.25, + sleep=fake_sleep, + ) + + with pytest.raises(CDEKClientError, match="retriable status 503"): + asyncio.run(client.register_order(_make_order_request())) + + assert len(http_client.calls) == 2 + assert sleep_calls == [0.25] + + +def test_cdek_client_register_order_raises_client_error_for_invalid_success_payload() -> None: + invalid_response = httpx.Response( + 200, + json={"entity": {}}, + request=httpx.Request("POST", "https://api.cdek.test/v2/orders"), + ) + http_client = SequenceHTTPClient([invalid_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, + ) + + with pytest.raises(CDEKClientError, match="response payload is invalid"): + asyncio.run(client.register_order(_make_order_request()))