Добавлена валидация цены
This commit is contained in:
@@ -18,5 +18,9 @@ class DeliveryProvider(ABC):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderClientError(RuntimeError):
|
||||||
|
"""Raised when a provider call fails for temporary or provider-side reasons."""
|
||||||
|
|
||||||
|
|
||||||
class ProviderRequestError(RuntimeError):
|
class ProviderRequestError(RuntimeError):
|
||||||
"""Raised when a provider rejects input request data."""
|
"""Raised when a provider rejects input request data."""
|
||||||
|
|||||||
@@ -3,13 +3,21 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
import logging
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import structlog
|
||||||
|
|
||||||
from app.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError
|
from app.adapters.delivery_providers.base import (
|
||||||
|
DeliveryProvider,
|
||||||
|
ProviderClientError,
|
||||||
|
ProviderRequestError,
|
||||||
|
)
|
||||||
from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient
|
from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient
|
||||||
from app.adapters.delivery_providers.cdek.mapper import map_cdek_response
|
from app.adapters.delivery_providers.cdek.mapper import (
|
||||||
|
CDEKMappingError,
|
||||||
|
map_cdek_response,
|
||||||
|
map_cdek_response_for_tariff_code,
|
||||||
|
)
|
||||||
from app.adapters.delivery_providers.cdek.order_mapper import (
|
from app.adapters.delivery_providers.cdek.order_mapper import (
|
||||||
CDEKOrderMappingError,
|
CDEKOrderMappingError,
|
||||||
map_cdek_existing_order_response,
|
map_cdek_existing_order_response,
|
||||||
@@ -21,10 +29,10 @@ from app.config import AdapterConfig
|
|||||||
from app.schemas.payment import InitPaymentRequest
|
from app.schemas.payment import InitPaymentRequest
|
||||||
from app.schemas.request import DeliveryCalculationRequest
|
from app.schemas.request import DeliveryCalculationRequest
|
||||||
from app.schemas.response import DeliveryPrice
|
from app.schemas.response import DeliveryPrice
|
||||||
log = logging.getLogger(__name__)
|
log = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class CDEKClientError(RuntimeError):
|
class CDEKClientError(ProviderClientError):
|
||||||
"""Raised when CDEK tariff request fails."""
|
"""Raised when CDEK tariff request fails."""
|
||||||
|
|
||||||
|
|
||||||
@@ -58,6 +66,29 @@ class CDEKClient:
|
|||||||
self, request: DeliveryCalculationRequest
|
self, request: DeliveryCalculationRequest
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
payload = await self._build_payload(request)
|
payload = await self._build_payload(request)
|
||||||
|
return await self._post_tariff_payload(
|
||||||
|
payload,
|
||||||
|
request_error_message=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_raw_payment_price(
|
||||||
|
self,
|
||||||
|
request: InitPaymentRequest,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload = self._build_payment_price_payload(request)
|
||||||
|
return await self._post_tariff_payload(
|
||||||
|
payload,
|
||||||
|
request_error_message=(
|
||||||
|
"CDEK payment price validation request was rejected with status"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _post_tariff_payload(
|
||||||
|
self,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
request_error_message: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
for attempt in range(self._retry_attempts + 1):
|
for attempt in range(self._retry_attempts + 1):
|
||||||
try:
|
try:
|
||||||
token = await self._auth_client.get_access_token()
|
token = await self._auth_client.get_access_token()
|
||||||
@@ -79,10 +110,27 @@ class CDEKClient:
|
|||||||
if attempt < self._retry_attempts:
|
if attempt < self._retry_attempts:
|
||||||
await self._sleep(self._retry_delay(attempt))
|
await self._sleep(self._retry_delay(attempt))
|
||||||
continue
|
continue
|
||||||
|
log.warning(
|
||||||
|
"cdek_tariff_request_server_error",
|
||||||
|
status_code=response.status_code,
|
||||||
|
response_body=_response_text_or_none(response),
|
||||||
|
request_payload=payload,
|
||||||
|
)
|
||||||
raise CDEKClientError(
|
raise CDEKClientError(
|
||||||
f"CDEK tariff request failed with status {response.status_code}."
|
f"CDEK tariff request failed with status {response.status_code}."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if request_error_message is not None and 400 <= response.status_code < 500:
|
||||||
|
log.warning(
|
||||||
|
"cdek_tariff_request_rejected",
|
||||||
|
status_code=response.status_code,
|
||||||
|
response_body=_response_text_or_none(response),
|
||||||
|
request_payload=payload,
|
||||||
|
)
|
||||||
|
raise CDEKRequestError(
|
||||||
|
f"{request_error_message} {response.status_code}."
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
raw_payload = response.json()
|
raw_payload = response.json()
|
||||||
@@ -182,6 +230,30 @@ class CDEKClient:
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
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
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if request.services is not None:
|
||||||
|
payload["services"] = [
|
||||||
|
service.model_dump(mode="python") for service in request.services
|
||||||
|
]
|
||||||
|
return payload
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_cdek_city_code(city_id: int) -> int:
|
def _get_cdek_city_code(city_id: int) -> int:
|
||||||
city_entry = cities_map.get(str(city_id))
|
city_entry = cities_map.get(str(city_id))
|
||||||
@@ -247,6 +319,21 @@ class CDEKProvider(DeliveryProvider):
|
|||||||
raw_payload = await self._client.get_raw_price(request)
|
raw_payload = await self._client.get_raw_price(request)
|
||||||
return map_cdek_response(raw_payload)
|
return map_cdek_response(raw_payload)
|
||||||
|
|
||||||
|
async def get_payment_price(
|
||||||
|
self,
|
||||||
|
request: InitPaymentRequest,
|
||||||
|
) -> DeliveryPrice | None:
|
||||||
|
raw_payload = await self._client.get_raw_payment_price(request)
|
||||||
|
try:
|
||||||
|
return map_cdek_response_for_tariff_code(
|
||||||
|
raw_payload,
|
||||||
|
tariff_code=request.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) -> str:
|
||||||
return await self._client.register_order(request)
|
return await self._client.register_order(request)
|
||||||
|
|
||||||
@@ -256,3 +343,10 @@ def _response_json_or_none(response: httpx.Response) -> object | None:
|
|||||||
return response.json()
|
return response.json()
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _response_text_or_none(response: httpx.Response) -> str | None:
|
||||||
|
try:
|
||||||
|
return response.text
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|||||||
@@ -19,6 +19,24 @@ def map_cdek_response(payload: dict[str, Any]) -> list[DeliveryPrice]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def map_cdek_response_for_tariff_code(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
tariff_code: int,
|
||||||
|
) -> DeliveryPrice | None:
|
||||||
|
tariff_codes = payload.get("tariff_codes")
|
||||||
|
if not isinstance(tariff_codes, list):
|
||||||
|
raise CDEKMappingError("CDEK response must include tariff_codes.")
|
||||||
|
|
||||||
|
payload_currency = payload.get("currency")
|
||||||
|
for tariff in tariff_codes:
|
||||||
|
if not isinstance(tariff, dict):
|
||||||
|
raise CDEKMappingError("CDEK tariff entry must be an object.")
|
||||||
|
if _tariff_code_matches(tariff.get("tariff_code"), tariff_code):
|
||||||
|
return _map_tariff(tariff, payload_currency=payload_currency)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _map_tariff(
|
def _map_tariff(
|
||||||
tariff: dict[str, Any],
|
tariff: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
@@ -47,6 +65,8 @@ def _map_tariff(
|
|||||||
currency=str(raw_currency).upper(),
|
currency=str(raw_currency).upper(),
|
||||||
delivery_days_min=int(period_min),
|
delivery_days_min=int(period_min),
|
||||||
delivery_days_max=int(period_max),
|
delivery_days_max=int(period_max),
|
||||||
|
tariff_code=_extract_tariff_code(tariff.get("tariff_code")),
|
||||||
|
bypass_parcel_type_filter=True,
|
||||||
)
|
)
|
||||||
except (ArithmeticError, TypeError, ValueError) as exc:
|
except (ArithmeticError, TypeError, ValueError) as exc:
|
||||||
raise CDEKMappingError("CDEK response fields have invalid values.") from exc
|
raise CDEKMappingError("CDEK response fields have invalid values.") from exc
|
||||||
@@ -62,3 +82,21 @@ def _get_tariffs(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
|||||||
raise CDEKMappingError("CDEK tariff entry must be an object.")
|
raise CDEKMappingError("CDEK tariff entry must be an object.")
|
||||||
tariffs.append(tariff)
|
tariffs.append(tariff)
|
||||||
return tariffs
|
return tariffs
|
||||||
|
|
||||||
|
|
||||||
|
def _tariff_code_matches(value: object, expected_tariff_code: int) -> bool:
|
||||||
|
if isinstance(value, bool) or value is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return int(value) == expected_tariff_code
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_tariff_code(value: object) -> int | None:
|
||||||
|
if isinstance(value, bool) or value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
|
|||||||
providers=providers,
|
providers=providers,
|
||||||
cache=cache,
|
cache=cache,
|
||||||
payment_adapter=payment_adapter,
|
payment_adapter=payment_adapter,
|
||||||
|
payment_price_validation_adapter=cdek_provider,
|
||||||
order_repository=order_repository,
|
order_repository=order_repository,
|
||||||
order_registration_adapter=cdek_provider,
|
order_registration_adapter=cdek_provider,
|
||||||
address_suggestion_providers=(
|
address_suggestion_providers=(
|
||||||
@@ -166,7 +167,7 @@ async def suggest_addresses(
|
|||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/init-payment",
|
"/order",
|
||||||
response_model=InitPaymentResponse,
|
response_model=InitPaymentResponse,
|
||||||
)
|
)
|
||||||
async def init_payment(
|
async def init_payment(
|
||||||
|
|||||||
+56
-1
@@ -12,6 +12,7 @@ DIMENSIONS_ROUND_SCALE = 1
|
|||||||
MIN_WEIGHT_KG = Decimal("0.01")
|
MIN_WEIGHT_KG = Decimal("0.01")
|
||||||
MIN_DIMENSION_CM = Decimal("0.1")
|
MIN_DIMENSION_CM = Decimal("0.1")
|
||||||
INTEGER_PRICE_QUANTIZER = Decimal("1")
|
INTEGER_PRICE_QUANTIZER = Decimal("1")
|
||||||
|
KOPECKS_IN_RUBLE = Decimal("100")
|
||||||
DOC_SERVICE_MARKERS = ("документ", "document")
|
DOC_SERVICE_MARKERS = ("документ", "document")
|
||||||
|
|
||||||
_MISSING = object()
|
_MISSING = object()
|
||||||
@@ -46,6 +47,8 @@ class ProviderPrice:
|
|||||||
currency: str
|
currency: str
|
||||||
delivery_days_min: int
|
delivery_days_min: int
|
||||||
delivery_days_max: int
|
delivery_days_max: int
|
||||||
|
tariff_code: int | None = None
|
||||||
|
bypass_parcel_type_filter: bool = False
|
||||||
|
|
||||||
|
|
||||||
def normalize_delivery_request(
|
def normalize_delivery_request(
|
||||||
@@ -118,6 +121,48 @@ def filter_and_sort_prices(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_expected_payment_amount_kopecks(
|
||||||
|
provider_price: object,
|
||||||
|
*,
|
||||||
|
price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
||||||
|
) -> int | None:
|
||||||
|
"""Return expected payment amount in kopecks for a RUB provider price."""
|
||||||
|
|
||||||
|
currency = _normalize_text(_get_optional_attr(provider_price, "currency")).upper()
|
||||||
|
if currency != "RUB":
|
||||||
|
return None
|
||||||
|
|
||||||
|
price = _try_to_decimal(_get_optional_attr(provider_price, "price"))
|
||||||
|
if price is None or not price.is_finite() or price <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
adjusted_price = _apply_price_multiplier_and_round(
|
||||||
|
price,
|
||||||
|
price_multiplier=_normalize_price_multiplier(price_multiplier),
|
||||||
|
)
|
||||||
|
if adjusted_price is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return int(adjusted_price * KOPECKS_IN_RUBLE)
|
||||||
|
|
||||||
|
|
||||||
|
def is_init_payment_price_valid(
|
||||||
|
requested_amount_kopecks: object,
|
||||||
|
provider_price: object,
|
||||||
|
*,
|
||||||
|
price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
||||||
|
) -> bool:
|
||||||
|
expected_amount_kopecks = calculate_expected_payment_amount_kopecks(
|
||||||
|
provider_price,
|
||||||
|
price_multiplier=price_multiplier,
|
||||||
|
)
|
||||||
|
if expected_amount_kopecks is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
requested_amount = _try_to_int(requested_amount_kopecks)
|
||||||
|
return requested_amount == expected_amount_kopecks
|
||||||
|
|
||||||
|
|
||||||
def filter_prices_by_parcel_type(
|
def filter_prices_by_parcel_type(
|
||||||
prices: Iterable[ProviderPrice],
|
prices: Iterable[ProviderPrice],
|
||||||
*,
|
*,
|
||||||
@@ -132,7 +177,8 @@ def filter_prices_by_parcel_type(
|
|||||||
return [
|
return [
|
||||||
price
|
price
|
||||||
for price in prices
|
for price in prices
|
||||||
if _matches_parcel_type(price.service_name, normalized_parcel_type)
|
if price.bypass_parcel_type_filter
|
||||||
|
or _matches_parcel_type(price.service_name, normalized_parcel_type)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -179,9 +225,18 @@ def _normalize_price(
|
|||||||
currency=currency,
|
currency=currency,
|
||||||
delivery_days_min=min_days,
|
delivery_days_min=min_days,
|
||||||
delivery_days_max=max_days,
|
delivery_days_max=max_days,
|
||||||
|
tariff_code=_try_to_int(_get_optional_attr(candidate, "tariff_code")),
|
||||||
|
bypass_parcel_type_filter=_extract_bypass_parcel_type_filter(candidate),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_bypass_parcel_type_filter(candidate: object) -> bool:
|
||||||
|
value = _get_optional_attr(candidate, "bypass_parcel_type_filter")
|
||||||
|
if value is _MISSING or value is None:
|
||||||
|
return False
|
||||||
|
return bool(value)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_weight(weight: Decimal, scale: int) -> Decimal:
|
def _normalize_weight(weight: Decimal, scale: int) -> Decimal:
|
||||||
rounded = _round_half_up(weight, scale)
|
rounded = _round_half_up(weight, scale)
|
||||||
if rounded < MIN_WEIGHT_KG:
|
if rounded < MIN_WEIGHT_KG:
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class InitPaymentRequest(BaseModel):
|
|||||||
order_uuid: str = Field(min_length=1)
|
order_uuid: str = Field(min_length=1)
|
||||||
price: int = Field(gt=0, strict=True, description="Payment amount in kopecks.")
|
price: int = Field(gt=0, strict=True, description="Payment amount in kopecks.")
|
||||||
type: Literal[2]
|
type: Literal[2]
|
||||||
tariff_code: Literal[535]
|
tariff_code: int
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
sender: PaymentParty
|
sender: PaymentParty
|
||||||
recipient: PaymentParty
|
recipient: PaymentParty
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ class DeliveryPrice(BaseModel):
|
|||||||
currency: str = Field(min_length=3, max_length=3)
|
currency: str = Field(min_length=3, max_length=3)
|
||||||
delivery_days_min: int = Field(ge=0)
|
delivery_days_min: int = Field(ge=0)
|
||||||
delivery_days_max: int = Field(ge=0)
|
delivery_days_max: int = Field(ge=0)
|
||||||
|
tariff_code: int | None = Field(default=None, ge=0)
|
||||||
|
bypass_parcel_type_filter: bool = Field(default=False, exclude=True)
|
||||||
|
|
||||||
|
|
||||||
class AddressSuggestion(BaseModel):
|
class AddressSuggestion(BaseModel):
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ from app.adapters.address_suggestions.base import (
|
|||||||
AddressSuggestionProvider,
|
AddressSuggestionProvider,
|
||||||
AddressSuggestionRequestError,
|
AddressSuggestionRequestError,
|
||||||
)
|
)
|
||||||
from app.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError
|
from app.adapters.delivery_providers.base import (
|
||||||
|
DeliveryProvider,
|
||||||
|
ProviderClientError,
|
||||||
|
ProviderRequestError,
|
||||||
|
)
|
||||||
from app.adapters.tbank.base import (
|
from app.adapters.tbank.base import (
|
||||||
TBankPaymentAdapterError,
|
TBankPaymentAdapterError,
|
||||||
TBankPaymentNotificationTokenError,
|
TBankPaymentNotificationTokenError,
|
||||||
@@ -29,7 +33,9 @@ from app.domain.price import (
|
|||||||
DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
||||||
DEFAULT_WEIGHT_ROUND_SCALE,
|
DEFAULT_WEIGHT_ROUND_SCALE,
|
||||||
NormalizedDeliveryRequest,
|
NormalizedDeliveryRequest,
|
||||||
|
calculate_expected_payment_amount_kopecks,
|
||||||
filter_and_sort_prices,
|
filter_and_sort_prices,
|
||||||
|
is_init_payment_price_valid,
|
||||||
normalize_delivery_request,
|
normalize_delivery_request,
|
||||||
)
|
)
|
||||||
from app.repositories.order import OrderData
|
from app.repositories.order import OrderData
|
||||||
@@ -95,6 +101,13 @@ class PaymentAdapterProtocol(Protocol):
|
|||||||
) -> None: ...
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentPriceValidationAdapterProtocol(Protocol):
|
||||||
|
async def get_payment_price(
|
||||||
|
self,
|
||||||
|
request: InitPaymentRequest,
|
||||||
|
) -> DeliveryPrice | None: ...
|
||||||
|
|
||||||
|
|
||||||
class OrderRegistrationAdapterProtocol(Protocol):
|
class OrderRegistrationAdapterProtocol(Protocol):
|
||||||
async def register_order(self, request: InitPaymentRequest) -> str: ...
|
async def register_order(self, request: InitPaymentRequest) -> str: ...
|
||||||
|
|
||||||
@@ -142,6 +155,9 @@ class AggregatorService:
|
|||||||
providers: Sequence[DeliveryProvider],
|
providers: Sequence[DeliveryProvider],
|
||||||
cache: PriceCacheProtocol | None = None,
|
cache: PriceCacheProtocol | None = None,
|
||||||
payment_adapter: PaymentAdapterProtocol | None = None,
|
payment_adapter: PaymentAdapterProtocol | None = None,
|
||||||
|
payment_price_validation_adapter: (
|
||||||
|
PaymentPriceValidationAdapterProtocol | None
|
||||||
|
) = None,
|
||||||
order_repository: OrderRepositoryProtocol | None = None,
|
order_repository: OrderRepositoryProtocol | None = None,
|
||||||
order_registration_adapter: OrderRegistrationAdapterProtocol | None = None,
|
order_registration_adapter: OrderRegistrationAdapterProtocol | None = None,
|
||||||
address_suggestion_providers: Sequence[AddressSuggestionProvider] = (),
|
address_suggestion_providers: Sequence[AddressSuggestionProvider] = (),
|
||||||
@@ -154,6 +170,7 @@ class AggregatorService:
|
|||||||
self._providers = tuple(providers)
|
self._providers = tuple(providers)
|
||||||
self._cache = cache
|
self._cache = cache
|
||||||
self._payment_adapter = payment_adapter
|
self._payment_adapter = payment_adapter
|
||||||
|
self._payment_price_validation_adapter = payment_price_validation_adapter
|
||||||
self._order_repository = order_repository
|
self._order_repository = order_repository
|
||||||
self._order_registration_adapter = order_registration_adapter
|
self._order_registration_adapter = order_registration_adapter
|
||||||
self._weight_round_scale = weight_round_scale
|
self._weight_round_scale = weight_round_scale
|
||||||
@@ -237,6 +254,8 @@ class AggregatorService:
|
|||||||
if self._payment_adapter is None:
|
if self._payment_adapter is None:
|
||||||
raise InitPaymentUnavailableError("Payment adapter is not configured.")
|
raise InitPaymentUnavailableError("Payment adapter is not configured.")
|
||||||
|
|
||||||
|
await self._validate_init_payment_price(request)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payment_url = await self._payment_adapter.create_payment_link(
|
payment_url = await self._payment_adapter.create_payment_link(
|
||||||
order_uuid=request.order_uuid,
|
order_uuid=request.order_uuid,
|
||||||
@@ -266,6 +285,85 @@ class AggregatorService:
|
|||||||
await self._persist_order(request=request, payment_url=payment_url)
|
await self._persist_order(request=request, payment_url=payment_url)
|
||||||
return InitPaymentResponse(payment_url=payment_url)
|
return InitPaymentResponse(payment_url=payment_url)
|
||||||
|
|
||||||
|
async def _validate_init_payment_price(
|
||||||
|
self,
|
||||||
|
request: InitPaymentRequest,
|
||||||
|
) -> None:
|
||||||
|
if self._payment_price_validation_adapter is None:
|
||||||
|
raise InitPaymentUnavailableError(
|
||||||
|
"Payment price validation adapter is not configured."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider_price = await self._payment_price_validation_adapter.get_payment_price(
|
||||||
|
request
|
||||||
|
)
|
||||||
|
except ProviderRequestError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"init_payment_price_validation_request_rejected",
|
||||||
|
order_uuid=request.order_uuid,
|
||||||
|
tariff_code=request.tariff_code,
|
||||||
|
requested_price_kopecks=request.price,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
raise InvalidInitPaymentRequestError(
|
||||||
|
"Payment init request is invalid for CDEK price validation."
|
||||||
|
) from exc
|
||||||
|
except ProviderClientError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"init_payment_price_validation_unavailable",
|
||||||
|
order_uuid=request.order_uuid,
|
||||||
|
tariff_code=request.tariff_code,
|
||||||
|
requested_price_kopecks=request.price,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
raise InitPaymentUnavailableError(
|
||||||
|
"Payment price validation is temporarily unavailable."
|
||||||
|
) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(
|
||||||
|
"init_payment_price_validation_unexpected_error",
|
||||||
|
order_uuid=request.order_uuid,
|
||||||
|
tariff_code=request.tariff_code,
|
||||||
|
requested_price_kopecks=request.price,
|
||||||
|
)
|
||||||
|
raise InitPaymentUnavailableError(
|
||||||
|
"Payment price validation is temporarily unavailable."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if provider_price is None:
|
||||||
|
logger.warning(
|
||||||
|
"init_payment_price_validation_tariff_not_found",
|
||||||
|
order_uuid=request.order_uuid,
|
||||||
|
tariff_code=request.tariff_code,
|
||||||
|
requested_price_kopecks=request.price,
|
||||||
|
)
|
||||||
|
raise InvalidInitPaymentRequestError(
|
||||||
|
"CDEK did not return the requested tariff for payment validation."
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_amount_kopecks = calculate_expected_payment_amount_kopecks(
|
||||||
|
provider_price,
|
||||||
|
price_multiplier=self._provider_price_multiplier,
|
||||||
|
)
|
||||||
|
if not is_init_payment_price_valid(
|
||||||
|
request.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,
|
||||||
|
expected_price_kopecks=expected_amount_kopecks,
|
||||||
|
provider_currency=getattr(provider_price, "currency", None),
|
||||||
|
provider_price=str(getattr(provider_price, "price", None)),
|
||||||
|
)
|
||||||
|
raise InvalidInitPaymentRequestError(
|
||||||
|
"Payment amount does not match CDEK validated delivery price."
|
||||||
|
)
|
||||||
|
|
||||||
async def handle_tbank_payment_notification(
|
async def handle_tbank_payment_notification(
|
||||||
self,
|
self,
|
||||||
notification: TBankPaymentNotification,
|
notification: TBankPaymentNotification,
|
||||||
|
|||||||
+10
-10
@@ -13,16 +13,6 @@ services:
|
|||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
volumes:
|
volumes:
|
||||||
- ./config.yaml:/config.yaml
|
- ./config.yaml:/config.yaml
|
||||||
migrations:
|
|
||||||
image: yusupal1ev/g2s-aggregator:0.0.2
|
|
||||||
container_name: g2s-aggregator-migrations
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
volumes:
|
|
||||||
- ./config.yaml:/config.yaml
|
|
||||||
command: ["poetry", "run", "alembic", "upgrade", "head"]
|
|
||||||
restart: "no"
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: redis
|
container_name: redis
|
||||||
@@ -45,5 +35,15 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
migrations:
|
||||||
|
image: yusupal1ev/g2s-aggregator:0.0.2
|
||||||
|
container_name: g2s-aggregator-migrations
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./config.yaml:/config.yaml
|
||||||
|
command: ["poetry", "run", "alembic", "upgrade", "head"]
|
||||||
|
restart: "no"
|
||||||
volumes:
|
volumes:
|
||||||
postgres:
|
postgres:
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ GET {{base_url}}/v2/orders?cdek_number=10240410458
|
|||||||
Authorization: Bearer {{auth_token}}
|
Authorization: Bearer {{auth_token}}
|
||||||
|
|
||||||
### 3. Инициализация оплаты доставки
|
### 3. Инициализация оплаты доставки
|
||||||
POST http://localhost:8000/api/v1/delivery/init-payment
|
POST http://localhost:8000/api/v1/delivery/order
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
+4
-3
@@ -36,10 +36,11 @@
|
|||||||
| 027 | DONE | 2026-04-11 | Add TBank payment adapter, init_payment endpoint and rename order flow | `spec/tasks/027_add_tbank_payment_adapter_and_order_payment_link.md` |
|
| 027 | DONE | 2026-04-11 | Add TBank payment adapter, init_payment endpoint and rename order flow | `spec/tasks/027_add_tbank_payment_adapter_and_order_payment_link.md` |
|
||||||
| 028 | DONE | 2026-04-12 | Add PostgreSQL adapter, order repository and persist order after payment link creation | `spec/tasks/028_add_postgresql_order_persistence.md` |
|
| 028 | DONE | 2026-04-12 | Add PostgreSQL adapter, order repository and persist order after payment link creation | `spec/tasks/028_add_postgresql_order_persistence.md` |
|
||||||
| 029 | DONE | 2026-04-18 | Add TBank payment notification and success URLs | `spec/tasks/029_add_tbank_payment_urls.md` |
|
| 029 | DONE | 2026-04-18 | Add TBank payment notification and success URLs | `spec/tasks/029_add_tbank_payment_urls.md` |
|
||||||
| 030 | TODO | 2026-04-18 | Add TBank payment notification webhook and CDEK order creation | `spec/tasks/030_add_tbank_payment_notification_webhook.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` |
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
- Total: **31**
|
- Total: **32**
|
||||||
- TODO: **1**
|
- TODO: **1**
|
||||||
- DONE: **30**
|
- DONE: **31**
|
||||||
|
|||||||
+1
-1
@@ -59,7 +59,7 @@
|
|||||||
### Controller (`app/controllers/v1/delivery.py`)
|
### Controller (`app/controllers/v1/delivery.py`)
|
||||||
- `POST /api/v1/delivery/price` — принимает `DeliveryCalculationRequest`, возвращает `list[DeliveryPrice]`
|
- `POST /api/v1/delivery/price` — принимает `DeliveryCalculationRequest`, возвращает `list[DeliveryPrice]`
|
||||||
- `POST /api/v1/delivery/suggest-address` — принимает `AddressSuggestRequest`, возвращает `list[AddressSuggestion]`
|
- `POST /api/v1/delivery/suggest-address` — принимает `AddressSuggestRequest`, возвращает `list[AddressSuggestion]`
|
||||||
- `POST /api/v1/delivery/init-payment` — принимает `InitPaymentRequest`, возвращает `InitPaymentResponse`
|
- `POST /api/v1/delivery/order` — принимает `InitPaymentRequest`, возвращает `InitPaymentResponse`
|
||||||
- `POST /api/v1/delivery/tbank/notifications` — принимает `TBankPaymentNotification`, возвращает plain text `OK` при успешной обработке
|
- `POST /api/v1/delivery/tbank/notifications` — принимает `TBankPaymentNotification`, возвращает plain text `OK` при успешной обработке
|
||||||
- Парсинг и валидация входных данных через Pydantic
|
- Парсинг и валидация входных данных через Pydantic
|
||||||
- Маппинг исключений сервиса в HTTP-ответы
|
- Маппинг исключений сервиса в HTTP-ответы
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ created: 2026-04-12
|
|||||||
- Секция `postgres` присутствует в `_RequiredYamlSections`.
|
- Секция `postgres` присутствует в `_RequiredYamlSections`.
|
||||||
- В `docker-compose.yml` добавлен сервис `postgres` и `app` зависит от него.
|
- В `docker-compose.yml` добавлен сервис `postgres` и `app` зависит от него.
|
||||||
- Wiring в `_build_aggregator_service` создаёт PostgreSQL engine, session factory, `OrderRepository` и передаёт его в `AggregatorService`.
|
- Wiring в `_build_aggregator_service` создаёт PostgreSQL engine, session factory, `OrderRepository` и передаёт его в `AggregatorService`.
|
||||||
- Запросы к эндпоинту `POST /api/v1/delivery/init-payment` продолжают возвращать `InitPaymentResponse` с `payment_url`.
|
- Запросы к эндпоинту `POST /api/v1/delivery/order` продолжают возвращать `InitPaymentResponse` с `payment_url`.
|
||||||
|
|
||||||
## Definition of Done
|
## Definition of Done
|
||||||
- [ ] Создан модуль `app/adapters/postgres/` с engine/session factory.
|
- [ ] Создан модуль `app/adapters/postgres/` с engine/session factory.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ TBank Init API должен получать URLs для обработки webh
|
|||||||
- `NotificationURL` и `SuccessURL` MUST приходить из секции `tbank_payment` YAML-конфига.
|
- `NotificationURL` и `SuccessURL` MUST приходить из секции `tbank_payment` YAML-конфига.
|
||||||
- TBank adapter MUST продолжать инкапсулировать HTTP-взаимодействие, auth token, retries, timeout, serialization и error handling.
|
- TBank adapter MUST продолжать инкапсулировать HTTP-взаимодействие, auth token, retries, timeout, serialization и error handling.
|
||||||
- Service и Controller MUST NOT содержать TBank-specific payload fields или provider HTTP-детали.
|
- Service и Controller MUST NOT содержать TBank-specific payload fields или provider HTTP-детали.
|
||||||
- Не изменять публичный request/response contract `POST /api/v1/delivery/init-payment`.
|
- Не изменять публичный request/response contract `POST /api/v1/delivery/order`.
|
||||||
- Не изменять price flow, address suggestion flow, CDEK adapter и order repository.
|
- Не изменять price flow, address suggestion flow, CDEK adapter и order repository.
|
||||||
- Не добавлять новые endpoints.
|
- Не добавлять новые endpoints.
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ TBank Init API должен получать URLs для обработки webh
|
|||||||
- Auth token TBank формируется с учётом тех же полей payload, которые отправляются в Init API, включая `NotificationURL` и `SuccessURL`, если текущая реализация token generation строится по request payload.
|
- Auth token TBank формируется с учётом тех же полей payload, которые отправляются в Init API, включая `NotificationURL` и `SuccessURL`, если текущая реализация token generation строится по request payload.
|
||||||
- Отсутствие `notification_url` или `success_url` в YAML-конфиге приводит к детерминированной ошибке валидации конфигурации.
|
- Отсутствие `notification_url` или `success_url` в YAML-конфиге приводит к детерминированной ошибке валидации конфигурации.
|
||||||
- `AggregatorService.init_payment()` продолжает вызывать `payment_adapter.create_payment_link(order_uuid, price)` без дополнительных URL-аргументов.
|
- `AggregatorService.init_payment()` продолжает вызывать `payment_adapter.create_payment_link(order_uuid, price)` без дополнительных URL-аргументов.
|
||||||
- Endpoint `POST /api/v1/delivery/init-payment` продолжает возвращать только `InitPaymentResponse(payment_url=...)`.
|
- Endpoint `POST /api/v1/delivery/order` продолжает возвращать только `InitPaymentResponse(payment_url=...)`.
|
||||||
|
|
||||||
## Definition of Done
|
## Definition of Done
|
||||||
- [ ] В `app/config.py` добавлены поля `notification_url` и `success_url` в TBank payment config.
|
- [ ] В `app/config.py` добавлены поля `notification_url` и `success_url` в TBank payment config.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: 030
|
id: 030
|
||||||
title: Add TBank payment notification webhook and CDEK order creation
|
title: Add TBank payment notification webhook and CDEK order creation
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-04-18
|
created: 2026-04-18
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ TBank Init API уже получает `NotificationURL`, а данные зая
|
|||||||
- Repository содержит только CRUD/query/update primitives; без workflow logic и business decisions.
|
- Repository содержит только CRUD/query/update primitives; без workflow logic и business decisions.
|
||||||
- CDEK order payload MUST передавать `order_uuid` как external идентификатор заказа CDEK (поле `number` в CDEK order contract), чтобы повторные вызовы CDEK при HTTP timeout/ретрае обрабатывались идемпотентно на стороне CDEK и никогда не создавали дубль заказа.
|
- CDEK order payload MUST передавать `order_uuid` как external идентификатор заказа CDEK (поле `number` в CDEK order contract), чтобы повторные вызовы CDEK при HTTP timeout/ретрае обрабатывались идемпотентно на стороне CDEK и никогда не создавали дубль заказа.
|
||||||
- Service MUST трактовать ответ CDEK "заказ с таким external id уже существует" (возврат существующего `entity.uuid` либо CDEK-specific duplicate response) как успех, сохранять возвращённый `cdek_order_uuid` и отвечать `OK`, а не как ошибку с повторной регистрацией.
|
- Service MUST трактовать ответ CDEK "заказ с таким external id уже существует" (возврат существующего `entity.uuid` либо CDEK-specific duplicate response) как успех, сохранять возвращённый `cdek_order_uuid` и отвечать `OK`, а не как ошибку с повторной регистрацией.
|
||||||
- Не изменять public contract `POST /api/v1/delivery/init-payment`, price flow и address suggestion flow.
|
- Не изменять public contract `POST /api/v1/delivery/order`, price flow и address suggestion flow.
|
||||||
- Не добавлять новые payment providers, refund flow, recurring payments, ручной retry endpoint или endpoint чтения заявок.
|
- Не добавлять новые payment providers, refund flow, recurring payments, ручной retry endpoint или endpoint чтения заявок.
|
||||||
- Не изменять файлы в `spec/`.
|
- Не изменять файлы в `spec/`.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
id: 031
|
||||||
|
title: Validate init-payment price with CDEK tariff
|
||||||
|
status: TODO
|
||||||
|
created: 2026-04-18
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
`POST /api/v1/delivery/order` сейчас принимает `price` от клиента и использует это значение как сумму платежа TBank. Клиент может передать произвольную сумму, не соответствующую тарифу CDEK.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Перед созданием ссылки на оплату валидировать `InitPaymentRequest.price` через CDEK `POST /calculator/tarifflist` для данных заявки. Если сумма не совпадает с валидированной суммой CDEK, не создавать payment link и вернуть детерминированную ошибку.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
- Controller не должен содержать business logic или provider-specific branching.
|
||||||
|
- Endpoint `POST /api/v1/delivery/order` должен по-прежнему вызывать ровно один метод Service: `AggregatorService.init_payment()`.
|
||||||
|
- Service выполняет только orchestration: вызывает injected CDEK adapter для расчёта цены, делегирует сравнение в Business Logic, затем вызывает TBank adapter только при успешной валидации.
|
||||||
|
- Pure правило сравнения цены должно жить в `app/domain/`: сравнение выполняется в копейках, с применением существующего provider price multiplier и округления `ROUND_HALF_UP`.
|
||||||
|
- Внешний IO для CDEK должен оставаться только в CDEK adapter; TBank-specific logic остаётся только в TBank adapter.
|
||||||
|
- CDEK validation MUST использовать существующий endpoint CDEK `POST /calculator/tarifflist`; запрещено использовать `/orders`, `/calculator/tariff` или registration flow для проверки цены.
|
||||||
|
- CDEK adapter должен переиспользовать существующие auth, retry, timeout, HTTP error handling и response mapping для tariff list там, где это совместимо с `InitPaymentRequest`.
|
||||||
|
- CDEK validation должна использовать данные существующего `InitPaymentRequest`: `tariff_code`, `from_location`, `to_location`, `packages` и `services` при наличии.
|
||||||
|
- Публичный request/response contract `POST /api/v1/delivery/order` не менять, кроме ужесточения runtime validation поля `price`.
|
||||||
|
- При mismatch цены нельзя вызывать TBank adapter и нельзя сохранять заявку в PostgreSQL.
|
||||||
|
- При ошибке CDEK validation нельзя вызывать TBank adapter и нельзя сохранять заявку в PostgreSQL.
|
||||||
|
- Не изменять price flow, address suggestion flow, payment notification webhook flow и CDEK order registration flow.
|
||||||
|
- Не добавлять новые endpoints, payment providers, ручной retry endpoint или endpoint чтения заявок.
|
||||||
|
- Не изменять файлы в `spec/`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
- CDEK adapter предоставляет service-facing метод для расчёта валидной цены доставки по `InitPaymentRequest` через `POST /calculator/tarifflist` без регистрации заказа в CDEK.
|
||||||
|
- Новый метод переиспользует существующую tariff-list инфраструктуру CDEK adapter: OAuth2 token, retry/timeout policy, handling 4xx/5xx/transport errors и mapping `tariff_codes` в internal price model.
|
||||||
|
- CDEK validation request использует `request.tariff_code`, `from_location`, `to_location`, `packages` и `services` из `InitPaymentRequest`.
|
||||||
|
- CDEK validation request отправляется на тот же configured base URL path `/calculator/tarifflist`, который используется текущим CDEK price flow.
|
||||||
|
- Если CDEK не возвращает цену для `request.tariff_code`, service завершает `init_payment()` детерминированной `InvalidInitPaymentRequestError`.
|
||||||
|
- Business Logic рассчитывает expected payment amount в копейках: provider price в `RUB` после существующего multiplier и `ROUND_HALF_UP` конвертируется в копейки и сравнивается с `InitPaymentRequest.price`.
|
||||||
|
- Если `InitPaymentRequest.price` не равен expected amount, `AggregatorService.init_payment()` поднимает `InvalidInitPaymentRequestError`.
|
||||||
|
- При успешной price validation `AggregatorService.init_payment()` вызывает TBank adapter с исходным `order_uuid` и валидированным `price`.
|
||||||
|
- При успешной price validation и успешном TBank response сохранение заявки в PostgreSQL остаётся прежним.
|
||||||
|
- При CDEK provider request error service маппит ошибку в `InvalidInitPaymentRequestError`.
|
||||||
|
- При CDEK transport, timeout или 5xx error service маппит ошибку в `InitPaymentUnavailableError`.
|
||||||
|
- Controller маппит `InvalidInitPaymentRequestError` в HTTP 400 и `InitPaymentUnavailableError` в HTTP 503 по текущему error contract.
|
||||||
|
- Tests подтверждают, что при mismatch цены TBank adapter и OrderRepository не вызываются.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
- [ ] Добавлен service-facing CDEK adapter method для расчёта цены по `InitPaymentRequest` через `POST /calculator/tarifflist`.
|
||||||
|
- [ ] Метод переиспользует существующие auth, retry, timeout, error handling и tariff response mapping CDEK adapter.
|
||||||
|
- [ ] Добавлена pure Business Logic для сравнения requested price с CDEK validated price в копейках.
|
||||||
|
- [ ] `AggregatorService.init_payment()` выполняет CDEK price validation до вызова TBank adapter.
|
||||||
|
- [ ] Ошибки CDEK validation маппятся в существующие service/controller error paths.
|
||||||
|
- [ ] TBank adapter вызывается только после успешной CDEK price validation.
|
||||||
|
- [ ] OrderRepository вызывается только после успешной CDEK price validation и успешного получения `payment_url`.
|
||||||
|
- [ ] Публичный contract `POST /api/v1/delivery/order` не изменён, кроме runtime rejection невалидной цены.
|
||||||
|
- [ ] Обновлены tests для domain, CDEK adapter, service и controller.
|
||||||
|
- [ ] Все команды из раздела Commands проходят.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
- Добавить или обновить `tests/domain/test_payment_price_validation.py`: exact match, mismatch, multiplier с `ROUND_HALF_UP`, конвертация `RUB` в копейки, не-`RUB` currency как deterministic validation failure.
|
||||||
|
- Добавить или обновить `tests/adapters/delivery_providers/cdek/test_payment_price_validation.py`: validation request отправляется на `/calculator/tarifflist`; payload строится из `InitPaymentRequest`, используется `tariff_code`, `packages`, `services`, `from_location`, `to_location`; success/error mapping покрыт stubs/mocks.
|
||||||
|
- Обновить `tests/services/test_init_payment.py`: success вызывает CDEK validation до TBank; mismatch возвращает `InvalidInitPaymentRequestError` без TBank и repository calls; CDEK request error маппится в `InvalidInitPaymentRequestError`; CDEK client error маппится в `InitPaymentUnavailableError`.
|
||||||
|
- Обновить `tests/controllers/v1/test_init_payment.py`: price mismatch возвращает HTTP 400; временная ошибка CDEK validation возвращает HTTP 503; controller продолжает делегировать ровно один service method.
|
||||||
|
- При необходимости обновить `tests/smoke/test_app_import.py` для wiring нового CDEK validation dependency.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
- `poetry run pytest tests/domain/test_payment_price_validation.py -q`
|
||||||
|
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_payment_price_validation.py -q`
|
||||||
|
- `poetry run pytest tests/services/test_init_payment.py -q`
|
||||||
|
- `poetry run pytest tests/controllers/v1/test_init_payment.py -q`
|
||||||
|
- `poetry run pytest tests/smoke/test_app_import.py -q`
|
||||||
|
- `poetry run pytest -q`
|
||||||
|
- `python3 spec/gen_spec_index.py --check`
|
||||||
@@ -10,6 +10,7 @@ def test_map_cdek_response_maps_all_tariffs_to_unified_model() -> None:
|
|||||||
"currency": "usd",
|
"currency": "usd",
|
||||||
"tariff_codes": [
|
"tariff_codes": [
|
||||||
{
|
{
|
||||||
|
"tariff_code": 7,
|
||||||
"tariff_name": "Express",
|
"tariff_name": "Express",
|
||||||
"delivery_sum": "1234.50",
|
"delivery_sum": "1234.50",
|
||||||
"currency": "rub",
|
"currency": "rub",
|
||||||
@@ -33,6 +34,25 @@ def test_map_cdek_response_maps_all_tariffs_to_unified_model() -> None:
|
|||||||
assert [price.currency for price in result] == ["RUB", "USD"]
|
assert [price.currency for price in result] == ["RUB", "USD"]
|
||||||
assert [price.delivery_days_min for price in result] == [2, 5]
|
assert [price.delivery_days_min for price in result] == [2, 5]
|
||||||
assert [price.delivery_days_max for price in result] == [4, 7]
|
assert [price.delivery_days_max for price in result] == [4, 7]
|
||||||
|
assert [price.tariff_code for price in result] == [7, 136]
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_cdek_response_returns_none_tariff_code_when_missing() -> None:
|
||||||
|
payload = {
|
||||||
|
"tariff_codes": [
|
||||||
|
{
|
||||||
|
"tariff_name": "Express",
|
||||||
|
"delivery_sum": "100.00",
|
||||||
|
"currency": "RUB",
|
||||||
|
"period_min": 1,
|
||||||
|
"period_max": 2,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = map_cdek_response(payload)
|
||||||
|
|
||||||
|
assert result[0].tariff_code is None
|
||||||
|
|
||||||
|
|
||||||
def test_map_cdek_response_raises_for_missing_tariff_codes() -> None:
|
def test_map_cdek_response_raises_for_missing_tariff_codes() -> None:
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import asyncio
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.adapters.delivery_providers.cdek.client import (
|
||||||
|
CDEKClient,
|
||||||
|
CDEKClientError,
|
||||||
|
CDEKProvider,
|
||||||
|
CDEKRequestError,
|
||||||
|
)
|
||||||
|
from app.schemas.payment import InitPaymentRequest
|
||||||
|
|
||||||
|
|
||||||
|
class StubAuthClient:
|
||||||
|
async def get_access_token(self) -> str:
|
||||||
|
return "test-token"
|
||||||
|
|
||||||
|
|
||||||
|
class SequenceHTTPClient:
|
||||||
|
def __init__(self, results: list[Any]) -> None:
|
||||||
|
self._results = results
|
||||||
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def _next_result(self) -> Any:
|
||||||
|
return self._results[len(self.calls) - 1]
|
||||||
|
|
||||||
|
async def post(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
json: dict[str, Any] | None = None,
|
||||||
|
data: dict[str, Any] | None = None,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
timeout: float | None = None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
self.calls.append(
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"url": url,
|
||||||
|
"json": json,
|
||||||
|
"data": data,
|
||||||
|
"headers": headers,
|
||||||
|
"timeout": timeout,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = self._next_result()
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
raise result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _make_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:
|
||||||
|
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",
|
||||||
|
"delivery_sum": "1250.00",
|
||||||
|
"currency": "RUB",
|
||||||
|
"period_min": 1,
|
||||||
|
"period_max": 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([response])
|
||||||
|
provider = CDEKProvider(
|
||||||
|
CDEKClient(
|
||||||
|
http_client=http_client, # type: ignore[arg-type]
|
||||||
|
auth_client=StubAuthClient(), # type: ignore[arg-type]
|
||||||
|
base_url="https://api.cdek.test/v2",
|
||||||
|
timeout_seconds=7.5,
|
||||||
|
retry_attempts=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(provider.get_payment_price(_make_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 == [
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"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",
|
||||||
|
},
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"weight": 1000,
|
||||||
|
"length": 20,
|
||||||
|
"width": 15,
|
||||||
|
"height": 10,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": [{"code": "INSURANCE", "parameter": "1000"}],
|
||||||
|
},
|
||||||
|
"data": None,
|
||||||
|
"headers": {"Authorization": "Bearer test-token"},
|
||||||
|
"timeout": 7.5,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_get_payment_price_omits_services_when_none() -> None:
|
||||||
|
response = httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"tariff_codes": []},
|
||||||
|
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
|
||||||
|
)
|
||||||
|
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",
|
||||||
|
retry_attempts=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(provider.get_payment_price(_make_request(services=None)))
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert "services" not in http_client.calls[0]["json"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_get_raw_payment_price_maps_4xx_to_request_error() -> None:
|
||||||
|
rejected_response = httpx.Response(
|
||||||
|
422,
|
||||||
|
json={"errors": [{"message": "bad request"}]},
|
||||||
|
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([rejected_response])
|
||||||
|
client = CDEKClient(
|
||||||
|
http_client=http_client, # type: ignore[arg-type]
|
||||||
|
auth_client=StubAuthClient(), # type: ignore[arg-type]
|
||||||
|
base_url="https://api.cdek.test/v2",
|
||||||
|
retry_attempts=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(CDEKRequestError, match="status 422"):
|
||||||
|
asyncio.run(client.get_raw_payment_price(_make_request()))
|
||||||
|
|
||||||
|
assert len(http_client.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_get_raw_payment_price_retries_5xx_and_raises_client_error() -> None:
|
||||||
|
first_response = httpx.Response(
|
||||||
|
503,
|
||||||
|
json={"message": "temporary failure"},
|
||||||
|
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
|
||||||
|
)
|
||||||
|
second_response = httpx.Response(
|
||||||
|
503,
|
||||||
|
json={"message": "temporary failure"},
|
||||||
|
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([first_response, second_response])
|
||||||
|
sleep_calls: list[float] = []
|
||||||
|
|
||||||
|
async def fake_sleep(seconds: float) -> None:
|
||||||
|
sleep_calls.append(seconds)
|
||||||
|
|
||||||
|
client = CDEKClient(
|
||||||
|
http_client=http_client, # type: ignore[arg-type]
|
||||||
|
auth_client=StubAuthClient(), # type: ignore[arg-type]
|
||||||
|
base_url="https://api.cdek.test/v2",
|
||||||
|
retry_attempts=1,
|
||||||
|
retry_backoff_seconds=0.25,
|
||||||
|
sleep=fake_sleep,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(CDEKClientError, match="status 503"):
|
||||||
|
asyncio.run(client.get_raw_payment_price(_make_request()))
|
||||||
|
|
||||||
|
assert len(http_client.calls) == 2
|
||||||
|
assert sleep_calls == [0.25]
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_get_payment_price_maps_invalid_success_payload_to_client_error() -> None:
|
||||||
|
invalid_response = httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"tariff_codes": [{"tariff_code": 535}]},
|
||||||
|
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([invalid_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",
|
||||||
|
retry_attempts=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(CDEKClientError, match="response payload is invalid"):
|
||||||
|
asyncio.run(provider.get_payment_price(_make_request()))
|
||||||
@@ -183,6 +183,7 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
|
|||||||
"currency": "RUB",
|
"currency": "RUB",
|
||||||
"delivery_days_min": 2,
|
"delivery_days_min": 2,
|
||||||
"delivery_days_max": 3,
|
"delivery_days_max": 3,
|
||||||
|
"tariff_code": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"provider": "stub-provider",
|
"provider": "stub-provider",
|
||||||
@@ -191,6 +192,7 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
|
|||||||
"currency": "RUB",
|
"currency": "RUB",
|
||||||
"delivery_days_min": 4,
|
"delivery_days_min": 4,
|
||||||
"delivery_days_max": 5,
|
"delivery_days_max": 5,
|
||||||
|
"tariff_code": None,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
assert second_response.json() == first_response.json()
|
assert second_response.json() == first_response.json()
|
||||||
@@ -308,9 +310,9 @@ def test_post_delivery_price_filters_response_by_optional_parcel_type(
|
|||||||
) -> None:
|
) -> None:
|
||||||
provider = StubPriceProvider(
|
provider = StubPriceProvider(
|
||||||
response=[
|
response=[
|
||||||
_make_price(service_name="Parcel locker", price="90.00"),
|
_make_price(service_name="Parcel locker", price="90.00", provider="other"),
|
||||||
_make_price(service_name="Срочный документ", price="150.00"),
|
_make_price(service_name="Срочный документ", price="150.00", provider="other"),
|
||||||
_make_price(service_name="DOCUMENT EXPRESS", price="200.00"),
|
_make_price(service_name="DOCUMENT EXPRESS", price="200.00", provider="other"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
service = AggregatorService(providers=[provider])
|
service = AggregatorService(providers=[provider])
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ def test_post_init_payment_returns_response_and_delegates_to_service() -> None:
|
|||||||
base_url="http://testserver",
|
base_url="http://testserver",
|
||||||
) as client:
|
) as client:
|
||||||
return await client.post(
|
return await client.post(
|
||||||
"/api/v1/delivery/init-payment",
|
"/api/v1/delivery/order",
|
||||||
json=_valid_payload(),
|
json=_valid_payload(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ def test_post_init_payment_rejects_invalid_payload() -> None:
|
|||||||
app = create_app()
|
app = create_app()
|
||||||
_install_service_override(app, service)
|
_install_service_override(app, service)
|
||||||
invalid_payload = _valid_payload()
|
invalid_payload = _valid_payload()
|
||||||
invalid_payload["tariff_code"] = 136
|
invalid_payload["tariff_code"] = "not-a-number"
|
||||||
|
|
||||||
async def run_request() -> httpx.Response:
|
async def run_request() -> httpx.Response:
|
||||||
transport = httpx.ASGITransport(app=app)
|
transport = httpx.ASGITransport(app=app)
|
||||||
@@ -110,7 +110,7 @@ def test_post_init_payment_rejects_invalid_payload() -> None:
|
|||||||
base_url="http://testserver",
|
base_url="http://testserver",
|
||||||
) as client:
|
) as client:
|
||||||
return await client.post(
|
return await client.post(
|
||||||
"/api/v1/delivery/init-payment",
|
"/api/v1/delivery/order",
|
||||||
json=invalid_payload,
|
json=invalid_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ def test_post_init_payment_rejects_missing_price() -> None:
|
|||||||
base_url="http://testserver",
|
base_url="http://testserver",
|
||||||
) as client:
|
) as client:
|
||||||
return await client.post(
|
return await client.post(
|
||||||
"/api/v1/delivery/init-payment",
|
"/api/v1/delivery/order",
|
||||||
json=invalid_payload,
|
json=invalid_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ def test_post_init_payment_rejects_non_positive_price() -> None:
|
|||||||
base_url="http://testserver",
|
base_url="http://testserver",
|
||||||
) as client:
|
) as client:
|
||||||
return await client.post(
|
return await client.post(
|
||||||
"/api/v1/delivery/init-payment",
|
"/api/v1/delivery/order",
|
||||||
json=invalid_payload,
|
json=invalid_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ def test_post_init_payment_rejects_non_integer_price() -> None:
|
|||||||
base_url="http://testserver",
|
base_url="http://testserver",
|
||||||
) as client:
|
) as client:
|
||||||
return await client.post(
|
return await client.post(
|
||||||
"/api/v1/delivery/init-payment",
|
"/api/v1/delivery/order",
|
||||||
json=invalid_payload,
|
json=invalid_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -209,7 +209,7 @@ def test_post_init_payment_rejects_sender_company_field() -> None:
|
|||||||
base_url="http://testserver",
|
base_url="http://testserver",
|
||||||
) as client:
|
) as client:
|
||||||
return await client.post(
|
return await client.post(
|
||||||
"/api/v1/delivery/init-payment",
|
"/api/v1/delivery/order",
|
||||||
json=invalid_payload,
|
json=invalid_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -236,7 +236,7 @@ def test_post_init_payment_rejects_sender_phones_field() -> None:
|
|||||||
base_url="http://testserver",
|
base_url="http://testserver",
|
||||||
) as client:
|
) as client:
|
||||||
return await client.post(
|
return await client.post(
|
||||||
"/api/v1/delivery/init-payment",
|
"/api/v1/delivery/order",
|
||||||
json=invalid_payload,
|
json=invalid_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -263,7 +263,7 @@ def test_post_init_payment_rejects_recipient_company_field() -> None:
|
|||||||
base_url="http://testserver",
|
base_url="http://testserver",
|
||||||
) as client:
|
) as client:
|
||||||
return await client.post(
|
return await client.post(
|
||||||
"/api/v1/delivery/init-payment",
|
"/api/v1/delivery/order",
|
||||||
json=invalid_payload,
|
json=invalid_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -290,7 +290,7 @@ def test_post_init_payment_rejects_recipient_phones_field() -> None:
|
|||||||
base_url="http://testserver",
|
base_url="http://testserver",
|
||||||
) as client:
|
) as client:
|
||||||
return await client.post(
|
return await client.post(
|
||||||
"/api/v1/delivery/init-payment",
|
"/api/v1/delivery/order",
|
||||||
json=invalid_payload,
|
json=invalid_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -314,7 +314,7 @@ def test_post_init_payment_accepts_request_without_services() -> None:
|
|||||||
transport=transport,
|
transport=transport,
|
||||||
base_url="http://testserver",
|
base_url="http://testserver",
|
||||||
) as client:
|
) as client:
|
||||||
return await client.post("/api/v1/delivery/init-payment", json=payload)
|
return await client.post("/api/v1/delivery/order", json=payload)
|
||||||
|
|
||||||
response = asyncio.run(run_request())
|
response = asyncio.run(run_request())
|
||||||
|
|
||||||
@@ -338,7 +338,7 @@ def test_post_init_payment_maps_invalid_request_to_400() -> None:
|
|||||||
base_url="http://testserver",
|
base_url="http://testserver",
|
||||||
) as client:
|
) as client:
|
||||||
return await client.post(
|
return await client.post(
|
||||||
"/api/v1/delivery/init-payment",
|
"/api/v1/delivery/order",
|
||||||
json=_valid_payload(),
|
json=_valid_payload(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -353,6 +353,37 @@ 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:
|
def test_post_init_payment_maps_service_exception_to_503() -> None:
|
||||||
service = StubAggregatorService(
|
service = StubAggregatorService(
|
||||||
response=None,
|
response=None,
|
||||||
@@ -368,7 +399,7 @@ def test_post_init_payment_maps_service_exception_to_503() -> None:
|
|||||||
base_url="http://testserver",
|
base_url="http://testserver",
|
||||||
) as client:
|
) as client:
|
||||||
return await client.post(
|
return await client.post(
|
||||||
"/api/v1/delivery/init-payment",
|
"/api/v1/delivery/order",
|
||||||
json=_valid_payload(),
|
json=_valid_payload(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -383,6 +414,37 @@ def test_post_init_payment_maps_service_exception_to_503() -> None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def test_delivery_order_endpoint_is_removed() -> None:
|
||||||
service = StubAggregatorService(response=None)
|
service = StubAggregatorService(response=None)
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from app.domain.price import (
|
||||||
|
calculate_expected_payment_amount_kopecks,
|
||||||
|
is_init_payment_price_valid,
|
||||||
|
)
|
||||||
|
from app.schemas.response import DeliveryPrice
|
||||||
|
|
||||||
|
|
||||||
|
def _make_price(**overrides: object) -> DeliveryPrice:
|
||||||
|
payload = {
|
||||||
|
"provider": "cdek",
|
||||||
|
"service_name": "CDEK tariff",
|
||||||
|
"price": Decimal("1250.00"),
|
||||||
|
"currency": "RUB",
|
||||||
|
"delivery_days_min": 1,
|
||||||
|
"delivery_days_max": 2,
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return DeliveryPrice.model_construct(**payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_payment_price_validation_accepts_exact_match() -> None:
|
||||||
|
provider_price = _make_price(price=Decimal("1250.00"))
|
||||||
|
|
||||||
|
assert is_init_payment_price_valid(125000, provider_price)
|
||||||
|
assert calculate_expected_payment_amount_kopecks(provider_price) == 125000
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_payment_price_validation_rejects_mismatch() -> None:
|
||||||
|
provider_price = _make_price(price=Decimal("1250.00"))
|
||||||
|
|
||||||
|
assert not is_init_payment_price_valid(124999, provider_price)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_payment_price_validation_applies_multiplier_and_rounds_half_up() -> None:
|
||||||
|
provider_price = _make_price(price=Decimal("100.50"))
|
||||||
|
|
||||||
|
expected_amount = calculate_expected_payment_amount_kopecks(
|
||||||
|
provider_price,
|
||||||
|
price_multiplier=Decimal("1.1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert expected_amount == 11100
|
||||||
|
assert is_init_payment_price_valid(
|
||||||
|
11100,
|
||||||
|
provider_price,
|
||||||
|
price_multiplier=Decimal("1.1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_payment_price_validation_converts_rub_to_kopecks() -> None:
|
||||||
|
provider_price = _make_price(price=Decimal("899.00"))
|
||||||
|
|
||||||
|
assert calculate_expected_payment_amount_kopecks(provider_price) == 89900
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_payment_price_validation_rejects_non_rub_currency() -> None:
|
||||||
|
provider_price = _make_price(price=Decimal("899.00"), currency="USD")
|
||||||
|
|
||||||
|
assert calculate_expected_payment_amount_kopecks(provider_price) is None
|
||||||
|
assert not is_init_payment_price_valid(89900, provider_price)
|
||||||
@@ -106,6 +106,41 @@ def test_filter_prices_by_parcel_type_returns_non_document_tariffs_for_parcel()
|
|||||||
assert [price.service_name for price in result] == ["Economy parcel", "Express"]
|
assert [price.service_name for price in result] == ["Economy parcel", "Express"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_prices_by_parcel_type_keeps_prices_with_bypass_flag_regardless_of_type() -> None:
|
||||||
|
bypass_document = _make_price(
|
||||||
|
provider="cdek",
|
||||||
|
service_name="Документ курьером",
|
||||||
|
bypass_parcel_type_filter=True,
|
||||||
|
)
|
||||||
|
bypass_parcel = _make_price(
|
||||||
|
provider="cdek",
|
||||||
|
service_name="Посылка склад-склад",
|
||||||
|
bypass_parcel_type_filter=True,
|
||||||
|
)
|
||||||
|
other_document = _make_price(provider="other", service_name="Document")
|
||||||
|
other_parcel = _make_price(provider="other", service_name="Parcel")
|
||||||
|
|
||||||
|
doc_result = filter_prices_by_parcel_type(
|
||||||
|
[bypass_document, bypass_parcel, other_document, other_parcel],
|
||||||
|
parcel_type=ParcelType.DOC,
|
||||||
|
)
|
||||||
|
parcel_result = filter_prices_by_parcel_type(
|
||||||
|
[bypass_document, bypass_parcel, other_document, other_parcel],
|
||||||
|
parcel_type=ParcelType.PARCEL,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [price.service_name for price in doc_result] == [
|
||||||
|
"Документ курьером",
|
||||||
|
"Посылка склад-склад",
|
||||||
|
"Document",
|
||||||
|
]
|
||||||
|
assert [price.service_name for price in parcel_result] == [
|
||||||
|
"Документ курьером",
|
||||||
|
"Посылка склад-склад",
|
||||||
|
"Parcel",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_filter_prices_by_parcel_type_returns_all_prices_when_type_is_missing() -> None:
|
def test_filter_prices_by_parcel_type_returns_all_prices_when_type_is_missing() -> None:
|
||||||
prices = [
|
prices = [
|
||||||
_make_price(service_name="Документ"),
|
_make_price(service_name="Документ"),
|
||||||
|
|||||||
@@ -218,12 +218,12 @@ def test_get_all_prices_cache_hit_skips_provider_call() -> None:
|
|||||||
StubCache(
|
StubCache(
|
||||||
forced_get_value=[
|
forced_get_value=[
|
||||||
_make_price(
|
_make_price(
|
||||||
"cdek",
|
"other",
|
||||||
"100.40",
|
"100.40",
|
||||||
service_name="DOCUMENT EXPRESS",
|
service_name="DOCUMENT EXPRESS",
|
||||||
).model_dump(mode="json"),
|
).model_dump(mode="json"),
|
||||||
_make_price(
|
_make_price(
|
||||||
"cdek",
|
"other",
|
||||||
"200.40",
|
"200.40",
|
||||||
service_name="Economy parcel",
|
service_name="Economy parcel",
|
||||||
).model_dump(mode="json"),
|
).model_dump(mode="json"),
|
||||||
@@ -238,10 +238,10 @@ def test_get_all_prices_applies_same_parcel_type_filter_for_fresh_and_cached_res
|
|||||||
expected_provider_calls: int,
|
expected_provider_calls: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
provider = StubProvider(
|
provider = StubProvider(
|
||||||
name="cdek",
|
name="other",
|
||||||
response=[
|
response=[
|
||||||
_make_price("cdek", "100.40", service_name="DOCUMENT EXPRESS"),
|
_make_price("other", "100.40", service_name="DOCUMENT EXPRESS"),
|
||||||
_make_price("cdek", "200.40", service_name="Economy parcel"),
|
_make_price("other", "200.40", service_name="Economy parcel"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
service = AggregatorService(
|
service = AggregatorService(
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from app.adapters.delivery_providers.base import ProviderClientError, ProviderRequestError
|
||||||
from app.adapters.tbank.base import (
|
from app.adapters.tbank.base import (
|
||||||
TBankPaymentAdapterError,
|
TBankPaymentAdapterError,
|
||||||
TBankPaymentRequestError,
|
TBankPaymentRequestError,
|
||||||
)
|
)
|
||||||
from app.repositories.order import OrderData
|
from app.repositories.order import OrderData
|
||||||
from app.schemas.payment import InitPaymentRequest, InitPaymentResponse
|
from app.schemas.payment import InitPaymentRequest, InitPaymentResponse
|
||||||
|
from app.schemas.response import DeliveryPrice
|
||||||
from app.services.aggregator import (
|
from app.services.aggregator import (
|
||||||
AggregatorService,
|
AggregatorService,
|
||||||
InitPaymentUnavailableError,
|
InitPaymentUnavailableError,
|
||||||
@@ -21,13 +24,17 @@ class StubPaymentAdapter:
|
|||||||
*,
|
*,
|
||||||
response: str | None = None,
|
response: str | None = None,
|
||||||
error: Exception | None = None,
|
error: Exception | None = None,
|
||||||
|
events: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._response = response
|
self._response = response
|
||||||
self._error = error
|
self._error = error
|
||||||
|
self._events = events
|
||||||
self.calls: list[tuple[str, int]] = []
|
self.calls: list[tuple[str, int]] = []
|
||||||
|
|
||||||
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str:
|
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str:
|
||||||
self.calls.append((order_uuid, amount_kopecks))
|
self.calls.append((order_uuid, amount_kopecks))
|
||||||
|
if self._events is not None:
|
||||||
|
self._events.append("tbank")
|
||||||
if self._error is not None:
|
if self._error is not None:
|
||||||
raise self._error
|
raise self._error
|
||||||
if self._response is None:
|
if self._response is None:
|
||||||
@@ -35,6 +42,31 @@ class StubPaymentAdapter:
|
|||||||
return self._response
|
return self._response
|
||||||
|
|
||||||
|
|
||||||
|
class StubPaymentPriceValidationAdapter:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
response: DeliveryPrice | None = None,
|
||||||
|
error: Exception | None = None,
|
||||||
|
events: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._response = response
|
||||||
|
self._error = error
|
||||||
|
self._events = events
|
||||||
|
self.calls: list[InitPaymentRequest] = []
|
||||||
|
|
||||||
|
async def get_payment_price(
|
||||||
|
self,
|
||||||
|
request: InitPaymentRequest,
|
||||||
|
) -> DeliveryPrice | None:
|
||||||
|
self.calls.append(request)
|
||||||
|
if self._events is not None:
|
||||||
|
self._events.append("cdek")
|
||||||
|
if self._error is not None:
|
||||||
|
raise self._error
|
||||||
|
return self._response
|
||||||
|
|
||||||
|
|
||||||
class StubOrderSessionContext:
|
class StubOrderSessionContext:
|
||||||
def __init__(self, session: object) -> None:
|
def __init__(self, session: object) -> None:
|
||||||
self._session = session
|
self._session = session
|
||||||
@@ -105,30 +137,57 @@ def _make_init_payment_request(**overrides: object) -> InitPaymentRequest:
|
|||||||
return InitPaymentRequest(**payload)
|
return InitPaymentRequest(**payload)
|
||||||
|
|
||||||
|
|
||||||
def test_init_payment_delegates_to_adapter_and_returns_payment_url() -> None:
|
def _make_cdek_price(**overrides: object) -> DeliveryPrice:
|
||||||
|
payload = {
|
||||||
|
"provider": "cdek",
|
||||||
|
"service_name": "CDEK tariff",
|
||||||
|
"price": Decimal("1250.00"),
|
||||||
|
"currency": "RUB",
|
||||||
|
"delivery_days_min": 1,
|
||||||
|
"delivery_days_max": 2,
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return DeliveryPrice.model_construct(**payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_payment_validates_cdek_price_before_tbank_and_returns_payment_url() -> None:
|
||||||
request = _make_init_payment_request()
|
request = _make_init_payment_request()
|
||||||
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
events: list[str] = []
|
||||||
service = AggregatorService(providers=[], payment_adapter=adapter)
|
validation_adapter = StubPaymentPriceValidationAdapter(
|
||||||
|
response=_make_cdek_price(),
|
||||||
|
events=events,
|
||||||
|
)
|
||||||
|
adapter = StubPaymentAdapter(response="https://pay.test/payment/1", events=events)
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=adapter,
|
||||||
|
payment_price_validation_adapter=validation_adapter,
|
||||||
|
)
|
||||||
|
|
||||||
result = asyncio.run(service.init_payment(request))
|
result = asyncio.run(service.init_payment(request))
|
||||||
|
|
||||||
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
|
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
|
||||||
|
assert validation_adapter.calls == [request]
|
||||||
assert adapter.calls == [("order-uuid-1", 125000)]
|
assert adapter.calls == [("order-uuid-1", 125000)]
|
||||||
|
assert events == ["cdek", "tbank"]
|
||||||
|
|
||||||
|
|
||||||
def test_init_payment_persists_order_after_successful_payment_link() -> None:
|
def test_init_payment_persists_order_after_successful_payment_link() -> None:
|
||||||
request = _make_init_payment_request()
|
request = _make_init_payment_request()
|
||||||
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||||
|
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
|
||||||
order_repository = StubOrderRepository()
|
order_repository = StubOrderRepository()
|
||||||
service = AggregatorService(
|
service = AggregatorService(
|
||||||
providers=[],
|
providers=[],
|
||||||
payment_adapter=adapter,
|
payment_adapter=adapter,
|
||||||
|
payment_price_validation_adapter=validation_adapter,
|
||||||
order_repository=order_repository,
|
order_repository=order_repository,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = asyncio.run(service.init_payment(request))
|
result = asyncio.run(service.init_payment(request))
|
||||||
|
|
||||||
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
|
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
|
||||||
|
assert validation_adapter.calls == [request]
|
||||||
assert adapter.calls == [("order-uuid-1", 125000)]
|
assert adapter.calls == [("order-uuid-1", 125000)]
|
||||||
assert order_repository.calls == [
|
assert order_repository.calls == [
|
||||||
(
|
(
|
||||||
@@ -179,16 +238,19 @@ def test_init_payment_persists_order_after_successful_payment_link() -> None:
|
|||||||
def test_init_payment_returns_payment_url_when_order_persistence_fails() -> None:
|
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")
|
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||||
|
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
|
||||||
order_repository = StubOrderRepository(error=RuntimeError("database down"))
|
order_repository = StubOrderRepository(error=RuntimeError("database down"))
|
||||||
service = AggregatorService(
|
service = AggregatorService(
|
||||||
providers=[],
|
providers=[],
|
||||||
payment_adapter=adapter,
|
payment_adapter=adapter,
|
||||||
|
payment_price_validation_adapter=validation_adapter,
|
||||||
order_repository=order_repository,
|
order_repository=order_repository,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = asyncio.run(service.init_payment(request))
|
result = asyncio.run(service.init_payment(request))
|
||||||
|
|
||||||
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
|
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
|
||||||
|
assert validation_adapter.calls == [request]
|
||||||
assert adapter.calls == [("order-uuid-1", 125000)]
|
assert adapter.calls == [("order-uuid-1", 125000)]
|
||||||
assert len(order_repository.calls) == 1
|
assert len(order_repository.calls) == 1
|
||||||
|
|
||||||
@@ -196,22 +258,34 @@ def test_init_payment_returns_payment_url_when_order_persistence_fails() -> None
|
|||||||
def test_init_payment_maps_provider_request_errors_to_invalid_payment_error() -> None:
|
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"))
|
adapter = StubPaymentAdapter(error=TBankPaymentRequestError("bad payload"))
|
||||||
service = AggregatorService(providers=[], payment_adapter=adapter)
|
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=adapter,
|
||||||
|
payment_price_validation_adapter=validation_adapter,
|
||||||
|
)
|
||||||
|
|
||||||
with pytest.raises(InvalidInitPaymentRequestError):
|
with pytest.raises(InvalidInitPaymentRequestError):
|
||||||
asyncio.run(service.init_payment(request))
|
asyncio.run(service.init_payment(request))
|
||||||
|
|
||||||
|
assert validation_adapter.calls == [request]
|
||||||
assert adapter.calls == [("order-uuid-1", 125000)]
|
assert adapter.calls == [("order-uuid-1", 125000)]
|
||||||
|
|
||||||
|
|
||||||
def test_init_payment_delegates_request_with_services_none_without_new_logic() -> None:
|
def test_init_payment_delegates_request_with_services_none_without_new_logic() -> None:
|
||||||
request = _make_init_payment_request(services=None)
|
request = _make_init_payment_request(services=None)
|
||||||
adapter = StubPaymentAdapter(response="https://pay.test/payment/2")
|
adapter = StubPaymentAdapter(response="https://pay.test/payment/2")
|
||||||
service = AggregatorService(providers=[], payment_adapter=adapter)
|
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))
|
result = asyncio.run(service.init_payment(request))
|
||||||
|
|
||||||
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/2")
|
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/2")
|
||||||
|
assert validation_adapter.calls == [request]
|
||||||
assert adapter.calls == [("order-uuid-1", 125000)]
|
assert adapter.calls == [("order-uuid-1", 125000)]
|
||||||
assert request.services is None
|
assert request.services is None
|
||||||
|
|
||||||
@@ -219,11 +293,17 @@ def test_init_payment_delegates_request_with_services_none_without_new_logic() -
|
|||||||
def test_init_payment_maps_client_failures_to_unavailable_error() -> 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"))
|
adapter = StubPaymentAdapter(error=TBankPaymentAdapterError("transport down"))
|
||||||
service = AggregatorService(providers=[], payment_adapter=adapter)
|
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=adapter,
|
||||||
|
payment_price_validation_adapter=validation_adapter,
|
||||||
|
)
|
||||||
|
|
||||||
with pytest.raises(InitPaymentUnavailableError):
|
with pytest.raises(InitPaymentUnavailableError):
|
||||||
asyncio.run(service.init_payment(request))
|
asyncio.run(service.init_payment(request))
|
||||||
|
|
||||||
|
assert validation_adapter.calls == [request]
|
||||||
assert adapter.calls == [("order-uuid-1", 125000)]
|
assert adapter.calls == [("order-uuid-1", 125000)]
|
||||||
|
|
||||||
|
|
||||||
@@ -232,3 +312,78 @@ def test_init_payment_without_configured_adapter_raises_unavailable_error() -> N
|
|||||||
|
|
||||||
with pytest.raises(InitPaymentUnavailableError):
|
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)
|
||||||
|
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||||
|
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
|
||||||
|
order_repository = StubOrderRepository()
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=adapter,
|
||||||
|
payment_price_validation_adapter=validation_adapter,
|
||||||
|
order_repository=order_repository,
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||||
|
validation_adapter = StubPaymentPriceValidationAdapter(response=None)
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=adapter,
|
||||||
|
payment_price_validation_adapter=validation_adapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||||
|
validation_adapter = StubPaymentPriceValidationAdapter(
|
||||||
|
error=ProviderRequestError("bad CDEK payload")
|
||||||
|
)
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=adapter,
|
||||||
|
payment_price_validation_adapter=validation_adapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||||
|
validation_adapter = StubPaymentPriceValidationAdapter(
|
||||||
|
error=ProviderClientError("CDEK unavailable")
|
||||||
|
)
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=adapter,
|
||||||
|
payment_price_validation_adapter=validation_adapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(InitPaymentUnavailableError):
|
||||||
|
asyncio.run(service.init_payment(request))
|
||||||
|
|
||||||
|
assert validation_adapter.calls == [request]
|
||||||
|
assert adapter.calls == []
|
||||||
|
|||||||
Reference in New Issue
Block a user