Files

169 lines
5.3 KiB
Python

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