Files
g2s-aggregator/tests/services/test_tbank_notifications.py
T
2026-05-23 20:24:23 +03:00

337 lines
11 KiB
Python

import asyncio
from dataclasses import dataclass, field
from typing import Any
import pytest
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderRegistrationResult,
)
from app.adapters.tbank.base import TBankPaymentNotificationTokenError
from app.schemas.payment import InitPaymentRequest, TBankPaymentNotification
from app.services.aggregator import (
AggregatorService,
InvalidTBankPaymentNotificationError,
TBankPaymentNotificationProcessingError,
)
from tests.payment_fixtures import make_init_payment_payload
def _default_payload() -> dict[str, Any]:
return make_init_payment_payload()
@dataclass
class StoredOrder:
order_uuid: str = "order-uuid-1"
payment_url: str = "https://pay.test/payment/1"
price: int = 125000
tariff_code: int = 535
account_email: str = "client@example.com"
payload: dict[str, Any] = field(default_factory=_default_payload)
payment_status: str | None = None
tbank_payment_id: int | None = None
cdek_order_uuid: str | None = None
cdek_waybill_uuid: str | None = None
cdek_waybill_url: str | None = None
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[CDEKOrderRegistrationResult] | None = None,
error: Exception | None = None,
) -> None:
self._responses = responses or [
CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid-1",
waybill_uuid=None,
waybill_url=None,
)
]
self._error = error
self.calls: list[InitPaymentRequest] = []
async def register_order(
self, request: InitPaymentRequest
) -> CDEKOrderRegistrationResult:
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=[
CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid-1",
waybill_uuid="waybill-uuid-1",
waybill_url="https://cdek.test/waybill/1.pdf",
)
]
)
service = AggregatorService(
providers=[],
payment_adapter=payment_adapter,
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 order.cdek_waybill_uuid is None
assert order.cdek_waybill_url is None
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=[
CDEKOrderRegistrationResult(
order_uuid="same-cdek-order-uuid",
waybill_uuid=None,
waybill_url=None,
),
CDEKOrderRegistrationResult(
order_uuid="same-cdek-order-uuid",
waybill_uuid=None,
waybill_url=None,
),
]
)
service = AggregatorService(
providers=[],
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",
]