Добавлен адаптер к tbank и формирование ссылки на оплату
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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"
|
||||
|
||||
@@ -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()))
|
||||
@@ -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()))
|
||||
Reference in New Issue
Block a user