Добавлена обработка уведомлений через ручку
This commit is contained in:
@@ -36,12 +36,21 @@ def upgrade() -> None:
|
||||
sa.Column("packages", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("services", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column("comment", sa.String(length=1024), nullable=True),
|
||||
sa.Column("payment_status", sa.String(length=64), nullable=True),
|
||||
sa.Column("tbank_payment_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("cdek_order_uuid", sa.String(length=128), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("order_uuid", name="uq_orders_order_uuid"),
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient
|
||||
from app.adapters.delivery_providers.cdek.mapper import map_cdek_response
|
||||
from app.adapters.delivery_providers.cdek.order_mapper import (
|
||||
CDEKOrderMappingError,
|
||||
map_cdek_existing_order_response,
|
||||
map_cdek_order_request,
|
||||
map_cdek_order_response,
|
||||
)
|
||||
@@ -123,6 +124,11 @@ class CDEKClient:
|
||||
)
|
||||
|
||||
if 400 <= response.status_code < 500:
|
||||
existing_order_uuid = map_cdek_existing_order_response(
|
||||
_response_json_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}."
|
||||
@@ -243,3 +249,10 @@ class CDEKProvider(DeliveryProvider):
|
||||
|
||||
async def register_order(self, request: InitPaymentRequest) -> str:
|
||||
return await self._client.register_order(request)
|
||||
|
||||
|
||||
def _response_json_or_none(response: httpx.Response) -> object | None:
|
||||
try:
|
||||
return response.json()
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@@ -15,6 +15,7 @@ class CDEKOrderMappingError(ValueError):
|
||||
|
||||
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),
|
||||
@@ -44,6 +45,22 @@ def map_cdek_order_response(payload: dict[str, Any]) -> str:
|
||||
return order_uuid
|
||||
|
||||
|
||||
_CDEK_DUPLICATE_ORDER_ERROR_CODES = frozenset({"v2_entity_already_exists"})
|
||||
|
||||
|
||||
def map_cdek_existing_order_response(payload: object) -> str | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
|
||||
if not _contains_cdek_duplicate_error_code(payload):
|
||||
return None
|
||||
|
||||
try:
|
||||
return map_cdek_order_response(payload)
|
||||
except CDEKOrderMappingError:
|
||||
return _find_first_uuid(payload)
|
||||
|
||||
|
||||
def _map_order_party(party: PaymentParty) -> dict[str, Any]:
|
||||
return {
|
||||
"name": party.name,
|
||||
@@ -56,3 +73,34 @@ 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 _contains_cdek_duplicate_error_code(payload: object) -> bool:
|
||||
if isinstance(payload, dict):
|
||||
code = payload.get("code")
|
||||
if isinstance(code, str) and code in _CDEK_DUPLICATE_ORDER_ERROR_CODES:
|
||||
return True
|
||||
return any(
|
||||
_contains_cdek_duplicate_error_code(value)
|
||||
for value in payload.values()
|
||||
)
|
||||
if isinstance(payload, list):
|
||||
return any(_contains_cdek_duplicate_error_code(item) for item in payload)
|
||||
return False
|
||||
|
||||
|
||||
def _find_first_uuid(payload: object) -> str | None:
|
||||
if isinstance(payload, dict):
|
||||
value = payload.get("uuid")
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
for nested_value in payload.values():
|
||||
nested_uuid = _find_first_uuid(nested_value)
|
||||
if nested_uuid is not None:
|
||||
return nested_uuid
|
||||
if isinstance(payload, list):
|
||||
for item in payload:
|
||||
nested_uuid = _find_first_uuid(item)
|
||||
if nested_uuid is not None:
|
||||
return nested_uuid
|
||||
return None
|
||||
|
||||
@@ -22,3 +22,7 @@ class TBankPaymentRequestError(TBankPaymentAdapterError):
|
||||
self.error_code = error_code
|
||||
self.provider_message = provider_message
|
||||
self.details = details
|
||||
|
||||
|
||||
class TBankPaymentNotificationTokenError(TBankPaymentRequestError):
|
||||
"""Raised when a TBank payment notification token is invalid."""
|
||||
|
||||
@@ -3,15 +3,18 @@
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.adapters.tbank.base import (
|
||||
TBankPaymentAdapterError,
|
||||
TBankPaymentNotificationTokenError,
|
||||
TBankPaymentRequestError,
|
||||
)
|
||||
from app.config import TBankPaymentConfig
|
||||
from app.schemas.payment import TBankPaymentNotification
|
||||
|
||||
|
||||
class TBankAdapter:
|
||||
@@ -112,6 +115,17 @@ class TBankAdapter:
|
||||
|
||||
raise TBankPaymentAdapterError("TBank payment init request failed unexpectedly.")
|
||||
|
||||
def verify_payment_notification(
|
||||
self,
|
||||
notification: TBankPaymentNotification,
|
||||
) -> None:
|
||||
payload = notification.model_dump(mode="python")
|
||||
expected_token = _build_tbank_token(payload, password=self._password)
|
||||
if not hmac.compare_digest(expected_token, notification.Token):
|
||||
raise TBankPaymentNotificationTokenError(
|
||||
"TBank payment notification token is invalid."
|
||||
)
|
||||
|
||||
def _build_payload(self, *, order_uuid: str, amount_kopecks: int) -> dict[str, Any]:
|
||||
if not order_uuid.strip():
|
||||
raise TBankPaymentRequestError("TBank payment order id must be non-empty.")
|
||||
@@ -164,11 +178,17 @@ def _build_tbank_token(payload: dict[str, Any], *, password: str) -> str:
|
||||
}
|
||||
token_payload["Password"] = password
|
||||
token_source = "".join(
|
||||
str(token_payload[key]) for key in sorted(token_payload)
|
||||
_stringify_token_value(token_payload[key]) for key in sorted(token_payload)
|
||||
)
|
||||
return hashlib.sha256(token_source.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _stringify_token_value(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _response_json_or_none(response: httpx.Response) -> object | None:
|
||||
try:
|
||||
return response.json()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Delivery API controller skeleton."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import PlainTextResponse
|
||||
|
||||
from app.adapters.postgres.engine import create_postgres_engine, create_postgres_session_factory
|
||||
from app.adapters.address_suggestions.dadata import DadataAddressSuggestionProvider
|
||||
@@ -14,7 +15,11 @@ from app.config import Settings
|
||||
from app.controllers.http_client import build_controller_http_client
|
||||
from app.repositories.cache.redis_cache import PriceCache
|
||||
from app.repositories.order import OrderRepository
|
||||
from app.schemas.payment import InitPaymentRequest, InitPaymentResponse
|
||||
from app.schemas.payment import (
|
||||
InitPaymentRequest,
|
||||
InitPaymentResponse,
|
||||
TBankPaymentNotification,
|
||||
)
|
||||
from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
|
||||
from app.schemas.response import AddressSuggestion, DeliveryPrice
|
||||
from app.services.aggregator import (
|
||||
@@ -24,7 +29,9 @@ from app.services.aggregator import (
|
||||
InvalidAddressSuggestRequestError,
|
||||
InvalidDeliveryRequestError,
|
||||
InvalidInitPaymentRequestError,
|
||||
InvalidTBankPaymentNotificationError,
|
||||
InitPaymentUnavailableError,
|
||||
TBankPaymentNotificationProcessingError,
|
||||
UnsupportedAddressSuggestionCountryError,
|
||||
)
|
||||
|
||||
@@ -68,6 +75,7 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
|
||||
cache=cache,
|
||||
payment_adapter=payment_adapter,
|
||||
order_repository=order_repository,
|
||||
order_registration_adapter=cdek_provider,
|
||||
address_suggestion_providers=(
|
||||
dadata_provider,
|
||||
yandex_geosuggest_provider,
|
||||
@@ -191,3 +199,40 @@ async def init_payment(
|
||||
"message": "Payment initialization is temporarily unavailable.",
|
||||
},
|
||||
) from exc
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tbank/notifications",
|
||||
response_class=PlainTextResponse,
|
||||
)
|
||||
async def handle_tbank_payment_notification(
|
||||
notification: TBankPaymentNotification,
|
||||
service: AggregatorService = Depends(get_aggregator_service),
|
||||
) -> PlainTextResponse:
|
||||
try:
|
||||
response_body = await service.handle_tbank_payment_notification(notification)
|
||||
except InvalidTBankPaymentNotificationError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"code": "invalid_tbank_payment_notification",
|
||||
"message": "TBank payment notification token is invalid.",
|
||||
},
|
||||
) from exc
|
||||
except TBankPaymentNotificationProcessingError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={
|
||||
"code": "tbank_payment_notification_processing_unavailable",
|
||||
"message": "TBank payment notification processing is temporarily unavailable.",
|
||||
},
|
||||
) from exc
|
||||
except AggregatorServiceError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={
|
||||
"code": "tbank_payment_notification_processing_unavailable",
|
||||
"message": "TBank payment notification processing is temporarily unavailable.",
|
||||
},
|
||||
) from exc
|
||||
return PlainTextResponse(response_body)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Pure rules for TBank payment notification handling."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class TBankPaymentNotificationAction(str, Enum):
|
||||
ACKNOWLEDGE_ONLY = "acknowledge_only"
|
||||
REGISTER_CDEK_ORDER = "register_cdek_order"
|
||||
|
||||
|
||||
def resolve_tbank_payment_notification_action(
|
||||
*,
|
||||
status: str,
|
||||
success: bool,
|
||||
error_code: str,
|
||||
) -> TBankPaymentNotificationAction:
|
||||
if status == "CONFIRMED" and success is True and error_code == "0":
|
||||
return TBankPaymentNotificationAction.REGISTER_CDEK_ORDER
|
||||
return TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, UniqueConstraint, Uuid, func
|
||||
from sqlalchemy import BigInteger, DateTime, Integer, String, UniqueConstraint, Uuid, func
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
@@ -56,8 +56,17 @@ class Order(Base):
|
||||
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)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ from contextlib import AbstractAsyncContextManager
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.repositories.order.models import Order
|
||||
@@ -51,3 +52,43 @@ class OrderRepository:
|
||||
await session.flush()
|
||||
await session.refresh(order)
|
||||
return order
|
||||
|
||||
async def get_order_by_order_uuid(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
order_uuid: str,
|
||||
) -> Order | None:
|
||||
result = await session.execute(
|
||||
select(Order).where(Order.order_uuid == order_uuid)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def mark_payment_status(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
order_uuid: str,
|
||||
status: str,
|
||||
payment_id: int,
|
||||
) -> Order | None:
|
||||
order = await self.get_order_by_order_uuid(session, order_uuid)
|
||||
if order is None:
|
||||
return None
|
||||
|
||||
order.payment_status = status
|
||||
order.tbank_payment_id = payment_id
|
||||
await session.flush()
|
||||
return order
|
||||
|
||||
async def mark_cdek_order_registered(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
order_uuid: str,
|
||||
cdek_order_uuid: str,
|
||||
) -> 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
|
||||
await session.flush()
|
||||
return order
|
||||
|
||||
@@ -21,7 +21,7 @@ def configure_logging(
|
||||
foreign_pre_chain=list(_shared_processors()),
|
||||
processors=[
|
||||
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
||||
structlog.processors.JSONRenderer(sort_keys=True),
|
||||
structlog.processors.JSONRenderer(sort_keys=True, ensure_ascii=False),
|
||||
],
|
||||
)
|
||||
handler = logging.StreamHandler(resolved_stream)
|
||||
|
||||
@@ -60,3 +60,16 @@ class InitPaymentRequest(BaseModel):
|
||||
|
||||
class InitPaymentResponse(BaseModel):
|
||||
payment_url: str = Field(min_length=1)
|
||||
|
||||
|
||||
class TBankPaymentNotification(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
TerminalKey: str = Field(min_length=1)
|
||||
OrderId: str = Field(min_length=1)
|
||||
Success: bool
|
||||
Status: str = Field(min_length=1)
|
||||
PaymentId: int = Field(gt=0, strict=True)
|
||||
ErrorCode: str = Field(min_length=1)
|
||||
Amount: int
|
||||
Token: str = Field(min_length=1)
|
||||
|
||||
+215
-1
@@ -18,8 +18,13 @@ from app.adapters.address_suggestions.base import (
|
||||
from app.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError
|
||||
from app.adapters.tbank.base import (
|
||||
TBankPaymentAdapterError,
|
||||
TBankPaymentNotificationTokenError,
|
||||
TBankPaymentRequestError,
|
||||
)
|
||||
from app.domain.payment_notifications import (
|
||||
TBankPaymentNotificationAction,
|
||||
resolve_tbank_payment_notification_action,
|
||||
)
|
||||
from app.domain.price import (
|
||||
DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
||||
DEFAULT_WEIGHT_ROUND_SCALE,
|
||||
@@ -28,7 +33,11 @@ from app.domain.price import (
|
||||
normalize_delivery_request,
|
||||
)
|
||||
from app.repositories.order import OrderData
|
||||
from app.schemas.payment import InitPaymentRequest, InitPaymentResponse
|
||||
from app.schemas.payment import (
|
||||
InitPaymentRequest,
|
||||
InitPaymentResponse,
|
||||
TBankPaymentNotification,
|
||||
)
|
||||
from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
|
||||
from app.schemas.response import AddressSuggestion, DeliveryPrice
|
||||
|
||||
@@ -63,6 +72,14 @@ class InitPaymentUnavailableError(AggregatorServiceError):
|
||||
"""Raised when payment initialization cannot be completed."""
|
||||
|
||||
|
||||
class InvalidTBankPaymentNotificationError(AggregatorServiceError):
|
||||
"""Raised when a TBank payment notification is invalid."""
|
||||
|
||||
|
||||
class TBankPaymentNotificationProcessingError(AggregatorServiceError):
|
||||
"""Raised when a TBank payment notification cannot be processed."""
|
||||
|
||||
|
||||
class PriceCacheProtocol(Protocol):
|
||||
async def get(self, key: str) -> object | None: ...
|
||||
|
||||
@@ -72,12 +89,42 @@ class PriceCacheProtocol(Protocol):
|
||||
class PaymentAdapterProtocol(Protocol):
|
||||
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str: ...
|
||||
|
||||
def verify_payment_notification(
|
||||
self,
|
||||
notification: TBankPaymentNotification,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class OrderRegistrationAdapterProtocol(Protocol):
|
||||
async def register_order(self, request: InitPaymentRequest) -> str: ...
|
||||
|
||||
|
||||
class OrderRepositoryProtocol(Protocol):
|
||||
def session(self) -> AbstractAsyncContextManager[object]: ...
|
||||
|
||||
async def create_order(self, session: object, order_data: OrderData) -> object: ...
|
||||
|
||||
async def get_order_by_order_uuid(
|
||||
self,
|
||||
session: object,
|
||||
order_uuid: str,
|
||||
) -> object | None: ...
|
||||
|
||||
async def mark_payment_status(
|
||||
self,
|
||||
session: object,
|
||||
order_uuid: str,
|
||||
status: str,
|
||||
payment_id: int,
|
||||
) -> object | None: ...
|
||||
|
||||
async def mark_cdek_order_registered(
|
||||
self,
|
||||
session: object,
|
||||
order_uuid: str,
|
||||
cdek_order_uuid: str,
|
||||
) -> object | None: ...
|
||||
|
||||
|
||||
class FilterAndSortPricesFn(Protocol):
|
||||
def __call__(
|
||||
@@ -96,6 +143,7 @@ class AggregatorService:
|
||||
cache: PriceCacheProtocol | None = None,
|
||||
payment_adapter: PaymentAdapterProtocol | None = None,
|
||||
order_repository: OrderRepositoryProtocol | None = None,
|
||||
order_registration_adapter: OrderRegistrationAdapterProtocol | None = None,
|
||||
address_suggestion_providers: Sequence[AddressSuggestionProvider] = (),
|
||||
address_suggestion_country_to_provider: Mapping[str, str] | None = None,
|
||||
*,
|
||||
@@ -107,6 +155,7 @@ class AggregatorService:
|
||||
self._cache = cache
|
||||
self._payment_adapter = payment_adapter
|
||||
self._order_repository = order_repository
|
||||
self._order_registration_adapter = order_registration_adapter
|
||||
self._weight_round_scale = weight_round_scale
|
||||
self._provider_price_multiplier = provider_price_multiplier
|
||||
self._filter_and_sort_prices = filter_and_sort_prices_fn
|
||||
@@ -217,6 +266,155 @@ class AggregatorService:
|
||||
await self._persist_order(request=request, payment_url=payment_url)
|
||||
return InitPaymentResponse(payment_url=payment_url)
|
||||
|
||||
async def handle_tbank_payment_notification(
|
||||
self,
|
||||
notification: TBankPaymentNotification,
|
||||
) -> str:
|
||||
if self._payment_adapter is None:
|
||||
raise TBankPaymentNotificationProcessingError(
|
||||
"Payment adapter is not configured."
|
||||
)
|
||||
|
||||
try:
|
||||
self._payment_adapter.verify_payment_notification(notification)
|
||||
except TBankPaymentNotificationTokenError as exc:
|
||||
raise InvalidTBankPaymentNotificationError(
|
||||
"TBank payment notification token is invalid."
|
||||
) from exc
|
||||
except TBankPaymentAdapterError as exc:
|
||||
raise TBankPaymentNotificationProcessingError(
|
||||
"TBank payment notification verification failed."
|
||||
) from exc
|
||||
|
||||
action = resolve_tbank_payment_notification_action(
|
||||
status=notification.Status,
|
||||
success=notification.Success,
|
||||
error_code=notification.ErrorCode,
|
||||
)
|
||||
order = await self._load_order_and_mark_payment_status(notification)
|
||||
|
||||
if action is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY:
|
||||
return "OK"
|
||||
|
||||
existing_cdek_order_uuid = getattr(order, "cdek_order_uuid", None)
|
||||
if existing_cdek_order_uuid:
|
||||
return "OK"
|
||||
|
||||
cdek_order_uuid = await self._register_cdek_order(order)
|
||||
await self._save_cdek_order_uuid(
|
||||
order_uuid=notification.OrderId,
|
||||
cdek_order_uuid=cdek_order_uuid,
|
||||
)
|
||||
return "OK"
|
||||
|
||||
async def _load_order_and_mark_payment_status(
|
||||
self,
|
||||
notification: TBankPaymentNotification,
|
||||
) -> object:
|
||||
if self._order_repository is None:
|
||||
raise TBankPaymentNotificationProcessingError(
|
||||
"Order repository is not configured."
|
||||
)
|
||||
|
||||
try:
|
||||
async with self._order_repository.session() as session:
|
||||
order = await self._order_repository.get_order_by_order_uuid(
|
||||
session,
|
||||
notification.OrderId,
|
||||
)
|
||||
if order is None:
|
||||
logger.warning(
|
||||
"tbank_payment_notification_order_not_found",
|
||||
order_uuid=notification.OrderId,
|
||||
)
|
||||
raise TBankPaymentNotificationProcessingError(
|
||||
"Order was not found for TBank payment notification."
|
||||
)
|
||||
updated_order = await self._order_repository.mark_payment_status(
|
||||
session,
|
||||
notification.OrderId,
|
||||
notification.Status,
|
||||
notification.PaymentId,
|
||||
)
|
||||
if updated_order is None:
|
||||
logger.warning(
|
||||
"tbank_payment_notification_order_not_found",
|
||||
order_uuid=notification.OrderId,
|
||||
)
|
||||
raise TBankPaymentNotificationProcessingError(
|
||||
"Order was not found for TBank payment notification."
|
||||
)
|
||||
return order
|
||||
except TBankPaymentNotificationProcessingError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"tbank_payment_notification_order_update_failed",
|
||||
order_uuid=notification.OrderId,
|
||||
payment_status=notification.Status,
|
||||
tbank_payment_id=notification.PaymentId,
|
||||
)
|
||||
raise TBankPaymentNotificationProcessingError(
|
||||
"TBank payment notification order update failed."
|
||||
) from exc
|
||||
|
||||
async def _register_cdek_order(self, order: object) -> str:
|
||||
if self._order_registration_adapter is None:
|
||||
raise TBankPaymentNotificationProcessingError(
|
||||
"CDEK order registration adapter is not configured."
|
||||
)
|
||||
|
||||
try:
|
||||
request = self._to_init_payment_request_from_order(order)
|
||||
return await self._order_registration_adapter.register_order(request)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"cdek_order_registration_failed",
|
||||
order_uuid=getattr(order, "order_uuid", None),
|
||||
)
|
||||
raise TBankPaymentNotificationProcessingError(
|
||||
"CDEK order registration failed."
|
||||
) from exc
|
||||
|
||||
async def _save_cdek_order_uuid(
|
||||
self,
|
||||
*,
|
||||
order_uuid: str,
|
||||
cdek_order_uuid: str,
|
||||
) -> None:
|
||||
if self._order_repository is None:
|
||||
raise TBankPaymentNotificationProcessingError(
|
||||
"Order repository is not configured."
|
||||
)
|
||||
|
||||
try:
|
||||
async with self._order_repository.session() as session:
|
||||
order = await self._order_repository.mark_cdek_order_registered(
|
||||
session,
|
||||
order_uuid,
|
||||
cdek_order_uuid,
|
||||
)
|
||||
if order is None:
|
||||
logger.warning(
|
||||
"cdek_order_uuid_order_not_found",
|
||||
order_uuid=order_uuid,
|
||||
cdek_order_uuid=cdek_order_uuid,
|
||||
)
|
||||
raise TBankPaymentNotificationProcessingError(
|
||||
"Order was not found while saving CDEK order UUID."
|
||||
)
|
||||
except TBankPaymentNotificationProcessingError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"cdek_order_uuid_persistence_failed",
|
||||
order_uuid=order_uuid,
|
||||
cdek_order_uuid=cdek_order_uuid,
|
||||
)
|
||||
raise TBankPaymentNotificationProcessingError(
|
||||
"CDEK order UUID persistence failed."
|
||||
) from exc
|
||||
|
||||
async def _persist_order(
|
||||
self,
|
||||
*,
|
||||
@@ -260,6 +458,22 @@ class AggregatorService:
|
||||
comment=request.comment,
|
||||
)
|
||||
|
||||
@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"),
|
||||
)
|
||||
|
||||
async def _get_provider_prices(
|
||||
self,
|
||||
*,
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ adapter:
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
notification_url: "https://merchant.example.com/tbank/notification"
|
||||
notification_url: "https://merchant.example.com/api/v1/delivery/tbank/notifications"
|
||||
success_url: "https://merchant.example.com/payment/success"
|
||||
auth:
|
||||
terminal_key: "change-me-terminal-key"
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ adapter:
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
notification_url: "https://merchant.test/tbank/notification"
|
||||
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||
success_url: "https://merchant.test/payment/success"
|
||||
auth:
|
||||
terminal_key: "test-terminal-key"
|
||||
|
||||
+3
-1
@@ -34,12 +34,14 @@ services:
|
||||
container_name: postgres
|
||||
environment:
|
||||
POSTGRES_DB: g2s_aggregator
|
||||
POSTGRES_USER: g2s_user
|
||||
POSTGRES_PASSWORD: 7ed0a5a0f24be266
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d g2s_aggregator"]
|
||||
test: ["CMD-SHELL", "pg_isready -U g2s_user -d g2s_aggregator"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
+3
-2
@@ -36,9 +36,10 @@
|
||||
| 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` |
|
||||
| 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` |
|
||||
|
||||
## Summary
|
||||
|
||||
- Total: **30**
|
||||
- TODO: **0**
|
||||
- Total: **31**
|
||||
- TODO: **1**
|
||||
- DONE: **30**
|
||||
|
||||
+37
-2
@@ -21,6 +21,9 @@
|
||||
- Принимать запрос на инициализацию оплаты доставки через TBank и возвращать ссылку на оплату без регистрации заказа в CDEK
|
||||
- Принимать сумму оплаты в поле `price` в копейках
|
||||
- После успешного получения ссылки на оплату от TBank сохранять все данные заявки вместе со ссылкой на оплату в PostgreSQL; ошибка сохранения не блокирует возврат ссылки клиенту
|
||||
- Принимать HTTP-уведомления TBank о статусах платежа на отдельный webhook endpoint
|
||||
- При получении валидного уведомления TBank со статусом `CONFIRMED` находить сохранённую заявку в PostgreSQL и регистрировать заказ в CDEK
|
||||
- Остальные валидные статусы платежа TBank подтверждать без регистрации заказа в CDEK
|
||||
- Выбирать сервис подсказок адреса по `country_code` через маппинг стран в конфиге
|
||||
- Для `RU`, `BY` и `KZ`, сопоставленных с provider id `dadata`, использовать `dadata.ru`
|
||||
- Для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ`, сопоставленных с provider id `yandex_geosuggest`, использовать Yandex Geosuggest
|
||||
@@ -44,7 +47,7 @@
|
||||
- Cache TTL: 15 минут
|
||||
- **TBank** — https://securepay.tinkoff.ru/v2/Init
|
||||
- Аутентификация: `TerminalKey` и token на основе password
|
||||
- Операции: инициализация платежа и получение payment URL
|
||||
- Операции: инициализация платежа, получение payment URL, проверка подписи HTTP-уведомлений
|
||||
|
||||
### Фаза 2+
|
||||
- Дополнительные провайдеры (Boxberry, DHL и др.) — подключаются через интерфейс `DeliveryProvider` без изменений в логике агрегации
|
||||
@@ -57,9 +60,10 @@
|
||||
- `POST /api/v1/delivery/price` — принимает `DeliveryCalculationRequest`, возвращает `list[DeliveryPrice]`
|
||||
- `POST /api/v1/delivery/suggest-address` — принимает `AddressSuggestRequest`, возвращает `list[AddressSuggestion]`
|
||||
- `POST /api/v1/delivery/init-payment` — принимает `InitPaymentRequest`, возвращает `InitPaymentResponse`
|
||||
- `POST /api/v1/delivery/tbank/notifications` — принимает `TBankPaymentNotification`, возвращает plain text `OK` при успешной обработке
|
||||
- Парсинг и валидация входных данных через Pydantic
|
||||
- Маппинг исключений сервиса в HTTP-ответы
|
||||
- Каждый endpoint вызывает ровно один метод Service: `AggregatorService.get_all_prices()`, `AggregatorService.suggest_addresses()` или `AggregatorService.init_payment()`
|
||||
- Каждый endpoint вызывает ровно один метод Service: `AggregatorService.get_all_prices()`, `AggregatorService.suggest_addresses()`, `AggregatorService.init_payment()` или `AggregatorService.handle_tbank_payment_notification()`
|
||||
|
||||
### Service (`app/services/aggregator.py`)
|
||||
- `AggregatorService.get_all_prices(request: DeliveryCalculationRequest) -> list[DeliveryPrice]`
|
||||
@@ -71,6 +75,10 @@
|
||||
- `AggregatorService.suggest_addresses()` выбирает address suggestion provider по `country_code` через injected config mapping и оркестрирует ровно один adapter call
|
||||
- `AggregatorService.init_payment(request: InitPaymentRequest) -> InitPaymentResponse`
|
||||
- `AggregatorService.init_payment()` оркестрирует инициализацию платежа через injected TBank adapter dependency, затем сохраняет данные заявки вместе с `payment_url` через injected order repository; ошибка сохранения логируется, но не блокирует возврат `payment_url`
|
||||
- `AggregatorService.handle_tbank_payment_notification(notification: TBankPaymentNotification) -> str`
|
||||
- `AggregatorService.handle_tbank_payment_notification()` оркестрирует проверку подписи уведомления через injected TBank adapter, определение действия по статусу через Business Logic, чтение сохранённой заявки через injected order repository и регистрацию заказа через injected CDEK adapter при статусе `CONFIRMED`
|
||||
- Повторное валидное уведомление `CONFIRMED` для заявки с уже сохранённым `cdek_order_uuid` не должно повторно создавать заказ в CDEK
|
||||
- Валидные уведомления TBank со статусами, отличными от `CONFIRMED`, подтверждаются ответом `OK` без обращения к CDEK
|
||||
- Service не содержит бизнес-логики и provider HTTP-деталей
|
||||
|
||||
### Business Logic (`app/domain/`)
|
||||
@@ -79,6 +87,7 @@
|
||||
- Логика сравнения цен
|
||||
- Правила применения конфигурируемого мультипликатора к ценам провайдеров
|
||||
- Округление цены после применения мультипликатора до целого значения по детерминированному правилу
|
||||
- Правило выбора действия для TBank payment notification по статусу платежа
|
||||
- Нормализация входных данных (например, идентификаторов городов и правил округления веса)
|
||||
- Чистые функции, без IO, без зависимостей от фреймворков
|
||||
|
||||
@@ -90,6 +99,9 @@
|
||||
### Repository (`app/repositories/order/`)
|
||||
- `OrderRepository` — сохранение данных заявки в PostgreSQL
|
||||
- Операции: `create_order(session, order_data)` — сохраняет запись заявки с данными `InitPaymentRequest` и `payment_url`
|
||||
- Операции: `get_order_by_order_uuid(session, order_uuid)` — получает сохранённую заявку для payment notification flow
|
||||
- Операции: `mark_payment_status(session, order_uuid, status, payment_id)` — сохраняет последний статус платежа TBank
|
||||
- Операции: `mark_cdek_order_registered(session, order_uuid, cdek_order_uuid)` — сохраняет UUID заказа CDEK после успешной регистрации
|
||||
- SQLAlchemy ORM model таблицы `orders` в `models.py`
|
||||
- Без бизнес-решений; только CRUD-примитивы
|
||||
|
||||
@@ -113,6 +125,7 @@
|
||||
- `base.py` — исключения TBank payment adapter
|
||||
- `client.py` — `TBankAdapter`
|
||||
- `TBankAdapter.create_payment_link(order_uuid: str, amount_kopecks: int) -> str` инициализирует платёж TBank и возвращает payment URL
|
||||
- `TBankAdapter.verify_payment_notification(notification: TBankPaymentNotification) -> None` проверяет token HTTP-уведомления TBank по top-level scalar fields, исключая `Token` и вложенные объекты
|
||||
- TBank adapter инкапсулирует HTTP-взаимодействие с TBank Init API, auth token, retries, timeout, serialization и error handling
|
||||
- TBank adapter владеет собственной секцией конфигурации `tbank_payment` с полями `init_url`, `auth.terminal_key`, `auth.password`, `timeout_seconds`, `retry_attempts`, `retry_backoff_seconds`
|
||||
|
||||
@@ -226,6 +239,28 @@ packages: list[{number: str, weight: int, length: int, width: int, height: int,
|
||||
payment_url: str
|
||||
```
|
||||
|
||||
### Входная: `TBankPaymentNotification`
|
||||
```
|
||||
TerminalKey: str
|
||||
OrderId: str
|
||||
Success: bool
|
||||
Status: str
|
||||
PaymentId: int
|
||||
ErrorCode: str
|
||||
Amount: int
|
||||
Token: str
|
||||
```
|
||||
|
||||
`PaymentId` приходит из TBank как JSON-число (long integer) и валидируется как положительное целое.
|
||||
Модель уведомления должна допускать дополнительные top-level поля TBank. Проверка token использует все top-level scalar fields кроме `Token`, добавляет `Password` из `tbank_payment.auth.password`, сортирует ключи по алфавиту, конкатенирует строковые представления значений (булевы — как lowercase `true`/`false`) и сравнивает SHA-256 hash с `Token`. Вложенные объекты, включая `Data` и `Receipt`, не участвуют в проверке token.
|
||||
|
||||
### Выходная: TBank notification response
|
||||
```
|
||||
OK
|
||||
```
|
||||
|
||||
Успешно обработанное HTTP-уведомление TBank возвращает `HTTP 200` и plain text body `OK`.
|
||||
|
||||
---
|
||||
|
||||
## Технологический стек
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
id: 030
|
||||
title: Add TBank payment notification webhook and CDEK order creation
|
||||
status: TODO
|
||||
created: 2026-04-18
|
||||
---
|
||||
|
||||
## Context
|
||||
TBank Init API уже получает `NotificationURL`, а данные заявки сохраняются в PostgreSQL после создания payment link. Сейчас приложение не принимает HTTP-уведомления TBank и не создаёт заказ в CDEK после подтверждения оплаты.
|
||||
|
||||
## Goal
|
||||
Добавить `POST /api/v1/delivery/tbank/notifications`, который принимает payment notification от TBank, проверяет token уведомления, при валидном статусе `CONFIRMED` находит сохранённую заявку по `OrderId` и регистрирует заказ в CDEK. Валидные уведомления с другими статусами должны подтверждаться без регистрации заказа в CDEK.
|
||||
|
||||
## Constraints
|
||||
- Controller отвечает только за routing, DTO validation и HTTP error mapping; endpoint вызывает ровно один метод Service: `AggregatorService.handle_tbank_payment_notification()`.
|
||||
- Service выполняет только orchestration: verification через TBank adapter, выбор действия через pure Business Logic, чтение/обновление заявки через OrderRepository и вызов injected CDEK order adapter.
|
||||
- Правило `CONFIRMED` -> создать заказ CDEK, остальные статусы -> подтвердить без CDEK должно быть pure Business Logic в `app/domain/`.
|
||||
- TBank-specific token verification должна быть инкапсулирована в `app/adapters/tbank/` и использовать `tbank_payment.auth.password`.
|
||||
- Проверка token MUST следовать контракту TBank HTTP-уведомлений: использовать top-level scalar fields кроме `Token`, не включать вложенные объекты (`Data`, `Receipt`), добавить `Password`, отсортировать ключи по алфавиту, конкатенировать значения, посчитать SHA-256 и сравнить с `Token`.
|
||||
- Успешно обработанное уведомление MUST возвращать `HTTP 200` с plain text body `OK`.
|
||||
- Валидные уведомления со статусами, отличными от `CONFIRMED`, MUST возвращать `OK` без чтения CDEK и без создания заказа.
|
||||
- Повторное валидное уведомление `CONFIRMED` для заявки с уже сохранённым `cdek_order_uuid` MUST возвращать `OK` без повторного вызова CDEK.
|
||||
- Если token невалиден, endpoint MUST возвращать deterministic `400` и не обращаться к repository или CDEK.
|
||||
- Если заявка для `OrderId` не найдена или CDEK registration не завершилась успешно, endpoint MUST не возвращать `OK`, чтобы TBank мог повторить notification delivery.
|
||||
- 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 и никогда не создавали дубль заказа.
|
||||
- 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.
|
||||
- Не добавлять новые payment providers, refund flow, recurring payments, ручной retry endpoint или endpoint чтения заявок.
|
||||
- Не изменять файлы в `spec/`.
|
||||
|
||||
## Acceptance criteria
|
||||
- Существует schema `TBankPaymentNotification` с обязательными полями `TerminalKey`, `OrderId`, `Success`, `Status`, `PaymentId`, `ErrorCode`, `Amount`, `Token` и поддержкой дополнительных top-level полей TBank. `PaymentId` валидируется как положительное целое (TBank присылает long integer) и сохраняется в `tbank_payment_id` колонке `BIGINT`.
|
||||
- `POST /api/v1/delivery/tbank/notifications` реализован в существующем controller `app/controllers/v1/delivery.py` и возвращает `PlainTextResponse("OK")` при успешной обработке.
|
||||
- Controller делегирует обработку ровно в `AggregatorService.handle_tbank_payment_notification()` и не содержит branching по TBank status.
|
||||
- `TBankAdapter` умеет проверять token payment notification через `tbank_payment.auth.password`; невалидный token маппится в service/controller error path без repository/CDEK side effects.
|
||||
- В `app/domain/` есть pure function, которая для `Status == "CONFIRMED"`, `Success == true` и `ErrorCode == "0"` возвращает действие регистрации CDEK, а для остальных статусов возвращает действие acknowledge-only.
|
||||
- `OrderRepository` предоставляет primitive для получения заявки по `order_uuid`, сохранения последнего TBank payment status/payment id и сохранения `cdek_order_uuid`.
|
||||
- Таблица `orders` содержит минимум поля `payment_status`, `tbank_payment_id` (BIGINT), `cdek_order_uuid`, `updated_at` в исходной миграции; отдельная миграция не вводится, так как production БД ещё не развёрнута.
|
||||
- При валидном `CONFIRMED` notification service получает order по `OrderId`, реконструирует `InitPaymentRequest` из сохранённых данных, вызывает injected CDEK order adapter registration и сохраняет полученный `cdek_order_uuid`.
|
||||
- Если для `OrderId` уже сохранён `cdek_order_uuid`, повторный `CONFIRMED` notification возвращает `OK` без повторного вызова CDEK.
|
||||
- CDEK order mapper проставляет `InitPaymentRequest.order_uuid` в поле external номера заказа CDEK (`number`), так что повторный POST с тем же external id не создаёт второй заказ в CDEK.
|
||||
- Если CDEK create фактически завершился успешно, но сохранение `cdek_order_uuid` в DB не прошло, следующий валидный `CONFIRMED` notification MUST завершиться сохранением того же `cdek_order_uuid` (полученного по тому же external id) без создания второго заказа в CDEK.
|
||||
- Валидные notification со статусами `AUTHORIZED`, `REJECTED`, `CANCELED`, `DEADLINE_EXPIRED` и неизвестными status values возвращают `OK` без регистрации CDEK order.
|
||||
- Ошибка поиска заявки или ошибка CDEK registration возвращает deterministic non-OK HTTP response и логируется.
|
||||
- `NotificationURL` в runtime/test/example конфигурации, если он присутствует в репозитории, указывает на `/api/v1/delivery/tbank/notifications`.
|
||||
|
||||
## Definition of Done
|
||||
- [ ] Добавлена schema `TBankPaymentNotification`.
|
||||
- [ ] Добавлена pure Business Logic для выбора действия по TBank payment notification status.
|
||||
- [ ] Добавлена token verification logic в TBank adapter.
|
||||
- [ ] Расширена SQLAlchemy model и обновлена существующая Alembic migration `20260412_028_create_orders_table` payment/CDEK status fields (production БД отсутствует, новая миграция не нужна).
|
||||
- [ ] Расширен `OrderRepository` primitives для read/update операций, нужных webhook flow.
|
||||
- [ ] Реализован `AggregatorService.handle_tbank_payment_notification()` с DI dependencies.
|
||||
- [ ] Добавлен endpoint `POST /api/v1/delivery/tbank/notifications` в существующий delivery controller.
|
||||
- [ ] Wiring использует существующие `TBankAdapter`, `OrderRepository` и CDEK provider/adapter без provider HTTP details в Service/Controller.
|
||||
- [ ] Повторные `CONFIRMED` notification обрабатываются идемпотентно.
|
||||
- [ ] CDEK order mapper проставляет `order_uuid` в поле external номера заказа CDEK.
|
||||
- [ ] Service корректно обрабатывает ответ CDEK "заказ уже существует" как успех и сохраняет возвращённый `cdek_order_uuid`.
|
||||
- [ ] Валидные non-`CONFIRMED` statuses подтверждаются без CDEK side effects.
|
||||
- [ ] Обновлены tests для domain, adapter, repository, service, controller и smoke wiring.
|
||||
- [ ] Все команды из раздела Commands проходят.
|
||||
|
||||
## Tests
|
||||
- Добавить `tests/domain/test_payment_notifications.py`: `CONFIRMED` + `Success=true` + `ErrorCode=0` -> create CDEK order action; остальные known/unknown statuses -> acknowledge-only.
|
||||
- Обновить или добавить `tests/adapters/tbank/test_notifications.py`: valid token, invalid token, exclusion of `Token`, exclusion of nested `Data`/`Receipt`, inclusion of extra scalar fields, deterministic SHA-256 comparison.
|
||||
- Обновить `tests/repositories/order/test_repository.py`: получение заявки по `order_uuid`, сохранение `payment_status`/`tbank_payment_id`, сохранение `cdek_order_uuid`, duplicate/idempotency scenario.
|
||||
- Обновить `tests/adapters/delivery_providers/cdek/test_order_mapper.py`: CDEK order payload содержит `InitPaymentRequest.order_uuid` в поле external номера заказа (`number`).
|
||||
- Добавить `tests/services/test_tbank_notifications.py`: valid `CONFIRMED` вызывает repository lookup, CDEK registration и save `cdek_order_uuid`; duplicate `CONFIRMED` не вызывает CDEK; non-`CONFIRMED` возвращает `OK` без CDEK; invalid token не обращается к repository; missing order/CDEK failure возвращает service error; сценарий "CDEK create succeeded, save `cdek_order_uuid` raised" — следующий `CONFIRMED` notification вызывает CDEK повторно с тем же external id, получает тот же `cdek_order_uuid`, сохраняет его и возвращает `OK` (суммарно ровно один реальный заказ в CDEK).
|
||||
- Добавить `tests/controllers/v1/test_tbank_notifications.py`: endpoint возвращает plain text `OK` для successful service response, 400 для invalid token, 503 для temporary processing failure, controller делегирует ровно один service method.
|
||||
- Обновить `tests/smoke/test_app_import.py` при необходимости для проверки wiring нового endpoint.
|
||||
|
||||
## Commands
|
||||
- `poetry run pytest tests/domain/test_payment_notifications.py -q`
|
||||
- `poetry run pytest tests/adapters/tbank/test_notifications.py -q`
|
||||
- `poetry run pytest tests/repositories/order/test_repository.py -q`
|
||||
- `poetry run pytest tests/services/test_tbank_notifications.py -q`
|
||||
- `poetry run pytest tests/controllers/v1/test_tbank_notifications.py -q`
|
||||
- `poetry run pytest tests/smoke/test_app_import.py -q`
|
||||
- `poetry run pytest -q`
|
||||
- `python3 spec/gen_spec_index.py --check`
|
||||
@@ -298,7 +298,7 @@ adapter:
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
notification_url: "https://merchant.test/tbank/notification"
|
||||
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||
success_url: "https://merchant.test/payment/success"
|
||||
auth:
|
||||
terminal_key: "test-terminal-key"
|
||||
|
||||
@@ -119,6 +119,7 @@ def test_provider_register_order_posts_cdek_contract_payload_and_maps_response()
|
||||
"method": "POST",
|
||||
"url": "https://api.cdek.test/v2/orders",
|
||||
"json": {
|
||||
"number": "order-uuid-1",
|
||||
"type": 2,
|
||||
"tariff_code": 535,
|
||||
"comment": "Test order",
|
||||
@@ -245,6 +246,70 @@ def test_cdek_client_register_order_maps_4xx_to_request_error() -> None:
|
||||
assert len(http_client.calls) == 1
|
||||
|
||||
|
||||
def test_cdek_client_register_order_maps_duplicate_external_id_to_success() -> None:
|
||||
duplicate_response = httpx.Response(
|
||||
409,
|
||||
json={
|
||||
"entity": {"uuid": "existing-cdek-order-uuid"},
|
||||
"requests": [
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"code": "v2_entity_already_exists",
|
||||
"message": "Order with this number already exists",
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
},
|
||||
request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
|
||||
)
|
||||
http_client = SequenceHTTPClient([duplicate_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,
|
||||
)
|
||||
|
||||
result = asyncio.run(client.register_order(_make_order_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"),
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
def test_cdek_client_register_order_retries_5xx_and_raises_client_error() -> None:
|
||||
first_response = httpx.Response(
|
||||
503,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from app.adapters.delivery_providers.cdek.order_mapper import (
|
||||
map_cdek_order_request,
|
||||
)
|
||||
from app.schemas.payment import InitPaymentRequest
|
||||
|
||||
|
||||
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())
|
||||
|
||||
assert payload["number"] == "order-uuid-1"
|
||||
@@ -49,7 +49,7 @@ class SequenceHTTPClient:
|
||||
def _make_adapter(
|
||||
http_client: SequenceHTTPClient,
|
||||
*,
|
||||
notification_url: str = "https://example.test/tbank/notify",
|
||||
notification_url: str = "https://example.test/api/v1/delivery/tbank/notifications",
|
||||
success_url: str = "https://example.test/payment/success",
|
||||
retry_attempts: int = 0,
|
||||
retry_backoff_seconds: float = 0.2,
|
||||
@@ -92,7 +92,7 @@ def test_create_payment_link_posts_signed_payload_and_maps_payment_url() -> None
|
||||
expected_token = hashlib.sha256(
|
||||
(
|
||||
"125000"
|
||||
"https://example.test/tbank/notify"
|
||||
"https://example.test/api/v1/delivery/tbank/notifications"
|
||||
"order-uuid-1"
|
||||
"test-password"
|
||||
"https://example.test/payment/success"
|
||||
@@ -108,7 +108,7 @@ def test_create_payment_link_posts_signed_payload_and_maps_payment_url() -> None
|
||||
"TerminalKey": "TBankTest",
|
||||
"Amount": 125000,
|
||||
"OrderId": "order-uuid-1",
|
||||
"NotificationURL": "https://example.test/tbank/notify",
|
||||
"NotificationURL": "https://example.test/api/v1/delivery/tbank/notifications",
|
||||
"SuccessURL": "https://example.test/payment/success",
|
||||
"Token": expected_token,
|
||||
},
|
||||
@@ -131,7 +131,7 @@ def test_from_config_posts_configured_urls_and_deterministic_token() -> None:
|
||||
http_client = SequenceHTTPClient([response])
|
||||
config = TBankPaymentConfig(
|
||||
init_url="https://securepay.tinkoff.ru/v2/Init",
|
||||
notification_url="https://merchant.test/tbank/notification",
|
||||
notification_url="https://merchant.test/api/v1/delivery/tbank/notifications",
|
||||
success_url="https://merchant.test/payment/success",
|
||||
auth=TBankPaymentAuthConfig(
|
||||
terminal_key="ConfigTerminal",
|
||||
@@ -156,7 +156,7 @@ def test_from_config_posts_configured_urls_and_deterministic_token() -> None:
|
||||
expected_token = hashlib.sha256(
|
||||
(
|
||||
"9900"
|
||||
"https://merchant.test/tbank/notification"
|
||||
"https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||
"order-uuid-2"
|
||||
"config-password"
|
||||
"https://merchant.test/payment/success"
|
||||
@@ -168,7 +168,7 @@ def test_from_config_posts_configured_urls_and_deterministic_token() -> None:
|
||||
"TerminalKey": "ConfigTerminal",
|
||||
"Amount": 9900,
|
||||
"OrderId": "order-uuid-2",
|
||||
"NotificationURL": "https://merchant.test/tbank/notification",
|
||||
"NotificationURL": "https://merchant.test/api/v1/delivery/tbank/notifications",
|
||||
"SuccessURL": "https://merchant.test/payment/success",
|
||||
"Token": expected_token,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import hashlib
|
||||
|
||||
import pytest
|
||||
|
||||
from app.adapters.tbank.base import TBankPaymentNotificationTokenError
|
||||
from app.adapters.tbank.client import TBankAdapter
|
||||
from app.schemas.payment import TBankPaymentNotification
|
||||
|
||||
|
||||
def _make_adapter() -> TBankAdapter:
|
||||
return TBankAdapter(
|
||||
http_client=object(), # type: ignore[arg-type]
|
||||
init_url="https://securepay.tinkoff.ru/v2/Init",
|
||||
notification_url="https://merchant.test/api/v1/delivery/tbank/notifications",
|
||||
success_url="https://merchant.test/payment/success",
|
||||
terminal_key="TestTerminal",
|
||||
password="test-password",
|
||||
)
|
||||
|
||||
|
||||
def _base_payload(**overrides: object) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"TerminalKey": "TestTerminal",
|
||||
"OrderId": "order-uuid-1",
|
||||
"Success": True,
|
||||
"Status": "CONFIRMED",
|
||||
"PaymentId": 8347568144,
|
||||
"ErrorCode": "0",
|
||||
"Amount": 125000,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def _build_notification_token(payload: dict[str, object]) -> str:
|
||||
token_payload = {
|
||||
key: value
|
||||
for key, value in payload.items()
|
||||
if key != "Token" and not isinstance(value, (dict, list))
|
||||
}
|
||||
token_payload["Password"] = "test-password"
|
||||
token_source = "".join(
|
||||
_stringify_token_value(token_payload[key]) for key in sorted(token_payload)
|
||||
)
|
||||
return hashlib.sha256(token_source.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _stringify_token_value(value: object) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _signed_notification(**overrides: object) -> TBankPaymentNotification:
|
||||
payload = _base_payload(**overrides)
|
||||
if "Token" not in payload:
|
||||
payload["Token"] = _build_notification_token(payload)
|
||||
return TBankPaymentNotification(**payload)
|
||||
|
||||
|
||||
def test_verify_payment_notification_accepts_valid_token() -> None:
|
||||
adapter = _make_adapter()
|
||||
notification = _signed_notification()
|
||||
|
||||
adapter.verify_payment_notification(notification)
|
||||
|
||||
|
||||
def test_verify_payment_notification_rejects_invalid_token() -> None:
|
||||
adapter = _make_adapter()
|
||||
notification = _signed_notification(Token="invalid-token")
|
||||
|
||||
with pytest.raises(TBankPaymentNotificationTokenError):
|
||||
adapter.verify_payment_notification(notification)
|
||||
|
||||
|
||||
def test_verify_payment_notification_excludes_token_from_hash_source() -> None:
|
||||
adapter = _make_adapter()
|
||||
payload = _base_payload()
|
||||
payload["Token"] = _build_notification_token(payload)
|
||||
notification = TBankPaymentNotification(**payload)
|
||||
|
||||
adapter.verify_payment_notification(notification)
|
||||
|
||||
|
||||
def test_verify_payment_notification_excludes_nested_data_and_receipt() -> None:
|
||||
adapter = _make_adapter()
|
||||
notification = _signed_notification(
|
||||
Data={"nested": "ignored"},
|
||||
Receipt={"Items": [{"Name": "Box", "Price": 125000}]},
|
||||
)
|
||||
|
||||
adapter.verify_payment_notification(notification)
|
||||
|
||||
|
||||
def test_verify_payment_notification_includes_extra_scalar_fields() -> None:
|
||||
adapter = _make_adapter()
|
||||
notification = _signed_notification(CardId="card-id-1")
|
||||
|
||||
adapter.verify_payment_notification(notification)
|
||||
|
||||
payload_without_extra = notification.model_dump(mode="python")
|
||||
del payload_without_extra["CardId"]
|
||||
payload_without_extra["Token"] = _build_notification_token(payload_without_extra)
|
||||
invalid_notification = TBankPaymentNotification(
|
||||
**payload_without_extra,
|
||||
CardId="card-id-1",
|
||||
)
|
||||
|
||||
with pytest.raises(TBankPaymentNotificationTokenError):
|
||||
adapter.verify_payment_notification(invalid_notification)
|
||||
|
||||
|
||||
def test_verify_payment_notification_serializes_booleans_as_lowercase() -> None:
|
||||
adapter = _make_adapter()
|
||||
notification = _signed_notification(Success=False)
|
||||
|
||||
adapter.verify_payment_notification(notification)
|
||||
|
||||
|
||||
def test_verify_payment_notification_uses_deterministic_sha256_comparison() -> None:
|
||||
payload = _base_payload()
|
||||
expected_token = hashlib.sha256(
|
||||
(
|
||||
"125000"
|
||||
"0"
|
||||
"order-uuid-1"
|
||||
"test-password"
|
||||
"8347568144"
|
||||
"CONFIRMED"
|
||||
"true"
|
||||
"TestTerminal"
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
assert _build_notification_token(payload) == expected_token
|
||||
@@ -17,7 +17,7 @@ adapter:
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
notification_url: "https://default.test/tbank/notification"
|
||||
notification_url: "https://default.test/api/v1/delivery/tbank/notifications"
|
||||
success_url: "https://default.test/payment/success"
|
||||
auth:
|
||||
terminal_key: "yaml-terminal-key"
|
||||
|
||||
@@ -25,7 +25,7 @@ adapter:
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
notification_url: "https://merchant.test/tbank/notification"
|
||||
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||
success_url: "https://merchant.test/payment/success"
|
||||
auth:
|
||||
terminal_key: "test-terminal-key"
|
||||
|
||||
@@ -14,7 +14,7 @@ repository:
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
notification_url: "https://merchant.test/tbank/notification"
|
||||
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||
success_url: "https://merchant.test/payment/success"
|
||||
auth:
|
||||
terminal_key: "test-terminal-key"
|
||||
|
||||
@@ -25,7 +25,7 @@ adapter:
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
notification_url: "https://merchant.test/tbank/notification"
|
||||
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||
success_url: "https://merchant.test/payment/success"
|
||||
auth:
|
||||
terminal_key: "test-terminal-key"
|
||||
|
||||
@@ -25,7 +25,7 @@ adapter:
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
notification_url: "https://merchant.test/tbank/notification"
|
||||
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||
success_url: "https://merchant.test/payment/success"
|
||||
auth:
|
||||
terminal_key: "test-terminal-key"
|
||||
|
||||
@@ -25,7 +25,7 @@ adapter:
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
notification_url: "https://merchant.test/tbank/notification"
|
||||
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||
success_url: "https://merchant.test/payment/success"
|
||||
auth:
|
||||
terminal_key: "test-terminal-key"
|
||||
|
||||
@@ -24,7 +24,7 @@ adapter:
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
notification_url: "https://merchant.test/tbank/notification"
|
||||
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||
success_url: "https://merchant.test/payment/success"
|
||||
auth:
|
||||
terminal_key: "test-terminal-key"
|
||||
|
||||
@@ -17,7 +17,7 @@ adapter:
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
notification_url: "https://override.test/tbank/notification"
|
||||
notification_url: "https://override.test/api/v1/delivery/tbank/notifications"
|
||||
success_url: "https://override.test/payment/success"
|
||||
auth:
|
||||
terminal_key: "override-terminal-key"
|
||||
|
||||
@@ -111,7 +111,7 @@ def _write_tbank_url_validation_config(
|
||||
include_success_url: bool = True,
|
||||
) -> Path:
|
||||
notification_url = (
|
||||
' notification_url: "https://merchant.test/tbank/notification"\n'
|
||||
' notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"\n'
|
||||
if include_notification_url
|
||||
else ""
|
||||
)
|
||||
@@ -179,7 +179,7 @@ def test_configuration_sections_are_loaded_from_yaml_file(
|
||||
assert settings.tbank_payment.init_url == "https://securepay.tinkoff.ru/v2/Init"
|
||||
assert (
|
||||
settings.tbank_payment.notification_url
|
||||
== "https://merchant.test/tbank/notification"
|
||||
== "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||
)
|
||||
assert settings.tbank_payment.success_url == "https://merchant.test/payment/success"
|
||||
assert settings.tbank_payment.auth.terminal_key == "test-terminal-key"
|
||||
@@ -260,7 +260,7 @@ def test_get_settings_returns_cached_instance(monkeypatch: pytest.MonkeyPatch) -
|
||||
assert first.tbank_payment.auth.terminal_key == "test-terminal-key"
|
||||
assert (
|
||||
first.tbank_payment.notification_url
|
||||
== "https://merchant.test/tbank/notification"
|
||||
== "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||
)
|
||||
assert first.postgres.dsn.endswith("/g2s_aggregator_test")
|
||||
assert first.address_suggestions.country_to_provider["RU"] == "dadata"
|
||||
@@ -287,7 +287,7 @@ def test_get_settings_uses_config_test_yaml_in_pytest_environment(
|
||||
assert settings.tbank_payment.auth.password == "override-password"
|
||||
assert (
|
||||
settings.tbank_payment.notification_url
|
||||
== "https://override.test/tbank/notification"
|
||||
== "https://override.test/api/v1/delivery/tbank/notifications"
|
||||
)
|
||||
assert settings.tbank_payment.success_url == "https://override.test/payment/success"
|
||||
assert settings.tbank_payment.timeout_seconds == 4.25
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
from app.controllers.v1.delivery import get_aggregator_service
|
||||
from app.main import create_app
|
||||
from app.schemas.payment import TBankPaymentNotification
|
||||
from app.services.aggregator import (
|
||||
InvalidTBankPaymentNotificationError,
|
||||
TBankPaymentNotificationProcessingError,
|
||||
)
|
||||
|
||||
|
||||
class StubAggregatorService:
|
||||
def __init__(self, *, response: str = "OK", error: Exception | None = None) -> None:
|
||||
self._response = response
|
||||
self._error = error
|
||||
self.calls: list[TBankPaymentNotification] = []
|
||||
|
||||
async def handle_tbank_payment_notification(
|
||||
self,
|
||||
notification: TBankPaymentNotification,
|
||||
) -> str:
|
||||
self.calls.append(notification)
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return self._response
|
||||
|
||||
|
||||
def _install_service_override(app, service: StubAggregatorService) -> None:
|
||||
async def override_service() -> StubAggregatorService:
|
||||
return service
|
||||
|
||||
app.dependency_overrides[get_aggregator_service] = override_service
|
||||
|
||||
|
||||
def _valid_payload() -> dict[str, object]:
|
||||
return {
|
||||
"TerminalKey": "TestTerminal",
|
||||
"OrderId": "order-uuid-1",
|
||||
"Success": True,
|
||||
"Status": "CONFIRMED",
|
||||
"PaymentId": 8347568144,
|
||||
"ErrorCode": "0",
|
||||
"Amount": 125000,
|
||||
"Token": "signed-token",
|
||||
"Data": {"nested": "accepted by schema"},
|
||||
}
|
||||
|
||||
|
||||
def _post_notification(app, payload: dict[str, object]) -> httpx.Response:
|
||||
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/tbank/notifications",
|
||||
json=payload,
|
||||
)
|
||||
|
||||
return asyncio.run(run_request())
|
||||
|
||||
|
||||
def test_post_tbank_notification_returns_plain_text_ok_and_delegates_once() -> None:
|
||||
service = StubAggregatorService(response="OK")
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
|
||||
response = _post_notification(app, _valid_payload())
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == "OK"
|
||||
assert response.headers["content-type"].startswith("text/plain")
|
||||
assert service.calls == [TBankPaymentNotification(**_valid_payload())]
|
||||
|
||||
|
||||
def test_post_tbank_notification_maps_invalid_token_to_400() -> None:
|
||||
service = StubAggregatorService(
|
||||
error=InvalidTBankPaymentNotificationError("invalid token")
|
||||
)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
|
||||
response = _post_notification(app, _valid_payload())
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "invalid_tbank_payment_notification"
|
||||
assert service.calls == [TBankPaymentNotification(**_valid_payload())]
|
||||
|
||||
|
||||
def test_post_tbank_notification_maps_processing_failure_to_503() -> None:
|
||||
service = StubAggregatorService(
|
||||
error=TBankPaymentNotificationProcessingError("temporary failure")
|
||||
)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
|
||||
response = _post_notification(app, _valid_payload())
|
||||
|
||||
assert response.status_code == 503
|
||||
assert (
|
||||
response.json()["detail"]["code"]
|
||||
== "tbank_payment_notification_processing_unavailable"
|
||||
)
|
||||
assert service.calls == [TBankPaymentNotification(**_valid_payload())]
|
||||
|
||||
|
||||
def test_post_tbank_notification_rejects_invalid_payload_without_service_call() -> None:
|
||||
service = StubAggregatorService()
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
payload = _valid_payload()
|
||||
del payload["Token"]
|
||||
|
||||
response = _post_notification(app, payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert service.calls == []
|
||||
@@ -0,0 +1,55 @@
|
||||
from app.domain.payment_notifications import (
|
||||
TBankPaymentNotificationAction,
|
||||
resolve_tbank_payment_notification_action,
|
||||
)
|
||||
|
||||
|
||||
def test_confirmed_success_zero_error_code_registers_cdek_order() -> None:
|
||||
result = resolve_tbank_payment_notification_action(
|
||||
status="CONFIRMED",
|
||||
success=True,
|
||||
error_code="0",
|
||||
)
|
||||
|
||||
assert result is TBankPaymentNotificationAction.REGISTER_CDEK_ORDER
|
||||
|
||||
|
||||
def test_confirmed_without_success_acknowledges_only() -> None:
|
||||
result = resolve_tbank_payment_notification_action(
|
||||
status="CONFIRMED",
|
||||
success=False,
|
||||
error_code="0",
|
||||
)
|
||||
|
||||
assert result is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
|
||||
|
||||
|
||||
def test_confirmed_with_non_zero_error_code_acknowledges_only() -> None:
|
||||
result = resolve_tbank_payment_notification_action(
|
||||
status="CONFIRMED",
|
||||
success=True,
|
||||
error_code="101",
|
||||
)
|
||||
|
||||
assert result is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
|
||||
|
||||
|
||||
def test_known_non_confirmed_statuses_acknowledge_only() -> None:
|
||||
for status in ("AUTHORIZED", "REJECTED", "CANCELED", "DEADLINE_EXPIRED"):
|
||||
result = resolve_tbank_payment_notification_action(
|
||||
status=status,
|
||||
success=True,
|
||||
error_code="0",
|
||||
)
|
||||
|
||||
assert result is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
|
||||
|
||||
|
||||
def test_unknown_status_acknowledges_only() -> None:
|
||||
result = resolve_tbank_payment_notification_action(
|
||||
status="UNKNOWN_STATUS",
|
||||
success=True,
|
||||
error_code="0",
|
||||
)
|
||||
|
||||
assert result is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
|
||||
+1
-1
@@ -181,7 +181,7 @@ adapter:
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
notification_url: "https://merchant.test/tbank/notification"
|
||||
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||
success_url: "https://merchant.test/payment/success"
|
||||
auth:
|
||||
terminal_key: "test-terminal-key"
|
||||
|
||||
@@ -101,7 +101,11 @@ def test_create_order_persists_all_required_fields() -> None:
|
||||
assert persisted_order.packages == order_data.packages
|
||||
assert persisted_order.services == order_data.services
|
||||
assert persisted_order.comment == "Test payment"
|
||||
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.created_at is not None
|
||||
assert persisted_order.updated_at is not None
|
||||
|
||||
asyncio.run(_with_repository(run))
|
||||
|
||||
@@ -143,3 +147,159 @@ def test_create_order_rejects_duplicate_order_uuid() -> None:
|
||||
await repository.create_order(session, order_data)
|
||||
|
||||
asyncio.run(_with_repository(run))
|
||||
|
||||
|
||||
def test_get_order_by_order_uuid_returns_persisted_order() -> None:
|
||||
async def run(
|
||||
repository: OrderRepository,
|
||||
_session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
order_data = _make_order_data()
|
||||
|
||||
async with repository.session() as session:
|
||||
await repository.create_order(session, order_data)
|
||||
|
||||
async with repository.session() as session:
|
||||
order = await repository.get_order_by_order_uuid(session, "order-uuid-1")
|
||||
|
||||
assert order is not None
|
||||
assert order.order_uuid == "order-uuid-1"
|
||||
assert order.payment_url == "https://pay.test/payment/1"
|
||||
|
||||
asyncio.run(_with_repository(run))
|
||||
|
||||
|
||||
def test_get_order_by_order_uuid_returns_none_for_missing_order() -> None:
|
||||
async def run(
|
||||
repository: OrderRepository,
|
||||
_session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
async with repository.session() as session:
|
||||
order = await repository.get_order_by_order_uuid(session, "missing-order")
|
||||
|
||||
assert order is None
|
||||
|
||||
asyncio.run(_with_repository(run))
|
||||
|
||||
|
||||
def test_mark_payment_status_persists_status_and_payment_id() -> 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:
|
||||
order = await repository.mark_payment_status(
|
||||
session,
|
||||
"order-uuid-1",
|
||||
"CONFIRMED",
|
||||
8347568144,
|
||||
)
|
||||
|
||||
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 order is not None
|
||||
assert persisted_order.payment_status == "CONFIRMED"
|
||||
assert persisted_order.tbank_payment_id == 8347568144
|
||||
|
||||
asyncio.run(_with_repository(run))
|
||||
|
||||
|
||||
def test_mark_payment_status_returns_none_for_missing_order() -> None:
|
||||
async def run(
|
||||
repository: OrderRepository,
|
||||
_session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
async with repository.session() as session:
|
||||
order = await repository.mark_payment_status(
|
||||
session,
|
||||
"missing-order",
|
||||
"CONFIRMED",
|
||||
8347568144,
|
||||
)
|
||||
|
||||
assert order is None
|
||||
|
||||
asyncio.run(_with_repository(run))
|
||||
|
||||
|
||||
def test_mark_cdek_order_registered_persists_cdek_order_uuid() -> 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:
|
||||
order = 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 order is not None
|
||||
assert persisted_order.cdek_order_uuid == "cdek-order-uuid-1"
|
||||
|
||||
asyncio.run(_with_repository(run))
|
||||
|
||||
|
||||
def test_mark_cdek_order_registered_is_idempotent_for_same_uuid() -> 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 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))
|
||||
orders = result.scalars().all()
|
||||
|
||||
assert len(orders) == 1
|
||||
assert orders[0].cdek_order_uuid == "cdek-order-uuid-1"
|
||||
|
||||
asyncio.run(_with_repository(run))
|
||||
|
||||
|
||||
def test_mark_cdek_order_registered_returns_none_for_missing_order() -> None:
|
||||
async def run(
|
||||
repository: OrderRepository,
|
||||
_session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
async with repository.session() as session:
|
||||
order = await repository.mark_cdek_order_registered(
|
||||
session,
|
||||
"missing-order",
|
||||
"cdek-order-uuid-1",
|
||||
)
|
||||
|
||||
assert order is None
|
||||
|
||||
asyncio.run(_with_repository(run))
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.adapters.tbank.base import TBankPaymentNotificationTokenError
|
||||
from app.schemas.payment import InitPaymentRequest, TBankPaymentNotification
|
||||
from app.services.aggregator import (
|
||||
AggregatorService,
|
||||
InvalidTBankPaymentNotificationError,
|
||||
TBankPaymentNotificationProcessingError,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
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
|
||||
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"}]
|
||||
|
||||
|
||||
class StubPaymentAdapter:
|
||||
def __init__(self, *, verify_error: Exception | None = None) -> None:
|
||||
self._verify_error = verify_error
|
||||
self.notifications: list[TBankPaymentNotification] = []
|
||||
|
||||
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def verify_payment_notification(
|
||||
self,
|
||||
notification: TBankPaymentNotification,
|
||||
) -> None:
|
||||
self.notifications.append(notification)
|
||||
if self._verify_error is not None:
|
||||
raise self._verify_error
|
||||
|
||||
|
||||
class StubOrderSessionContext:
|
||||
def __init__(self, session: object) -> None:
|
||||
self._session = session
|
||||
|
||||
async def __aenter__(self) -> object:
|
||||
return self._session
|
||||
|
||||
async def __aexit__(self, exc_type, exc, traceback) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class StubOrderRepository:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
orders: list[StoredOrder] | None = None,
|
||||
mark_cdek_errors: list[Exception | None] | None = None,
|
||||
) -> None:
|
||||
self._orders = {order.order_uuid: order for order in orders or []}
|
||||
self._mark_cdek_errors = mark_cdek_errors or []
|
||||
self.session_value = object()
|
||||
self.calls: list[tuple[str, tuple[object, ...]]] = []
|
||||
|
||||
def session(self) -> StubOrderSessionContext:
|
||||
self.calls.append(("session", ()))
|
||||
return StubOrderSessionContext(self.session_value)
|
||||
|
||||
async def create_order(self, session: object, order_data: object) -> object:
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_order_by_order_uuid(
|
||||
self,
|
||||
session: object,
|
||||
order_uuid: str,
|
||||
) -> StoredOrder | None:
|
||||
self.calls.append(("get_order_by_order_uuid", (session, order_uuid)))
|
||||
return self._orders.get(order_uuid)
|
||||
|
||||
async def mark_payment_status(
|
||||
self,
|
||||
session: object,
|
||||
order_uuid: str,
|
||||
status: str,
|
||||
payment_id: int,
|
||||
) -> StoredOrder | None:
|
||||
self.calls.append(
|
||||
("mark_payment_status", (session, order_uuid, status, payment_id))
|
||||
)
|
||||
order = self._orders.get(order_uuid)
|
||||
if order is None:
|
||||
return None
|
||||
order.payment_status = status
|
||||
order.tbank_payment_id = payment_id
|
||||
return order
|
||||
|
||||
async def mark_cdek_order_registered(
|
||||
self,
|
||||
session: object,
|
||||
order_uuid: str,
|
||||
cdek_order_uuid: str,
|
||||
) -> StoredOrder | None:
|
||||
self.calls.append(
|
||||
("mark_cdek_order_registered", (session, order_uuid, cdek_order_uuid))
|
||||
)
|
||||
if self._mark_cdek_errors:
|
||||
error = self._mark_cdek_errors.pop(0)
|
||||
if error is not None:
|
||||
raise error
|
||||
|
||||
order = self._orders.get(order_uuid)
|
||||
if order is None:
|
||||
return None
|
||||
order.cdek_order_uuid = cdek_order_uuid
|
||||
return order
|
||||
|
||||
|
||||
class StubCDEKOrderAdapter:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
responses: list[str] | None = None,
|
||||
error: Exception | None = None,
|
||||
) -> None:
|
||||
self._responses = responses or ["cdek-order-uuid-1"]
|
||||
self._error = error
|
||||
self.calls: list[InitPaymentRequest] = []
|
||||
|
||||
async def register_order(self, request: InitPaymentRequest) -> str:
|
||||
self.calls.append(request)
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return self._responses.pop(0)
|
||||
|
||||
|
||||
def _make_notification(**overrides: object) -> TBankPaymentNotification:
|
||||
payload: dict[str, object] = {
|
||||
"TerminalKey": "TestTerminal",
|
||||
"OrderId": "order-uuid-1",
|
||||
"Success": True,
|
||||
"Status": "CONFIRMED",
|
||||
"PaymentId": 8347568144,
|
||||
"ErrorCode": "0",
|
||||
"Amount": 125000,
|
||||
"Token": "signed-token",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return TBankPaymentNotification(**payload)
|
||||
|
||||
|
||||
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"])
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=payment_adapter,
|
||||
order_repository=order_repository,
|
||||
order_registration_adapter=cdek_adapter,
|
||||
)
|
||||
notification = _make_notification()
|
||||
|
||||
result = asyncio.run(service.handle_tbank_payment_notification(notification))
|
||||
|
||||
assert result == "OK"
|
||||
assert payment_adapter.notifications == [notification]
|
||||
assert order.payment_status == "CONFIRMED"
|
||||
assert order.tbank_payment_id == 8347568144
|
||||
assert order.cdek_order_uuid == "cdek-order-uuid-1"
|
||||
assert len(cdek_adapter.calls) == 1
|
||||
assert cdek_adapter.calls[0].order_uuid == "order-uuid-1"
|
||||
|
||||
|
||||
def test_duplicate_confirmed_notification_does_not_call_cdek() -> None:
|
||||
order = StoredOrder(cdek_order_uuid="existing-cdek-order-uuid")
|
||||
cdek_adapter = StubCDEKOrderAdapter()
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=StubPaymentAdapter(),
|
||||
order_repository=StubOrderRepository(orders=[order]),
|
||||
order_registration_adapter=cdek_adapter,
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
service.handle_tbank_payment_notification(_make_notification())
|
||||
)
|
||||
|
||||
assert result == "OK"
|
||||
assert cdek_adapter.calls == []
|
||||
assert order.cdek_order_uuid == "existing-cdek-order-uuid"
|
||||
|
||||
|
||||
def test_non_confirmed_notification_acknowledges_without_cdek() -> None:
|
||||
order = StoredOrder()
|
||||
cdek_adapter = StubCDEKOrderAdapter()
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=StubPaymentAdapter(),
|
||||
order_repository=StubOrderRepository(orders=[order]),
|
||||
order_registration_adapter=cdek_adapter,
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
service.handle_tbank_payment_notification(
|
||||
_make_notification(Status="AUTHORIZED")
|
||||
)
|
||||
)
|
||||
|
||||
assert result == "OK"
|
||||
assert order.payment_status == "AUTHORIZED"
|
||||
assert cdek_adapter.calls == []
|
||||
|
||||
|
||||
def test_invalid_token_does_not_access_repository_or_cdek() -> None:
|
||||
order_repository = StubOrderRepository(orders=[StoredOrder()])
|
||||
cdek_adapter = StubCDEKOrderAdapter()
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=StubPaymentAdapter(
|
||||
verify_error=TBankPaymentNotificationTokenError("bad token")
|
||||
),
|
||||
order_repository=order_repository,
|
||||
order_registration_adapter=cdek_adapter,
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidTBankPaymentNotificationError):
|
||||
asyncio.run(service.handle_tbank_payment_notification(_make_notification()))
|
||||
|
||||
assert order_repository.calls == []
|
||||
assert cdek_adapter.calls == []
|
||||
|
||||
|
||||
def test_missing_order_returns_processing_error_without_cdek() -> None:
|
||||
cdek_adapter = StubCDEKOrderAdapter()
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=StubPaymentAdapter(),
|
||||
order_repository=StubOrderRepository(),
|
||||
order_registration_adapter=cdek_adapter,
|
||||
)
|
||||
|
||||
with pytest.raises(TBankPaymentNotificationProcessingError):
|
||||
asyncio.run(service.handle_tbank_payment_notification(_make_notification()))
|
||||
|
||||
assert cdek_adapter.calls == []
|
||||
|
||||
|
||||
def test_cdek_registration_failure_returns_processing_error() -> None:
|
||||
cdek_adapter = StubCDEKOrderAdapter(error=RuntimeError("cdek down"))
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=StubPaymentAdapter(),
|
||||
order_repository=StubOrderRepository(orders=[StoredOrder()]),
|
||||
order_registration_adapter=cdek_adapter,
|
||||
)
|
||||
|
||||
with pytest.raises(TBankPaymentNotificationProcessingError):
|
||||
asyncio.run(service.handle_tbank_payment_notification(_make_notification()))
|
||||
|
||||
assert len(cdek_adapter.calls) == 1
|
||||
|
||||
|
||||
def test_repeated_confirmed_after_cdek_uuid_save_failure_uses_same_external_id() -> None:
|
||||
order = StoredOrder()
|
||||
order_repository = StubOrderRepository(
|
||||
orders=[order],
|
||||
mark_cdek_errors=[RuntimeError("db down"), None],
|
||||
)
|
||||
cdek_adapter = StubCDEKOrderAdapter(
|
||||
responses=["same-cdek-order-uuid", "same-cdek-order-uuid"]
|
||||
)
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=StubPaymentAdapter(),
|
||||
order_repository=order_repository,
|
||||
order_registration_adapter=cdek_adapter,
|
||||
)
|
||||
notification = _make_notification()
|
||||
|
||||
with pytest.raises(TBankPaymentNotificationProcessingError):
|
||||
asyncio.run(service.handle_tbank_payment_notification(notification))
|
||||
|
||||
assert order.cdek_order_uuid is None
|
||||
|
||||
result = asyncio.run(service.handle_tbank_payment_notification(notification))
|
||||
|
||||
assert result == "OK"
|
||||
assert order.cdek_order_uuid == "same-cdek-order-uuid"
|
||||
assert [request.order_uuid for request in cdek_adapter.calls] == [
|
||||
"order-uuid-1",
|
||||
"order-uuid-1",
|
||||
]
|
||||
@@ -35,6 +35,9 @@ def test_app_import_smoke(monkeypatch) -> None:
|
||||
|
||||
assert isinstance(main_module.app, FastAPI)
|
||||
assert isinstance(main_module.app.state.settings, Settings)
|
||||
assert "/api/v1/delivery/tbank/notifications" in {
|
||||
route.path for route in main_module.app.routes
|
||||
}
|
||||
assert logging_bootstrap_calls == [None]
|
||||
assert tracing_bootstrap_calls == [
|
||||
(main_module.app, main_module.app.state.settings.observability)
|
||||
|
||||
Reference in New Issue
Block a user