Добавлена обработка уведомлений через ручку
This commit is contained in:
@@ -298,7 +298,7 @@ adapter:
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
notification_url: "https://merchant.test/tbank/notification"
|
||||
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||
success_url: "https://merchant.test/payment/success"
|
||||
auth:
|
||||
terminal_key: "test-terminal-key"
|
||||
|
||||
@@ -119,6 +119,7 @@ def test_provider_register_order_posts_cdek_contract_payload_and_maps_response()
|
||||
"method": "POST",
|
||||
"url": "https://api.cdek.test/v2/orders",
|
||||
"json": {
|
||||
"number": "order-uuid-1",
|
||||
"type": 2,
|
||||
"tariff_code": 535,
|
||||
"comment": "Test order",
|
||||
@@ -245,6 +246,70 @@ def test_cdek_client_register_order_maps_4xx_to_request_error() -> None:
|
||||
assert len(http_client.calls) == 1
|
||||
|
||||
|
||||
def test_cdek_client_register_order_maps_duplicate_external_id_to_success() -> None:
|
||||
duplicate_response = httpx.Response(
|
||||
409,
|
||||
json={
|
||||
"entity": {"uuid": "existing-cdek-order-uuid"},
|
||||
"requests": [
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"code": "v2_entity_already_exists",
|
||||
"message": "Order with this number already exists",
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
},
|
||||
request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
|
||||
)
|
||||
http_client = SequenceHTTPClient([duplicate_response])
|
||||
client = CDEKClient(
|
||||
http_client=http_client, # type: ignore[arg-type]
|
||||
auth_client=StubAuthClient(), # type: ignore[arg-type]
|
||||
base_url="https://api.cdek.test/v2",
|
||||
retry_attempts=2,
|
||||
)
|
||||
|
||||
result = asyncio.run(client.register_order(_make_order_request()))
|
||||
|
||||
assert result == "existing-cdek-order-uuid"
|
||||
assert len(http_client.calls) == 1
|
||||
|
||||
|
||||
def test_cdek_client_register_order_4xx_with_unrelated_entity_uuid_raises_request_error() -> None:
|
||||
unrelated_response = httpx.Response(
|
||||
409,
|
||||
json={
|
||||
"entity": {"uuid": "unrelated-uuid"},
|
||||
"requests": [
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"code": "v2_recipient_phone_invalid",
|
||||
"message": "Recipient phone is invalid",
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
},
|
||||
request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
|
||||
)
|
||||
http_client = SequenceHTTPClient([unrelated_response])
|
||||
client = CDEKClient(
|
||||
http_client=http_client, # type: ignore[arg-type]
|
||||
auth_client=StubAuthClient(), # type: ignore[arg-type]
|
||||
base_url="https://api.cdek.test/v2",
|
||||
retry_attempts=2,
|
||||
)
|
||||
|
||||
with pytest.raises(CDEKRequestError):
|
||||
asyncio.run(client.register_order(_make_order_request()))
|
||||
|
||||
assert len(http_client.calls) == 1
|
||||
|
||||
|
||||
def test_cdek_client_register_order_retries_5xx_and_raises_client_error() -> None:
|
||||
first_response = httpx.Response(
|
||||
503,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
from app.adapters.delivery_providers.cdek.order_mapper import (
|
||||
map_cdek_order_request,
|
||||
)
|
||||
from app.schemas.payment import InitPaymentRequest
|
||||
|
||||
|
||||
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",
|
||||
"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_cdek_order_payload_uses_order_uuid_as_external_number() -> None:
|
||||
payload = map_cdek_order_request(_make_order_request())
|
||||
|
||||
assert payload["number"] == "order-uuid-1"
|
||||
@@ -49,7 +49,7 @@ class SequenceHTTPClient:
|
||||
def _make_adapter(
|
||||
http_client: SequenceHTTPClient,
|
||||
*,
|
||||
notification_url: str = "https://example.test/tbank/notify",
|
||||
notification_url: str = "https://example.test/api/v1/delivery/tbank/notifications",
|
||||
success_url: str = "https://example.test/payment/success",
|
||||
retry_attempts: int = 0,
|
||||
retry_backoff_seconds: float = 0.2,
|
||||
@@ -92,7 +92,7 @@ def test_create_payment_link_posts_signed_payload_and_maps_payment_url() -> None
|
||||
expected_token = hashlib.sha256(
|
||||
(
|
||||
"125000"
|
||||
"https://example.test/tbank/notify"
|
||||
"https://example.test/api/v1/delivery/tbank/notifications"
|
||||
"order-uuid-1"
|
||||
"test-password"
|
||||
"https://example.test/payment/success"
|
||||
@@ -108,7 +108,7 @@ def test_create_payment_link_posts_signed_payload_and_maps_payment_url() -> None
|
||||
"TerminalKey": "TBankTest",
|
||||
"Amount": 125000,
|
||||
"OrderId": "order-uuid-1",
|
||||
"NotificationURL": "https://example.test/tbank/notify",
|
||||
"NotificationURL": "https://example.test/api/v1/delivery/tbank/notifications",
|
||||
"SuccessURL": "https://example.test/payment/success",
|
||||
"Token": expected_token,
|
||||
},
|
||||
@@ -131,7 +131,7 @@ def test_from_config_posts_configured_urls_and_deterministic_token() -> None:
|
||||
http_client = SequenceHTTPClient([response])
|
||||
config = TBankPaymentConfig(
|
||||
init_url="https://securepay.tinkoff.ru/v2/Init",
|
||||
notification_url="https://merchant.test/tbank/notification",
|
||||
notification_url="https://merchant.test/api/v1/delivery/tbank/notifications",
|
||||
success_url="https://merchant.test/payment/success",
|
||||
auth=TBankPaymentAuthConfig(
|
||||
terminal_key="ConfigTerminal",
|
||||
@@ -156,7 +156,7 @@ def test_from_config_posts_configured_urls_and_deterministic_token() -> None:
|
||||
expected_token = hashlib.sha256(
|
||||
(
|
||||
"9900"
|
||||
"https://merchant.test/tbank/notification"
|
||||
"https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||
"order-uuid-2"
|
||||
"config-password"
|
||||
"https://merchant.test/payment/success"
|
||||
@@ -168,7 +168,7 @@ def test_from_config_posts_configured_urls_and_deterministic_token() -> None:
|
||||
"TerminalKey": "ConfigTerminal",
|
||||
"Amount": 9900,
|
||||
"OrderId": "order-uuid-2",
|
||||
"NotificationURL": "https://merchant.test/tbank/notification",
|
||||
"NotificationURL": "https://merchant.test/api/v1/delivery/tbank/notifications",
|
||||
"SuccessURL": "https://merchant.test/payment/success",
|
||||
"Token": expected_token,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import hashlib
|
||||
|
||||
import pytest
|
||||
|
||||
from app.adapters.tbank.base import TBankPaymentNotificationTokenError
|
||||
from app.adapters.tbank.client import TBankAdapter
|
||||
from app.schemas.payment import TBankPaymentNotification
|
||||
|
||||
|
||||
def _make_adapter() -> TBankAdapter:
|
||||
return TBankAdapter(
|
||||
http_client=object(), # type: ignore[arg-type]
|
||||
init_url="https://securepay.tinkoff.ru/v2/Init",
|
||||
notification_url="https://merchant.test/api/v1/delivery/tbank/notifications",
|
||||
success_url="https://merchant.test/payment/success",
|
||||
terminal_key="TestTerminal",
|
||||
password="test-password",
|
||||
)
|
||||
|
||||
|
||||
def _base_payload(**overrides: object) -> dict[str, object]:
|
||||
payload: dict[str, object] = {
|
||||
"TerminalKey": "TestTerminal",
|
||||
"OrderId": "order-uuid-1",
|
||||
"Success": True,
|
||||
"Status": "CONFIRMED",
|
||||
"PaymentId": 8347568144,
|
||||
"ErrorCode": "0",
|
||||
"Amount": 125000,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def _build_notification_token(payload: dict[str, object]) -> str:
|
||||
token_payload = {
|
||||
key: value
|
||||
for key, value in payload.items()
|
||||
if key != "Token" and not isinstance(value, (dict, list))
|
||||
}
|
||||
token_payload["Password"] = "test-password"
|
||||
token_source = "".join(
|
||||
_stringify_token_value(token_payload[key]) for key in sorted(token_payload)
|
||||
)
|
||||
return hashlib.sha256(token_source.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _stringify_token_value(value: object) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _signed_notification(**overrides: object) -> TBankPaymentNotification:
|
||||
payload = _base_payload(**overrides)
|
||||
if "Token" not in payload:
|
||||
payload["Token"] = _build_notification_token(payload)
|
||||
return TBankPaymentNotification(**payload)
|
||||
|
||||
|
||||
def test_verify_payment_notification_accepts_valid_token() -> None:
|
||||
adapter = _make_adapter()
|
||||
notification = _signed_notification()
|
||||
|
||||
adapter.verify_payment_notification(notification)
|
||||
|
||||
|
||||
def test_verify_payment_notification_rejects_invalid_token() -> None:
|
||||
adapter = _make_adapter()
|
||||
notification = _signed_notification(Token="invalid-token")
|
||||
|
||||
with pytest.raises(TBankPaymentNotificationTokenError):
|
||||
adapter.verify_payment_notification(notification)
|
||||
|
||||
|
||||
def test_verify_payment_notification_excludes_token_from_hash_source() -> None:
|
||||
adapter = _make_adapter()
|
||||
payload = _base_payload()
|
||||
payload["Token"] = _build_notification_token(payload)
|
||||
notification = TBankPaymentNotification(**payload)
|
||||
|
||||
adapter.verify_payment_notification(notification)
|
||||
|
||||
|
||||
def test_verify_payment_notification_excludes_nested_data_and_receipt() -> None:
|
||||
adapter = _make_adapter()
|
||||
notification = _signed_notification(
|
||||
Data={"nested": "ignored"},
|
||||
Receipt={"Items": [{"Name": "Box", "Price": 125000}]},
|
||||
)
|
||||
|
||||
adapter.verify_payment_notification(notification)
|
||||
|
||||
|
||||
def test_verify_payment_notification_includes_extra_scalar_fields() -> None:
|
||||
adapter = _make_adapter()
|
||||
notification = _signed_notification(CardId="card-id-1")
|
||||
|
||||
adapter.verify_payment_notification(notification)
|
||||
|
||||
payload_without_extra = notification.model_dump(mode="python")
|
||||
del payload_without_extra["CardId"]
|
||||
payload_without_extra["Token"] = _build_notification_token(payload_without_extra)
|
||||
invalid_notification = TBankPaymentNotification(
|
||||
**payload_without_extra,
|
||||
CardId="card-id-1",
|
||||
)
|
||||
|
||||
with pytest.raises(TBankPaymentNotificationTokenError):
|
||||
adapter.verify_payment_notification(invalid_notification)
|
||||
|
||||
|
||||
def test_verify_payment_notification_serializes_booleans_as_lowercase() -> None:
|
||||
adapter = _make_adapter()
|
||||
notification = _signed_notification(Success=False)
|
||||
|
||||
adapter.verify_payment_notification(notification)
|
||||
|
||||
|
||||
def test_verify_payment_notification_uses_deterministic_sha256_comparison() -> None:
|
||||
payload = _base_payload()
|
||||
expected_token = hashlib.sha256(
|
||||
(
|
||||
"125000"
|
||||
"0"
|
||||
"order-uuid-1"
|
||||
"test-password"
|
||||
"8347568144"
|
||||
"CONFIRMED"
|
||||
"true"
|
||||
"TestTerminal"
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
assert _build_notification_token(payload) == expected_token
|
||||
Reference in New Issue
Block a user