import asyncio from decimal import Decimal import pytest from app.adapters.delivery_providers.base import ProviderClientError, ProviderRequestError from app.adapters.tbank.base import ( TBankPaymentAdapterError, TBankPaymentRequestError, ) from app.repositories.order import OrderData from app.schemas.payment import InitPaymentRequest, InitPaymentResponse from app.schemas.response import DeliveryPrice from app.services.aggregator import ( AggregatorService, InitPaymentUnavailableError, InvalidInitPaymentRequestError, ) from tests.payment_fixtures import make_init_payment_payload, make_init_payment_request class StubPaymentAdapter: def __init__( self, *, response: str | None = None, error: Exception | None = None, events: list[str] | None = None, ) -> None: self._response = response self._error = error self._events = events self.calls: list[tuple[str, int]] = [] async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str: self.calls.append((order_uuid, amount_kopecks)) if self._events is not None: self._events.append("tbank") if self._error is not None: raise self._error if self._response is None: raise RuntimeError("Stub payment adapter has no response configured.") return self._response class StubPaymentPriceValidationAdapter: def __init__( self, *, response: DeliveryPrice | None = None, error: Exception | None = None, events: list[str] | None = None, ) -> None: self._response = response self._error = error self._events = events self.calls: list[InitPaymentRequest] = [] async def get_payment_price( self, request: InitPaymentRequest, ) -> DeliveryPrice | None: self.calls.append(request) if self._events is not None: self._events.append("cdek") if self._error is not None: raise self._error return self._response 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, *, error: Exception | None = None) -> None: self._error = error self.session_value = object() self.calls: list[tuple[object, OrderData]] = [] def session(self) -> StubOrderSessionContext: return StubOrderSessionContext(self.session_value) async def create_order(self, session: object, order_data: OrderData) -> object: self.calls.append((session, order_data)) if self._error is not None: raise self._error return object() def _make_cdek_price(**overrides: object) -> DeliveryPrice: payload = { "provider": "cdek", "service_name": "CDEK tariff", "price": Decimal("1250.00"), "currency": "RUB", "delivery_days_min": 1, "delivery_days_max": 2, } payload.update(overrides) return DeliveryPrice.model_construct(**payload) def test_init_payment_validates_cdek_price_before_tbank_and_returns_payment_url() -> None: request = make_init_payment_request() events: list[str] = [] validation_adapter = StubPaymentPriceValidationAdapter( response=_make_cdek_price(), events=events, ) adapter = StubPaymentAdapter(response="https://pay.test/payment/1", events=events) service = AggregatorService( providers=[], payment_adapter=adapter, payment_price_validation_adapters={"cdek": validation_adapter}, order_uuid_factory=lambda: "order-uuid-1", ) result = asyncio.run(service.init_payment(request)) assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1") assert validation_adapter.calls == [request] assert adapter.calls == [("order-uuid-1", 125000)] assert events == ["cdek", "tbank"] def test_init_payment_persists_order_payload_after_successful_payment_link() -> None: payload = make_init_payment_payload() request = InitPaymentRequest.model_validate(payload) adapter = StubPaymentAdapter(response="https://pay.test/payment/1") validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price()) order_repository = StubOrderRepository() service = AggregatorService( providers=[], payment_adapter=adapter, payment_price_validation_adapters={"cdek": validation_adapter}, order_repository=order_repository, order_uuid_factory=lambda: "order-uuid-1", ) asyncio.run(service.init_payment(request)) assert len(order_repository.calls) == 1 session, order_data = order_repository.calls[0] assert session is order_repository.session_value assert order_data.order_uuid == "order-uuid-1" assert order_data.payment_url == "https://pay.test/payment/1" assert order_data.price == 125000 assert order_data.tariff_code == "535" assert order_data.account_email == "client@example.com" assert order_data.payload == request.model_dump(mode="json", by_alias=True) def test_init_payment_returns_payment_url_when_order_persistence_fails() -> None: request = make_init_payment_request() adapter = StubPaymentAdapter(response="https://pay.test/payment/1") validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price()) order_repository = StubOrderRepository(error=RuntimeError("database down")) service = AggregatorService( providers=[], payment_adapter=adapter, payment_price_validation_adapters={"cdek": validation_adapter}, order_repository=order_repository, order_uuid_factory=lambda: "order-uuid-1", ) result = asyncio.run(service.init_payment(request)) assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1") assert adapter.calls == [("order-uuid-1", 125000)] assert len(order_repository.calls) == 1 def test_init_payment_maps_provider_request_errors_to_invalid_payment_error() -> None: request = make_init_payment_request() adapter = StubPaymentAdapter(error=TBankPaymentRequestError("bad payload")) validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price()) service = AggregatorService( providers=[], payment_adapter=adapter, payment_price_validation_adapters={"cdek": validation_adapter}, ) with pytest.raises(InvalidInitPaymentRequestError): asyncio.run(service.init_payment(request)) def test_init_payment_maps_client_failures_to_unavailable_error() -> None: request = make_init_payment_request() adapter = StubPaymentAdapter(error=TBankPaymentAdapterError("transport down")) validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price()) service = AggregatorService( providers=[], payment_adapter=adapter, payment_price_validation_adapters={"cdek": validation_adapter}, ) with pytest.raises(InitPaymentUnavailableError): asyncio.run(service.init_payment(request)) def test_init_payment_without_configured_adapter_raises_unavailable_error() -> None: service = AggregatorService(providers=[]) with pytest.raises(InitPaymentUnavailableError): asyncio.run(service.init_payment(make_init_payment_request())) def test_init_payment_rejects_cdek_price_mismatch_without_tbank_or_repository_calls() -> None: payload = make_init_payment_payload() payload["systemData"]["tariff"]["price"] = 124999 request = InitPaymentRequest.model_validate(payload) adapter = StubPaymentAdapter(response="https://pay.test/payment/1") validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price()) order_repository = StubOrderRepository() service = AggregatorService( providers=[], payment_adapter=adapter, payment_price_validation_adapters={"cdek": validation_adapter}, order_repository=order_repository, ) with pytest.raises(InvalidInitPaymentRequestError): asyncio.run(service.init_payment(request)) assert adapter.calls == [] assert order_repository.calls == [] def test_init_payment_rejects_when_cdek_does_not_return_requested_tariff() -> None: request = make_init_payment_request() adapter = StubPaymentAdapter(response="https://pay.test/payment/1") validation_adapter = StubPaymentPriceValidationAdapter(response=None) service = AggregatorService( providers=[], payment_adapter=adapter, payment_price_validation_adapters={"cdek": validation_adapter}, ) with pytest.raises(InvalidInitPaymentRequestError): asyncio.run(service.init_payment(request)) assert adapter.calls == [] def test_init_payment_maps_cdek_request_error_to_invalid_payment_error() -> None: request = make_init_payment_request() adapter = StubPaymentAdapter(response="https://pay.test/payment/1") validation_adapter = StubPaymentPriceValidationAdapter( error=ProviderRequestError("bad CDEK payload") ) service = AggregatorService( providers=[], payment_adapter=adapter, payment_price_validation_adapters={"cdek": validation_adapter}, ) with pytest.raises(InvalidInitPaymentRequestError): asyncio.run(service.init_payment(request)) def test_init_payment_maps_cdek_client_error_to_unavailable_error() -> None: request = make_init_payment_request() adapter = StubPaymentAdapter(response="https://pay.test/payment/1") validation_adapter = StubPaymentPriceValidationAdapter( error=ProviderClientError("CDEK unavailable") ) service = AggregatorService( providers=[], payment_adapter=adapter, payment_price_validation_adapters={"cdek": validation_adapter}, ) with pytest.raises(InitPaymentUnavailableError): asyncio.run(service.init_payment(request))