458 lines
14 KiB
Python
458 lines
14 KiB
Python
import asyncio
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from app.adapters.delivery_providers.cdek.order_mapper import (
|
|
CDEKOrderRegistrationResult,
|
|
)
|
|
from app.adapters.tbank.base import TBankPaymentNotificationTokenError
|
|
from app.schemas.payment import InitPaymentRequest, TBankPaymentNotification
|
|
from app.services.aggregator import (
|
|
AggregatorService,
|
|
InvalidTBankPaymentNotificationError,
|
|
TBankPaymentNotificationProcessingError,
|
|
)
|
|
from tests.payment_fixtures import make_init_payment_payload
|
|
|
|
|
|
def _default_payload() -> dict[str, Any]:
|
|
return make_init_payment_payload()
|
|
|
|
|
|
@dataclass
|
|
class StoredOrder:
|
|
order_uuid: str = "order-uuid-1"
|
|
payment_url: str = "https://pay.test/payment/1"
|
|
price: int = 125000
|
|
tariff_code: str = "535"
|
|
provider: str = "cdek"
|
|
account_email: str = "client@example.com"
|
|
payload: dict[str, Any] = field(default_factory=_default_payload)
|
|
payment_status: str | None = None
|
|
tbank_payment_id: int | None = None
|
|
cdek_order_uuid: str | None = None
|
|
cse_order_number: str | None = None
|
|
cdek_waybill_uuid: str | None = None
|
|
cdek_waybill_url: str | None = None
|
|
payment_email_sent_at: object | None = None
|
|
|
|
|
|
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,
|
|
*,
|
|
for_update: bool = False,
|
|
) -> 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
|
|
|
|
async def record_payment_email_sent(
|
|
self,
|
|
session: object,
|
|
*,
|
|
order_uuid: str,
|
|
sent_at: object,
|
|
) -> StoredOrder | None:
|
|
self.calls.append(
|
|
("record_payment_email_sent", (session, order_uuid, sent_at))
|
|
)
|
|
order = self._orders.get(order_uuid)
|
|
if order is not None and order.payment_email_sent_at is None:
|
|
order.payment_email_sent_at = sent_at
|
|
return order
|
|
|
|
|
|
class StubEmailSender:
|
|
def __init__(self, *, error: Exception | None = None) -> None:
|
|
self._error = error
|
|
self.calls: list[dict[str, str]] = []
|
|
|
|
async def send_email(
|
|
self, *, to: str, subject: str, body: str
|
|
) -> None:
|
|
self.calls.append({"to": to, "subject": subject, "body": body})
|
|
if self._error is not None:
|
|
raise self._error
|
|
|
|
|
|
class StubCDEKOrderAdapter:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
responses: list[CDEKOrderRegistrationResult] | None = None,
|
|
error: Exception | None = None,
|
|
) -> None:
|
|
self._responses = responses or [
|
|
CDEKOrderRegistrationResult(
|
|
order_uuid="cdek-order-uuid-1",
|
|
waybill_uuid=None,
|
|
waybill_url=None,
|
|
)
|
|
]
|
|
self._error = error
|
|
self.calls: list[tuple[InitPaymentRequest, str]] = []
|
|
|
|
async def register_order(
|
|
self, request: InitPaymentRequest, order_uuid: str
|
|
) -> CDEKOrderRegistrationResult:
|
|
self.calls.append((request, order_uuid))
|
|
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=[
|
|
CDEKOrderRegistrationResult(
|
|
order_uuid="cdek-order-uuid-1",
|
|
waybill_uuid="waybill-uuid-1",
|
|
waybill_url="https://cdek.test/waybill/1.pdf",
|
|
)
|
|
]
|
|
)
|
|
service = AggregatorService(
|
|
providers=[],
|
|
payment_adapter=payment_adapter,
|
|
order_repository=order_repository,
|
|
order_registration_adapters={"cdek": 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 order.cdek_waybill_uuid is None
|
|
assert order.cdek_waybill_url is None
|
|
assert len(cdek_adapter.calls) == 1
|
|
assert cdek_adapter.calls[0][1] == "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_adapters={"cdek": 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_adapters={"cdek": 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_adapters={"cdek": 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_adapters={"cdek": 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_adapters={"cdek": 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=[
|
|
CDEKOrderRegistrationResult(
|
|
order_uuid="same-cdek-order-uuid",
|
|
waybill_uuid=None,
|
|
waybill_url=None,
|
|
),
|
|
CDEKOrderRegistrationResult(
|
|
order_uuid="same-cdek-order-uuid",
|
|
waybill_uuid=None,
|
|
waybill_url=None,
|
|
),
|
|
]
|
|
)
|
|
service = AggregatorService(
|
|
providers=[],
|
|
payment_adapter=StubPaymentAdapter(),
|
|
order_repository=order_repository,
|
|
order_registration_adapters={"cdek": 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 [order_uuid for _, order_uuid in cdek_adapter.calls] == [
|
|
"order-uuid-1",
|
|
"order-uuid-1",
|
|
]
|
|
|
|
|
|
def test_confirmed_notification_sends_payment_confirmation_email() -> None:
|
|
order = StoredOrder()
|
|
email_sender = StubEmailSender()
|
|
service = AggregatorService(
|
|
providers=[],
|
|
payment_adapter=StubPaymentAdapter(),
|
|
order_repository=StubOrderRepository(orders=[order]),
|
|
order_registration_adapters={"cdek": StubCDEKOrderAdapter()},
|
|
email_sender=email_sender,
|
|
)
|
|
|
|
result = asyncio.run(
|
|
service.handle_tbank_payment_notification(_make_notification())
|
|
)
|
|
|
|
assert result == "OK"
|
|
assert len(email_sender.calls) == 1
|
|
assert email_sender.calls[0]["to"] == "client@example.com"
|
|
assert "order-uuid-1" in email_sender.calls[0]["subject"]
|
|
assert order.payment_email_sent_at is not None
|
|
|
|
|
|
def test_non_confirmed_notification_does_not_send_email() -> None:
|
|
order = StoredOrder()
|
|
email_sender = StubEmailSender()
|
|
service = AggregatorService(
|
|
providers=[],
|
|
payment_adapter=StubPaymentAdapter(),
|
|
order_repository=StubOrderRepository(orders=[order]),
|
|
order_registration_adapters={"cdek": StubCDEKOrderAdapter()},
|
|
email_sender=email_sender,
|
|
)
|
|
|
|
result = asyncio.run(
|
|
service.handle_tbank_payment_notification(
|
|
_make_notification(Status="AUTHORIZED")
|
|
)
|
|
)
|
|
|
|
assert result == "OK"
|
|
assert email_sender.calls == []
|
|
assert order.payment_email_sent_at is None
|
|
|
|
|
|
def test_duplicate_notification_does_not_resend_payment_email() -> None:
|
|
from datetime import datetime, timezone
|
|
|
|
order = StoredOrder(
|
|
cdek_order_uuid="existing-cdek-order-uuid",
|
|
payment_email_sent_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
|
)
|
|
email_sender = StubEmailSender()
|
|
service = AggregatorService(
|
|
providers=[],
|
|
payment_adapter=StubPaymentAdapter(),
|
|
order_repository=StubOrderRepository(orders=[order]),
|
|
order_registration_adapters={"cdek": StubCDEKOrderAdapter()},
|
|
email_sender=email_sender,
|
|
)
|
|
|
|
result = asyncio.run(
|
|
service.handle_tbank_payment_notification(_make_notification())
|
|
)
|
|
|
|
assert result == "OK"
|
|
assert email_sender.calls == []
|
|
|
|
|
|
def test_email_failure_does_not_break_notification_handling() -> None:
|
|
order = StoredOrder()
|
|
email_sender = StubEmailSender(error=RuntimeError("smtp down"))
|
|
service = AggregatorService(
|
|
providers=[],
|
|
payment_adapter=StubPaymentAdapter(),
|
|
order_repository=StubOrderRepository(orders=[order]),
|
|
order_registration_adapters={"cdek": StubCDEKOrderAdapter()},
|
|
email_sender=email_sender,
|
|
)
|
|
|
|
result = asyncio.run(
|
|
service.handle_tbank_payment_notification(_make_notification())
|
|
)
|
|
|
|
assert result == "OK"
|
|
assert len(email_sender.calls) == 1
|
|
assert order.cdek_order_uuid == "cdek-order-uuid-1"
|