Добавлена обработка уведомлений через ручку

This commit is contained in:
Раис Юсупалиев
2026-04-18 21:27:15 +03:00
parent 78e9ad4fa9
commit 6b66af5eb3
38 changed files with 1519 additions and 32 deletions
@@ -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"
+6 -6
View File
@@ -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,
}
+135
View File
@@ -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
+1 -1
View File
@@ -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"
+4 -4
View File
@@ -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
View File
@@ -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"
+160
View File
@@ -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))
+342
View File
@@ -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",
]
+3
View File
@@ -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)