Добавлен адаптер к tbank и формирование ссылки на оплату

This commit is contained in:
Раис Юсупалиев
2026-04-12 18:56:41 +03:00
parent ab0b66e1c2
commit 2b201a08be
34 changed files with 1322 additions and 268 deletions
@@ -296,6 +296,12 @@ adapter:
cdek_timeout_seconds: 7.5
cdek_cache_ttl_seconds: 777
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
observability:
enabled: false
service_name: "cdek-adapter-test-service"
@@ -10,7 +10,7 @@ from app.adapters.delivery_providers.cdek.client import (
CDEKProvider,
CDEKRequestError,
)
from app.schemas.order import OrderCreateRequest
from app.schemas.payment import InitPaymentRequest
class StubAuthClient:
@@ -51,8 +51,10 @@ class SequenceHTTPClient:
return result
def _make_order_request(**overrides: object) -> OrderCreateRequest:
def _make_order_request(**overrides: object) -> InitPaymentRequest:
payload: dict[str, object] = {
"order_uuid": "order-uuid-1",
"price": 125000,
"type": 2,
"tariff_code": 535,
"comment": "Test order",
@@ -89,7 +91,7 @@ def _make_order_request(**overrides: object) -> OrderCreateRequest:
],
}
payload.update(overrides)
return OrderCreateRequest(**payload)
return InitPaymentRequest(**payload)
def test_provider_register_order_posts_cdek_contract_payload_and_maps_response() -> None:
@@ -111,8 +113,7 @@ def test_provider_register_order_posts_cdek_contract_payload_and_maps_response()
result = asyncio.run(provider.register_order(_make_order_request()))
assert result.provider == "cdek"
assert result.order_uuid == "cdek-order-uuid"
assert result == "cdek-order-uuid"
assert http_client.calls == [
{
"method": "POST",
+170
View File
@@ -0,0 +1,170 @@
import asyncio
import hashlib
from typing import Any
import httpx
import pytest
from app.adapters.tbank.client import TBankAdapter
from app.adapters.tbank.base import (
TBankPaymentAdapterError,
TBankPaymentRequestError,
)
class SequenceHTTPClient:
def __init__(self, results: list[Any]) -> None:
self._results = results
self.calls: list[dict[str, Any]] = []
def _next_result(self) -> Any:
return self._results[len(self.calls) - 1]
async def post(
self,
url: str,
*,
json: dict[str, Any] | None = None,
data: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
timeout: float | None = None,
) -> httpx.Response:
self.calls.append(
{
"method": "POST",
"url": url,
"json": json,
"data": data,
"headers": headers,
"timeout": timeout,
}
)
result = self._next_result()
if isinstance(result, Exception):
raise result
return result
def _make_adapter(
http_client: SequenceHTTPClient,
*,
retry_attempts: int = 0,
retry_backoff_seconds: float = 0.2,
sleep_calls: list[float] | None = None,
) -> TBankAdapter:
async def fake_sleep(seconds: float) -> None:
if sleep_calls is not None:
sleep_calls.append(seconds)
return TBankAdapter(
http_client=http_client, # type: ignore[arg-type]
init_url="https://securepay.tinkoff.ru/v2/Init",
terminal_key="TBankTest",
password="test-password",
timeout_seconds=7.5,
retry_attempts=retry_attempts,
retry_backoff_seconds=retry_backoff_seconds,
sleep=fake_sleep,
)
def test_create_payment_link_posts_signed_payload_and_maps_payment_url() -> None:
response = httpx.Response(
200,
json={"Success": True, "PaymentURL": "https://securepay.test/pay/1"},
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
)
http_client = SequenceHTTPClient([response])
adapter = _make_adapter(http_client)
result = asyncio.run(
adapter.create_payment_link(
order_uuid="order-uuid-1",
amount_kopecks=125000,
)
)
expected_token = hashlib.sha256(
"125000order-uuid-1test-passwordTBankTest".encode("utf-8")
).hexdigest()
assert result == "https://securepay.test/pay/1"
assert http_client.calls == [
{
"method": "POST",
"url": "https://securepay.tinkoff.ru/v2/Init",
"json": {
"TerminalKey": "TBankTest",
"Amount": 125000,
"OrderId": "order-uuid-1",
"Token": expected_token,
},
"data": None,
"headers": {
"Accept": "application/json",
"Content-Type": "application/json",
},
"timeout": 7.5,
}
]
def test_create_payment_link_maps_4xx_to_request_error() -> None:
response = httpx.Response(
400,
json={"Success": False, "Message": "bad request"},
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
)
http_client = SequenceHTTPClient([response])
adapter = _make_adapter(http_client)
with pytest.raises(TBankPaymentRequestError, match="status 400"):
asyncio.run(adapter.create_payment_link("order-uuid-1", 125000))
def test_create_payment_link_maps_unsuccessful_payload_to_request_error() -> None:
response = httpx.Response(
200,
json={"Success": False, "Message": "bad request"},
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
)
http_client = SequenceHTTPClient([response])
adapter = _make_adapter(http_client)
with pytest.raises(TBankPaymentRequestError):
asyncio.run(adapter.create_payment_link("order-uuid-1", 125000))
def test_create_payment_link_maps_transport_errors_to_client_error() -> None:
request = httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init")
http_client = SequenceHTTPClient([httpx.ConnectError("down", request=request)])
adapter = _make_adapter(http_client)
with pytest.raises(TBankPaymentAdapterError, match="retry attempts"):
asyncio.run(adapter.create_payment_link("order-uuid-1", 125000))
def test_create_payment_link_retries_5xx_and_raises_client_error() -> None:
first_response = httpx.Response(
503,
json={"Message": "temporary failure"},
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
)
second_response = httpx.Response(
503,
json={"Message": "temporary failure"},
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
)
http_client = SequenceHTTPClient([first_response, second_response])
sleep_calls: list[float] = []
adapter = _make_adapter(
http_client,
retry_attempts=1,
retry_backoff_seconds=0.25,
sleep_calls=sleep_calls,
)
with pytest.raises(TBankPaymentAdapterError, match="retriable status 503"):
asyncio.run(adapter.create_payment_link("order-uuid-1", 125000))
assert len(http_client.calls) == 2
assert sleep_calls == [0.25]
@@ -15,6 +15,12 @@ adapter:
cdek_client_id: "yaml-id"
cdek_client_secret: "yaml-secret"
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
auth:
terminal_key: "yaml-terminal-key"
password: "yaml-password"
address_suggestions:
country_to_provider:
RU: "dadata"
@@ -23,6 +23,12 @@ adapter:
cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
address_suggestions:
country_to_provider:
RU: "dadata"
@@ -12,6 +12,12 @@ repository:
redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
address_suggestions:
country_to_provider:
RU: "dadata"
@@ -23,6 +23,12 @@ adapter:
cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
observability:
enabled: false
service_name: "missing-address-suggestions-service"
@@ -23,6 +23,12 @@ adapter:
cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
address_suggestions:
country_to_provider:
RU: "dadata"
@@ -23,6 +23,12 @@ adapter:
cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
address_suggestions:
country_to_provider:
RU: "dadata"
@@ -22,6 +22,12 @@ adapter:
cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
address_suggestions:
country_to_provider:
RU: "dadata"
@@ -15,6 +15,15 @@ adapter:
cdek_client_id: "test-id"
cdek_client_secret: "test-secret"
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
auth:
terminal_key: "override-terminal-key"
password: "override-password"
timeout_seconds: 4.25
retry_attempts: 1
retry_backoff_seconds: 0.05
address_suggestions:
country_to_provider:
RU: "dadata"
+12
View File
@@ -126,6 +126,12 @@ def test_configuration_sections_are_loaded_from_yaml_file(
assert settings.adapter.cdek_retry_backoff_seconds == 0.2
assert settings.adapter.cdek_timeout_seconds == 10.0
assert settings.adapter.cdek_cache_ttl_seconds == 900
assert settings.tbank_payment.init_url == "https://securepay.tinkoff.ru/v2/Init"
assert settings.tbank_payment.auth.terminal_key == "test-terminal-key"
assert settings.tbank_payment.auth.password == "test-password"
assert settings.tbank_payment.timeout_seconds == 10.0
assert settings.tbank_payment.retry_attempts == 2
assert settings.tbank_payment.retry_backoff_seconds == 0.2
assert settings.address_suggestions.country_to_provider == _expected_country_mapping()
assert (
settings.address_suggestions.dadata.url
@@ -192,6 +198,7 @@ def test_get_settings_returns_cached_instance(monkeypatch: pytest.MonkeyPatch) -
assert first is second
assert first.service.provider_timeout_seconds == 10.0
assert first.business_logic.provider_price_multiplier == Decimal("1.0")
assert first.tbank_payment.auth.terminal_key == "test-terminal-key"
assert first.address_suggestions.country_to_provider["RU"] == "dadata"
assert first.observability.service_name == "g2s-aggregator-test"
get_settings.cache_clear()
@@ -212,6 +219,11 @@ def test_get_settings_uses_config_test_yaml_in_pytest_environment(
assert settings.controller.api_prefix == "/from-config-test-yaml"
assert settings.business_logic.provider_price_multiplier == Decimal("1.25")
assert settings.adapter.cdek_client_id == "test-id"
assert settings.tbank_payment.auth.terminal_key == "override-terminal-key"
assert settings.tbank_payment.auth.password == "override-password"
assert settings.tbank_payment.timeout_seconds == 4.25
assert settings.tbank_payment.retry_attempts == 1
assert settings.tbank_payment.retry_backoff_seconds == 0.05
assert settings.address_suggestions.country_to_provider == {
"RU": "dadata",
"AM": "yandex_geosuggest",
@@ -4,10 +4,10 @@ import httpx
from app.controllers.v1.delivery import get_aggregator_service
from app.main import create_app
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
from app.schemas.payment import InitPaymentRequest, InitPaymentResponse
from app.services.aggregator import (
AggregatorServiceError,
InvalidOrderCreateRequestError,
InitPaymentUnavailableError,
InvalidInitPaymentRequestError,
)
@@ -15,9 +15,9 @@ class StubAggregatorService:
def __init__(self, *, response: object, error: Exception | None = None) -> None:
self._response = response
self._error = error
self.calls: list[OrderCreateRequest] = []
self.calls: list[InitPaymentRequest] = []
async def create_order(self, request: OrderCreateRequest) -> object:
async def init_payment(self, request: InitPaymentRequest) -> object:
self.calls.append(request)
if self._error is not None:
raise self._error
@@ -33,9 +33,11 @@ def _install_service_override(app, service: StubAggregatorService) -> None:
def _valid_payload() -> dict[str, object]:
return {
"order_uuid": "order-uuid-1",
"price": 125000,
"type": 2,
"tariff_code": 535,
"comment": "Test order",
"comment": "Test payment",
"sender": {
"name": "Petr Petrov",
"email": "sender@example.com",
@@ -70,8 +72,8 @@ def _valid_payload() -> dict[str, object]:
}
def test_post_delivery_order_returns_response_and_delegates_to_service() -> None:
expected_response = OrderCreateResponse(provider="cdek", order_uuid="order-uuid-1")
def test_post_init_payment_returns_response_and_delegates_to_service() -> None:
expected_response = InitPaymentResponse(payment_url="https://pay.test/payment/1")
service = StubAggregatorService(response=expected_response)
app = create_app()
_install_service_override(app, service)
@@ -82,16 +84,19 @@ def test_post_delivery_order_returns_response_and_delegates_to_service() -> None
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/order", json=_valid_payload())
return await client.post(
"/api/v1/delivery/init-payment",
json=_valid_payload(),
)
response = asyncio.run(run_request())
assert response.status_code == 200
assert response.json() == expected_response.model_dump(mode="json")
assert service.calls == [OrderCreateRequest(**_valid_payload())]
assert service.calls == [InitPaymentRequest(**_valid_payload())]
def test_post_delivery_order_rejects_invalid_payload() -> None:
def test_post_init_payment_rejects_invalid_payload() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
@@ -104,7 +109,10 @@ def test_post_delivery_order_rejects_invalid_payload() -> None:
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/order", json=invalid_payload)
return await client.post(
"/api/v1/delivery/init-payment",
json=invalid_payload,
)
response = asyncio.run(run_request())
@@ -112,7 +120,79 @@ def test_post_delivery_order_rejects_invalid_payload() -> None:
assert service.calls == []
def test_post_delivery_order_rejects_sender_company_field() -> None:
def test_post_init_payment_rejects_missing_price() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
del invalid_payload["price"]
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/init-payment",
json=invalid_payload,
)
response = asyncio.run(run_request())
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_non_positive_price() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["price"] = 0
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/init-payment",
json=invalid_payload,
)
response = asyncio.run(run_request())
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_non_integer_price() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["price"] = 125000.5
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/init-payment",
json=invalid_payload,
)
response = asyncio.run(run_request())
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_sender_company_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
@@ -128,7 +208,10 @@ def test_post_delivery_order_rejects_sender_company_field() -> None:
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/order", json=invalid_payload)
return await client.post(
"/api/v1/delivery/init-payment",
json=invalid_payload,
)
response = asyncio.run(run_request())
@@ -136,7 +219,7 @@ def test_post_delivery_order_rejects_sender_company_field() -> None:
assert service.calls == []
def test_post_delivery_order_rejects_sender_phones_field() -> None:
def test_post_init_payment_rejects_sender_phones_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
@@ -152,7 +235,10 @@ def test_post_delivery_order_rejects_sender_phones_field() -> None:
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/order", json=invalid_payload)
return await client.post(
"/api/v1/delivery/init-payment",
json=invalid_payload,
)
response = asyncio.run(run_request())
@@ -160,7 +246,7 @@ def test_post_delivery_order_rejects_sender_phones_field() -> None:
assert service.calls == []
def test_post_delivery_order_rejects_recipient_company_field() -> None:
def test_post_init_payment_rejects_recipient_company_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
@@ -176,7 +262,10 @@ def test_post_delivery_order_rejects_recipient_company_field() -> None:
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/order", json=invalid_payload)
return await client.post(
"/api/v1/delivery/init-payment",
json=invalid_payload,
)
response = asyncio.run(run_request())
@@ -184,7 +273,7 @@ def test_post_delivery_order_rejects_recipient_company_field() -> None:
assert service.calls == []
def test_post_delivery_order_rejects_recipient_phones_field() -> None:
def test_post_init_payment_rejects_recipient_phones_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
@@ -200,7 +289,10 @@ def test_post_delivery_order_rejects_recipient_phones_field() -> None:
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/order", json=invalid_payload)
return await client.post(
"/api/v1/delivery/init-payment",
json=invalid_payload,
)
response = asyncio.run(run_request())
@@ -208,8 +300,8 @@ def test_post_delivery_order_rejects_recipient_phones_field() -> None:
assert service.calls == []
def test_post_delivery_order_accepts_request_without_services() -> None:
expected_response = OrderCreateResponse(provider="cdek", order_uuid="order-uuid-1")
def test_post_init_payment_accepts_request_without_services() -> None:
expected_response = InitPaymentResponse(payment_url="https://pay.test/payment/1")
service = StubAggregatorService(response=expected_response)
app = create_app()
_install_service_override(app, service)
@@ -222,19 +314,19 @@ def test_post_delivery_order_accepts_request_without_services() -> None:
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/order", json=payload)
return await client.post("/api/v1/delivery/init-payment", json=payload)
response = asyncio.run(run_request())
assert response.status_code == 200
assert response.json() == expected_response.model_dump(mode="json")
assert service.calls == [OrderCreateRequest(**payload)]
assert service.calls == [InitPaymentRequest(**payload)]
def test_post_delivery_order_maps_invalid_request_to_400() -> None:
def test_post_init_payment_maps_invalid_request_to_400() -> None:
service = StubAggregatorService(
response=None,
error=InvalidOrderCreateRequestError("invalid payload"),
error=InvalidInitPaymentRequestError("invalid payload"),
)
app = create_app()
_install_service_override(app, service)
@@ -245,23 +337,26 @@ def test_post_delivery_order_maps_invalid_request_to_400() -> None:
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/order", json=_valid_payload())
return await client.post(
"/api/v1/delivery/init-payment",
json=_valid_payload(),
)
response = asyncio.run(run_request())
assert response.status_code == 400
assert response.json() == {
"detail": {
"code": "invalid_order_create_request",
"message": "Order request contains invalid or unsupported CDEK data.",
"code": "invalid_init_payment_request",
"message": "Payment request contains invalid or unsupported TBank data.",
}
}
def test_post_delivery_order_maps_service_exception_to_503() -> None:
def test_post_init_payment_maps_service_exception_to_503() -> None:
service = StubAggregatorService(
response=None,
error=AggregatorServiceError("service unavailable"),
error=InitPaymentUnavailableError("service unavailable"),
)
app = create_app()
_install_service_override(app, service)
@@ -272,14 +367,36 @@ def test_post_delivery_order_maps_service_exception_to_503() -> None:
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/order", json=_valid_payload())
return await client.post(
"/api/v1/delivery/init-payment",
json=_valid_payload(),
)
response = asyncio.run(run_request())
assert response.status_code == 503
assert response.json() == {
"detail": {
"code": "order_creation_unavailable",
"message": "CDEK order creation is temporarily unavailable.",
"code": "init_payment_unavailable",
"message": "Payment initialization is temporarily unavailable.",
}
}
def test_delivery_order_endpoint_is_removed() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/order", json=_valid_payload())
response = asyncio.run(run_request())
assert response.status_code == 404
assert service.calls == []
+6
View File
@@ -179,6 +179,12 @@ adapter:
cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
auth:
terminal_key: "test-terminal-key"
password: "test-password"
observability:
enabled: false
service_name: "repository-test-service"
+129
View File
@@ -0,0 +1,129 @@
import asyncio
import pytest
from app.adapters.tbank.base import (
TBankPaymentAdapterError,
TBankPaymentRequestError,
)
from app.schemas.payment import InitPaymentRequest, InitPaymentResponse
from app.services.aggregator import (
AggregatorService,
InitPaymentUnavailableError,
InvalidInitPaymentRequestError,
)
class StubPaymentAdapter:
def __init__(
self,
*,
response: str | None = None,
error: Exception | None = None,
) -> None:
self._response = response
self._error = error
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._error is not None:
raise self._error
if self._response is None:
raise RuntimeError("Stub payment adapter has no response configured.")
return self._response
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 test_init_payment_delegates_to_adapter_and_returns_payment_url() -> None:
request = _make_init_payment_request()
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
service = AggregatorService(providers=[], payment_adapter=adapter)
result = asyncio.run(service.init_payment(request))
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
assert adapter.calls == [("order-uuid-1", 125000)]
def test_init_payment_maps_provider_request_errors_to_invalid_payment_error() -> None:
request = _make_init_payment_request()
adapter = StubPaymentAdapter(error=TBankPaymentRequestError("bad payload"))
service = AggregatorService(providers=[], payment_adapter=adapter)
with pytest.raises(InvalidInitPaymentRequestError):
asyncio.run(service.init_payment(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")
service = AggregatorService(providers=[], payment_adapter=adapter)
result = asyncio.run(service.init_payment(request))
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/2")
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"))
service = AggregatorService(providers=[], payment_adapter=adapter)
with pytest.raises(InitPaymentUnavailableError):
asyncio.run(service.init_payment(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()))
-128
View File
@@ -1,128 +0,0 @@
import asyncio
import pytest
from app.adapters.delivery_providers.base import ProviderRequestError
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
from app.services.aggregator import (
AggregatorService,
InvalidOrderCreateRequestError,
OrderCreationUnavailableError,
)
class StubOrderAdapter:
def __init__(
self,
*,
response: OrderCreateResponse | None = None,
error: Exception | None = None,
) -> None:
self._response = response
self._error = error
self.calls: list[OrderCreateRequest] = []
async def register_order(self, request: OrderCreateRequest) -> OrderCreateResponse:
self.calls.append(request)
if self._error is not None:
raise self._error
if self._response is None:
raise RuntimeError("Stub order adapter has no response configured.")
return self._response
def _make_order_request(**overrides: object) -> OrderCreateRequest:
payload: dict[str, object] = {
"type": 2,
"tariff_code": 535,
"comment": "Test order",
"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 OrderCreateRequest(**payload)
def test_create_order_delegates_to_adapter_and_returns_created_order() -> None:
request = _make_order_request()
adapter = StubOrderAdapter(
response=OrderCreateResponse(provider="cdek", order_uuid="order-uuid-1")
)
service = AggregatorService(providers=[], order_adapter=adapter)
result = asyncio.run(service.create_order(request))
assert result == OrderCreateResponse(provider="cdek", order_uuid="order-uuid-1")
assert adapter.calls == [request]
def test_create_order_maps_provider_request_errors_to_invalid_order_error() -> None:
request = _make_order_request()
adapter = StubOrderAdapter(error=ProviderRequestError("bad payload"))
service = AggregatorService(providers=[], order_adapter=adapter)
with pytest.raises(InvalidOrderCreateRequestError):
asyncio.run(service.create_order(request))
assert adapter.calls == [request]
def test_create_order_delegates_request_with_services_none_without_new_logic() -> None:
request = _make_order_request(services=None)
adapter = StubOrderAdapter(
response=OrderCreateResponse(provider="cdek", order_uuid="order-uuid-2")
)
service = AggregatorService(providers=[], order_adapter=adapter)
result = asyncio.run(service.create_order(request))
assert result == OrderCreateResponse(provider="cdek", order_uuid="order-uuid-2")
assert adapter.calls == [request]
assert adapter.calls[0].services is None
def test_create_order_maps_transport_failures_to_unavailable_error() -> None:
request = _make_order_request()
adapter = StubOrderAdapter(error=RuntimeError("transport down"))
service = AggregatorService(providers=[], order_adapter=adapter)
with pytest.raises(OrderCreationUnavailableError):
asyncio.run(service.create_order(request))
assert adapter.calls == [request]
def test_create_order_without_configured_adapter_raises_unavailable_error() -> None:
service = AggregatorService(providers=[])
with pytest.raises(OrderCreationUnavailableError):
asyncio.run(service.create_order(_make_order_request()))