169 lines
5.3 KiB
Python
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
|