016 add creating order to adapter

This commit is contained in:
Раис Юсупалиев
2026-03-14 03:54:55 +03:00
parent 66beab4682
commit 15a17be4ed
12 changed files with 607 additions and 12 deletions
+1
View File
@@ -2,3 +2,4 @@
*.iml *.iml
/config.yaml /config.yaml
__pycache__ __pycache__
http-client.private.env.json
@@ -10,7 +10,13 @@ import httpx
from app.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError from app.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError
from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient 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.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.config import AdapterConfig
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
from app.schemas.request import DeliveryRequest from app.schemas.request import DeliveryRequest
from app.schemas.response import DeliveryPrice from app.schemas.response import DeliveryPrice
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -41,6 +47,7 @@ class CDEKClient:
normalized_base_url = base_url.rstrip("/") normalized_base_url = base_url.rstrip("/")
self._city_lookup_url = f"{normalized_base_url}/location/suggest/cities" self._city_lookup_url = f"{normalized_base_url}/location/suggest/cities"
self._tariff_url = f"{normalized_base_url}/calculator/tarifflist" self._tariff_url = f"{normalized_base_url}/calculator/tarifflist"
self._orders_url = f"{normalized_base_url}/orders"
self._timeout_seconds = timeout_seconds self._timeout_seconds = timeout_seconds
self._retry_attempts = retry_attempts self._retry_attempts = retry_attempts
self._retry_backoff_seconds = retry_backoff_seconds self._retry_backoff_seconds = retry_backoff_seconds
@@ -86,6 +93,62 @@ class CDEKClient:
raise CDEKClientError("CDEK tariff request failed unexpectedly.") 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: def _retry_delay(self, attempt: int) -> float:
return self._retry_backoff_seconds * (attempt + 1) return self._retry_backoff_seconds * (attempt + 1)
@@ -221,3 +284,6 @@ class CDEKProvider(DeliveryProvider):
async def get_price(self, request: DeliveryRequest) -> DeliveryPrice: async def get_price(self, request: DeliveryRequest) -> DeliveryPrice:
raw_payload = await self._client.get_raw_price(request) raw_payload = await self._client.get_raw_price(request)
return map_cdek_response(raw_payload) return map_cdek_response(raw_payload)
async def register_order(self, request: OrderCreateRequest) -> OrderCreateResponse:
return await self._client.register_order(request)
@@ -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)
+53
View File
@@ -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)
+8
View File
@@ -0,0 +1,8 @@
{
"test": {
"base_url": "https://api.edu.cdek.ru"
},
"prod": {
"base_url": "https://api.cdek.ru"
}
}
+73
View File
@@ -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);
%}
+6 -4
View File
@@ -1,7 +1,7 @@
# Spec Tasks Index # Spec Tasks Index
> ⚠️ This file is generated. Do not edit manually. > ⚠️ 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 ## 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` | | 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` | | 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` | | 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 ## Summary
- Total: **16** - Total: **18**
- TODO: **1** - TODO: **1**
- DONE: **15** - DONE: **17**
+47 -6
View File
@@ -16,6 +16,7 @@
## Продуктовые требования ## Продуктовые требования
- Принимать запрос на расчёт стоимости доставки (откуда, куда, вес, габариты) - Принимать запрос на расчёт стоимости доставки (откуда, куда, вес, габариты)
- Принимать запрос на создание заказа CDEK по контракту из `http-client.http` для сценария "доставка, до двери"
- Опрашивать всех зарегистрированных провайдеров параллельно - Опрашивать всех зарегистрированных провайдеров параллельно
- Возвращать унифицированный список тарифов, отсортированных по цене - Возвращать унифицированный список тарифов, отсортированных по цене
- Если провайдер вернул ошибку или не ответил вовремя — исключить его из результата, не падая целиком - Если провайдер вернул ошибку или не ответил вовремя — исключить его из результата, не падая целиком
@@ -29,7 +30,7 @@
### Фаза 1 ### Фаза 1
- **CDEK** — https://apidoc.cdek.ru/ - **CDEK** — https://apidoc.cdek.ru/
- Аутентификация: OAuth2 (client credentials) - Аутентификация: OAuth2 (client credentials)
- Операции: расчёт тарифа - Операции: расчёт тарифа, регистрация заказа
- Cache TTL: 15 минут - Cache TTL: 15 минут
### Фаза 2+ ### Фаза 2+
@@ -41,16 +42,19 @@
### Controller (`app/controllers/v1/delivery.py`) ### Controller (`app/controllers/v1/delivery.py`)
- `POST /api/v1/delivery/price` — принимает `DeliveryRequest`, возвращает `list[DeliveryPrice]` - `POST /api/v1/delivery/price` — принимает `DeliveryRequest`, возвращает `list[DeliveryPrice]`
- `POST /api/v1/delivery/order` — принимает `OrderCreateRequest`, возвращает `OrderCreateResponse`
- Парсинг и валидация входных данных через Pydantic - Парсинг и валидация входных данных через Pydantic
- Маппинг исключений сервиса в HTTP-ответы - Маппинг исключений сервиса в HTTP-ответы
- Вызывает ровно один метод Service: `AggregatorService.get_all_prices()` - Каждый endpoint вызывает ровно один метод Service: `AggregatorService.get_all_prices()` или `AggregatorService.create_order()`
### Service (`app/services/aggregator.py`) ### Service (`app/services/aggregator.py`)
- `AggregatorService.get_all_prices(request: DeliveryRequest) -> list[DeliveryPrice]` - `AggregatorService.get_all_prices(request: DeliveryRequest) -> list[DeliveryPrice]`
- Распределяет запросы по всем зарегистрированным провайдерам через `asyncio.gather(..., return_exceptions=True)` - Распределяет запросы по всем зарегистрированным провайдерам через `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/`) ### Business Logic (`app/domain/`)
- Правила сортировки и фильтрации тарифов - Правила сортировки и фильтрации тарифов
@@ -75,7 +79,10 @@
- `cdek/client.py` — HTTP-клиент (httpx AsyncClient), аутентификация, ретраи, таймаут (10с) - `cdek/client.py` — HTTP-клиент (httpx AsyncClient), аутентификация, ретраи, таймаут (10с)
- `cdek/auth.py` — управление OAuth2-токеном - `cdek/auth.py` — управление OAuth2-токеном
- `cdek/mapper.py` — ответ CDEK → `DeliveryPrice` - `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 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
```
--- ---
## Технологический стек ## Технологический стек
@@ -135,13 +174,15 @@ app/
│ └── cdek/ │ └── cdek/
│ ├── client.py │ ├── client.py
│ ├── auth.py │ ├── auth.py
── mapper.py ── mapper.py
│ └── order_mapper.py
├── repositories/ ├── repositories/
│ └── cache/ │ └── cache/
│ └── redis_cache.py # Repository │ └── redis_cache.py # Repository
├── schemas/ ├── schemas/
│ ├── request.py │ ├── request.py
── response.py ── response.py
│ └── order.py
└── config.py └── config.py
``` ```
@@ -1,7 +1,7 @@
--- ---
id: 015 id: 015
title: Add minimal OpenTelemetry tracing title: Add minimal OpenTelemetry tracing
status: TODO status: DONE
created: 2026-03-13 created: 2026-03-13
--- ---
@@ -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`
@@ -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`
@@ -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()))