Добавлен заказ накладной

This commit is contained in:
Раис Юсупалиев
2026-05-23 17:29:56 +03:00
parent 39cd5ddc7a
commit c494d50566
21 changed files with 1384 additions and 1126 deletions
@@ -0,0 +1,97 @@
"""Rework orders table to a single payload JSONB column."""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260513_032"
down_revision: str | None = "20260412_028"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.drop_column("orders", "sender")
op.drop_column("orders", "recipient")
op.drop_column("orders", "from_location")
op.drop_column("orders", "to_location")
op.drop_column("orders", "packages")
op.drop_column("orders", "services")
op.drop_column("orders", "comment")
op.drop_column("orders", "delivery_type")
op.add_column(
"orders",
sa.Column(
"payload",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
),
)
op.add_column(
"orders",
sa.Column("account_email", sa.String(length=320), nullable=False),
)
def downgrade() -> None:
op.drop_column("orders", "account_email")
op.drop_column("orders", "payload")
op.add_column(
"orders",
sa.Column("delivery_type", sa.Integer(), nullable=False),
)
op.add_column(
"orders",
sa.Column("comment", sa.String(length=1024), nullable=True),
)
op.add_column(
"orders",
sa.Column(
"services",
postgresql.JSONB(astext_type=sa.Text()),
nullable=True,
),
)
op.add_column(
"orders",
sa.Column(
"packages",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
),
)
op.add_column(
"orders",
sa.Column(
"to_location",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
),
)
op.add_column(
"orders",
sa.Column(
"from_location",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
),
)
op.add_column(
"orders",
sa.Column(
"recipient",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
),
)
op.add_column(
"orders",
sa.Column(
"sender",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
),
)
@@ -0,0 +1,28 @@
"""Add CDEK waybill columns to orders table."""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260523_033"
down_revision: str | None = "20260513_032"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"orders",
sa.Column("cdek_waybill_uuid", sa.String(length=128), nullable=True),
)
op.add_column(
"orders",
sa.Column("cdek_waybill_url", sa.String(length=2048), nullable=True),
)
def downgrade() -> None:
op.drop_column("orders", "cdek_waybill_url")
op.drop_column("orders", "cdek_waybill_uuid")
+49 -25
View File
@@ -20,9 +20,13 @@ from app.adapters.delivery_providers.cdek.mapper import (
)
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderMappingError,
CDEKOrderRegistrationResult,
centimeters_string_to_int,
kilograms_string_to_grams,
map_cdek_existing_order_response,
map_cdek_order_request,
map_cdek_order_response,
resolve_cdek_city_code,
)
from app.cities import cities_map
from app.config import AdapterConfig
@@ -143,7 +147,9 @@ class CDEKClient:
raise CDEKClientError("CDEK tariff request failed unexpectedly.")
async def register_order(self, request: InitPaymentRequest) -> str:
async def register_order(
self, request: InitPaymentRequest
) -> CDEKOrderRegistrationResult:
payload = map_cdek_order_request(request)
for attempt in range(self._retry_attempts + 1):
try:
@@ -172,11 +178,17 @@ class CDEKClient:
)
if 400 <= response.status_code < 500:
existing_order_uuid = map_cdek_existing_order_response(
_response_json_or_none(response)
response_payload = _response_json_or_none(response)
existing_result = map_cdek_existing_order_response(response_payload)
if existing_result is not None:
return existing_result
log.warning(
"cdek_order_register_rejected",
status_code=response.status_code,
request_payload=payload,
response_payload=response_payload,
response_body=_response_text_or_none(response),
)
if existing_order_uuid is not None:
return existing_order_uuid
raise CDEKRequestError(
"CDEK order registration request was rejected with status "
f"{response.status_code}."
@@ -195,6 +207,12 @@ class CDEKClient:
"CDEK order registration payload must be a JSON object."
)
log.info(
"cdek_order_register_raw_response",
status_code=response.status_code,
response_payload=raw_payload,
)
try:
return map_cdek_order_response(raw_payload)
except CDEKOrderMappingError as exc:
@@ -234,25 +252,29 @@ class CDEKClient:
def _build_payment_price_payload(
request: InitPaymentRequest,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"type": request.type,
"from_location": request.from_location.model_dump(mode="python"),
"to_location": request.to_location.model_dump(mode="python"),
"packages": [
{
"weight": package.weight * 1000,
"length": package.length,
"width": package.width,
"height": package.height,
}
for package in request.packages
],
try:
from_code = resolve_cdek_city_code(request.sender_address.city_id)
to_code = resolve_cdek_city_code(request.receiver_address.city_id)
weight_grams = kilograms_string_to_grams(request.system_data.weight)
except CDEKOrderMappingError as exc:
raise CDEKRequestError(str(exc)) from exc
package: dict[str, Any] = {"weight": weight_grams}
dimensions = request.system_data.dimensions
if request.system_data.parcel_type == "parcel" and dimensions is not None:
try:
package["length"] = centimeters_string_to_int(dimensions.length, "length")
package["width"] = centimeters_string_to_int(dimensions.width, "width")
package["height"] = centimeters_string_to_int(dimensions.height, "height")
except CDEKOrderMappingError as exc:
raise CDEKRequestError(str(exc)) from exc
return {
"type": 2,
"from_location": {"code": from_code},
"to_location": {"code": to_code},
"packages": [package],
}
if request.services is not None:
payload["services"] = [
service.model_dump(mode="python") for service in request.services
]
return payload
@staticmethod
def _get_cdek_city_code(city_id: int) -> int:
@@ -327,14 +349,16 @@ class CDEKProvider(DeliveryProvider):
try:
return map_cdek_response_for_tariff_code(
raw_payload,
tariff_code=request.tariff_code,
tariff_code=request.system_data.tariff.tariff_code,
)
except CDEKMappingError as exc:
raise CDEKClientError(
"CDEK payment price validation response payload is invalid."
) from exc
async def register_order(self, request: InitPaymentRequest) -> str:
async def register_order(
self, request: InitPaymentRequest
) -> CDEKOrderRegistrationResult:
return await self._client.register_order(request)
@@ -1,11 +1,16 @@
"""CDEK order registration payload mappers."""
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from typing import Any
from app.cities import cities_map
from app.schemas.payment import (
Address,
Contact,
Dimensions,
InitPaymentRequest,
PaymentPackage,
PaymentParty,
SystemData,
)
@@ -13,27 +18,35 @@ class CDEKOrderMappingError(ValueError):
"""Raised when CDEK order payload cannot be mapped."""
_CDEK_WAYBILL_PRINT_TYPE = "WAYBILL"
_CDEK_WAYBILL_RELATED_ENTITY_TYPE = "waybill"
@dataclass(frozen=True)
class CDEKOrderRegistrationResult:
order_uuid: str
waybill_uuid: str | None
waybill_url: str | None
def map_cdek_order_request(request: InitPaymentRequest) -> dict[str, Any]:
payload: dict[str, Any] = {
"number": request.order_uuid,
"type": request.type,
"tariff_code": request.tariff_code,
"sender": _map_order_party(request.sender),
"recipient": _map_order_party(request.recipient),
"from_location": request.from_location.model_dump(mode="python"),
"to_location": request.to_location.model_dump(mode="python"),
"packages": [_map_order_package(package) for package in request.packages],
"type": 2,
"tariff_code": request.system_data.tariff.tariff_code,
"print": _CDEK_WAYBILL_PRINT_TYPE,
"sender": _map_party(request.sender_contact),
"recipient": _map_party(request.receiver_contact),
"from_location": _map_location(request.sender_address),
"to_location": _map_location(request.receiver_address),
"packages": [_map_package(request)],
}
if request.comment is not None:
payload["comment"] = request.comment
if request.services is not None:
payload["services"] = [
service.model_dump(mode="python") for service in request.services
]
if request.content.description:
payload["comment"] = request.content.description
return payload
def map_cdek_order_response(payload: dict[str, Any]) -> str:
def map_cdek_order_response(payload: dict[str, Any]) -> CDEKOrderRegistrationResult:
entity = payload.get("entity")
if not isinstance(entity, dict):
raise CDEKOrderMappingError("CDEK order response must include entity object.")
@@ -42,13 +55,20 @@ def map_cdek_order_response(payload: dict[str, Any]) -> str:
if not isinstance(order_uuid, str) or not order_uuid:
raise CDEKOrderMappingError("CDEK order response must include entity.uuid.")
return order_uuid
waybill_uuid, waybill_url = _extract_waybill(payload)
return CDEKOrderRegistrationResult(
order_uuid=order_uuid,
waybill_uuid=waybill_uuid,
waybill_url=waybill_url,
)
_CDEK_DUPLICATE_ORDER_ERROR_CODES = frozenset({"v2_entity_already_exists"})
def map_cdek_existing_order_response(payload: object) -> str | None:
def map_cdek_existing_order_response(
payload: object,
) -> CDEKOrderRegistrationResult | None:
if not isinstance(payload, dict):
return None
@@ -58,21 +78,156 @@ def map_cdek_existing_order_response(payload: object) -> str | None:
try:
return map_cdek_order_response(payload)
except CDEKOrderMappingError:
return _find_first_uuid(payload)
order_uuid = _find_first_uuid(payload)
if order_uuid is None:
return None
waybill_uuid, waybill_url = _extract_waybill(payload)
return CDEKOrderRegistrationResult(
order_uuid=order_uuid,
waybill_uuid=waybill_uuid,
waybill_url=waybill_url,
)
def _map_order_party(party: PaymentParty) -> dict[str, Any]:
def _extract_waybill(payload: dict[str, Any]) -> tuple[str | None, str | None]:
related_entities = payload.get("related_entities")
if not isinstance(related_entities, list):
return None, None
for entry in related_entities:
if not isinstance(entry, dict):
continue
if entry.get("type") != _CDEK_WAYBILL_RELATED_ENTITY_TYPE:
continue
uuid_value = entry.get("uuid")
url_value = entry.get("url")
waybill_uuid = uuid_value if isinstance(uuid_value, str) and uuid_value else None
waybill_url = url_value if isinstance(url_value, str) and url_value else None
if waybill_uuid is None and waybill_url is None:
continue
return waybill_uuid, waybill_url
return None, None
def compose_address_line(address: Address) -> str:
"""Build a CDEK address line from structured fields."""
parts = [address.city, address.street, address.house]
line = ", ".join(part for part in parts if part)
if address.apartment:
line = f"{line}, кв. {address.apartment}"
return line
def resolve_cdek_city_code(city_id: int) -> int:
"""Resolve CDEK city code from cities_map by city identifier."""
city_entry = cities_map.get(str(city_id))
if not isinstance(city_entry, dict):
raise CDEKOrderMappingError(
f"CDEK city mapping is not configured for city id {city_id}."
)
cdek_data = city_entry.get("cdek")
if not isinstance(cdek_data, dict):
raise CDEKOrderMappingError(
f"CDEK city mapping is not configured for city id {city_id}."
)
raw_city_code = cdek_data.get("code")
if raw_city_code is None or isinstance(raw_city_code, bool):
raise CDEKOrderMappingError(
f"CDEK city code is invalid for city id {city_id}."
)
try:
return int(raw_city_code)
except (TypeError, ValueError) as exc:
raise CDEKOrderMappingError(
f"CDEK city code is invalid for city id {city_id}."
) from exc
_GRAMS_IN_KILOGRAM = Decimal(1000)
_INTEGER_QUANTIZER = Decimal(1)
def kilograms_string_to_grams(value: str) -> int:
kilograms = _parse_positive_decimal(value, field_name="weight")
return int(
(kilograms * _GRAMS_IN_KILOGRAM).quantize(
_INTEGER_QUANTIZER, rounding=ROUND_HALF_UP
)
)
def centimeters_string_to_int(value: str, field_name: str) -> int:
measurement = _parse_positive_decimal(value, field_name=field_name)
return int(measurement.quantize(_INTEGER_QUANTIZER, rounding=ROUND_HALF_UP))
def _parse_positive_decimal(value: str, *, field_name: str) -> Decimal:
try:
parsed = Decimal(value)
except (InvalidOperation, TypeError, ValueError) as exc:
raise CDEKOrderMappingError(
f"{field_name} is not a valid decimal: {value!r}."
) from exc
if not parsed.is_finite() or parsed <= 0:
raise CDEKOrderMappingError(
f"{field_name} must be a positive decimal: {value!r}."
)
return parsed
def _map_party(contact: Contact) -> dict[str, Any]:
phone: dict[str, Any] = {"number": contact.phone}
if contact.phone_ext:
phone["additional"] = contact.phone_ext
party: dict[str, Any] = {
"name": contact.full_name,
"phones": [phone],
}
if contact.email:
party["email"] = contact.email
if contact.is_company:
party["contragent_type"] = "LEGAL_ENTITY"
party["company"] = contact.company_name
party["inn"] = contact.inn
party["kpp"] = contact.kpp
else:
party["company"] = contact.full_name
return party
def _map_location(address: Address) -> dict[str, Any]:
return {
"name": party.name,
"email": party.email,
"phones": [party.phone.model_dump(mode="python")],
"code": resolve_cdek_city_code(address.city_id),
"address": compose_address_line(address),
"postal_code": address.zip,
}
def _map_order_package(package: PaymentPackage) -> dict[str, Any]:
payload = package.model_dump(mode="python", exclude_none=True)
payload["weight"] = package.weight * 1000
return payload
def _map_package(request: InitPaymentRequest) -> dict[str, Any]:
system_data: SystemData = request.system_data
weight_grams = kilograms_string_to_grams(request.system_data.weight)
description = request.content.description or request.order_uuid
package: dict[str, Any] = {
"number": request.order_uuid,
"weight": weight_grams,
"comment": description,
}
if system_data.parcel_type == "parcel" and system_data.dimensions is not None:
package.update(_map_dimensions(system_data.dimensions))
return package
def _map_dimensions(dimensions: Dimensions) -> dict[str, int]:
return {
"length": centimeters_string_to_int(dimensions.length, "length"),
"width": centimeters_string_to_int(dimensions.width, "width"),
"height": centimeters_string_to_int(dimensions.height, "height"),
}
def _contains_cdek_duplicate_error_code(payload: object) -> bool:
+4 -20
View File
@@ -32,33 +32,17 @@ class Order(Base):
order_uuid: Mapped[str] = mapped_column(String(128), nullable=False)
payment_url: Mapped[str] = mapped_column(String(2048), nullable=False)
price: Mapped[int] = mapped_column(Integer, nullable=False)
delivery_type: Mapped[int] = mapped_column(Integer, nullable=False)
tariff_code: Mapped[int] = mapped_column(Integer, nullable=False)
sender: Mapped[dict[str, Any]] = mapped_column(_json_payload_type(), nullable=False)
recipient: Mapped[dict[str, Any]] = mapped_column(
account_email: Mapped[str] = mapped_column(String(320), nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(
_json_payload_type(),
nullable=False,
)
from_location: Mapped[dict[str, Any]] = mapped_column(
_json_payload_type(),
nullable=False,
)
to_location: Mapped[dict[str, Any]] = mapped_column(
_json_payload_type(),
nullable=False,
)
packages: Mapped[list[dict[str, Any]]] = mapped_column(
_json_payload_type(),
nullable=False,
)
services: Mapped[list[dict[str, Any]] | None] = mapped_column(
_json_payload_type(),
nullable=True,
)
comment: Mapped[str | None] = mapped_column(String(1024), nullable=True)
payment_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
tbank_payment_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
cdek_order_uuid: Mapped[str | None] = mapped_column(String(128), nullable=True)
cdek_waybill_uuid: Mapped[str | None] = mapped_column(String(128), nullable=True)
cdek_waybill_url: Mapped[str | None] = mapped_column(String(2048), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
+8 -16
View File
@@ -15,15 +15,9 @@ class OrderData:
order_uuid: str
payment_url: str
price: int
delivery_type: int
tariff_code: int
sender: dict[str, Any]
recipient: dict[str, Any]
from_location: dict[str, Any]
to_location: dict[str, Any]
packages: list[dict[str, Any]]
services: list[dict[str, Any]] | None
comment: str | None
account_email: str
payload: dict[str, Any]
class OrderRepository:
@@ -38,15 +32,9 @@ class OrderRepository:
order_uuid=order_data.order_uuid,
payment_url=order_data.payment_url,
price=order_data.price,
delivery_type=order_data.delivery_type,
tariff_code=order_data.tariff_code,
sender=order_data.sender,
recipient=order_data.recipient,
from_location=order_data.from_location,
to_location=order_data.to_location,
packages=order_data.packages,
services=order_data.services,
comment=order_data.comment,
account_email=order_data.account_email,
payload=order_data.payload,
)
session.add(order)
await session.flush()
@@ -84,11 +72,15 @@ class OrderRepository:
session: AsyncSession,
order_uuid: str,
cdek_order_uuid: str,
cdek_waybill_uuid: str | None = None,
cdek_waybill_url: str | None = None,
) -> Order | None:
order = await self.get_order_by_order_uuid(session, order_uuid)
if order is None:
return None
order.cdek_order_uuid = cdek_order_uuid
order.cdek_waybill_uuid = cdek_waybill_uuid
order.cdek_waybill_url = cdek_waybill_url
await session.flush()
return order
+104 -43
View File
@@ -1,61 +1,122 @@
"""Schemas for delivery payment initialization."""
from datetime import datetime
from decimal import Decimal, InvalidOperation
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic.alias_generators import to_camel
class PaymentPhone(BaseModel):
number: str = Field(min_length=1)
def _validate_positive_decimal_string(value: str) -> str:
try:
parsed = Decimal(value)
except (InvalidOperation, TypeError, ValueError) as exc:
raise ValueError("must be a positive decimal number") from exc
if not parsed.is_finite() or parsed <= 0:
raise ValueError("must be a positive decimal number")
return value
class PaymentParty(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1)
email: str = Field(min_length=1)
phone: PaymentPhone
@model_validator(mode="before")
@classmethod
def reject_company_field(cls, value: object) -> object:
if isinstance(value, dict) and "company" in value:
raise ValueError("company is not allowed")
return value
class _CamelModel(BaseModel):
model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=False,
extra="forbid",
)
class DeliveryLocation(BaseModel):
address: str = Field(min_length=1)
class Address(_CamelModel):
city_id: int = Field(gt=0, strict=True)
city: str = Field(min_length=1)
country_code: str = Field(min_length=2, max_length=2)
class PaymentService(BaseModel):
code: str = Field(min_length=1)
parameter: str = Field(min_length=1)
class PaymentPackage(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)
street: str = Field(min_length=1)
house: str = Field(min_length=1)
apartment: str | None = None
zip: str = Field(min_length=1)
comment: str | None = None
class InitPaymentRequest(BaseModel):
order_uuid: str = Field(min_length=1)
class Contact(_CamelModel):
full_name: str = Field(min_length=1)
email: str | None = None
phone: str = Field(min_length=1)
phone_ext: str | None = None
is_company: bool
company_name: str | None = None
inn: str | None = None
kpp: str | None = None
@model_validator(mode="after")
def _validate_company_requisites(self) -> "Contact":
if self.is_company:
missing = [
name
for name, value in (
("companyName", self.company_name),
("inn", self.inn),
("kpp", self.kpp),
)
if value is None or value == ""
]
if missing:
raise ValueError(
f"{', '.join(missing)} are required when isCompany is true"
)
return self
class Content(_CamelModel):
description: str | None = None
class SystemDataTariff(_CamelModel):
provider: str = Field(min_length=1)
service_name: str = Field(min_length=1)
price: int = Field(gt=0, strict=True, description="Payment amount in kopecks.")
type: Literal[2]
tariff_code: int
comment: str | None = None
sender: PaymentParty
recipient: PaymentParty
from_location: DeliveryLocation
to_location: DeliveryLocation
services: list[PaymentService] | None = Field(default=None, min_length=1)
packages: list[PaymentPackage] = Field(min_length=1)
delivery_days_min: int = Field(ge=0, strict=True)
delivery_days_max: int = Field(ge=0, strict=True)
tariff_code: int = Field(gt=0, strict=True)
class Dimensions(_CamelModel):
length: str = Field(min_length=1)
width: str = Field(min_length=1)
height: str = Field(min_length=1)
_validate_dimensions = field_validator("length", "width", "height")(
_validate_positive_decimal_string
)
class SystemData(_CamelModel):
tariff: SystemDataTariff
parcel_type: Literal["doc", "parcel"]
doc_packaging: Literal["envelope", "bag"] | None = None
weight: str = Field(min_length=1)
dimensions: Dimensions | None = None
_validate_weight = field_validator("weight")(_validate_positive_decimal_string)
@model_validator(mode="after")
def _validate_dimensions_for_parcel_type(self) -> "SystemData":
if self.parcel_type == "parcel" and self.dimensions is None:
raise ValueError("dimensions are required when parcelType is 'parcel'")
if self.parcel_type == "doc" and self.doc_packaging is None:
raise ValueError("docPackaging is required when parcelType is 'doc'")
return self
class InitPaymentRequest(_CamelModel):
order_uuid: str = Field(min_length=1)
sender_address: Address
sender_contact: Contact
receiver_address: Address
receiver_contact: Contact
content: Content
pickup_date: datetime
delivery_date: datetime | None = None
account_email: str = Field(min_length=1)
system_data: SystemData
class InitPaymentResponse(BaseModel):
+39 -40
View File
@@ -20,6 +20,9 @@ from app.adapters.delivery_providers.base import (
ProviderClientError,
ProviderRequestError,
)
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderRegistrationResult,
)
from app.adapters.tbank.base import (
TBankPaymentAdapterError,
TBankPaymentNotificationTokenError,
@@ -109,7 +112,9 @@ class PaymentPriceValidationAdapterProtocol(Protocol):
class OrderRegistrationAdapterProtocol(Protocol):
async def register_order(self, request: InitPaymentRequest) -> str: ...
async def register_order(
self, request: InitPaymentRequest
) -> CDEKOrderRegistrationResult: ...
class OrderRepositoryProtocol(Protocol):
@@ -136,6 +141,8 @@ class OrderRepositoryProtocol(Protocol):
session: object,
order_uuid: str,
cdek_order_uuid: str,
cdek_waybill_uuid: str | None = None,
cdek_waybill_url: str | None = None,
) -> object | None: ...
@@ -259,7 +266,7 @@ class AggregatorService:
try:
payment_url = await self._payment_adapter.create_payment_link(
order_uuid=request.order_uuid,
amount_kopecks=request.price,
amount_kopecks=request.system_data.tariff.price,
)
except TBankPaymentRequestError as exc:
logger.exception(
@@ -294,6 +301,8 @@ class AggregatorService:
"Payment price validation adapter is not configured."
)
tariff_code = request.system_data.tariff.tariff_code
requested_price = request.system_data.tariff.price
try:
provider_price = await self._payment_price_validation_adapter.get_payment_price(
request
@@ -302,8 +311,8 @@ class AggregatorService:
logger.warning(
"init_payment_price_validation_request_rejected",
order_uuid=request.order_uuid,
tariff_code=request.tariff_code,
requested_price_kopecks=request.price,
tariff_code=tariff_code,
requested_price_kopecks=requested_price,
error=str(exc),
)
raise InvalidInitPaymentRequestError(
@@ -313,8 +322,8 @@ class AggregatorService:
logger.warning(
"init_payment_price_validation_unavailable",
order_uuid=request.order_uuid,
tariff_code=request.tariff_code,
requested_price_kopecks=request.price,
tariff_code=tariff_code,
requested_price_kopecks=requested_price,
error=str(exc),
)
raise InitPaymentUnavailableError(
@@ -324,8 +333,8 @@ class AggregatorService:
logger.exception(
"init_payment_price_validation_unexpected_error",
order_uuid=request.order_uuid,
tariff_code=request.tariff_code,
requested_price_kopecks=request.price,
tariff_code=tariff_code,
requested_price_kopecks=requested_price,
)
raise InitPaymentUnavailableError(
"Payment price validation is temporarily unavailable."
@@ -335,8 +344,8 @@ class AggregatorService:
logger.warning(
"init_payment_price_validation_tariff_not_found",
order_uuid=request.order_uuid,
tariff_code=request.tariff_code,
requested_price_kopecks=request.price,
tariff_code=tariff_code,
requested_price_kopecks=requested_price,
)
raise InvalidInitPaymentRequestError(
"CDEK did not return the requested tariff for payment validation."
@@ -347,15 +356,15 @@ class AggregatorService:
price_multiplier=self._provider_price_multiplier,
)
if not is_init_payment_price_valid(
request.price,
requested_price,
provider_price,
price_multiplier=self._provider_price_multiplier,
):
logger.warning(
"init_payment_price_mismatch",
order_uuid=request.order_uuid,
tariff_code=request.tariff_code,
requested_price_kopecks=request.price,
tariff_code=tariff_code,
requested_price_kopecks=requested_price,
expected_price_kopecks=expected_amount_kopecks,
provider_currency=getattr(provider_price, "currency", None),
provider_price=str(getattr(provider_price, "price", None)),
@@ -398,10 +407,12 @@ class AggregatorService:
if existing_cdek_order_uuid:
return "OK"
cdek_order_uuid = await self._register_cdek_order(order)
registration_result = await self._register_cdek_order(order)
await self._save_cdek_order_uuid(
order_uuid=notification.OrderId,
cdek_order_uuid=cdek_order_uuid,
cdek_order_uuid=registration_result.order_uuid,
cdek_waybill_uuid=registration_result.waybill_uuid,
cdek_waybill_url=registration_result.waybill_url,
)
return "OK"
@@ -456,7 +467,9 @@ class AggregatorService:
"TBank payment notification order update failed."
) from exc
async def _register_cdek_order(self, order: object) -> str:
async def _register_cdek_order(
self, order: object
) -> CDEKOrderRegistrationResult:
if self._order_registration_adapter is None:
raise TBankPaymentNotificationProcessingError(
"CDEK order registration adapter is not configured."
@@ -479,6 +492,8 @@ class AggregatorService:
*,
order_uuid: str,
cdek_order_uuid: str,
cdek_waybill_uuid: str | None,
cdek_waybill_url: str | None,
) -> None:
if self._order_repository is None:
raise TBankPaymentNotificationProcessingError(
@@ -491,6 +506,8 @@ class AggregatorService:
session,
order_uuid,
cdek_order_uuid,
cdek_waybill_uuid,
cdek_waybill_url,
)
if order is None:
logger.warning(
@@ -540,37 +557,19 @@ class AggregatorService:
request: InitPaymentRequest,
payment_url: str,
) -> OrderData:
payload = request.model_dump(mode="json")
return OrderData(
order_uuid=request.order_uuid,
payment_url=payment_url,
price=request.price,
delivery_type=request.type,
tariff_code=request.tariff_code,
sender=payload["sender"],
recipient=payload["recipient"],
from_location=payload["from_location"],
to_location=payload["to_location"],
packages=payload["packages"],
services=payload["services"],
comment=request.comment,
price=request.system_data.tariff.price,
tariff_code=request.system_data.tariff.tariff_code,
account_email=request.account_email,
payload=request.model_dump(mode="json", by_alias=True),
)
@staticmethod
def _to_init_payment_request_from_order(order: object) -> InitPaymentRequest:
return InitPaymentRequest(
order_uuid=getattr(order, "order_uuid"),
price=getattr(order, "price"),
type=getattr(order, "delivery_type"),
tariff_code=getattr(order, "tariff_code"),
comment=getattr(order, "comment"),
sender=getattr(order, "sender"),
recipient=getattr(order, "recipient"),
from_location=getattr(order, "from_location"),
to_location=getattr(order, "to_location"),
services=getattr(order, "services"),
packages=getattr(order, "packages"),
)
payload = getattr(order, "payload")
return InitPaymentRequest.model_validate(payload)
async def _get_provider_prices(
self,
+90 -35
View File
@@ -17,45 +17,69 @@ POST http://localhost:8000/api/v1/delivery/order
Content-Type: application/json
{
"order_uuid": "order-uuid-1",
"price": 125000,
"type": 2,
"tariff_code": 535,
"comment": "Тестовая оплата доставки",
"sender": {
"name": "Петр Петров",
"orderUuid": "order-uuid-1",
"senderAddress": {
"cityId": 1,
"city": "Дубай",
"street": "Sheikh Zayed Road",
"house": "10",
"apartment": "201",
"zip": "12345",
"comment": "Домофон 12"
},
"senderContact": {
"fullName": "Петр Петров",
"email": "sender@example.com",
"phone": {
"number": "+79009876543"
}
"phone": "+79009876543",
"phoneExt": null,
"isCompany": false,
"companyName": null,
"inn": null,
"kpp": null
},
"recipient": {
"name": "Иван Иванов",
"receiverAddress": {
"cityId": 2,
"city": "Шарджа",
"street": "Al Wahda",
"house": "5",
"apartment": null,
"zip": "54321",
"comment": null
},
"receiverContact": {
"fullName": "Иван Иванов",
"email": "ivan@example.com",
"phone": {
"number": "+79001234567"
"phone": "+79001234567",
"phoneExt": "101",
"isCompany": false,
"companyName": null,
"inn": null,
"kpp": null
},
"content": {
"description": "Наушники"
},
"pickupDate": "2026-05-15T10:00:00.000Z",
"deliveryDate": "2026-05-18T18:00:00.000Z",
"accountEmail": "client@example.com",
"systemData": {
"tariff": {
"provider": "СДЭК",
"serviceName": "Экспресс лайт",
"price": 125000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": 535
},
"parcelType": "parcel",
"docPackaging": null,
"weight": "1.0",
"dimensions": {
"length": "20",
"width": "15",
"height": "10"
}
},
"from_location": {
"address": "ул. Ленина, 1",
"city": "Москва",
"country_code": "RU"
},
"to_location": {
"address": "ул. Пушкина, 10",
"city": "Новосибирск",
"country_code": "RU"
},
"packages": [
{
"number": "1",
"weight": 1,
"length": 20,
"width": 15,
"height": 10,
"comment": "Упаковка 1"
}
]
}
}
> {%
@@ -66,3 +90,34 @@ Content-Type: application/json
GET {{base_url}}/v2/calculator/alltariffs
Authorization: Bearer {{auth_token}}
X-User-Lang: rus
### 6. Формирование накладной по заказу
POST {{base_url}}/v2/print/orders
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{
"orders": [
{
"order_uuid": "abd93f2d-7c5a-4820-9fdd-cab39aadbb1f"
}
],
"copy_count": 1
}
### 7. Получение накладной по UUID
GET {{base_url}}/v2/print/orders/31ff53f5-4c01-43aa-afa2-b7efdf0c06da
Authorization: Bearer {{auth_token}}
### 8. Просмотр заказа CDEK по UUID (с related_entities)
GET {{base_url}}/v2/orders/1e2282ba-529e-4ee7-9d82-2d7837145755
Authorization: Bearer {{auth_token}}
### 9. Скачивание готовой квитанции (PDF)
# Подставь WAYBILL_UUID из related_entities[type=waybill] (### 8).
# Файл сохраняется рядом с http-client.http как waybill.pdf.
GET {{base_url}}/v2/print/orders/31ff53f5-4c01-43aa-afa2-b7efdf0c06da.pdf
Authorization: Bearer {{auth_token}}
Accept: application/pdf
>>! waybill.pdf
+3 -2
View File
@@ -38,9 +38,10 @@
| 029 | DONE | 2026-04-18 | Add TBank payment notification and success URLs | `spec/tasks/029_add_tbank_payment_urls.md` |
| 030 | DONE | 2026-04-18 | Add TBank payment notification webhook and CDEK order creation | `spec/tasks/030_add_tbank_payment_notification_webhook.md` |
| 031 | TODO | 2026-04-18 | Validate init-payment price with CDEK tariff | `spec/tasks/031_validate_init_payment_price_with_cdek.md` |
| 032 | TODO | 2026-05-13 | Rework init-payment contract to camelCase and structured address/contact | `spec/tasks/032_rework_init_payment_contract_camelcase.md` |
## Summary
- Total: **32**
- TODO: **1**
- Total: **33**
- TODO: **2**
- DONE: **31**
+59 -27
View File
@@ -119,7 +119,15 @@
- Каждый адаптер владеет своей конфигурацией; наружу экспонирует только service-facing methods, необходимые соответствующему use-case
- Для расчёта тарифа CDEK adapter принимает city identifiers из `DeliveryCalculationRequest`, находит запись в `cities_map`, берёт `cdek.code` и передаёт его в CDEK API
Для CDEK order contract mapper принимает `InitPaymentRequest`, сериализует `sender.phone` и `recipient.phone` в provider payload `phones` с одним элементом, не отправляет `services` при отсутствии значения, конвертирует `packages[*].weight` из килограммов в граммы и возвращает `entity.uuid` строкой.
Для CDEK order contract mapper принимает новый `InitPaymentRequest` и собирает provider payload:
- `number = orderUuid`, `type = 2`, `tariff_code = systemData.tariff.tariffCode`.
- `sender`/`recipient` маппятся из `senderContact`/`receiverContact`: `name = fullName`, `email`, `phones = [{number, additional: phoneExt}]`. При `isCompany=true` добавляются `contragent_type="LEGAL_ENTITY"`, `company`, `inn`, `kpp`.
- `from_location`/`to_location` собираются из `senderAddress`/`receiverAddress`: `code` берётся из `cities_map` по `cityId`, `address` склеивается строкой `"{city}, {street}, {house}, кв. {apartment}"` (хвост `кв.` опускается при пустом `apartment`), `postal_code = zip`.
- `packages[0]` содержит `number = orderUuid`, `weight = round(float(systemData.weight) * 1000)` в граммах; при `parcelType='parcel'` добавляются `length`, `width`, `height` из `systemData.dimensions`; при `parcelType='doc'` габариты не передаются.
- `packages[0].items[0]` пробрасывает `content.description` как `name`.
- `shipment_point.date = pickupDate.date()`, `delivery_point.date = deliveryDate.date()` (если задан).
- Поле верхнего уровня `comment` собирается из `content.description` (если есть).
- Возвращает `entity.uuid` строкой.
### Adapter (`app/adapters/tbank`)
- `base.py` — исключения TBank payment adapter
@@ -203,36 +211,60 @@ postal_code: str | None
```
### Входная: `InitPaymentRequest`
Контракт ручки `/api/v1/delivery/order` использует camelCase в JSON; Pydantic-
модели хранят snake_case поля и принимают входной JSON через alias-generator.
```
order_uuid: str
price: int
type: Literal[2]
tariff_code: Literal[535]
comment: str | None
sender:
name: str
email: str
phone: {number: str}
recipient:
name: str
email: str
phone: {number: str}
from_location:
address: str
orderUuid: str
senderAddress:
cityId: int
city: str
country_code: str
to_location:
address: str
city: str
country_code: str
services: list[{code: str, parameter: str}] | None
packages: list[{number: str, weight: int, length: int, width: int, height: int, comment: str | None}]
street: str
house: str
apartment: str | None
zip: str
comment: str | None
senderContact:
fullName: str
email: str | None
phone: str
phoneExt: str | None
isCompany: bool
companyName: str | None # обязателен при isCompany=true
inn: str | None # обязателен при isCompany=true
kpp: str | None # обязателен при isCompany=true
receiverAddress: <структура senderAddress>
receiverContact: <структура senderContact>
content:
description: str | None
pickupDate: datetime # ISO 8601
deliveryDate: datetime | None # ISO 8601
accountEmail: str
systemData:
tariff:
provider: str
serviceName: str
price: int # копейки
deliveryDaysMin: int
deliveryDaysMax: int
tariffCode: int
parcelType: Literal[doc, parcel]
docPackaging: Literal[envelope, bag] | None # для doc
weight: str
dimensions: # null/отсутствует для doc
length: str
width: str
height: str
```
`price` задаётся в копейках, является обязательным целым числом и должен быть больше 0.
`from_location.address` и `to_location.address` должны содержать точные значения адреса, выбранные клиентом; payment flow не выполняет address suggestion lookup.
`sender.phone` и `recipient.phone` представляют единственный телефон для соответствующей стороны; передача нескольких телефонов во входном API не поддерживается.
`packages[*].weight` в `InitPaymentRequest` задаётся в килограммах; CDEK order mapper конвертирует его в граммы для provider payload.
`systemData.tariff.price` задаётся в копейках, является обязательным целым
числом и должен быть больше 0; backend использует его как сумму платежа
TBank без пересчёта.
`senderAddress.cityId` и `receiverAddress.cityId` используют общий справочник
`cities_map` (тот же идентификатор, что и в `DeliveryCalculationRequest`).
Для `parcelType='doc'` `systemData.dimensions` отсутствует или равен `null`,
для `parcelType='parcel'` — обязателен.
При `isCompany=true` поля `companyName`, `inn`, `kpp` обязательны.
`pickupDate` и `deliveryDate` принимаются и сохраняются как ISO datetime.
### Выходная: `InitPaymentResponse`
```
@@ -0,0 +1,102 @@
---
id: 032
title: Rework init-payment contract to camelCase and structured address/contact
status: TODO
created: 2026-05-13
---
## Context
Фронт переходит на новый контракт ручки `POST /api/v1/delivery/order`. В нём
адрес/контакт структурированы по-новому, появились флаг юр-лица и реквизиты
(`isCompany`/`companyName`/`inn`/`kpp`/`phoneExt`), описание груза и вес
(`content.description`), даты `pickupDate`/`deliveryDate`,
`accountEmail`, блок `systemData` с зафиксированным тарифом, parcelType,
docPackaging и dimensions. Поле наименования — camelCase. Это breaking change
без обратной совместимости.
## Goal
Привести бэкенд к новому контракту `/api/v1/delivery/order`, сохранив текущий
flow «валидация цены через CDEK → создание payment URL в TBank → persist в
PostgreSQL → возврат `payment_url`; webhook `CONFIRMED` → регистрация заказа в
CDEK».
## Constraints
- camelCase в API; внутренние имена остаются Python-friendly через Pydantic
alias-generator. JSON принимается ТОЛЬКО в camelCase.
- `orderUuid` и `systemData.tariff.tariffCode` приходят от фронта.
- `systemData.tariff.price` — итоговая сумма в копейках; backend использует её
как `amount_kopecks` для TBank без пересчёта.
- Расширить `senderAddress` и `receiverAddress` обязательным полем `cityId: int`
(то же, что в `/price`), чтобы резолвить CDEK city code из `cities_map`.
- При `isCompany=true` поля `companyName`, `inn`, `kpp` обязательны.
- При `parcelType='doc'` `dimensions` принимает значение `null`; CDEK packages
отправляются без length/width/height.
- При `parcelType='parcel'` `dimensions` обязателен.
- Адрес для CDEK собирается строкой `"{city}, {street}, {house}, кв. {apartment}"`;
если `apartment` пустой/отсутствует — без хвоста `, кв. ...`.
- `pickupDate` → CDEK `shipment_point.date`; `deliveryDate` → CDEK
`delivery_point.date` (если задан).
- `content.description` пробрасывается в CDEK `packages[0].items[0].name` и
`comment` верхнего уровня.
- `phoneExt` пробрасывается в CDEK `phones[0].additional`.
- Сохранять весь принятый payload в JSONB-колонке `payload` записи `orders`.
- ORM-модель `orders` рефакторится: вместо колонок `sender/recipient/
from_location/to_location/packages/services/comment/delivery_type` —
одна колонка `payload JSONB NOT NULL`, плюс `account_email VARCHAR`.
Колонки `order_uuid`, `payment_url`, `price`, `tariff_code`, `payment_status`,
`tbank_payment_id`, `cdek_order_uuid`, `created_at`, `updated_at` сохраняются.
## Acceptance criteria
- `POST /api/v1/delivery/order` принимает новый camelCase payload и возвращает
`InitPaymentResponse` без изменений (`{ "payment_url": str }`).
- Pydantic-модели валидируют: обязательность реквизитов юр-лица при
`isCompany=true`; `parcelType='doc'` ⇒ `dimensions=null`/отсутствует;
`parcelType='parcel'` ⇒ `dimensions` обязателен; `price > 0`.
- Service вызывает `payment_price_validation_adapter.get_payment_price(request)`
и `payment_adapter.create_payment_link(order_uuid, amount_kopecks)` с
`amount_kopecks = request.systemData.tariff.price`.
- CDEK order mapper из нового `InitPaymentRequest` формирует payload с полями:
`number`, `type=2`, `tariff_code`, `sender`, `recipient`,
`from_location.code/address/postal_code`, `to_location.code/address/postal_code`,
`packages[0]` с `weight` (граммы) и опциональными dimensions,
`packages[0].items` с описанием груза, `shipment_point.date`,
`delivery_point.date` (если есть), `comment`.
- При `isCompany=true` CDEK получает `contragent_type="LEGAL_ENTITY"`, `company`,
`inn`, `kpp` для соответствующей стороны.
- ORM `Order` хранит весь payload в `payload` JSONB; репозиторий сохраняет и
читает его без потерь; миграция переводит таблицу со старой структуры на
новую (drop старые колонки, добавить `payload`, `account_email`).
## Definition of Done
- [ ] Pydantic-модели `payment.py` переписаны под новый camelCase-контракт с
обязательными валидациями.
- [ ] CDEK order mapper и `_build_payment_price_payload` собирают payload из
новой структуры.
- [ ] `AggregatorService.init_payment` использует `systemData.tariff.price` и
`orderUuid` напрямую; persist использует обновлённый `OrderData`.
- [ ] ORM-модель `Order`, `OrderData` и репозиторий перешли на `payload` JSONB
и `account_email`.
- [ ] Alembic-миграция `20260513_032_rework_orders_payload.py` применяется и
откатывается.
- [ ] Тесты на schemas, mapper, payment validation, controller и service
обновлены и проходят.
- [ ] `http-client.http` обновлён под новый payload.
## Tests
- `tests/controllers/v1/test_init_payment.py` — fixture/assertion обновлены под
camelCase; добавлены кейсы валидации юр-лица и doc/parcel dimensions.
- `tests/adapters/delivery_providers/cdek/test_order_mapper.py` —
mapping `phoneExt → additional`, контрагент юр-лица, address composition,
doc без dimensions, shipment/delivery point dates.
- `tests/adapters/delivery_providers/cdek/test_payment_price_validation.py` —
validation payload собирается из `senderAddress.cityId`, `systemData.weight`,
`systemData.dimensions`.
- `tests/services/test_init_payment.py` — `init_payment` берёт сумму из
`systemData.tariff.price`, `orderUuid` пробрасывается.
- `tests/repositories/order/test_repository.py` (если есть) — обновлён под
новую модель `Order`.
## Commands
- `pytest tests/`
- `alembic upgrade head` (smoke на пустой базе)
- `python3 spec/gen_spec_index.py --check`
View File
@@ -10,7 +10,10 @@ from app.adapters.delivery_providers.cdek.client import (
CDEKProvider,
CDEKRequestError,
)
from app.schemas.payment import InitPaymentRequest
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderRegistrationResult,
)
from tests.payment_fixtures import make_init_payment_request
class StubAuthClient:
@@ -51,179 +54,53 @@ class SequenceHTTPClient:
return result
def _make_order_request(**overrides: object) -> InitPaymentRequest:
payload: dict[str, object] = {
"order_uuid": "order-uuid-1",
"price": 125000,
"type": 2,
"tariff_code": 535,
"comment": "Test order",
"sender": {
"name": "Petr Petrov",
"email": "sender@example.com",
"phone": {"number": "+79009876543"},
},
"recipient": {
"name": "Ivan Ivanov",
"email": "ivan@example.com",
"phone": {"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": 1,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
}
],
}
payload.update(overrides)
return InitPaymentRequest(**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(
def _make_provider(http_client: SequenceHTTPClient, **kwargs: Any) -> CDEKProvider:
return 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,
**kwargs,
)
)
result = asyncio.run(provider.register_order(_make_order_request()))
assert result == "cdek-order-uuid"
assert http_client.calls == [
{
"method": "POST",
"url": "https://api.cdek.test/v2/orders",
"json": {
"number": "order-uuid-1",
"type": 2,
"tariff_code": 535,
"comment": "Test order",
"sender": {
"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_provider_register_order_omits_services_when_none_and_converts_package_weights() -> None:
def test_provider_register_order_posts_payload_and_maps_response() -> None:
response = httpx.Response(
200,
json={"entity": {"uuid": "cdek-order-uuid"}},
json={
"entity": {"uuid": "cdek-order-uuid"},
"related_entities": [
{
"type": "waybill",
"uuid": "waybill-uuid-1",
"url": "https://cdek.test/waybill/1.pdf",
}
],
},
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,
)
)
request = _make_order_request(
services=None,
packages=[
{
"number": "1",
"weight": 1,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
},
{
"number": "2",
"weight": 2,
"length": 25,
"width": 18,
"height": 12,
"comment": "Package 2",
},
],
)
provider = _make_provider(http_client)
asyncio.run(provider.register_order(request))
result = asyncio.run(provider.register_order(make_init_payment_request()))
assert result == CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid",
waybill_uuid="waybill-uuid-1",
waybill_url="https://cdek.test/waybill/1.pdf",
)
assert http_client.calls[0]["url"] == "https://api.cdek.test/v2/orders"
payload = http_client.calls[0]["json"]
assert "services" not in payload
assert payload["sender"]["phones"] == [{"number": "+79009876543"}]
assert payload["recipient"]["phones"] == [{"number": "+79001234567"}]
assert payload["packages"] == [
{
"number": "1",
"weight": 1000,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
},
{
"number": "2",
"weight": 2000,
"length": 25,
"width": 18,
"height": 12,
"comment": "Package 2",
},
]
assert payload["number"] == "order-uuid-1"
assert payload["type"] == 2
assert payload["tariff_code"] == 535
assert payload["print"] == "WAYBILL"
assert payload["from_location"]["code"] == 7017
assert payload["to_location"]["code"] == 16454
assert payload["packages"][0]["weight"] == 1000
def test_cdek_client_register_order_maps_4xx_to_request_error() -> None:
@@ -241,7 +118,7 @@ def test_cdek_client_register_order_maps_4xx_to_request_error() -> None:
)
with pytest.raises(CDEKRequestError, match="status 422"):
asyncio.run(client.register_order(_make_order_request()))
asyncio.run(client.register_order(make_init_payment_request()))
assert len(http_client.calls) == 1
@@ -272,41 +149,13 @@ def test_cdek_client_register_order_maps_duplicate_external_id_to_success() -> N
retry_attempts=2,
)
result = asyncio.run(client.register_order(_make_order_request()))
result = asyncio.run(client.register_order(make_init_payment_request()))
assert result == "existing-cdek-order-uuid"
assert len(http_client.calls) == 1
def test_cdek_client_register_order_4xx_with_unrelated_entity_uuid_raises_request_error() -> None:
unrelated_response = httpx.Response(
409,
json={
"entity": {"uuid": "unrelated-uuid"},
"requests": [
{
"errors": [
{
"code": "v2_recipient_phone_invalid",
"message": "Recipient phone is invalid",
}
]
}
],
},
request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
assert result == CDEKOrderRegistrationResult(
order_uuid="existing-cdek-order-uuid",
waybill_uuid=None,
waybill_url=None,
)
http_client = SequenceHTTPClient([unrelated_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):
asyncio.run(client.register_order(_make_order_request()))
assert len(http_client.calls) == 1
@@ -337,7 +186,7 @@ def test_cdek_client_register_order_retries_5xx_and_raises_client_error() -> Non
)
with pytest.raises(CDEKClientError, match="retriable status 503"):
asyncio.run(client.register_order(_make_order_request()))
asyncio.run(client.register_order(make_init_payment_request()))
assert len(http_client.calls) == 2
assert sleep_calls == [0.25]
@@ -358,4 +207,4 @@ def test_cdek_client_register_order_raises_client_error_for_invalid_success_payl
)
with pytest.raises(CDEKClientError, match="response payload is invalid"):
asyncio.run(client.register_order(_make_order_request()))
asyncio.run(client.register_order(make_init_payment_request()))
@@ -1,53 +1,209 @@
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderRegistrationResult,
compose_address_line,
map_cdek_existing_order_response,
map_cdek_order_request,
map_cdek_order_response,
)
from app.schemas.payment import InitPaymentRequest
from app.schemas.payment import Address
from tests.payment_fixtures import make_init_payment_request
def _make_order_request(**overrides: object) -> InitPaymentRequest:
payload: dict[str, object] = {
"order_uuid": "order-uuid-1",
"price": 125000,
"type": 2,
"tariff_code": 535,
"comment": "Test order",
"sender": {
"name": "Petr Petrov",
"email": "sender@example.com",
"phone": {"number": "+79009876543"},
},
"recipient": {
"name": "Ivan Ivanov",
"email": "ivan@example.com",
"phone": {"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": 1,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
}
],
}
payload.update(overrides)
return InitPaymentRequest(**payload)
def test_cdek_order_payload_uses_order_uuid_as_external_number() -> None:
payload = map_cdek_order_request(_make_order_request())
def test_order_payload_uses_order_uuid_and_tariff_code_from_system_data() -> None:
payload = map_cdek_order_request(make_init_payment_request())
assert payload["number"] == "order-uuid-1"
assert payload["type"] == 2
assert payload["tariff_code"] == 535
def test_order_payload_requests_waybill_print() -> None:
payload = map_cdek_order_request(make_init_payment_request())
assert payload["print"] == "WAYBILL"
def test_map_cdek_order_response_extracts_order_uuid_without_waybill() -> None:
result = map_cdek_order_response({"entity": {"uuid": "cdek-order-uuid"}})
assert result == CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid",
waybill_uuid=None,
waybill_url=None,
)
def test_map_cdek_order_response_extracts_waybill_from_related_entities() -> None:
result = map_cdek_order_response(
{
"entity": {"uuid": "cdek-order-uuid"},
"related_entities": [
{"type": "delivery", "uuid": "ignored"},
{
"type": "waybill",
"uuid": "waybill-uuid-1",
"url": "https://cdek.test/waybill/1.pdf",
},
],
}
)
assert result == CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid",
waybill_uuid="waybill-uuid-1",
waybill_url="https://cdek.test/waybill/1.pdf",
)
def test_map_cdek_existing_order_response_returns_waybill_for_duplicate() -> None:
result = map_cdek_existing_order_response(
{
"entity": {"uuid": "existing-uuid"},
"requests": [
{"errors": [{"code": "v2_entity_already_exists", "message": "dup"}]}
],
"related_entities": [
{
"type": "waybill",
"uuid": "waybill-existing",
"url": "https://cdek.test/waybill/existing.pdf",
}
],
}
)
assert result == CDEKOrderRegistrationResult(
order_uuid="existing-uuid",
waybill_uuid="waybill-existing",
waybill_url="https://cdek.test/waybill/existing.pdf",
)
def test_order_payload_maps_phones_and_phone_ext_to_additional() -> None:
payload = map_cdek_order_request(make_init_payment_request())
assert payload["sender"]["phones"] == [{"number": "+79009876543"}]
assert payload["recipient"]["phones"] == [
{"number": "+79001234567", "additional": "101"}
]
def test_order_payload_maps_locations_with_address_and_postal_code() -> None:
payload = map_cdek_order_request(make_init_payment_request())
assert payload["from_location"] == {
"code": 7017,
"address": "Дубай, Sheikh Zayed Road, 10, кв. 201",
"postal_code": "12345",
}
assert payload["to_location"] == {
"code": 16454,
"address": "Шарджа, Al Wahda, 5",
"postal_code": "54321",
}
def test_order_payload_for_parcel_includes_dimensions() -> None:
payload = map_cdek_order_request(make_init_payment_request())
package = payload["packages"][0]
assert package["weight"] == 1000
assert package["length"] == 20
assert package["width"] == 15
assert package["height"] == 10
def test_order_payload_for_doc_omits_dimensions() -> None:
request = make_init_payment_request(
content={"description": "Docs"},
systemData={
"tariff": {
"provider": "СДЭК",
"serviceName": "Документы",
"price": 50000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": 535,
},
"parcelType": "doc",
"docPackaging": "envelope",
"weight": "0.5",
"dimensions": None,
},
)
payload = map_cdek_order_request(request)
package = payload["packages"][0]
assert package["weight"] == 500
assert "length" not in package
assert "width" not in package
assert "height" not in package
def test_order_payload_omits_shipment_and_delivery_point() -> None:
payload = map_cdek_order_request(make_init_payment_request())
assert "shipment_point" not in payload
assert "delivery_point" not in payload
def test_order_payload_includes_company_requisites_for_legal_entity() -> None:
request = make_init_payment_request(
senderContact={
"fullName": "Romashka LLC",
"email": "buh@romashka.test",
"phone": "+74950000000",
"phoneExt": None,
"isCompany": True,
"companyName": "Romashka LLC",
"inn": "7707083893",
"kpp": "770701001",
}
)
sender = map_cdek_order_request(request)["sender"]
assert sender["contragent_type"] == "LEGAL_ENTITY"
assert sender["company"] == "Romashka LLC"
assert sender["inn"] == "7707083893"
assert sender["kpp"] == "770701001"
def test_order_payload_propagates_description_to_comments_without_items() -> None:
payload = map_cdek_order_request(make_init_payment_request())
assert payload["comment"] == "Headphones"
assert payload["packages"][0]["comment"] == "Headphones"
assert "items" not in payload["packages"][0]
def test_order_payload_falls_back_package_comment_to_order_uuid() -> None:
payload = map_cdek_order_request(
make_init_payment_request(content={"description": None})
)
assert payload["packages"][0]["comment"] == "order-uuid-1"
def test_order_payload_includes_company_for_individual_sender_as_full_name() -> None:
payload = map_cdek_order_request(make_init_payment_request())
assert payload["sender"]["company"] == "Petr Petrov"
assert payload["recipient"]["company"] == "Ivan Ivanov"
assert "contragent_type" not in payload["sender"]
assert "contragent_type" not in payload["recipient"]
def test_compose_address_line_handles_empty_apartment() -> None:
address = Address(
cityId=1,
city="Москва",
street="Lenina",
house="1",
apartment=None,
zip="12345",
comment=None,
)
assert compose_address_line(address) == "Москва, Lenina, 1"
@@ -11,7 +11,7 @@ from app.adapters.delivery_providers.cdek.client import (
CDEKProvider,
CDEKRequestError,
)
from app.schemas.payment import InitPaymentRequest
from tests.payment_fixtures import make_init_payment_request
class StubAuthClient:
@@ -52,62 +52,11 @@ class SequenceHTTPClient:
return result
def _make_request(**overrides: object) -> InitPaymentRequest:
payload: dict[str, object] = {
"order_uuid": "order-uuid-1",
"price": 125000,
"type": 2,
"tariff_code": 535,
"comment": "Test payment",
"sender": {
"name": "Petr Petrov",
"email": "sender@example.com",
"phone": {"number": "+79009876543"},
},
"recipient": {
"name": "Ivan Ivanov",
"email": "ivan@example.com",
"phone": {"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": 1,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
}
],
}
payload.update(overrides)
return InitPaymentRequest(**payload)
def test_provider_get_payment_price_posts_tarifflist_payload_and_maps_requested_tariff() -> None:
def test_provider_get_payment_price_posts_payload_from_camelcase_request() -> None:
response = httpx.Response(
200,
json={
"tariff_codes": [
{
"tariff_code": 234,
"tariff_name": "Other tariff",
"delivery_sum": "999.00",
"currency": "RUB",
"period_min": 3,
"period_max": 5,
},
{
"tariff_code": 535,
"tariff_name": "CDEK tariff",
@@ -131,11 +80,9 @@ def test_provider_get_payment_price_posts_tarifflist_payload_and_maps_requested_
)
)
result = asyncio.run(provider.get_payment_price(_make_request()))
result = asyncio.run(provider.get_payment_price(make_init_payment_request()))
assert result is not None
assert result.provider == "cdek"
assert result.service_name == "CDEK tariff"
assert result.price == Decimal("1250.00")
assert http_client.calls == [
{
@@ -143,16 +90,8 @@ def test_provider_get_payment_price_posts_tarifflist_payload_and_maps_requested_
"url": "https://api.cdek.test/v2/calculator/tarifflist",
"json": {
"type": 2,
"from_location": {
"address": "Lenina 1",
"city": "Moscow",
"country_code": "RU",
},
"to_location": {
"address": "Pushkina 10",
"city": "Novosibirsk",
"country_code": "RU",
},
"from_location": {"code": 7017},
"to_location": {"code": 16454},
"packages": [
{
"weight": 1000,
@@ -161,7 +100,6 @@ def test_provider_get_payment_price_posts_tarifflist_payload_and_maps_requested_
"height": 10,
}
],
"services": [{"code": "INSURANCE", "parameter": "1000"}],
},
"data": None,
"headers": {"Authorization": "Bearer test-token"},
@@ -170,7 +108,7 @@ def test_provider_get_payment_price_posts_tarifflist_payload_and_maps_requested_
]
def test_provider_get_payment_price_omits_services_when_none() -> None:
def test_provider_get_payment_price_omits_dimensions_for_doc() -> None:
response = httpx.Response(
200,
json={"tariff_codes": []},
@@ -185,11 +123,28 @@ def test_provider_get_payment_price_omits_services_when_none() -> None:
retry_attempts=0,
)
)
request = make_init_payment_request(
content={"description": "Docs"},
systemData={
"tariff": {
"provider": "СДЭК",
"serviceName": "Документы",
"price": 50000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": 535,
},
"parcelType": "doc",
"docPackaging": "envelope",
"weight": "0.5",
"dimensions": None,
},
)
result = asyncio.run(provider.get_payment_price(_make_request(services=None)))
asyncio.run(provider.get_payment_price(request))
assert result is None
assert "services" not in http_client.calls[0]["json"]
package = http_client.calls[0]["json"]["packages"][0]
assert package == {"weight": 500}
def test_client_get_raw_payment_price_maps_4xx_to_request_error() -> None:
@@ -207,7 +162,7 @@ def test_client_get_raw_payment_price_maps_4xx_to_request_error() -> None:
)
with pytest.raises(CDEKRequestError, match="status 422"):
asyncio.run(client.get_raw_payment_price(_make_request()))
asyncio.run(client.get_raw_payment_price(make_init_payment_request()))
assert len(http_client.calls) == 1
@@ -239,7 +194,7 @@ def test_client_get_raw_payment_price_retries_5xx_and_raises_client_error() -> N
)
with pytest.raises(CDEKClientError, match="status 503"):
asyncio.run(client.get_raw_payment_price(_make_request()))
asyncio.run(client.get_raw_payment_price(make_init_payment_request()))
assert len(http_client.calls) == 2
assert sleep_calls == [0.25]
@@ -262,4 +217,4 @@ def test_provider_get_payment_price_maps_invalid_success_payload_to_client_error
)
with pytest.raises(CDEKClientError, match="response payload is invalid"):
asyncio.run(provider.get_payment_price(_make_request()))
asyncio.run(provider.get_payment_price(make_init_payment_request()))
+140 -326
View File
@@ -9,6 +9,7 @@ from app.services.aggregator import (
InitPaymentUnavailableError,
InvalidInitPaymentRequestError,
)
from tests.payment_fixtures import make_init_payment_payload
class StubAggregatorService:
@@ -31,45 +32,16 @@ def _install_service_override(app, service: StubAggregatorService) -> None:
app.dependency_overrides[get_aggregator_service] = override_service
def _valid_payload() -> dict[str, object]:
return {
"order_uuid": "order-uuid-1",
"price": 125000,
"type": 2,
"tariff_code": 535,
"comment": "Test payment",
"sender": {
"name": "Petr Petrov",
"email": "sender@example.com",
"phone": {"number": "+79009876543"},
},
"recipient": {
"name": "Ivan Ivanov",
"email": "ivan@example.com",
"phone": {"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": 1,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
}
],
}
def _post(app, payload: dict[str, object]) -> httpx.Response:
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/order", json=payload)
return asyncio.run(run_request())
def test_post_init_payment_returns_response_and_delegates_to_service() -> None:
@@ -77,44 +49,64 @@ def test_post_init_payment_returns_response_and_delegates_to_service() -> None:
service = StubAggregatorService(response=expected_response)
app = create_app()
_install_service_override(app, service)
payload = make_init_payment_payload()
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/order",
json=_valid_payload(),
)
response = asyncio.run(run_request())
response = _post(app, payload)
assert response.status_code == 200
assert response.json() == expected_response.model_dump(mode="json")
assert service.calls == [InitPaymentRequest(**_valid_payload())]
assert service.calls == [InitPaymentRequest.model_validate(payload)]
def test_post_init_payment_rejects_invalid_payload() -> None:
def test_post_init_payment_rejects_invalid_tariff_code() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["tariff_code"] = "not-a-number"
invalid_payload = make_init_payment_payload()
invalid_payload["systemData"]["tariff"]["tariffCode"] = "not-a-number"
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/order",
json=invalid_payload,
)
response = _post(app, invalid_payload)
response = asyncio.run(run_request())
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_snake_case_top_level_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
invalid_payload["order_uuid"] = invalid_payload.pop("orderUuid")
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_snake_case_nested_address_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
sender_address = invalid_payload["senderAddress"]
sender_address["city_id"] = sender_address.pop("cityId")
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_snake_case_nested_tariff_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
tariff = invalid_payload["systemData"]["tariff"]
tariff["tariff_code"] = tariff.pop("tariffCode")
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
@@ -124,21 +116,10 @@ def test_post_init_payment_rejects_missing_price() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
del invalid_payload["price"]
invalid_payload = make_init_payment_payload()
del invalid_payload["systemData"]["tariff"]["price"]
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/order",
json=invalid_payload,
)
response = asyncio.run(run_request())
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
@@ -148,21 +129,10 @@ def test_post_init_payment_rejects_non_positive_price() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["price"] = 0
invalid_payload = make_init_payment_payload()
invalid_payload["systemData"]["tariff"]["price"] = 0
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/order",
json=invalid_payload,
)
response = asyncio.run(run_request())
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
@@ -172,155 +142,102 @@ def test_post_init_payment_rejects_non_integer_price() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["price"] = 125000.5
invalid_payload = make_init_payment_payload()
invalid_payload["systemData"]["tariff"]["price"] = 125000.5
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/order",
json=invalid_payload,
)
response = asyncio.run(run_request())
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_sender_company_field() -> None:
def test_post_init_payment_requires_company_fields_when_is_company_true() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["sender"] = {
**invalid_payload["sender"], # type: ignore[arg-type]
"company": "Romashka LLC",
}
invalid_payload = make_init_payment_payload()
invalid_payload["senderContact"]["isCompany"] = True
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/order",
json=invalid_payload,
)
response = asyncio.run(run_request())
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_sender_phones_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["sender"] = {
**invalid_payload["sender"], # type: ignore[arg-type]
"phones": [{"number": "+79009876543"}],
}
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/order",
json=invalid_payload,
)
response = asyncio.run(run_request())
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_recipient_company_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["recipient"] = {
**invalid_payload["recipient"], # type: ignore[arg-type]
"company": "Romashka LLC",
}
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/order",
json=invalid_payload,
)
response = asyncio.run(run_request())
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_recipient_phones_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["recipient"] = {
**invalid_payload["recipient"], # type: ignore[arg-type]
"phones": [{"number": "+79001234567"}],
}
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/order",
json=invalid_payload,
)
response = asyncio.run(run_request())
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_accepts_request_without_services() -> None:
def test_post_init_payment_accepts_company_with_full_requisites() -> None:
expected_response = InitPaymentResponse(payment_url="https://pay.test/payment/1")
service = StubAggregatorService(response=expected_response)
app = create_app()
_install_service_override(app, service)
payload = _valid_payload()
del payload["services"]
payload = make_init_payment_payload()
payload["senderContact"].update(
{
"isCompany": True,
"companyName": "Romashka LLC",
"inn": "7707083893",
"kpp": "770701001",
}
)
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/order", json=payload)
response = asyncio.run(run_request())
response = _post(app, payload)
assert response.status_code == 200
assert response.json() == expected_response.model_dump(mode="json")
assert service.calls == [InitPaymentRequest(**payload)]
assert service.calls == [InitPaymentRequest.model_validate(payload)]
def test_post_init_payment_rejects_non_numeric_weight() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
invalid_payload["systemData"]["weight"] = "string"
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_non_numeric_dimension() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
invalid_payload["systemData"]["dimensions"]["length"] = "string"
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_parcel_without_dimensions() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
invalid_payload["systemData"]["dimensions"] = None
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_accepts_doc_without_dimensions() -> None:
expected_response = InitPaymentResponse(payment_url="https://pay.test/payment/1")
service = StubAggregatorService(response=expected_response)
app = create_app()
_install_service_override(app, service)
payload = make_init_payment_payload()
payload["systemData"]["parcelType"] = "doc"
payload["systemData"]["docPackaging"] = "envelope"
payload["systemData"]["dimensions"] = None
response = _post(app, payload)
assert response.status_code == 200
assert service.calls == [InitPaymentRequest.model_validate(payload)]
def test_post_init_payment_maps_invalid_request_to_400() -> None:
@@ -331,18 +248,7 @@ def test_post_init_payment_maps_invalid_request_to_400() -> None:
app = create_app()
_install_service_override(app, service)
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/order",
json=_valid_payload(),
)
response = asyncio.run(run_request())
response = _post(app, make_init_payment_payload())
assert response.status_code == 400
assert response.json() == {
@@ -353,37 +259,6 @@ def test_post_init_payment_maps_invalid_request_to_400() -> None:
}
def test_post_init_payment_maps_price_mismatch_to_400() -> None:
service = StubAggregatorService(
response=None,
error=InvalidInitPaymentRequestError("price mismatch"),
)
app = create_app()
_install_service_override(app, service)
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/order",
json=_valid_payload(),
)
response = asyncio.run(run_request())
assert response.status_code == 400
assert response.json() == {
"detail": {
"code": "invalid_init_payment_request",
"message": "Payment request contains invalid or unsupported TBank data.",
}
}
assert service.calls == [InitPaymentRequest(**_valid_payload())]
def test_post_init_payment_maps_service_exception_to_503() -> None:
service = StubAggregatorService(
response=None,
@@ -392,18 +267,7 @@ def test_post_init_payment_maps_service_exception_to_503() -> None:
app = create_app()
_install_service_override(app, service)
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/order",
json=_valid_payload(),
)
response = asyncio.run(run_request())
response = _post(app, make_init_payment_payload())
assert response.status_code == 503
assert response.json() == {
@@ -412,53 +276,3 @@ def test_post_init_payment_maps_service_exception_to_503() -> None:
"message": "Payment initialization is temporarily unavailable.",
}
}
def test_post_init_payment_maps_temporary_cdek_validation_error_to_503() -> None:
service = StubAggregatorService(
response=None,
error=InitPaymentUnavailableError("CDEK validation unavailable"),
)
app = create_app()
_install_service_override(app, service)
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/order",
json=_valid_payload(),
)
response = asyncio.run(run_request())
assert response.status_code == 503
assert response.json() == {
"detail": {
"code": "init_payment_unavailable",
"message": "Payment initialization is temporarily unavailable.",
}
}
assert service.calls == [InitPaymentRequest(**_valid_payload())]
def test_delivery_order_endpoint_is_removed() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/order", json=_valid_payload())
response = asyncio.run(run_request())
assert response.status_code == 404
assert service.calls == []
+81
View File
@@ -0,0 +1,81 @@
"""Shared fixtures for the camelCase init-payment contract."""
from typing import Any
from app.schemas.payment import InitPaymentRequest
def make_init_payment_payload(**overrides: Any) -> dict[str, Any]:
"""Return a valid camelCase JSON payload for /api/v1/delivery/order."""
payload: dict[str, Any] = {
"orderUuid": "order-uuid-1",
"senderAddress": {
"cityId": 1,
"city": "Дубай",
"street": "Sheikh Zayed Road",
"house": "10",
"apartment": "201",
"zip": "12345",
"comment": "Domofon 12",
},
"senderContact": {
"fullName": "Petr Petrov",
"email": "sender@example.com",
"phone": "+79009876543",
"phoneExt": None,
"isCompany": False,
"companyName": None,
"inn": None,
"kpp": None,
},
"receiverAddress": {
"cityId": 2,
"city": "Шарджа",
"street": "Al Wahda",
"house": "5",
"apartment": None,
"zip": "54321",
"comment": None,
},
"receiverContact": {
"fullName": "Ivan Ivanov",
"email": "ivan@example.com",
"phone": "+79001234567",
"phoneExt": "101",
"isCompany": False,
"companyName": None,
"inn": None,
"kpp": None,
},
"content": {
"description": "Headphones",
},
"pickupDate": "2026-05-15T10:00:00.000Z",
"deliveryDate": "2026-05-18T18:00:00.000Z",
"accountEmail": "client@example.com",
"systemData": {
"tariff": {
"provider": "СДЭК",
"serviceName": "Экспресс лайт",
"price": 125000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": 535,
},
"parcelType": "parcel",
"docPackaging": None,
"weight": "1.0",
"dimensions": {
"length": "20",
"width": "15",
"height": "10",
},
},
}
payload.update(overrides)
return payload
def make_init_payment_request(**overrides: Any) -> InitPaymentRequest:
return InitPaymentRequest.model_validate(make_init_payment_payload(**overrides))
+40 -64
View File
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
from app.repositories.order import OrderData, OrderRepository
from app.repositories.order.models import Base, Order
from tests.payment_fixtures import make_init_payment_payload
def _make_order_data(**overrides: object) -> OrderData:
@@ -17,40 +18,9 @@ def _make_order_data(**overrides: object) -> OrderData:
"order_uuid": "order-uuid-1",
"payment_url": "https://pay.test/payment/1",
"price": 125000,
"delivery_type": 2,
"tariff_code": 535,
"comment": "Test payment",
"sender": {
"name": "Petr Petrov",
"email": "sender@example.com",
"phone": {"number": "+79009876543"},
},
"recipient": {
"name": "Ivan Ivanov",
"email": "ivan@example.com",
"phone": {"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": 1,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
}
],
"account_email": "client@example.com",
"payload": make_init_payment_payload(),
}
payload.update(overrides)
return OrderData(**payload)
@@ -92,46 +62,20 @@ def test_create_order_persists_all_required_fields() -> None:
assert persisted_order.order_uuid == "order-uuid-1"
assert persisted_order.payment_url == "https://pay.test/payment/1"
assert persisted_order.price == 125000
assert persisted_order.delivery_type == 2
assert persisted_order.tariff_code == 535
assert persisted_order.sender == order_data.sender
assert persisted_order.recipient == order_data.recipient
assert persisted_order.from_location == order_data.from_location
assert persisted_order.to_location == order_data.to_location
assert persisted_order.packages == order_data.packages
assert persisted_order.services == order_data.services
assert persisted_order.comment == "Test payment"
assert persisted_order.account_email == "client@example.com"
assert persisted_order.payload == order_data.payload
assert persisted_order.payment_status is None
assert persisted_order.tbank_payment_id is None
assert persisted_order.cdek_order_uuid is None
assert persisted_order.cdek_waybill_uuid is None
assert persisted_order.cdek_waybill_url is None
assert persisted_order.created_at is not None
assert persisted_order.updated_at is not None
asyncio.run(_with_repository(run))
def test_create_order_persists_nullable_services_and_comment() -> None:
async def run(
repository: OrderRepository,
session_factory: async_sessionmaker[AsyncSession],
) -> None:
order_data = _make_order_data(services=None, comment=None)
async with repository.session() as session:
await repository.create_order(session, order_data)
async with session_factory() as session:
result = await session.execute(
select(Order).where(Order.order_uuid == "order-uuid-1")
)
persisted_order = result.scalar_one()
assert persisted_order.services is None
assert persisted_order.comment is None
asyncio.run(_with_repository(run))
def test_create_order_rejects_duplicate_order_uuid() -> None:
async def run(
repository: OrderRepository,
@@ -229,7 +173,7 @@ def test_mark_payment_status_returns_none_for_missing_order() -> None:
asyncio.run(_with_repository(run))
def test_mark_cdek_order_registered_persists_cdek_order_uuid() -> None:
def test_mark_cdek_order_registered_persists_cdek_order_and_waybill() -> None:
async def run(
repository: OrderRepository,
session_factory: async_sessionmaker[AsyncSession],
@@ -242,6 +186,8 @@ def test_mark_cdek_order_registered_persists_cdek_order_uuid() -> None:
session,
"order-uuid-1",
"cdek-order-uuid-1",
"waybill-uuid-1",
"https://cdek.test/waybill/1.pdf",
)
async with session_factory() as session:
@@ -252,6 +198,36 @@ def test_mark_cdek_order_registered_persists_cdek_order_uuid() -> None:
assert order is not None
assert persisted_order.cdek_order_uuid == "cdek-order-uuid-1"
assert persisted_order.cdek_waybill_uuid == "waybill-uuid-1"
assert persisted_order.cdek_waybill_url == "https://cdek.test/waybill/1.pdf"
asyncio.run(_with_repository(run))
def test_mark_cdek_order_registered_allows_missing_waybill_fields() -> None:
async def run(
repository: OrderRepository,
session_factory: async_sessionmaker[AsyncSession],
) -> None:
async with repository.session() as session:
await repository.create_order(session, _make_order_data())
async with repository.session() as session:
await repository.mark_cdek_order_registered(
session,
"order-uuid-1",
"cdek-order-uuid-1",
)
async with session_factory() as session:
result = await session.execute(
select(Order).where(Order.order_uuid == "order-uuid-1")
)
persisted_order = result.scalar_one()
assert persisted_order.cdek_order_uuid == "cdek-order-uuid-1"
assert persisted_order.cdek_waybill_uuid is None
assert persisted_order.cdek_waybill_url is None
asyncio.run(_with_repository(run))
+25 -135
View File
@@ -16,6 +16,7 @@ from app.services.aggregator import (
InitPaymentUnavailableError,
InvalidInitPaymentRequestError,
)
from tests.payment_fixtures import make_init_payment_payload, make_init_payment_request
class StubPaymentAdapter:
@@ -94,49 +95,6 @@ class StubOrderRepository:
return object()
def _make_init_payment_request(**overrides: object) -> InitPaymentRequest:
payload: dict[str, object] = {
"order_uuid": "order-uuid-1",
"price": 125000,
"type": 2,
"tariff_code": 535,
"comment": "Test payment",
"sender": {
"name": "Petr Petrov",
"email": "sender@example.com",
"phone": {"number": "+79009876543"},
},
"recipient": {
"name": "Ivan Ivanov",
"email": "ivan@example.com",
"phone": {"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": 1,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
}
],
}
payload.update(overrides)
return InitPaymentRequest(**payload)
def _make_cdek_price(**overrides: object) -> DeliveryPrice:
payload = {
"provider": "cdek",
@@ -151,7 +109,7 @@ def _make_cdek_price(**overrides: object) -> DeliveryPrice:
def test_init_payment_validates_cdek_price_before_tbank_and_returns_payment_url() -> None:
request = _make_init_payment_request()
request = make_init_payment_request()
events: list[str] = []
validation_adapter = StubPaymentPriceValidationAdapter(
response=_make_cdek_price(),
@@ -172,8 +130,9 @@ def test_init_payment_validates_cdek_price_before_tbank_and_returns_payment_url(
assert events == ["cdek", "tbank"]
def test_init_payment_persists_order_after_successful_payment_link() -> None:
request = _make_init_payment_request()
def test_init_payment_persists_order_payload_after_successful_payment_link() -> None:
payload = make_init_payment_payload()
request = InitPaymentRequest.model_validate(payload)
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
order_repository = StubOrderRepository()
@@ -184,59 +143,21 @@ def test_init_payment_persists_order_after_successful_payment_link() -> None:
order_repository=order_repository,
)
result = asyncio.run(service.init_payment(request))
asyncio.run(service.init_payment(request))
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
assert validation_adapter.calls == [request]
assert adapter.calls == [("order-uuid-1", 125000)]
assert order_repository.calls == [
(
order_repository.session_value,
OrderData(
order_uuid="order-uuid-1",
payment_url="https://pay.test/payment/1",
price=125000,
delivery_type=2,
tariff_code=535,
sender={
"name": "Petr Petrov",
"email": "sender@example.com",
"phone": {"number": "+79009876543"},
},
recipient={
"name": "Ivan Ivanov",
"email": "ivan@example.com",
"phone": {"number": "+79001234567"},
},
from_location={
"address": "Lenina 1",
"city": "Moscow",
"country_code": "RU",
},
to_location={
"address": "Pushkina 10",
"city": "Novosibirsk",
"country_code": "RU",
},
packages=[
{
"number": "1",
"weight": 1,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
}
],
services=[{"code": "INSURANCE", "parameter": "1000"}],
comment="Test payment",
),
)
]
assert len(order_repository.calls) == 1
session, order_data = order_repository.calls[0]
assert session is order_repository.session_value
assert order_data.order_uuid == "order-uuid-1"
assert order_data.payment_url == "https://pay.test/payment/1"
assert order_data.price == 125000
assert order_data.tariff_code == 535
assert order_data.account_email == "client@example.com"
assert order_data.payload == request.model_dump(mode="json", by_alias=True)
def test_init_payment_returns_payment_url_when_order_persistence_fails() -> None:
request = _make_init_payment_request()
request = make_init_payment_request()
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
order_repository = StubOrderRepository(error=RuntimeError("database down"))
@@ -250,13 +171,12 @@ def test_init_payment_returns_payment_url_when_order_persistence_fails() -> None
result = asyncio.run(service.init_payment(request))
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
assert validation_adapter.calls == [request]
assert adapter.calls == [("order-uuid-1", 125000)]
assert len(order_repository.calls) == 1
def test_init_payment_maps_provider_request_errors_to_invalid_payment_error() -> None:
request = _make_init_payment_request()
request = make_init_payment_request()
adapter = StubPaymentAdapter(error=TBankPaymentRequestError("bad payload"))
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
service = AggregatorService(
@@ -268,30 +188,9 @@ def test_init_payment_maps_provider_request_errors_to_invalid_payment_error() ->
with pytest.raises(InvalidInitPaymentRequestError):
asyncio.run(service.init_payment(request))
assert validation_adapter.calls == [request]
assert adapter.calls == [("order-uuid-1", 125000)]
def test_init_payment_delegates_request_with_services_none_without_new_logic() -> None:
request = _make_init_payment_request(services=None)
adapter = StubPaymentAdapter(response="https://pay.test/payment/2")
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
)
result = asyncio.run(service.init_payment(request))
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/2")
assert validation_adapter.calls == [request]
assert adapter.calls == [("order-uuid-1", 125000)]
assert request.services is None
def test_init_payment_maps_client_failures_to_unavailable_error() -> None:
request = _make_init_payment_request()
request = make_init_payment_request()
adapter = StubPaymentAdapter(error=TBankPaymentAdapterError("transport down"))
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
service = AggregatorService(
@@ -303,19 +202,18 @@ def test_init_payment_maps_client_failures_to_unavailable_error() -> None:
with pytest.raises(InitPaymentUnavailableError):
asyncio.run(service.init_payment(request))
assert validation_adapter.calls == [request]
assert adapter.calls == [("order-uuid-1", 125000)]
def test_init_payment_without_configured_adapter_raises_unavailable_error() -> None:
service = AggregatorService(providers=[])
with pytest.raises(InitPaymentUnavailableError):
asyncio.run(service.init_payment(_make_init_payment_request()))
asyncio.run(service.init_payment(make_init_payment_request()))
def test_init_payment_rejects_cdek_price_mismatch_without_tbank_or_repository_calls() -> None:
request = _make_init_payment_request(price=124999)
payload = make_init_payment_payload()
payload["systemData"]["tariff"]["price"] = 124999
request = InitPaymentRequest.model_validate(payload)
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
order_repository = StubOrderRepository()
@@ -329,13 +227,12 @@ def test_init_payment_rejects_cdek_price_mismatch_without_tbank_or_repository_ca
with pytest.raises(InvalidInitPaymentRequestError):
asyncio.run(service.init_payment(request))
assert validation_adapter.calls == [request]
assert adapter.calls == []
assert order_repository.calls == []
def test_init_payment_rejects_when_cdek_does_not_return_requested_tariff() -> None:
request = _make_init_payment_request()
request = make_init_payment_request()
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(response=None)
service = AggregatorService(
@@ -347,12 +244,11 @@ def test_init_payment_rejects_when_cdek_does_not_return_requested_tariff() -> No
with pytest.raises(InvalidInitPaymentRequestError):
asyncio.run(service.init_payment(request))
assert validation_adapter.calls == [request]
assert adapter.calls == []
def test_init_payment_maps_cdek_request_error_to_invalid_payment_error() -> None:
request = _make_init_payment_request()
request = make_init_payment_request()
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(
error=ProviderRequestError("bad CDEK payload")
@@ -366,12 +262,9 @@ def test_init_payment_maps_cdek_request_error_to_invalid_payment_error() -> None
with pytest.raises(InvalidInitPaymentRequestError):
asyncio.run(service.init_payment(request))
assert validation_adapter.calls == [request]
assert adapter.calls == []
def test_init_payment_maps_cdek_client_error_to_unavailable_error() -> None:
request = _make_init_payment_request()
request = make_init_payment_request()
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(
error=ProviderClientError("CDEK unavailable")
@@ -384,6 +277,3 @@ def test_init_payment_maps_cdek_client_error_to_unavailable_error() -> None:
with pytest.raises(InitPaymentUnavailableError):
asyncio.run(service.init_payment(request))
assert validation_adapter.calls == [request]
assert adapter.calls == []
+61 -54
View File
@@ -1,9 +1,12 @@
import asyncio
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Any
import pytest
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderRegistrationResult,
)
from app.adapters.tbank.base import TBankPaymentNotificationTokenError
from app.schemas.payment import InitPaymentRequest, TBankPaymentNotification
from app.services.aggregator import (
@@ -11,6 +14,11 @@ from app.services.aggregator import (
InvalidTBankPaymentNotificationError,
TBankPaymentNotificationProcessingError,
)
from tests.payment_fixtures import make_init_payment_payload
def _default_payload() -> dict[str, Any]:
return make_init_payment_payload()
@dataclass
@@ -18,57 +26,14 @@ class StoredOrder:
order_uuid: str = "order-uuid-1"
payment_url: str = "https://pay.test/payment/1"
price: int = 125000
delivery_type: int = 2
tariff_code: int = 535
comment: str | None = "Test payment"
sender: dict[str, Any] | None = None
recipient: dict[str, Any] | None = None
from_location: dict[str, Any] | None = None
to_location: dict[str, Any] | None = None
packages: list[dict[str, Any]] | None = None
services: list[dict[str, Any]] | None = None
account_email: str = "client@example.com"
payload: dict[str, Any] = field(default_factory=_default_payload)
payment_status: str | None = None
tbank_payment_id: int | None = None
cdek_order_uuid: str | None = None
def __post_init__(self) -> None:
if self.sender is None:
self.sender = {
"name": "Petr Petrov",
"email": "sender@example.com",
"phone": {"number": "+79009876543"},
}
if self.recipient is None:
self.recipient = {
"name": "Ivan Ivanov",
"email": "ivan@example.com",
"phone": {"number": "+79001234567"},
}
if self.from_location is None:
self.from_location = {
"address": "Lenina 1",
"city": "Moscow",
"country_code": "RU",
}
if self.to_location is None:
self.to_location = {
"address": "Pushkina 10",
"city": "Novosibirsk",
"country_code": "RU",
}
if self.packages is None:
self.packages = [
{
"number": "1",
"weight": 1,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
}
]
if self.services is None:
self.services = [{"code": "INSURANCE", "parameter": "1000"}]
cdek_waybill_uuid: str | None = None
cdek_waybill_url: str | None = None
class StubPaymentAdapter:
@@ -148,9 +113,20 @@ class StubOrderRepository:
session: object,
order_uuid: str,
cdek_order_uuid: str,
cdek_waybill_uuid: str | None = None,
cdek_waybill_url: str | None = None,
) -> StoredOrder | None:
self.calls.append(
("mark_cdek_order_registered", (session, order_uuid, cdek_order_uuid))
(
"mark_cdek_order_registered",
(
session,
order_uuid,
cdek_order_uuid,
cdek_waybill_uuid,
cdek_waybill_url,
),
)
)
if self._mark_cdek_errors:
error = self._mark_cdek_errors.pop(0)
@@ -161,6 +137,8 @@ class StubOrderRepository:
if order is None:
return None
order.cdek_order_uuid = cdek_order_uuid
order.cdek_waybill_uuid = cdek_waybill_uuid
order.cdek_waybill_url = cdek_waybill_url
return order
@@ -168,14 +146,22 @@ class StubCDEKOrderAdapter:
def __init__(
self,
*,
responses: list[str] | None = None,
responses: list[CDEKOrderRegistrationResult] | None = None,
error: Exception | None = None,
) -> None:
self._responses = responses or ["cdek-order-uuid-1"]
self._responses = responses or [
CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid-1",
waybill_uuid=None,
waybill_url=None,
)
]
self._error = error
self.calls: list[InitPaymentRequest] = []
async def register_order(self, request: InitPaymentRequest) -> str:
async def register_order(
self, request: InitPaymentRequest
) -> CDEKOrderRegistrationResult:
self.calls.append(request)
if self._error is not None:
raise self._error
@@ -201,7 +187,15 @@ def test_confirmed_notification_registers_cdek_order_and_saves_uuid() -> None:
order = StoredOrder()
payment_adapter = StubPaymentAdapter()
order_repository = StubOrderRepository(orders=[order])
cdek_adapter = StubCDEKOrderAdapter(responses=["cdek-order-uuid-1"])
cdek_adapter = StubCDEKOrderAdapter(
responses=[
CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid-1",
waybill_uuid="waybill-uuid-1",
waybill_url="https://cdek.test/waybill/1.pdf",
)
]
)
service = AggregatorService(
providers=[],
payment_adapter=payment_adapter,
@@ -217,6 +211,8 @@ def test_confirmed_notification_registers_cdek_order_and_saves_uuid() -> None:
assert order.payment_status == "CONFIRMED"
assert order.tbank_payment_id == 8347568144
assert order.cdek_order_uuid == "cdek-order-uuid-1"
assert order.cdek_waybill_uuid == "waybill-uuid-1"
assert order.cdek_waybill_url == "https://cdek.test/waybill/1.pdf"
assert len(cdek_adapter.calls) == 1
assert cdek_adapter.calls[0].order_uuid == "order-uuid-1"
@@ -317,7 +313,18 @@ def test_repeated_confirmed_after_cdek_uuid_save_failure_uses_same_external_id()
mark_cdek_errors=[RuntimeError("db down"), None],
)
cdek_adapter = StubCDEKOrderAdapter(
responses=["same-cdek-order-uuid", "same-cdek-order-uuid"]
responses=[
CDEKOrderRegistrationResult(
order_uuid="same-cdek-order-uuid",
waybill_uuid=None,
waybill_url=None,
),
CDEKOrderRegistrationResult(
order_uuid="same-cdek-order-uuid",
waybill_uuid=None,
waybill_url=None,
),
]
)
service = AggregatorService(
providers=[],