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", ]