Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from testcontainers.community.postgres import PostgresContainer
|
||||
from testcontainers.core.container import DockerContainer
|
||||
from testcontainers.core.wait_strategies import LogMessageWaitStrategy
|
||||
|
||||
from app.api import router
|
||||
from app.auth import get_settings
|
||||
from app.config import Settings
|
||||
from app.db import Base, get_session
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
async def migrate_to_head(database_url: str) -> None:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"alembic",
|
||||
"upgrade",
|
||||
"head",
|
||||
cwd=PROJECT_ROOT,
|
||||
env={**os.environ, "DATABASE_URL": database_url},
|
||||
)
|
||||
assert await process.wait() == 0
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def postgres_url() -> Iterator[str]:
|
||||
with PostgresContainer("postgres:16-alpine", driver=None) as postgres:
|
||||
url = postgres.get_connection_url(driver=None)
|
||||
yield url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def rabbitmq_url() -> Iterator[str]:
|
||||
rabbitmq = (
|
||||
DockerContainer("rabbitmq:3.13-alpine")
|
||||
.with_exposed_ports(5672)
|
||||
.with_env("RABBITMQ_DEFAULT_USER", "payments")
|
||||
.with_env("RABBITMQ_DEFAULT_PASS", "payments")
|
||||
.waiting_for(LogMessageWaitStrategy("Server startup complete").with_startup_timeout(60))
|
||||
)
|
||||
with rabbitmq:
|
||||
host = rabbitmq.get_container_host_ip()
|
||||
port = rabbitmq.get_exposed_port(5672)
|
||||
yield f"amqp://payments:payments@{host}:{port}/"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def database(
|
||||
postgres_url: str,
|
||||
) -> AsyncIterator[async_sessionmaker[AsyncSession]]:
|
||||
engine = create_async_engine(postgres_url)
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.drop_all)
|
||||
await connection.exec_driver_sql("DROP TABLE IF EXISTS alembic_version")
|
||||
await engine.dispose()
|
||||
|
||||
await migrate_to_head(postgres_url)
|
||||
|
||||
engine = create_async_engine(postgres_url)
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session(
|
||||
database: async_sessionmaker[AsyncSession],
|
||||
) -> AsyncIterator[AsyncSession]:
|
||||
async with database() as session:
|
||||
yield session
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def api_client(
|
||||
database: async_sessionmaker[AsyncSession],
|
||||
) -> AsyncIterator[httpx2.AsyncClient]:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
|
||||
async def override_session() -> AsyncIterator[AsyncSession]:
|
||||
async with database() as session:
|
||||
yield session
|
||||
|
||||
app.dependency_overrides[get_session] = override_session
|
||||
app.dependency_overrides[get_settings] = lambda: Settings(api_key="test-key")
|
||||
|
||||
transport = httpx2.ASGITransport(app=app)
|
||||
async with httpx2.AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://testserver",
|
||||
) as client:
|
||||
yield client
|
||||
@@ -0,0 +1,64 @@
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from app.db import Base
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parents[2]
|
||||
|
||||
|
||||
async def run_alembic(command: str, database_url: str) -> None:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-m",
|
||||
"alembic",
|
||||
command,
|
||||
"head" if command == "upgrade" else "base",
|
||||
cwd=PROJECT_ROOT,
|
||||
env={**os.environ, "DATABASE_URL": database_url},
|
||||
)
|
||||
assert await process.wait() == 0
|
||||
|
||||
|
||||
async def test_migration_up_and_down(postgres_url: str) -> None:
|
||||
engine = create_async_engine(postgres_url)
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.drop_all)
|
||||
await connection.execute(text("DROP TABLE IF EXISTS alembic_version"))
|
||||
await engine.dispose()
|
||||
|
||||
await run_alembic("upgrade", postgres_url)
|
||||
|
||||
engine = create_async_engine(postgres_url)
|
||||
async with engine.connect() as connection:
|
||||
tables, indexes, unique_constraints = await connection.run_sync(
|
||||
lambda sync_connection: (
|
||||
set(inspect(sync_connection).get_table_names()),
|
||||
{index["name"] for index in inspect(sync_connection).get_indexes("outbox")},
|
||||
{
|
||||
constraint["name"]
|
||||
for constraint in inspect(sync_connection).get_unique_constraints("payments")
|
||||
},
|
||||
)
|
||||
)
|
||||
await engine.dispose()
|
||||
|
||||
assert {"payments", "outbox", "alembic_version"} <= tables
|
||||
assert {"ix_outbox_aggregate_id", "ix_outbox_published_at"} <= indexes
|
||||
assert unique_constraints
|
||||
|
||||
await run_alembic("downgrade", postgres_url)
|
||||
|
||||
engine = create_async_engine(postgres_url)
|
||||
async with engine.connect() as connection:
|
||||
remaining_tables = await connection.run_sync(
|
||||
lambda sync_connection: set(inspect(sync_connection).get_table_names())
|
||||
)
|
||||
await engine.dispose()
|
||||
|
||||
assert "payments" not in remaining_tables
|
||||
assert "outbox" not in remaining_tables
|
||||
@@ -0,0 +1,168 @@
|
||||
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
|
||||
@@ -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 == []
|
||||
@@ -0,0 +1,122 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
|
||||
import httpx2
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import OutboxEvent, Payment, PaymentStatus
|
||||
|
||||
PAYMENT_BODY: dict[str, object] = {
|
||||
"amount": "1250.50",
|
||||
"currency": "RUB",
|
||||
"description": "Integration payment",
|
||||
"metadata": {"order_id": 123},
|
||||
"webhook_url": "https://example.com/webhook",
|
||||
}
|
||||
AUTH_HEADERS = {"X-API-Key": "test-key"}
|
||||
|
||||
|
||||
async def create_payment(
|
||||
client: httpx2.AsyncClient,
|
||||
idempotency_key: str,
|
||||
body: dict[str, object] | None = None,
|
||||
) -> httpx2.Response:
|
||||
return await client.post(
|
||||
"/api/v1/payments",
|
||||
headers={**AUTH_HEADERS, "Idempotency-Key": idempotency_key},
|
||||
json=body or PAYMENT_BODY,
|
||||
)
|
||||
|
||||
|
||||
async def test_create_payment_persists_payment_and_outbox_atomically(
|
||||
api_client: httpx2.AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
response = await create_payment(api_client, "order-123")
|
||||
|
||||
assert response.status_code == 202
|
||||
body = response.json()
|
||||
assert body["status"] == PaymentStatus.PENDING
|
||||
|
||||
payment_id = uuid.UUID(body["payment_id"])
|
||||
payment = await db_session.get(Payment, payment_id)
|
||||
event = await db_session.scalar(
|
||||
select(OutboxEvent).where(OutboxEvent.aggregate_id == payment_id)
|
||||
)
|
||||
assert payment is not None
|
||||
assert payment.amount == Decimal("1250.50")
|
||||
assert payment.metadata_ == PAYMENT_BODY["metadata"]
|
||||
assert event is not None
|
||||
assert event.payload == {"payment_id": str(payment_id)}
|
||||
assert event.published_at is None
|
||||
|
||||
|
||||
async def test_get_returns_created_payment(
|
||||
api_client: httpx2.AsyncClient,
|
||||
) -> None:
|
||||
created = await create_payment(api_client, "get-payment")
|
||||
payment_id = created.json()["payment_id"]
|
||||
|
||||
response = await api_client.get(
|
||||
f"/api/v1/payments/{payment_id}",
|
||||
headers=AUTH_HEADERS,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"payment_id": payment_id,
|
||||
"amount": "1250.50",
|
||||
"currency": "RUB",
|
||||
"description": "Integration payment",
|
||||
"metadata": {"order_id": 123},
|
||||
"status": "pending",
|
||||
"idempotency_key": "get-payment",
|
||||
"webhook_url": "https://example.com/webhook",
|
||||
"created_at": created.json()["created_at"],
|
||||
"processed_at": None,
|
||||
}
|
||||
|
||||
|
||||
async def test_get_unknown_payment_returns_404(api_client: httpx2.AsyncClient) -> None:
|
||||
response = await api_client.get(
|
||||
f"/api/v1/payments/{uuid.uuid4()}",
|
||||
headers=AUTH_HEADERS,
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Payment not found"}
|
||||
|
||||
|
||||
async def test_repeated_idempotency_key_returns_original_payment(
|
||||
api_client: httpx2.AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
first = await create_payment(api_client, "same-key")
|
||||
second_body: dict[str, object] = {
|
||||
**PAYMENT_BODY,
|
||||
"amount": "999.99",
|
||||
"currency": "USD",
|
||||
}
|
||||
second = await create_payment(api_client, "same-key", second_body)
|
||||
|
||||
assert second.status_code == 202
|
||||
assert second.json()["payment_id"] == first.json()["payment_id"]
|
||||
assert await db_session.scalar(select(func.count()).select_from(Payment)) == 1
|
||||
assert await db_session.scalar(select(func.count()).select_from(OutboxEvent)) == 1
|
||||
|
||||
|
||||
async def test_concurrent_requests_create_one_payment_and_one_event(
|
||||
api_client: httpx2.AsyncClient,
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
responses = await asyncio.gather(
|
||||
create_payment(api_client, "concurrent-key"),
|
||||
create_payment(api_client, "concurrent-key"),
|
||||
)
|
||||
|
||||
assert {response.status_code for response in responses} == {202}
|
||||
assert len({response.json()["payment_id"] for response in responses}) == 1
|
||||
assert await db_session.scalar(select(func.count()).select_from(Payment)) == 1
|
||||
assert await db_session.scalar(select(func.count()).select_from(OutboxEvent)) == 1
|
||||
@@ -0,0 +1,115 @@
|
||||
import asyncio
|
||||
import json
|
||||
from decimal import Decimal
|
||||
|
||||
import aio_pika
|
||||
from faststream.exceptions import RejectMessage
|
||||
from faststream.rabbit import Channel, RabbitBroker
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.messaging import (
|
||||
DEAD_QUEUE,
|
||||
PAYMENTS_EXCHANGE,
|
||||
PAYMENTS_QUEUE,
|
||||
PAYMENTS_ROUTING_KEY,
|
||||
declare_topology,
|
||||
)
|
||||
from app.models import Currency, OutboxEvent
|
||||
from app.outbox import OutboxPublisher
|
||||
from app.payments import create_payment
|
||||
from app.schemas import PaymentCreate
|
||||
|
||||
|
||||
async def wait_for_message(
|
||||
queue: aio_pika.abc.AbstractQueue,
|
||||
) -> aio_pika.abc.AbstractIncomingMessage:
|
||||
async with asyncio.timeout(5):
|
||||
while True:
|
||||
message = await queue.get(fail=False)
|
||||
if message is not None:
|
||||
return message
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
|
||||
async def test_rejected_payment_message_is_dead_lettered(rabbitmq_url: str) -> None:
|
||||
broker = RabbitBroker(
|
||||
rabbitmq_url,
|
||||
default_channel=Channel(publisher_confirms=True, on_return_raises=True),
|
||||
)
|
||||
|
||||
@broker.subscriber(PAYMENTS_QUEUE, PAYMENTS_EXCHANGE)
|
||||
async def reject_message(_: dict[str, str]) -> None:
|
||||
raise RejectMessage()
|
||||
|
||||
await broker.start()
|
||||
connection = await aio_pika.connect_robust(rabbitmq_url)
|
||||
try:
|
||||
await declare_topology(broker)
|
||||
payload = {"payment_id": "00000000-0000-0000-0000-000000000001"}
|
||||
await broker.publish(
|
||||
payload,
|
||||
queue=PAYMENTS_ROUTING_KEY,
|
||||
exchange=PAYMENTS_EXCHANGE,
|
||||
persist=True,
|
||||
)
|
||||
|
||||
channel = await connection.channel()
|
||||
dead_queue = await channel.declare_queue(DEAD_QUEUE.name, passive=True)
|
||||
dead_message = await wait_for_message(dead_queue)
|
||||
assert json.loads(dead_message.body) == payload
|
||||
await dead_message.ack()
|
||||
finally:
|
||||
await connection.close()
|
||||
await broker.stop()
|
||||
|
||||
|
||||
async def test_outbox_publishes_to_real_payments_queue(
|
||||
rabbitmq_url: str,
|
||||
database: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
broker = RabbitBroker(
|
||||
rabbitmq_url,
|
||||
default_channel=Channel(publisher_confirms=True, on_return_raises=True),
|
||||
)
|
||||
await broker.start()
|
||||
connection = await aio_pika.connect_robust(rabbitmq_url)
|
||||
try:
|
||||
await declare_topology(broker)
|
||||
channel = await connection.channel()
|
||||
queue = await channel.declare_queue(PAYMENTS_QUEUE.name, passive=True)
|
||||
await queue.purge()
|
||||
|
||||
async with database() as session:
|
||||
payment = await create_payment(
|
||||
session,
|
||||
PaymentCreate(
|
||||
amount=Decimal("14.50"),
|
||||
currency=Currency.RUB,
|
||||
description="Real RabbitMQ outbox test",
|
||||
webhook_url="https://example.com/hook",
|
||||
),
|
||||
"real-rabbit-outbox",
|
||||
)
|
||||
|
||||
publisher = OutboxPublisher(
|
||||
database,
|
||||
broker,
|
||||
batch_size=10,
|
||||
poll_interval=0.01,
|
||||
publish_timeout=5,
|
||||
)
|
||||
assert await publisher.publish_batch() == 1
|
||||
|
||||
message = await queue.get(timeout=5, fail=True)
|
||||
assert json.loads(message.body) == {"payment_id": str(payment.id)}
|
||||
await message.ack()
|
||||
async with database() as session:
|
||||
event = await session.scalar(
|
||||
select(OutboxEvent).where(OutboxEvent.aggregate_id == payment.id)
|
||||
)
|
||||
assert event is not None
|
||||
assert event.published_at is not None
|
||||
finally:
|
||||
await connection.close()
|
||||
await broker.stop()
|
||||
Reference in New Issue
Block a user