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: str = "535" provider: str = "cdek" 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 cse_order_number: str | None = None cdek_waybill_uuid: str | None = None cdek_waybill_url: str | None = None payment_email_sent_at: object | 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, *, for_update: bool = False, ) -> 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 async def record_payment_email_sent( self, session: object, *, order_uuid: str, sent_at: object, ) -> StoredOrder | None: self.calls.append( ("record_payment_email_sent", (session, order_uuid, sent_at)) ) order = self._orders.get(order_uuid) if order is not None and order.payment_email_sent_at is None: order.payment_email_sent_at = sent_at return order class StubEmailSender: def __init__(self, *, error: Exception | None = None) -> None: self._error = error self.calls: list[dict[str, str]] = [] async def send_email( self, *, to: str, subject: str, body: str ) -> None: self.calls.append({"to": to, "subject": subject, "body": body}) if self._error is not None: raise self._error 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[tuple[InitPaymentRequest, str]] = [] async def register_order( self, request: InitPaymentRequest, order_uuid: str ) -> CDEKOrderRegistrationResult: self.calls.append((request, order_uuid)) 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_adapters={"cdek": 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][1] == "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_adapters={"cdek": 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_adapters={"cdek": 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_adapters={"cdek": 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_adapters={"cdek": 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_adapters={"cdek": 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_adapters={"cdek": 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 [order_uuid for _, order_uuid in cdek_adapter.calls] == [ "order-uuid-1", "order-uuid-1", ] def test_confirmed_notification_sends_payment_confirmation_email() -> None: order = StoredOrder() email_sender = StubEmailSender() service = AggregatorService( providers=[], payment_adapter=StubPaymentAdapter(), order_repository=StubOrderRepository(orders=[order]), order_registration_adapters={"cdek": StubCDEKOrderAdapter()}, email_sender=email_sender, ) result = asyncio.run( service.handle_tbank_payment_notification(_make_notification()) ) assert result == "OK" assert len(email_sender.calls) == 1 assert email_sender.calls[0]["to"] == "client@example.com" assert "order-uuid-1" in email_sender.calls[0]["subject"] assert order.payment_email_sent_at is not None def test_non_confirmed_notification_does_not_send_email() -> None: order = StoredOrder() email_sender = StubEmailSender() service = AggregatorService( providers=[], payment_adapter=StubPaymentAdapter(), order_repository=StubOrderRepository(orders=[order]), order_registration_adapters={"cdek": StubCDEKOrderAdapter()}, email_sender=email_sender, ) result = asyncio.run( service.handle_tbank_payment_notification( _make_notification(Status="AUTHORIZED") ) ) assert result == "OK" assert email_sender.calls == [] assert order.payment_email_sent_at is None def test_duplicate_notification_does_not_resend_payment_email() -> None: from datetime import datetime, timezone order = StoredOrder( cdek_order_uuid="existing-cdek-order-uuid", payment_email_sent_at=datetime(2026, 1, 1, tzinfo=timezone.utc), ) email_sender = StubEmailSender() service = AggregatorService( providers=[], payment_adapter=StubPaymentAdapter(), order_repository=StubOrderRepository(orders=[order]), order_registration_adapters={"cdek": StubCDEKOrderAdapter()}, email_sender=email_sender, ) result = asyncio.run( service.handle_tbank_payment_notification(_make_notification()) ) assert result == "OK" assert email_sender.calls == [] def test_email_failure_does_not_break_notification_handling() -> None: order = StoredOrder() email_sender = StubEmailSender(error=RuntimeError("smtp down")) service = AggregatorService( providers=[], payment_adapter=StubPaymentAdapter(), order_repository=StubOrderRepository(orders=[order]), order_registration_adapters={"cdek": StubCDEKOrderAdapter()}, email_sender=email_sender, ) result = asyncio.run( service.handle_tbank_payment_notification(_make_notification()) ) assert result == "OK" assert len(email_sender.calls) == 1 assert order.cdek_order_uuid == "cdek-order-uuid-1"