Files

283 lines
10 KiB
Python

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))