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