gendesign/tradein-mvp/backend/tests/services/payments/test_tbank_client.py
bot-backend 00d1f78668
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
fix(tradein/payments): строгий разбор нотификации и отказ вместо догадок на враждебном входе
2026-08-06 15:48:57 +03:00

423 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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