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