162 lines
4.7 KiB
Python
162 lines
4.7 KiB
Python
import uuid
|
|
from datetime import UTC, datetime
|
|
from decimal import Decimal
|
|
|
|
import pytest
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
|
|
|
from app.config import Settings
|
|
from app.models import Currency, Payment, PaymentStatus
|
|
from app.processing import (
|
|
PaymentGateway,
|
|
PaymentNotFoundError,
|
|
PaymentProcessor,
|
|
WebhookClient,
|
|
)
|
|
from app.schemas import PaymentCreatedEvent, PaymentWebhook
|
|
|
|
|
|
class StubGateway(PaymentGateway):
|
|
def __init__(self, result: PaymentStatus) -> None:
|
|
self.result = result
|
|
self.calls = 0
|
|
|
|
async def charge(self) -> PaymentStatus:
|
|
self.calls += 1
|
|
return self.result
|
|
|
|
|
|
class RecordingWebhook(WebhookClient):
|
|
def __init__(self, failures: int = 0) -> None:
|
|
self.failures = failures
|
|
self.calls: list[tuple[str, PaymentWebhook]] = []
|
|
|
|
async def send(self, url: str, payload: PaymentWebhook) -> None:
|
|
self.calls.append((url, payload))
|
|
if len(self.calls) <= self.failures:
|
|
raise RuntimeError("webhook unavailable")
|
|
|
|
|
|
async def insert_payment(
|
|
database: async_sessionmaker[AsyncSession],
|
|
*,
|
|
status: PaymentStatus = PaymentStatus.PENDING,
|
|
processed_at: datetime | None = None,
|
|
delivered_at: datetime | None = None,
|
|
) -> uuid.UUID:
|
|
payment_id = uuid.uuid4()
|
|
async with database() as session:
|
|
session.add(
|
|
Payment(
|
|
id=payment_id,
|
|
amount=Decimal("19.95"),
|
|
currency=Currency.EUR,
|
|
description="Processor integration test",
|
|
metadata_={"order_id": 77},
|
|
status=status,
|
|
idempotency_key=str(payment_id),
|
|
webhook_url="https://example.com/hook",
|
|
processed_at=processed_at,
|
|
webhook_delivered_at=delivered_at,
|
|
)
|
|
)
|
|
await session.commit()
|
|
return payment_id
|
|
|
|
|
|
def make_processor(
|
|
database: async_sessionmaker[AsyncSession],
|
|
gateway: StubGateway,
|
|
webhook: RecordingWebhook,
|
|
) -> PaymentProcessor:
|
|
return PaymentProcessor(
|
|
database,
|
|
gateway,
|
|
webhook,
|
|
Settings(processing_max_attempts=3, retry_base_delay=0),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"result",
|
|
[PaymentStatus.SUCCEEDED, PaymentStatus.FAILED],
|
|
)
|
|
async def test_pending_payment_is_processed_and_webhook_is_recorded(
|
|
database: async_sessionmaker[AsyncSession],
|
|
result: PaymentStatus,
|
|
) -> None:
|
|
payment_id = await insert_payment(database)
|
|
gateway = StubGateway(result)
|
|
webhook = RecordingWebhook()
|
|
|
|
await make_processor(database, gateway, webhook).process(
|
|
PaymentCreatedEvent(payment_id=payment_id)
|
|
)
|
|
|
|
async with database() as session:
|
|
payment = await session.get(Payment, payment_id)
|
|
assert payment is not None
|
|
assert payment.status is result
|
|
assert payment.processed_at is not None
|
|
assert payment.webhook_delivered_at is not None
|
|
assert gateway.calls == 1
|
|
assert len(webhook.calls) == 1
|
|
assert webhook.calls[0][1].status is result
|
|
assert webhook.calls[0][1].metadata == {"order_id": 77}
|
|
|
|
|
|
async def test_webhook_retry_does_not_charge_payment_again(
|
|
database: async_sessionmaker[AsyncSession],
|
|
) -> None:
|
|
payment_id = await insert_payment(database)
|
|
gateway = StubGateway(PaymentStatus.SUCCEEDED)
|
|
webhook = RecordingWebhook(failures=2)
|
|
|
|
await make_processor(database, gateway, webhook).process(
|
|
PaymentCreatedEvent(payment_id=payment_id)
|
|
)
|
|
|
|
assert gateway.calls == 1
|
|
assert len(webhook.calls) == 3
|
|
async with database() as session:
|
|
payment = await session.get(Payment, payment_id)
|
|
assert payment is not None
|
|
assert payment.status is PaymentStatus.SUCCEEDED
|
|
assert payment.webhook_delivered_at is not None
|
|
|
|
|
|
async def test_delivered_payment_is_idempotent(
|
|
database: async_sessionmaker[AsyncSession],
|
|
) -> None:
|
|
now = datetime.now(UTC)
|
|
payment_id = await insert_payment(
|
|
database,
|
|
status=PaymentStatus.SUCCEEDED,
|
|
processed_at=now,
|
|
delivered_at=now,
|
|
)
|
|
gateway = StubGateway(PaymentStatus.FAILED)
|
|
webhook = RecordingWebhook()
|
|
|
|
await make_processor(database, gateway, webhook).process(
|
|
PaymentCreatedEvent(payment_id=payment_id)
|
|
)
|
|
|
|
assert gateway.calls == 0
|
|
assert webhook.calls == []
|
|
|
|
|
|
async def test_missing_payment_exhausts_retries(
|
|
database: async_sessionmaker[AsyncSession],
|
|
) -> None:
|
|
gateway = StubGateway(PaymentStatus.SUCCEEDED)
|
|
webhook = RecordingWebhook()
|
|
|
|
with pytest.raises(PaymentNotFoundError):
|
|
await make_processor(database, gateway, webhook).process(
|
|
PaymentCreatedEvent(payment_id=uuid.uuid4())
|
|
)
|
|
|
|
assert gateway.calls == 0
|
|
assert webhook.calls == []
|