Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
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 == []
|
||||
Reference in New Issue
Block a user