From c494d5056698a66a52d5be398a5a95053236f7d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=B0=D0=B8=D1=81=20=D0=AE=D1=81=D1=83=D0=BF=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B5=D0=B2?= Date: Sat, 23 May 2026 17:29:56 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=B7=D0=B0=D0=BA=D0=B0=D0=B7=20=D0=BD=D0=B0=D0=BA?= =?UTF-8?q?=D0=BB=D0=B0=D0=B4=D0=BD=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260513_032_rework_orders_payload.py | 97 ++++ .../20260523_033_add_cdek_waybill_columns.py | 28 ++ .../delivery_providers/cdek/client.py | 74 ++- .../delivery_providers/cdek/order_mapper.py | 209 +++++++- app/repositories/order/models.py | 24 +- app/repositories/order/repository.py | 24 +- app/schemas/payment.py | 147 ++++-- app/services/aggregator.py | 79 ++- http-client.http | 125 +++-- spec/index.md | 5 +- spec/overview.md | 86 +++- ..._rework_init_payment_contract_camelcase.md | 102 ++++ tests/__init__.py | 0 .../cdek/test_order_client.py | 233 ++------- .../cdek/test_order_mapper.py | 248 ++++++++-- .../cdek/test_payment_price_validation.py | 103 ++-- tests/controllers/v1/test_init_payment.py | 466 ++++++------------ tests/payment_fixtures.py | 81 +++ tests/repositories/order/test_repository.py | 104 ++-- tests/services/test_init_payment.py | 160 +----- tests/services/test_tbank_notifications.py | 115 +++-- 21 files changed, 1384 insertions(+), 1126 deletions(-) create mode 100644 alembic/versions/20260513_032_rework_orders_payload.py create mode 100644 alembic/versions/20260523_033_add_cdek_waybill_columns.py create mode 100644 spec/tasks/032_rework_init_payment_contract_camelcase.md create mode 100644 tests/__init__.py create mode 100644 tests/payment_fixtures.py diff --git a/alembic/versions/20260513_032_rework_orders_payload.py b/alembic/versions/20260513_032_rework_orders_payload.py new file mode 100644 index 0000000..a292a85 --- /dev/null +++ b/alembic/versions/20260513_032_rework_orders_payload.py @@ -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, + ), + ) diff --git a/alembic/versions/20260523_033_add_cdek_waybill_columns.py b/alembic/versions/20260523_033_add_cdek_waybill_columns.py new file mode 100644 index 0000000..4727066 --- /dev/null +++ b/alembic/versions/20260523_033_add_cdek_waybill_columns.py @@ -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") diff --git a/app/adapters/delivery_providers/cdek/client.py b/app/adapters/delivery_providers/cdek/client.py index ba71da3..43e00fd 100644 --- a/app/adapters/delivery_providers/cdek/client.py +++ b/app/adapters/delivery_providers/cdek/client.py @@ -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) diff --git a/app/adapters/delivery_providers/cdek/order_mapper.py b/app/adapters/delivery_providers/cdek/order_mapper.py index 23b3278..1872a90 100644 --- a/app/adapters/delivery_providers/cdek/order_mapper.py +++ b/app/adapters/delivery_providers/cdek/order_mapper.py @@ -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: diff --git a/app/repositories/order/models.py b/app/repositories/order/models.py index 198e278..7317da9 100644 --- a/app/repositories/order/models.py +++ b/app/repositories/order/models.py @@ -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(), diff --git a/app/repositories/order/repository.py b/app/repositories/order/repository.py index 9251680..fc2162c 100644 --- a/app/repositories/order/repository.py +++ b/app/repositories/order/repository.py @@ -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 diff --git a/app/schemas/payment.py b/app/schemas/payment.py index fb86000..3e91264 100644 --- a/app/schemas/payment.py +++ b/app/schemas/payment.py @@ -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): diff --git a/app/services/aggregator.py b/app/services/aggregator.py index 479a395..8241ece 100644 --- a/app/services/aggregator.py +++ b/app/services/aggregator.py @@ -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, diff --git a/http-client.http b/http-client.http index d09b74c..59980ff 100644 --- a/http-client.http +++ b/http-client.http @@ -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 diff --git a/spec/index.md b/spec/index.md index 3053623..a1a554e 100644 --- a/spec/index.md +++ b/spec/index.md @@ -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** diff --git a/spec/overview.md b/spec/overview.md index ac6014a..ff26e45 100644 --- a/spec/overview.md +++ b/spec/overview.md @@ -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` ``` diff --git a/spec/tasks/032_rework_init_payment_contract_camelcase.md b/spec/tasks/032_rework_init_payment_contract_camelcase.md new file mode 100644 index 0000000..516e83a --- /dev/null +++ b/spec/tasks/032_rework_init_payment_contract_camelcase.md @@ -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` diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/adapters/delivery_providers/cdek/test_order_client.py b/tests/adapters/delivery_providers/cdek/test_order_client.py index 5735d45..b963cff 100644 --- a/tests/adapters/delivery_providers/cdek/test_order_client.py +++ b/tests/adapters/delivery_providers/cdek/test_order_client.py @@ -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())) diff --git a/tests/adapters/delivery_providers/cdek/test_order_mapper.py b/tests/adapters/delivery_providers/cdek/test_order_mapper.py index 59891bc..e91b7c6 100644 --- a/tests/adapters/delivery_providers/cdek/test_order_mapper.py +++ b/tests/adapters/delivery_providers/cdek/test_order_mapper.py @@ -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" diff --git a/tests/adapters/delivery_providers/cdek/test_payment_price_validation.py b/tests/adapters/delivery_providers/cdek/test_payment_price_validation.py index 7ad3742..0896903 100644 --- a/tests/adapters/delivery_providers/cdek/test_payment_price_validation.py +++ b/tests/adapters/delivery_providers/cdek/test_payment_price_validation.py @@ -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())) diff --git a/tests/controllers/v1/test_init_payment.py b/tests/controllers/v1/test_init_payment.py index d6ebf1b..1e60095 100644 --- a/tests/controllers/v1/test_init_payment.py +++ b/tests/controllers/v1/test_init_payment.py @@ -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 == [] diff --git a/tests/payment_fixtures.py b/tests/payment_fixtures.py new file mode 100644 index 0000000..82c979d --- /dev/null +++ b/tests/payment_fixtures.py @@ -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)) diff --git a/tests/repositories/order/test_repository.py b/tests/repositories/order/test_repository.py index a4d4545..f5b6f8f 100644 --- a/tests/repositories/order/test_repository.py +++ b/tests/repositories/order/test_repository.py @@ -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)) diff --git a/tests/services/test_init_payment.py b/tests/services/test_init_payment.py index 4455cc3..289d0c5 100644 --- a/tests/services/test_init_payment.py +++ b/tests/services/test_init_payment.py @@ -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 == [] diff --git a/tests/services/test_tbank_notifications.py b/tests/services/test_tbank_notifications.py index 961ab64..8bc5a92 100644 --- a/tests/services/test_tbank_notifications.py +++ b/tests/services/test_tbank_notifications.py @@ -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=[],