Files
payment-processing-test/tests/integration/test_payment_processor.py
T

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 == []