All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m10s
423 lines
14 KiB
Python
423 lines
14 KiB
Python
"""Unit-тесты `app.services.payments.tbank_client.TBankClient`.
|
||
|
||
NEVER calls real T-Bank API — только `httpx.MockTransport` (тот же паттерн,
|
||
что `tests/services/tgbot/test_client.py` и `tests/services/test_dadata.py`).
|
||
`asyncio.sleep` патчится no-op'ом, чтобы retry-тесты шли мгновенно
|
||
независимо от реального backoff.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Callable
|
||
from unittest import mock
|
||
|
||
import httpx
|
||
import pytest
|
||
|
||
from app.services.payments.tbank_client import TBankApiError, TBankClient
|
||
|
||
_REAL_ASYNC_CLIENT = httpx.AsyncClient
|
||
|
||
|
||
def _install_transport(handler: Callable[[httpx.Request], httpx.Response]) -> None:
|
||
transport = httpx.MockTransport(handler)
|
||
|
||
def factory(*_: object, **__: object) -> httpx.AsyncClient:
|
||
return _REAL_ASYNC_CLIENT(transport=transport)
|
||
|
||
mock.patch("app.services.payments.tbank_client.httpx.AsyncClient", factory).start()
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _stop_patches_and_noop_sleep():
|
||
sleep_patcher = mock.patch(
|
||
"app.services.payments.tbank_client.asyncio.sleep", return_value=None
|
||
)
|
||
sleep_patcher.start()
|
||
yield
|
||
mock.patch.stopall()
|
||
|
||
|
||
def _client(**kwargs: object) -> TBankClient:
|
||
defaults: dict[str, object] = {
|
||
"terminal_key": "MerchantTerminalKey",
|
||
"password": "test-password",
|
||
"base_url": "https://rest-api-test.tinkoff.ru",
|
||
}
|
||
defaults.update(kwargs)
|
||
return TBankClient(**defaults) # type: ignore[arg-type]
|
||
|
||
|
||
# ── happy path ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
async def test_init_payment_happy_path_returns_payment_url() -> None:
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
assert request.url.path.endswith("/v2/Init")
|
||
return httpx.Response(
|
||
200,
|
||
json={
|
||
"Success": True,
|
||
"TerminalKey": "MerchantTerminalKey",
|
||
"Status": "NEW",
|
||
"PaymentId": "12345",
|
||
"OrderId": "order-1",
|
||
"Amount": 10000,
|
||
"PaymentURL": "https://securepay.tinkoff.ru/abc",
|
||
},
|
||
)
|
||
|
||
_install_transport(handler)
|
||
client = _client()
|
||
result = await client.init_payment(order_id="order-1", amount_kopecks=10000)
|
||
|
||
assert result["PaymentId"] == "12345"
|
||
assert result["PaymentURL"] == "https://securepay.tinkoff.ru/abc"
|
||
|
||
|
||
async def test_init_payment_signs_request_with_token() -> None:
|
||
"""Запрос обязан содержать TerminalKey + Token в теле."""
|
||
captured: dict[str, object] = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
import json
|
||
|
||
captured["body"] = json.loads(request.content)
|
||
return httpx.Response(200, json={"Success": True, "PaymentId": "1"})
|
||
|
||
_install_transport(handler)
|
||
client = _client()
|
||
await client.init_payment(order_id="00000", amount_kopecks=19200)
|
||
|
||
body = captured["body"]
|
||
assert isinstance(body, dict)
|
||
assert body["TerminalKey"] == "MerchantTerminalKey"
|
||
assert isinstance(body.get("Token"), str) and len(body["Token"]) == 64
|
||
|
||
|
||
async def test_init_payment_receipt_excluded_from_signed_token_but_present_in_body() -> None:
|
||
"""`Receipt` уходит в тело запроса, но не участвует в Token (см. token.py)."""
|
||
import json
|
||
|
||
from app.services.payments.token import sign
|
||
|
||
captured: dict[str, object] = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
captured["body"] = json.loads(request.content)
|
||
return httpx.Response(200, json={"Success": True, "PaymentId": "1"})
|
||
|
||
_install_transport(handler)
|
||
client = _client()
|
||
receipt = {"Email": "a@test.ru", "Taxation": "osn", "Items": []}
|
||
await client.init_payment(order_id="00000", amount_kopecks=19200, receipt=receipt)
|
||
|
||
body = captured["body"]
|
||
assert isinstance(body, dict)
|
||
assert body["Receipt"] == receipt
|
||
|
||
# Token, реально ушедший в теле, обязан совпадать с sign() тела БЕЗ Receipt
|
||
# (Receipt — dict, sign() сам его игнорирует) — пересчитаем и сверим.
|
||
without_token = {k: v for k, v in body.items() if k != "Token"}
|
||
assert body["Token"] == sign(without_token, "test-password")
|
||
|
||
|
||
async def test_get_state_posts_to_correct_path() -> None:
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
assert request.url.path.endswith("/v2/GetState")
|
||
return httpx.Response(200, json={"Success": True, "Status": "CONFIRMED"})
|
||
|
||
_install_transport(handler)
|
||
client = _client()
|
||
result = await client.get_state(payment_id="12345")
|
||
assert result["Status"] == "CONFIRMED"
|
||
|
||
|
||
async def test_check_order_posts_to_correct_path() -> None:
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
assert request.url.path.endswith("/v2/CheckOrder")
|
||
return httpx.Response(200, json={"Success": True, "Payments": []})
|
||
|
||
_install_transport(handler)
|
||
client = _client()
|
||
result = await client.check_order(order_id="order-1")
|
||
assert result["Payments"] == []
|
||
|
||
|
||
async def test_confirm_posts_to_correct_path() -> None:
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
assert request.url.path.endswith("/v2/Confirm")
|
||
return httpx.Response(200, json={"Success": True, "Status": "CONFIRMED"})
|
||
|
||
_install_transport(handler)
|
||
client = _client()
|
||
result = await client.confirm(payment_id="12345")
|
||
assert result["Status"] == "CONFIRMED"
|
||
|
||
|
||
async def test_cancel_posts_to_correct_path() -> None:
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
assert request.url.path.endswith("/v2/Cancel")
|
||
return httpx.Response(200, json={"Success": True, "Status": "REFUNDED"})
|
||
|
||
_install_transport(handler)
|
||
client = _client()
|
||
result = await client.cancel(payment_id="12345", amount_kopecks=5000)
|
||
assert result["Status"] == "REFUNDED"
|
||
|
||
|
||
# ── retry policy ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
async def test_retries_on_5xx_then_succeeds() -> None:
|
||
calls = {"n": 0}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
calls["n"] += 1
|
||
if calls["n"] < 3:
|
||
return httpx.Response(502, json={"ErrorCode": "502", "Message": "bad gw"})
|
||
return httpx.Response(200, json={"Success": True, "PaymentId": "1"})
|
||
|
||
_install_transport(handler)
|
||
client = _client()
|
||
result = await client.init_payment(order_id="1", amount_kopecks=100)
|
||
|
||
assert result["PaymentId"] == "1"
|
||
assert calls["n"] == 3
|
||
|
||
|
||
async def test_retries_on_network_error_then_succeeds() -> None:
|
||
calls = {"n": 0}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
calls["n"] += 1
|
||
if calls["n"] < 2:
|
||
raise httpx.ConnectError("connection refused", request=request)
|
||
return httpx.Response(200, json={"Success": True, "PaymentId": "1"})
|
||
|
||
_install_transport(handler)
|
||
client = _client()
|
||
result = await client.init_payment(order_id="1", amount_kopecks=100)
|
||
|
||
assert result["PaymentId"] == "1"
|
||
assert calls["n"] == 2
|
||
|
||
|
||
async def test_gives_up_after_max_retries_on_persistent_5xx() -> None:
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
return httpx.Response(500, json={"ErrorCode": "500", "Message": "boom"})
|
||
|
||
_install_transport(handler)
|
||
client = _client()
|
||
|
||
with pytest.raises(TBankApiError) as exc_info:
|
||
await client.get_state(payment_id="1")
|
||
|
||
assert exc_info.value.error_code == "500"
|
||
|
||
|
||
async def test_does_not_retry_on_4xx() -> None:
|
||
calls = {"n": 0}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
calls["n"] += 1
|
||
return httpx.Response(401, json={"ErrorCode": "401", "Message": "Terminal not found"})
|
||
|
||
_install_transport(handler)
|
||
client = _client()
|
||
|
||
with pytest.raises(TBankApiError) as exc_info:
|
||
await client.init_payment(order_id="1", amount_kopecks=100)
|
||
|
||
assert exc_info.value.error_code == "401"
|
||
assert calls["n"] == 1 # НЕ ретраится
|
||
|
||
|
||
async def test_business_failure_success_false_raises_without_retry() -> None:
|
||
"""HTTP 200, но `Success: false` — бизнес-отказ банка, не сбой транспорта."""
|
||
calls = {"n": 0}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
calls["n"] += 1
|
||
return httpx.Response(
|
||
200,
|
||
json={
|
||
"Success": False,
|
||
"ErrorCode": "9999",
|
||
"Message": "Неверные параметры запроса",
|
||
"Details": "Amount must be positive",
|
||
},
|
||
)
|
||
|
||
_install_transport(handler)
|
||
client = _client()
|
||
|
||
with pytest.raises(TBankApiError) as exc_info:
|
||
await client.init_payment(order_id="1", amount_kopecks=100)
|
||
|
||
assert exc_info.value.error_code == "9999"
|
||
assert exc_info.value.message == "Неверные параметры запроса"
|
||
assert calls["n"] == 1 # НЕ ретраится
|
||
|
||
|
||
async def test_confirm_retries_on_5xx_then_succeeds() -> None:
|
||
"""Денежный вызов `Confirm` ретраится на 5xx так же, как `Init`/`GetState`."""
|
||
calls = {"n": 0}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
calls["n"] += 1
|
||
if calls["n"] < 3:
|
||
return httpx.Response(502, json={"ErrorCode": "502", "Message": "bad gw"})
|
||
return httpx.Response(200, json={"Success": True, "Status": "CONFIRMED"})
|
||
|
||
_install_transport(handler)
|
||
client = _client()
|
||
result = await client.confirm(payment_id="1")
|
||
|
||
assert result["Status"] == "CONFIRMED"
|
||
assert calls["n"] == 3
|
||
|
||
|
||
async def test_confirm_retries_on_network_error_then_succeeds() -> None:
|
||
calls = {"n": 0}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
calls["n"] += 1
|
||
if calls["n"] < 2:
|
||
raise httpx.ConnectError("connection refused", request=request)
|
||
return httpx.Response(200, json={"Success": True, "Status": "CONFIRMED"})
|
||
|
||
_install_transport(handler)
|
||
client = _client()
|
||
result = await client.confirm(payment_id="1")
|
||
|
||
assert result["Status"] == "CONFIRMED"
|
||
assert calls["n"] == 2
|
||
|
||
|
||
async def test_confirm_gives_up_after_max_retries_on_persistent_5xx() -> None:
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
return httpx.Response(500, json={"ErrorCode": "500", "Message": "boom"})
|
||
|
||
_install_transport(handler)
|
||
client = _client()
|
||
|
||
with pytest.raises(TBankApiError) as exc_info:
|
||
await client.confirm(payment_id="1")
|
||
|
||
assert exc_info.value.error_code == "500"
|
||
|
||
|
||
async def test_confirm_does_not_retry_on_4xx() -> None:
|
||
calls = {"n": 0}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
calls["n"] += 1
|
||
return httpx.Response(401, json={"ErrorCode": "401", "Message": "Terminal not found"})
|
||
|
||
_install_transport(handler)
|
||
client = _client()
|
||
|
||
with pytest.raises(TBankApiError) as exc_info:
|
||
await client.confirm(payment_id="1")
|
||
|
||
assert exc_info.value.error_code == "401"
|
||
assert calls["n"] == 1 # НЕ ретраится
|
||
|
||
|
||
async def test_cancel_retries_on_5xx_then_succeeds() -> None:
|
||
"""Денежный вызов `Cancel` ретраится на 5xx так же, как `Init`/`GetState`."""
|
||
calls = {"n": 0}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
calls["n"] += 1
|
||
if calls["n"] < 3:
|
||
return httpx.Response(503, json={"ErrorCode": "503", "Message": "unavailable"})
|
||
return httpx.Response(200, json={"Success": True, "Status": "REFUNDED"})
|
||
|
||
_install_transport(handler)
|
||
client = _client()
|
||
result = await client.cancel(payment_id="1")
|
||
|
||
assert result["Status"] == "REFUNDED"
|
||
assert calls["n"] == 3
|
||
|
||
|
||
async def test_cancel_retries_on_network_error_then_succeeds() -> None:
|
||
calls = {"n": 0}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
calls["n"] += 1
|
||
if calls["n"] < 2:
|
||
raise httpx.ConnectTimeout("timed out", request=request)
|
||
return httpx.Response(200, json={"Success": True, "Status": "REFUNDED"})
|
||
|
||
_install_transport(handler)
|
||
client = _client()
|
||
result = await client.cancel(payment_id="1")
|
||
|
||
assert result["Status"] == "REFUNDED"
|
||
assert calls["n"] == 2
|
||
|
||
|
||
async def test_cancel_gives_up_after_max_retries_on_persistent_network_error() -> None:
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
raise httpx.ConnectError("connection refused", request=request)
|
||
|
||
_install_transport(handler)
|
||
client = _client()
|
||
|
||
with pytest.raises(TBankApiError) as exc_info:
|
||
await client.cancel(payment_id="1")
|
||
|
||
assert exc_info.value.error_code == "network_error"
|
||
|
||
|
||
async def test_cancel_does_not_retry_on_4xx() -> None:
|
||
calls = {"n": 0}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
calls["n"] += 1
|
||
return httpx.Response(401, json={"ErrorCode": "401", "Message": "Terminal not found"})
|
||
|
||
_install_transport(handler)
|
||
client = _client()
|
||
|
||
with pytest.raises(TBankApiError) as exc_info:
|
||
await client.cancel(payment_id="1")
|
||
|
||
assert exc_info.value.error_code == "401"
|
||
assert calls["n"] == 1 # НЕ ретраится
|
||
|
||
|
||
async def test_malformed_json_response_raises_tbank_api_error() -> None:
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
return httpx.Response(200, content=b"not json at all")
|
||
|
||
_install_transport(handler)
|
||
client = _client()
|
||
|
||
with pytest.raises(TBankApiError):
|
||
await client.get_state(payment_id="1")
|
||
|
||
|
||
# ── безопасность: пароль не попадает в тело запроса ─────────────────────────
|
||
|
||
|
||
async def test_password_never_sent_in_request_body() -> None:
|
||
import json
|
||
|
||
captured: dict[str, object] = {}
|
||
|
||
def handler(request: httpx.Request) -> httpx.Response:
|
||
captured["body"] = json.loads(request.content)
|
||
return httpx.Response(200, json={"Success": True, "PaymentId": "1"})
|
||
|
||
_install_transport(handler)
|
||
client = _client(password="super-secret-password")
|
||
await client.init_payment(order_id="1", amount_kopecks=100)
|
||
|
||
body = captured["body"]
|
||
assert isinstance(body, dict)
|
||
assert "Password" not in body
|
||
assert "super-secret-password" not in json.dumps(body)
|