016 add creating order to adapter
This commit is contained in:
@@ -2,3 +2,4 @@
|
||||
*.iml
|
||||
/config.yaml
|
||||
__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.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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"test": {
|
||||
"base_url": "https://api.edu.cdek.ru"
|
||||
},
|
||||
"prod": {
|
||||
"base_url": "https://api.cdek.ru"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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**
|
||||
|
||||
+47
-6
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Технологический стек
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: 015
|
||||
title: Add minimal OpenTelemetry tracing
|
||||
status: TODO
|
||||
status: DONE
|
||||
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()))
|
||||
Reference in New Issue
Block a user