Добавлена обработка уведомлений через ручку
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
|
||||
@@ -17,7 +17,7 @@ adapter:
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
notification_url: "https://default.test/tbank/notification"
|
||||
notification_url: "https://default.test/api/v1/delivery/tbank/notifications"
|
||||
success_url: "https://default.test/payment/success"
|
||||
auth:
|
||||
terminal_key: "yaml-terminal-key"
|
||||
|
||||
@@ -25,7 +25,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"
|
||||
|
||||
@@ -14,7 +14,7 @@ repository:
|
||||
|
||||
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"
|
||||
|
||||
@@ -25,7 +25,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"
|
||||
|
||||
@@ -25,7 +25,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"
|
||||
|
||||
@@ -25,7 +25,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"
|
||||
|
||||
@@ -24,7 +24,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"
|
||||
|
||||
@@ -17,7 +17,7 @@ adapter:
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
notification_url: "https://override.test/tbank/notification"
|
||||
notification_url: "https://override.test/api/v1/delivery/tbank/notifications"
|
||||
success_url: "https://override.test/payment/success"
|
||||
auth:
|
||||
terminal_key: "override-terminal-key"
|
||||
|
||||
@@ -111,7 +111,7 @@ def _write_tbank_url_validation_config(
|
||||
include_success_url: bool = True,
|
||||
) -> Path:
|
||||
notification_url = (
|
||||
' notification_url: "https://merchant.test/tbank/notification"\n'
|
||||
' notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"\n'
|
||||
if include_notification_url
|
||||
else ""
|
||||
)
|
||||
@@ -179,7 +179,7 @@ def test_configuration_sections_are_loaded_from_yaml_file(
|
||||
assert settings.tbank_payment.init_url == "https://securepay.tinkoff.ru/v2/Init"
|
||||
assert (
|
||||
settings.tbank_payment.notification_url
|
||||
== "https://merchant.test/tbank/notification"
|
||||
== "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||
)
|
||||
assert settings.tbank_payment.success_url == "https://merchant.test/payment/success"
|
||||
assert settings.tbank_payment.auth.terminal_key == "test-terminal-key"
|
||||
@@ -260,7 +260,7 @@ def test_get_settings_returns_cached_instance(monkeypatch: pytest.MonkeyPatch) -
|
||||
assert first.tbank_payment.auth.terminal_key == "test-terminal-key"
|
||||
assert (
|
||||
first.tbank_payment.notification_url
|
||||
== "https://merchant.test/tbank/notification"
|
||||
== "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||
)
|
||||
assert first.postgres.dsn.endswith("/g2s_aggregator_test")
|
||||
assert first.address_suggestions.country_to_provider["RU"] == "dadata"
|
||||
@@ -287,7 +287,7 @@ def test_get_settings_uses_config_test_yaml_in_pytest_environment(
|
||||
assert settings.tbank_payment.auth.password == "override-password"
|
||||
assert (
|
||||
settings.tbank_payment.notification_url
|
||||
== "https://override.test/tbank/notification"
|
||||
== "https://override.test/api/v1/delivery/tbank/notifications"
|
||||
)
|
||||
assert settings.tbank_payment.success_url == "https://override.test/payment/success"
|
||||
assert settings.tbank_payment.timeout_seconds == 4.25
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
from app.controllers.v1.delivery import get_aggregator_service
|
||||
from app.main import create_app
|
||||
from app.schemas.payment import TBankPaymentNotification
|
||||
from app.services.aggregator import (
|
||||
InvalidTBankPaymentNotificationError,
|
||||
TBankPaymentNotificationProcessingError,
|
||||
)
|
||||
|
||||
|
||||
class StubAggregatorService:
|
||||
def __init__(self, *, response: str = "OK", error: Exception | None = None) -> None:
|
||||
self._response = response
|
||||
self._error = error
|
||||
self.calls: list[TBankPaymentNotification] = []
|
||||
|
||||
async def handle_tbank_payment_notification(
|
||||
self,
|
||||
notification: TBankPaymentNotification,
|
||||
) -> str:
|
||||
self.calls.append(notification)
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return self._response
|
||||
|
||||
|
||||
def _install_service_override(app, service: StubAggregatorService) -> None:
|
||||
async def override_service() -> StubAggregatorService:
|
||||
return service
|
||||
|
||||
app.dependency_overrides[get_aggregator_service] = override_service
|
||||
|
||||
|
||||
def _valid_payload() -> dict[str, object]:
|
||||
return {
|
||||
"TerminalKey": "TestTerminal",
|
||||
"OrderId": "order-uuid-1",
|
||||
"Success": True,
|
||||
"Status": "CONFIRMED",
|
||||
"PaymentId": 8347568144,
|
||||
"ErrorCode": "0",
|
||||
"Amount": 125000,
|
||||
"Token": "signed-token",
|
||||
"Data": {"nested": "accepted by schema"},
|
||||
}
|
||||
|
||||
|
||||
def _post_notification(app, payload: dict[str, object]) -> httpx.Response:
|
||||
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/tbank/notifications",
|
||||
json=payload,
|
||||
)
|
||||
|
||||
return asyncio.run(run_request())
|
||||
|
||||
|
||||
def test_post_tbank_notification_returns_plain_text_ok_and_delegates_once() -> None:
|
||||
service = StubAggregatorService(response="OK")
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
|
||||
response = _post_notification(app, _valid_payload())
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == "OK"
|
||||
assert response.headers["content-type"].startswith("text/plain")
|
||||
assert service.calls == [TBankPaymentNotification(**_valid_payload())]
|
||||
|
||||
|
||||
def test_post_tbank_notification_maps_invalid_token_to_400() -> None:
|
||||
service = StubAggregatorService(
|
||||
error=InvalidTBankPaymentNotificationError("invalid token")
|
||||
)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
|
||||
response = _post_notification(app, _valid_payload())
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["code"] == "invalid_tbank_payment_notification"
|
||||
assert service.calls == [TBankPaymentNotification(**_valid_payload())]
|
||||
|
||||
|
||||
def test_post_tbank_notification_maps_processing_failure_to_503() -> None:
|
||||
service = StubAggregatorService(
|
||||
error=TBankPaymentNotificationProcessingError("temporary failure")
|
||||
)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
|
||||
response = _post_notification(app, _valid_payload())
|
||||
|
||||
assert response.status_code == 503
|
||||
assert (
|
||||
response.json()["detail"]["code"]
|
||||
== "tbank_payment_notification_processing_unavailable"
|
||||
)
|
||||
assert service.calls == [TBankPaymentNotification(**_valid_payload())]
|
||||
|
||||
|
||||
def test_post_tbank_notification_rejects_invalid_payload_without_service_call() -> None:
|
||||
service = StubAggregatorService()
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
payload = _valid_payload()
|
||||
del payload["Token"]
|
||||
|
||||
response = _post_notification(app, payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert service.calls == []
|
||||
@@ -0,0 +1,55 @@
|
||||
from app.domain.payment_notifications import (
|
||||
TBankPaymentNotificationAction,
|
||||
resolve_tbank_payment_notification_action,
|
||||
)
|
||||
|
||||
|
||||
def test_confirmed_success_zero_error_code_registers_cdek_order() -> None:
|
||||
result = resolve_tbank_payment_notification_action(
|
||||
status="CONFIRMED",
|
||||
success=True,
|
||||
error_code="0",
|
||||
)
|
||||
|
||||
assert result is TBankPaymentNotificationAction.REGISTER_CDEK_ORDER
|
||||
|
||||
|
||||
def test_confirmed_without_success_acknowledges_only() -> None:
|
||||
result = resolve_tbank_payment_notification_action(
|
||||
status="CONFIRMED",
|
||||
success=False,
|
||||
error_code="0",
|
||||
)
|
||||
|
||||
assert result is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
|
||||
|
||||
|
||||
def test_confirmed_with_non_zero_error_code_acknowledges_only() -> None:
|
||||
result = resolve_tbank_payment_notification_action(
|
||||
status="CONFIRMED",
|
||||
success=True,
|
||||
error_code="101",
|
||||
)
|
||||
|
||||
assert result is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
|
||||
|
||||
|
||||
def test_known_non_confirmed_statuses_acknowledge_only() -> None:
|
||||
for status in ("AUTHORIZED", "REJECTED", "CANCELED", "DEADLINE_EXPIRED"):
|
||||
result = resolve_tbank_payment_notification_action(
|
||||
status=status,
|
||||
success=True,
|
||||
error_code="0",
|
||||
)
|
||||
|
||||
assert result is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
|
||||
|
||||
|
||||
def test_unknown_status_acknowledges_only() -> None:
|
||||
result = resolve_tbank_payment_notification_action(
|
||||
status="UNKNOWN_STATUS",
|
||||
success=True,
|
||||
error_code="0",
|
||||
)
|
||||
|
||||
assert result is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
|
||||
+1
-1
@@ -181,7 +181,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"
|
||||
|
||||
@@ -101,7 +101,11 @@ def test_create_order_persists_all_required_fields() -> None:
|
||||
assert persisted_order.packages == order_data.packages
|
||||
assert persisted_order.services == order_data.services
|
||||
assert persisted_order.comment == "Test payment"
|
||||
assert persisted_order.payment_status is None
|
||||
assert persisted_order.tbank_payment_id is None
|
||||
assert persisted_order.cdek_order_uuid is None
|
||||
assert persisted_order.created_at is not None
|
||||
assert persisted_order.updated_at is not None
|
||||
|
||||
asyncio.run(_with_repository(run))
|
||||
|
||||
@@ -143,3 +147,159 @@ def test_create_order_rejects_duplicate_order_uuid() -> None:
|
||||
await repository.create_order(session, order_data)
|
||||
|
||||
asyncio.run(_with_repository(run))
|
||||
|
||||
|
||||
def test_get_order_by_order_uuid_returns_persisted_order() -> None:
|
||||
async def run(
|
||||
repository: OrderRepository,
|
||||
_session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
order_data = _make_order_data()
|
||||
|
||||
async with repository.session() as session:
|
||||
await repository.create_order(session, order_data)
|
||||
|
||||
async with repository.session() as session:
|
||||
order = await repository.get_order_by_order_uuid(session, "order-uuid-1")
|
||||
|
||||
assert order is not None
|
||||
assert order.order_uuid == "order-uuid-1"
|
||||
assert order.payment_url == "https://pay.test/payment/1"
|
||||
|
||||
asyncio.run(_with_repository(run))
|
||||
|
||||
|
||||
def test_get_order_by_order_uuid_returns_none_for_missing_order() -> None:
|
||||
async def run(
|
||||
repository: OrderRepository,
|
||||
_session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
async with repository.session() as session:
|
||||
order = await repository.get_order_by_order_uuid(session, "missing-order")
|
||||
|
||||
assert order is None
|
||||
|
||||
asyncio.run(_with_repository(run))
|
||||
|
||||
|
||||
def test_mark_payment_status_persists_status_and_payment_id() -> None:
|
||||
async def run(
|
||||
repository: OrderRepository,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
async with repository.session() as session:
|
||||
await repository.create_order(session, _make_order_data())
|
||||
|
||||
async with repository.session() as session:
|
||||
order = await repository.mark_payment_status(
|
||||
session,
|
||||
"order-uuid-1",
|
||||
"CONFIRMED",
|
||||
8347568144,
|
||||
)
|
||||
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(
|
||||
select(Order).where(Order.order_uuid == "order-uuid-1")
|
||||
)
|
||||
persisted_order = result.scalar_one()
|
||||
|
||||
assert order is not None
|
||||
assert persisted_order.payment_status == "CONFIRMED"
|
||||
assert persisted_order.tbank_payment_id == 8347568144
|
||||
|
||||
asyncio.run(_with_repository(run))
|
||||
|
||||
|
||||
def test_mark_payment_status_returns_none_for_missing_order() -> None:
|
||||
async def run(
|
||||
repository: OrderRepository,
|
||||
_session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
async with repository.session() as session:
|
||||
order = await repository.mark_payment_status(
|
||||
session,
|
||||
"missing-order",
|
||||
"CONFIRMED",
|
||||
8347568144,
|
||||
)
|
||||
|
||||
assert order is None
|
||||
|
||||
asyncio.run(_with_repository(run))
|
||||
|
||||
|
||||
def test_mark_cdek_order_registered_persists_cdek_order_uuid() -> None:
|
||||
async def run(
|
||||
repository: OrderRepository,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
async with repository.session() as session:
|
||||
await repository.create_order(session, _make_order_data())
|
||||
|
||||
async with repository.session() as session:
|
||||
order = await repository.mark_cdek_order_registered(
|
||||
session,
|
||||
"order-uuid-1",
|
||||
"cdek-order-uuid-1",
|
||||
)
|
||||
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(
|
||||
select(Order).where(Order.order_uuid == "order-uuid-1")
|
||||
)
|
||||
persisted_order = result.scalar_one()
|
||||
|
||||
assert order is not None
|
||||
assert persisted_order.cdek_order_uuid == "cdek-order-uuid-1"
|
||||
|
||||
asyncio.run(_with_repository(run))
|
||||
|
||||
|
||||
def test_mark_cdek_order_registered_is_idempotent_for_same_uuid() -> None:
|
||||
async def run(
|
||||
repository: OrderRepository,
|
||||
session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
async with repository.session() as session:
|
||||
await repository.create_order(session, _make_order_data())
|
||||
|
||||
async with repository.session() as session:
|
||||
await repository.mark_cdek_order_registered(
|
||||
session,
|
||||
"order-uuid-1",
|
||||
"cdek-order-uuid-1",
|
||||
)
|
||||
|
||||
async with repository.session() as session:
|
||||
await repository.mark_cdek_order_registered(
|
||||
session,
|
||||
"order-uuid-1",
|
||||
"cdek-order-uuid-1",
|
||||
)
|
||||
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(select(Order))
|
||||
orders = result.scalars().all()
|
||||
|
||||
assert len(orders) == 1
|
||||
assert orders[0].cdek_order_uuid == "cdek-order-uuid-1"
|
||||
|
||||
asyncio.run(_with_repository(run))
|
||||
|
||||
|
||||
def test_mark_cdek_order_registered_returns_none_for_missing_order() -> None:
|
||||
async def run(
|
||||
repository: OrderRepository,
|
||||
_session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
async with repository.session() as session:
|
||||
order = await repository.mark_cdek_order_registered(
|
||||
session,
|
||||
"missing-order",
|
||||
"cdek-order-uuid-1",
|
||||
)
|
||||
|
||||
assert order is None
|
||||
|
||||
asyncio.run(_with_repository(run))
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.adapters.tbank.base import TBankPaymentNotificationTokenError
|
||||
from app.schemas.payment import InitPaymentRequest, TBankPaymentNotification
|
||||
from app.services.aggregator import (
|
||||
AggregatorService,
|
||||
InvalidTBankPaymentNotificationError,
|
||||
TBankPaymentNotificationProcessingError,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoredOrder:
|
||||
order_uuid: str = "order-uuid-1"
|
||||
payment_url: str = "https://pay.test/payment/1"
|
||||
price: int = 125000
|
||||
delivery_type: int = 2
|
||||
tariff_code: int = 535
|
||||
comment: str | None = "Test payment"
|
||||
sender: dict[str, Any] | None = None
|
||||
recipient: dict[str, Any] | None = None
|
||||
from_location: dict[str, Any] | None = None
|
||||
to_location: dict[str, Any] | None = None
|
||||
packages: list[dict[str, Any]] | None = None
|
||||
services: list[dict[str, Any]] | None = None
|
||||
payment_status: str | None = None
|
||||
tbank_payment_id: int | None = None
|
||||
cdek_order_uuid: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.sender is None:
|
||||
self.sender = {
|
||||
"name": "Petr Petrov",
|
||||
"email": "sender@example.com",
|
||||
"phone": {"number": "+79009876543"},
|
||||
}
|
||||
if self.recipient is None:
|
||||
self.recipient = {
|
||||
"name": "Ivan Ivanov",
|
||||
"email": "ivan@example.com",
|
||||
"phone": {"number": "+79001234567"},
|
||||
}
|
||||
if self.from_location is None:
|
||||
self.from_location = {
|
||||
"address": "Lenina 1",
|
||||
"city": "Moscow",
|
||||
"country_code": "RU",
|
||||
}
|
||||
if self.to_location is None:
|
||||
self.to_location = {
|
||||
"address": "Pushkina 10",
|
||||
"city": "Novosibirsk",
|
||||
"country_code": "RU",
|
||||
}
|
||||
if self.packages is None:
|
||||
self.packages = [
|
||||
{
|
||||
"number": "1",
|
||||
"weight": 1,
|
||||
"length": 20,
|
||||
"width": 15,
|
||||
"height": 10,
|
||||
"comment": "Package 1",
|
||||
}
|
||||
]
|
||||
if self.services is None:
|
||||
self.services = [{"code": "INSURANCE", "parameter": "1000"}]
|
||||
|
||||
|
||||
class StubPaymentAdapter:
|
||||
def __init__(self, *, verify_error: Exception | None = None) -> None:
|
||||
self._verify_error = verify_error
|
||||
self.notifications: list[TBankPaymentNotification] = []
|
||||
|
||||
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def verify_payment_notification(
|
||||
self,
|
||||
notification: TBankPaymentNotification,
|
||||
) -> None:
|
||||
self.notifications.append(notification)
|
||||
if self._verify_error is not None:
|
||||
raise self._verify_error
|
||||
|
||||
|
||||
class StubOrderSessionContext:
|
||||
def __init__(self, session: object) -> None:
|
||||
self._session = session
|
||||
|
||||
async def __aenter__(self) -> object:
|
||||
return self._session
|
||||
|
||||
async def __aexit__(self, exc_type, exc, traceback) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class StubOrderRepository:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
orders: list[StoredOrder] | None = None,
|
||||
mark_cdek_errors: list[Exception | None] | None = None,
|
||||
) -> None:
|
||||
self._orders = {order.order_uuid: order for order in orders or []}
|
||||
self._mark_cdek_errors = mark_cdek_errors or []
|
||||
self.session_value = object()
|
||||
self.calls: list[tuple[str, tuple[object, ...]]] = []
|
||||
|
||||
def session(self) -> StubOrderSessionContext:
|
||||
self.calls.append(("session", ()))
|
||||
return StubOrderSessionContext(self.session_value)
|
||||
|
||||
async def create_order(self, session: object, order_data: object) -> object:
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_order_by_order_uuid(
|
||||
self,
|
||||
session: object,
|
||||
order_uuid: str,
|
||||
) -> StoredOrder | None:
|
||||
self.calls.append(("get_order_by_order_uuid", (session, order_uuid)))
|
||||
return self._orders.get(order_uuid)
|
||||
|
||||
async def mark_payment_status(
|
||||
self,
|
||||
session: object,
|
||||
order_uuid: str,
|
||||
status: str,
|
||||
payment_id: int,
|
||||
) -> StoredOrder | None:
|
||||
self.calls.append(
|
||||
("mark_payment_status", (session, order_uuid, status, payment_id))
|
||||
)
|
||||
order = self._orders.get(order_uuid)
|
||||
if order is None:
|
||||
return None
|
||||
order.payment_status = status
|
||||
order.tbank_payment_id = payment_id
|
||||
return order
|
||||
|
||||
async def mark_cdek_order_registered(
|
||||
self,
|
||||
session: object,
|
||||
order_uuid: str,
|
||||
cdek_order_uuid: str,
|
||||
) -> StoredOrder | None:
|
||||
self.calls.append(
|
||||
("mark_cdek_order_registered", (session, order_uuid, cdek_order_uuid))
|
||||
)
|
||||
if self._mark_cdek_errors:
|
||||
error = self._mark_cdek_errors.pop(0)
|
||||
if error is not None:
|
||||
raise error
|
||||
|
||||
order = self._orders.get(order_uuid)
|
||||
if order is None:
|
||||
return None
|
||||
order.cdek_order_uuid = cdek_order_uuid
|
||||
return order
|
||||
|
||||
|
||||
class StubCDEKOrderAdapter:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
responses: list[str] | None = None,
|
||||
error: Exception | None = None,
|
||||
) -> None:
|
||||
self._responses = responses or ["cdek-order-uuid-1"]
|
||||
self._error = error
|
||||
self.calls: list[InitPaymentRequest] = []
|
||||
|
||||
async def register_order(self, request: InitPaymentRequest) -> str:
|
||||
self.calls.append(request)
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
return self._responses.pop(0)
|
||||
|
||||
|
||||
def _make_notification(**overrides: object) -> TBankPaymentNotification:
|
||||
payload: dict[str, object] = {
|
||||
"TerminalKey": "TestTerminal",
|
||||
"OrderId": "order-uuid-1",
|
||||
"Success": True,
|
||||
"Status": "CONFIRMED",
|
||||
"PaymentId": 8347568144,
|
||||
"ErrorCode": "0",
|
||||
"Amount": 125000,
|
||||
"Token": "signed-token",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return TBankPaymentNotification(**payload)
|
||||
|
||||
|
||||
def test_confirmed_notification_registers_cdek_order_and_saves_uuid() -> None:
|
||||
order = StoredOrder()
|
||||
payment_adapter = StubPaymentAdapter()
|
||||
order_repository = StubOrderRepository(orders=[order])
|
||||
cdek_adapter = StubCDEKOrderAdapter(responses=["cdek-order-uuid-1"])
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=payment_adapter,
|
||||
order_repository=order_repository,
|
||||
order_registration_adapter=cdek_adapter,
|
||||
)
|
||||
notification = _make_notification()
|
||||
|
||||
result = asyncio.run(service.handle_tbank_payment_notification(notification))
|
||||
|
||||
assert result == "OK"
|
||||
assert payment_adapter.notifications == [notification]
|
||||
assert order.payment_status == "CONFIRMED"
|
||||
assert order.tbank_payment_id == 8347568144
|
||||
assert order.cdek_order_uuid == "cdek-order-uuid-1"
|
||||
assert len(cdek_adapter.calls) == 1
|
||||
assert cdek_adapter.calls[0].order_uuid == "order-uuid-1"
|
||||
|
||||
|
||||
def test_duplicate_confirmed_notification_does_not_call_cdek() -> None:
|
||||
order = StoredOrder(cdek_order_uuid="existing-cdek-order-uuid")
|
||||
cdek_adapter = StubCDEKOrderAdapter()
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=StubPaymentAdapter(),
|
||||
order_repository=StubOrderRepository(orders=[order]),
|
||||
order_registration_adapter=cdek_adapter,
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
service.handle_tbank_payment_notification(_make_notification())
|
||||
)
|
||||
|
||||
assert result == "OK"
|
||||
assert cdek_adapter.calls == []
|
||||
assert order.cdek_order_uuid == "existing-cdek-order-uuid"
|
||||
|
||||
|
||||
def test_non_confirmed_notification_acknowledges_without_cdek() -> None:
|
||||
order = StoredOrder()
|
||||
cdek_adapter = StubCDEKOrderAdapter()
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=StubPaymentAdapter(),
|
||||
order_repository=StubOrderRepository(orders=[order]),
|
||||
order_registration_adapter=cdek_adapter,
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
service.handle_tbank_payment_notification(
|
||||
_make_notification(Status="AUTHORIZED")
|
||||
)
|
||||
)
|
||||
|
||||
assert result == "OK"
|
||||
assert order.payment_status == "AUTHORIZED"
|
||||
assert cdek_adapter.calls == []
|
||||
|
||||
|
||||
def test_invalid_token_does_not_access_repository_or_cdek() -> None:
|
||||
order_repository = StubOrderRepository(orders=[StoredOrder()])
|
||||
cdek_adapter = StubCDEKOrderAdapter()
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=StubPaymentAdapter(
|
||||
verify_error=TBankPaymentNotificationTokenError("bad token")
|
||||
),
|
||||
order_repository=order_repository,
|
||||
order_registration_adapter=cdek_adapter,
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidTBankPaymentNotificationError):
|
||||
asyncio.run(service.handle_tbank_payment_notification(_make_notification()))
|
||||
|
||||
assert order_repository.calls == []
|
||||
assert cdek_adapter.calls == []
|
||||
|
||||
|
||||
def test_missing_order_returns_processing_error_without_cdek() -> None:
|
||||
cdek_adapter = StubCDEKOrderAdapter()
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=StubPaymentAdapter(),
|
||||
order_repository=StubOrderRepository(),
|
||||
order_registration_adapter=cdek_adapter,
|
||||
)
|
||||
|
||||
with pytest.raises(TBankPaymentNotificationProcessingError):
|
||||
asyncio.run(service.handle_tbank_payment_notification(_make_notification()))
|
||||
|
||||
assert cdek_adapter.calls == []
|
||||
|
||||
|
||||
def test_cdek_registration_failure_returns_processing_error() -> None:
|
||||
cdek_adapter = StubCDEKOrderAdapter(error=RuntimeError("cdek down"))
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=StubPaymentAdapter(),
|
||||
order_repository=StubOrderRepository(orders=[StoredOrder()]),
|
||||
order_registration_adapter=cdek_adapter,
|
||||
)
|
||||
|
||||
with pytest.raises(TBankPaymentNotificationProcessingError):
|
||||
asyncio.run(service.handle_tbank_payment_notification(_make_notification()))
|
||||
|
||||
assert len(cdek_adapter.calls) == 1
|
||||
|
||||
|
||||
def test_repeated_confirmed_after_cdek_uuid_save_failure_uses_same_external_id() -> None:
|
||||
order = StoredOrder()
|
||||
order_repository = StubOrderRepository(
|
||||
orders=[order],
|
||||
mark_cdek_errors=[RuntimeError("db down"), None],
|
||||
)
|
||||
cdek_adapter = StubCDEKOrderAdapter(
|
||||
responses=["same-cdek-order-uuid", "same-cdek-order-uuid"]
|
||||
)
|
||||
service = AggregatorService(
|
||||
providers=[],
|
||||
payment_adapter=StubPaymentAdapter(),
|
||||
order_repository=order_repository,
|
||||
order_registration_adapter=cdek_adapter,
|
||||
)
|
||||
notification = _make_notification()
|
||||
|
||||
with pytest.raises(TBankPaymentNotificationProcessingError):
|
||||
asyncio.run(service.handle_tbank_payment_notification(notification))
|
||||
|
||||
assert order.cdek_order_uuid is None
|
||||
|
||||
result = asyncio.run(service.handle_tbank_payment_notification(notification))
|
||||
|
||||
assert result == "OK"
|
||||
assert order.cdek_order_uuid == "same-cdek-order-uuid"
|
||||
assert [request.order_uuid for request in cdek_adapter.calls] == [
|
||||
"order-uuid-1",
|
||||
"order-uuid-1",
|
||||
]
|
||||
@@ -35,6 +35,9 @@ def test_app_import_smoke(monkeypatch) -> None:
|
||||
|
||||
assert isinstance(main_module.app, FastAPI)
|
||||
assert isinstance(main_module.app.state.settings, Settings)
|
||||
assert "/api/v1/delivery/tbank/notifications" in {
|
||||
route.path for route in main_module.app.routes
|
||||
}
|
||||
assert logging_bootstrap_calls == [None]
|
||||
assert tracing_bootstrap_calls == [
|
||||
(main_module.app, main_module.app.state.settings.observability)
|
||||
|
||||
Reference in New Issue
Block a user