283 lines
9.9 KiB
Python
283 lines
9.9 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_adapter=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_adapter=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_adapter=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_adapter=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_adapter=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_adapter=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_adapter=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_adapter=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_adapter=validation_adapter,
|
|
)
|
|
|
|
with pytest.raises(InitPaymentUnavailableError):
|
|
asyncio.run(service.init_payment(request))
|