import asyncio from collections.abc import AsyncIterator from typing import Any, cast import pytest from faststream.rabbit import RabbitBroker from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.models import OutboxEvent from app.outbox import OutboxPublisher from app.payments import create_payment from app.schemas import PaymentCreate class RecordingBroker: def __init__(self, error: Exception | None = None) -> None: self.error = error self.calls: list[tuple[dict[str, Any], dict[str, Any]]] = [] async def publish(self, payload: dict[str, Any], **kwargs: Any) -> None: self.calls.append((payload, kwargs)) if self.error is not None: raise self.error class BlockingBroker(RecordingBroker): def __init__(self) -> None: super().__init__() self.started = asyncio.Event() self.release = asyncio.Event() async def publish(self, payload: dict[str, Any], **kwargs: Any) -> None: self.calls.append((payload, kwargs)) self.started.set() await self.release.wait() @pytest.fixture async def pending_event( database: async_sessionmaker[AsyncSession], ) -> AsyncIterator[OutboxEvent]: async with database() as session: payment = await create_payment( session, PaymentCreate( amount="10.00", currency="USD", description="Outbox test", webhook_url="https://example.com/hook", ), "outbox-key", ) event = await session.scalar( select(OutboxEvent).where(OutboxEvent.aggregate_id == payment.id) ) assert event is not None yield event def make_publisher( database: async_sessionmaker[AsyncSession], broker: RecordingBroker, ) -> OutboxPublisher: return OutboxPublisher( database, cast(RabbitBroker, broker), batch_size=10, poll_interval=0.01, publish_timeout=1, ) async def test_successful_publish_marks_event( database: async_sessionmaker[AsyncSession], pending_event: OutboxEvent, ) -> None: broker = RecordingBroker() published = await make_publisher(database, broker).publish_batch() async with database() as session: event = await session.get(OutboxEvent, pending_event.id) assert event is not None assert event.published_at is not None assert event.attempts == 1 assert event.last_error is None assert published == 1 assert broker.calls[0][0] == {"payment_id": str(pending_event.aggregate_id)} assert broker.calls[0][1]["queue"] == "payments.new" assert broker.calls[0][1]["persist"] is True async def test_failed_publish_remains_pending( database: async_sessionmaker[AsyncSession], pending_event: OutboxEvent, ) -> None: broker = RecordingBroker(RuntimeError("rabbit unavailable")) published = await make_publisher(database, broker).publish_batch() async with database() as session: event = await session.get(OutboxEvent, pending_event.id) assert event is not None assert event.published_at is None assert event.attempts == 1 assert event.last_error == "rabbit unavailable" assert published == 0 async def test_failed_event_is_published_on_next_iteration( database: async_sessionmaker[AsyncSession], pending_event: OutboxEvent, ) -> None: broker = RecordingBroker(RuntimeError("rabbit unavailable")) publisher = make_publisher(database, broker) assert await publisher.publish_batch() == 0 broker.error = None assert await publisher.publish_batch() == 1 async with database() as session: event = await session.get(OutboxEvent, pending_event.id) assert event is not None assert event.published_at is not None assert event.attempts == 2 assert event.last_error is None async def test_concurrent_publishers_publish_event_once( database: async_sessionmaker[AsyncSession], pending_event: OutboxEvent, ) -> None: blocking_broker = BlockingBroker() other_broker = RecordingBroker() first = asyncio.create_task(make_publisher(database, blocking_broker).publish_batch()) await asyncio.wait_for(blocking_broker.started.wait(), timeout=1) second_result = await make_publisher(database, other_broker).publish_batch() blocking_broker.release.set() first_result = await asyncio.wait_for(first, timeout=1) assert first_result == 1 assert second_result == 0 assert len(blocking_broker.calls) == 1 assert other_broker.calls == [] async with database() as session: event = await session.get(OutboxEvent, pending_event.id) assert event is not None assert event.published_at is not None async def test_stop_event_prevents_publishing_selected_events( database: async_sessionmaker[AsyncSession], pending_event: OutboxEvent, ) -> None: broker = RecordingBroker() stop_event = asyncio.Event() stop_event.set() published = await make_publisher(database, broker).publish_batch(stop_event) assert published == 0 assert broker.calls == [] async with database() as session: event = await session.get(OutboxEvent, pending_event.id) assert event is not None assert event.published_at is None