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, ) 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_init_payment_request(**overrides: object) -> InitPaymentRequest: payload: dict[str, object] = { "order_uuid": "order-uuid-1", "price": 125000, "type": 2, "tariff_code": 535, "comment": "Test payment", "sender": { "name": "Petr Petrov", "email": "sender@example.com", "phone": {"number": "+79009876543"}, }, "recipient": { "name": "Ivan Ivanov", "email": "ivan@example.com", "phone": {"number": "+79001234567"}, }, "from_location": { "address": "Lenina 1", "city": "Moscow", "country_code": "RU", }, "to_location": { "address": "Pushkina 10", "city": "Novosibirsk", "country_code": "RU", }, "services": [{"code": "INSURANCE", "parameter": "1000"}], "packages": [ { "number": "1", "weight": 1, "length": 20, "width": 15, "height": 10, "comment": "Package 1", } ], } payload.update(overrides) return InitPaymentRequest(**payload) 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_adapter=validation_adapter, ) 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_after_successful_payment_link() -> None: request = _make_init_payment_request() 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_adapter=validation_adapter, order_repository=order_repository, ) 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 order_repository.calls == [ ( order_repository.session_value, OrderData( order_uuid="order-uuid-1", payment_url="https://pay.test/payment/1", price=125000, delivery_type=2, tariff_code=535, sender={ "name": "Petr Petrov", "email": "sender@example.com", "phone": {"number": "+79009876543"}, }, recipient={ "name": "Ivan Ivanov", "email": "ivan@example.com", "phone": {"number": "+79001234567"}, }, from_location={ "address": "Lenina 1", "city": "Moscow", "country_code": "RU", }, to_location={ "address": "Pushkina 10", "city": "Novosibirsk", "country_code": "RU", }, packages=[ { "number": "1", "weight": 1, "length": 20, "width": 15, "height": 10, "comment": "Package 1", } ], services=[{"code": "INSURANCE", "parameter": "1000"}], comment="Test payment", ), ) ] 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_adapter=validation_adapter, order_repository=order_repository, ) 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 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_adapter=validation_adapter, ) with pytest.raises(InvalidInitPaymentRequestError): asyncio.run(service.init_payment(request)) assert validation_adapter.calls == [request] assert adapter.calls == [("order-uuid-1", 125000)] def test_init_payment_delegates_request_with_services_none_without_new_logic() -> None: request = _make_init_payment_request(services=None) adapter = StubPaymentAdapter(response="https://pay.test/payment/2") validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price()) service = AggregatorService( providers=[], payment_adapter=adapter, payment_price_validation_adapter=validation_adapter, ) result = asyncio.run(service.init_payment(request)) assert result == InitPaymentResponse(payment_url="https://pay.test/payment/2") assert validation_adapter.calls == [request] assert adapter.calls == [("order-uuid-1", 125000)] assert request.services is None 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_adapter=validation_adapter, ) with pytest.raises(InitPaymentUnavailableError): asyncio.run(service.init_payment(request)) assert validation_adapter.calls == [request] assert adapter.calls == [("order-uuid-1", 125000)] 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: request = _make_init_payment_request(price=124999) 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_adapter=validation_adapter, order_repository=order_repository, ) with pytest.raises(InvalidInitPaymentRequestError): asyncio.run(service.init_payment(request)) assert validation_adapter.calls == [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_adapter=validation_adapter, ) with pytest.raises(InvalidInitPaymentRequestError): asyncio.run(service.init_payment(request)) assert validation_adapter.calls == [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_adapter=validation_adapter, ) with pytest.raises(InvalidInitPaymentRequestError): asyncio.run(service.init_payment(request)) assert validation_adapter.calls == [request] assert adapter.calls == [] 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_adapter=validation_adapter, ) with pytest.raises(InitPaymentUnavailableError): asyncio.run(service.init_payment(request)) assert validation_adapter.calls == [request] assert adapter.calls == []