Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
|
||||
for item in items:
|
||||
parts = Path(item.path).parts
|
||||
if "unit" in parts:
|
||||
item.add_marker(pytest.mark.unit)
|
||||
elif "integration" in parts:
|
||||
item.add_marker(pytest.mark.integration)
|
||||
@@ -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()
|
||||
@@ -6,12 +6,15 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api import router
|
||||
from app.auth import get_settings
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client() -> AsyncIterator[httpx2.AsyncClient]:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_settings] = lambda: Settings(api_key="change-me")
|
||||
transport = httpx2.ASGITransport(app=app)
|
||||
async with httpx2.AsyncClient(
|
||||
transport=transport,
|
||||
@@ -27,6 +30,15 @@ async def test_get_payment_requires_api_key(client: httpx2.AsyncClient) -> None:
|
||||
assert response.json() == {"detail": "Invalid or missing API key"}
|
||||
|
||||
|
||||
async def test_get_payment_rejects_wrong_api_key(client: httpx2.AsyncClient) -> None:
|
||||
response = await client.get(
|
||||
f"/api/v1/payments/{uuid.uuid4()}",
|
||||
headers={"X-API-Key": "wrong-key"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_create_payment_requires_idempotency_key(client: httpx2.AsyncClient) -> None:
|
||||
response = await client.post(
|
||||
"/api/v1/payments",
|
||||
@@ -0,0 +1,30 @@
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from faststream.exceptions import RejectMessage
|
||||
|
||||
from app import consumer
|
||||
from app.schemas import PaymentCreatedEvent
|
||||
|
||||
|
||||
async def test_handler_delegates_to_processor(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
processor = AsyncMock()
|
||||
monkeypatch.setattr(consumer, "processor", processor)
|
||||
event = PaymentCreatedEvent(payment_id=uuid.uuid4())
|
||||
|
||||
await consumer.handle_payment(event)
|
||||
|
||||
processor.process.assert_awaited_once_with(event)
|
||||
|
||||
|
||||
async def test_handler_rejects_after_processor_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
processor = AsyncMock()
|
||||
processor.process.side_effect = RuntimeError("exhausted")
|
||||
monkeypatch.setattr(consumer, "processor", processor)
|
||||
event = PaymentCreatedEvent(payment_id=uuid.uuid4())
|
||||
|
||||
with pytest.raises(RejectMessage):
|
||||
await consumer.handle_payment(event)
|
||||
@@ -0,0 +1,89 @@
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app import main
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
class FakeBroker:
|
||||
instance: "FakeBroker"
|
||||
|
||||
def __init__(self, *_: Any, **__: Any) -> None:
|
||||
type(self).instance = self
|
||||
self.started = False
|
||||
self.stopped = False
|
||||
|
||||
async def start(self) -> None:
|
||||
self.started = True
|
||||
|
||||
async def stop(self) -> None:
|
||||
self.stopped = True
|
||||
|
||||
|
||||
class CooperativePublisher:
|
||||
instance: "CooperativePublisher"
|
||||
|
||||
def __init__(self, *_: Any, **__: Any) -> None:
|
||||
type(self).instance = self
|
||||
self.stopped = False
|
||||
|
||||
async def run(self, stop_event: asyncio.Event) -> None:
|
||||
await stop_event.wait()
|
||||
self.stopped = True
|
||||
|
||||
|
||||
class StuckPublisher:
|
||||
instance: "StuckPublisher"
|
||||
|
||||
def __init__(self, *_: Any, **__: Any) -> None:
|
||||
type(self).instance = self
|
||||
self.cancelled = False
|
||||
|
||||
async def run(self, _: asyncio.Event) -> None:
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
self.cancelled = True
|
||||
|
||||
|
||||
async def test_lifespan_starts_and_stops_resources(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
declare_topology = AsyncMock()
|
||||
close_database = AsyncMock()
|
||||
monkeypatch.setattr(main, "RabbitBroker", FakeBroker)
|
||||
monkeypatch.setattr(main, "OutboxPublisher", CooperativePublisher)
|
||||
monkeypatch.setattr(main, "declare_topology", declare_topology)
|
||||
monkeypatch.setattr(main, "close_database", close_database)
|
||||
|
||||
async with main.lifespan(FastAPI()):
|
||||
assert FakeBroker.instance.started is True
|
||||
|
||||
assert CooperativePublisher.instance.stopped is True
|
||||
assert FakeBroker.instance.stopped is True
|
||||
declare_topology.assert_awaited_once_with(FakeBroker.instance)
|
||||
close_database.assert_awaited_once_with()
|
||||
|
||||
|
||||
async def test_lifespan_cancels_stuck_publisher(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(main, "RabbitBroker", FakeBroker)
|
||||
monkeypatch.setattr(main, "OutboxPublisher", StuckPublisher)
|
||||
monkeypatch.setattr(main, "declare_topology", AsyncMock())
|
||||
monkeypatch.setattr(main, "close_database", AsyncMock())
|
||||
monkeypatch.setattr(
|
||||
main,
|
||||
"settings",
|
||||
Settings(outbox_publish_timeout=0.001, outbox_poll_interval=0.001),
|
||||
)
|
||||
|
||||
async with main.lifespan(FastAPI()):
|
||||
pass
|
||||
|
||||
assert StuckPublisher.instance.cancelled is True
|
||||
assert FakeBroker.instance.stopped is True
|
||||
@@ -0,0 +1,31 @@
|
||||
from typing import cast
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from faststream.rabbit import RabbitBroker
|
||||
|
||||
from app.messaging import (
|
||||
DEAD_ROUTING_KEY,
|
||||
PAYMENTS_ROUTING_KEY,
|
||||
declare_topology,
|
||||
)
|
||||
|
||||
|
||||
async def test_declare_topology_binds_both_queues() -> None:
|
||||
broker = AsyncMock()
|
||||
payments_exchange = object()
|
||||
dead_exchange = object()
|
||||
payments_queue = AsyncMock()
|
||||
dead_queue = AsyncMock()
|
||||
broker.declare_exchange.side_effect = [payments_exchange, dead_exchange]
|
||||
broker.declare_queue.side_effect = [payments_queue, dead_queue]
|
||||
|
||||
await declare_topology(cast(RabbitBroker, broker))
|
||||
|
||||
payments_queue.bind.assert_awaited_once_with(
|
||||
payments_exchange,
|
||||
routing_key=PAYMENTS_ROUTING_KEY,
|
||||
)
|
||||
dead_queue.bind.assert_awaited_once_with(
|
||||
dead_exchange,
|
||||
routing_key=DEAD_ROUTING_KEY,
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
import asyncio
|
||||
from typing import cast
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from faststream.rabbit import RabbitBroker
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.outbox import OutboxPublisher
|
||||
|
||||
|
||||
def make_publisher() -> OutboxPublisher:
|
||||
return OutboxPublisher(
|
||||
cast(async_sessionmaker[AsyncSession], object()),
|
||||
cast(RabbitBroker, object()),
|
||||
batch_size=1,
|
||||
poll_interval=0.001,
|
||||
publish_timeout=1,
|
||||
)
|
||||
|
||||
|
||||
async def test_run_stops_after_stop_event(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
publisher = make_publisher()
|
||||
stop_event = asyncio.Event()
|
||||
|
||||
async def publish_batch(_: asyncio.Event) -> int:
|
||||
stop_event.set()
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(publisher, "publish_batch", publish_batch)
|
||||
|
||||
await asyncio.wait_for(publisher.run(stop_event), timeout=1)
|
||||
|
||||
assert stop_event.is_set()
|
||||
|
||||
|
||||
async def test_run_recovers_from_iteration_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
publisher = make_publisher()
|
||||
stop_event = asyncio.Event()
|
||||
iteration_finished = asyncio.Event()
|
||||
|
||||
async def fail_iteration(_: asyncio.Event) -> int:
|
||||
iteration_finished.set()
|
||||
raise RuntimeError("temporary database error")
|
||||
|
||||
publish_batch = AsyncMock(side_effect=fail_iteration)
|
||||
|
||||
async def stop_after_error() -> None:
|
||||
await iteration_finished.wait()
|
||||
stop_event.set()
|
||||
|
||||
monkeypatch.setattr(publisher, "publish_batch", publish_batch)
|
||||
stopper = asyncio.create_task(stop_after_error())
|
||||
|
||||
await asyncio.wait_for(publisher.run(stop_event), timeout=1)
|
||||
await stopper
|
||||
|
||||
publish_batch.assert_awaited_once_with(stop_event)
|
||||
@@ -0,0 +1,86 @@
|
||||
import json
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import httpx2
|
||||
import pytest
|
||||
|
||||
from app.config import Settings
|
||||
from app.models import Currency, PaymentStatus
|
||||
from app.processing import PaymentGateway, WebhookClient
|
||||
from app.schemas import PaymentWebhook
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("success_rate", "expected"),
|
||||
[
|
||||
(1.0, PaymentStatus.SUCCEEDED),
|
||||
(0.0, PaymentStatus.FAILED),
|
||||
],
|
||||
)
|
||||
async def test_gateway_returns_configured_result(
|
||||
success_rate: float,
|
||||
expected: PaymentStatus,
|
||||
) -> None:
|
||||
gateway = PaymentGateway(
|
||||
Settings(
|
||||
processing_min_seconds=0,
|
||||
processing_max_seconds=0,
|
||||
processing_success_rate=success_rate,
|
||||
)
|
||||
)
|
||||
|
||||
assert await gateway.charge() is expected
|
||||
|
||||
|
||||
async def test_webhook_client_posts_serialized_payload(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
requests: list[httpx2.Request] = []
|
||||
|
||||
def handler(request: httpx2.Request) -> httpx2.Response:
|
||||
requests.append(request)
|
||||
return httpx2.Response(204)
|
||||
|
||||
transport = httpx2.MockTransport(handler)
|
||||
async_client = httpx2.AsyncClient
|
||||
monkeypatch.setattr(
|
||||
"app.processing.httpx2.AsyncClient",
|
||||
lambda **kwargs: async_client(transport=transport, **kwargs),
|
||||
)
|
||||
payload = PaymentWebhook(
|
||||
payment_id=uuid.uuid4(),
|
||||
amount=Decimal("10.25"),
|
||||
currency=Currency.USD,
|
||||
status=PaymentStatus.SUCCEEDED,
|
||||
processed_at=datetime.now(UTC),
|
||||
metadata={"order_id": 42},
|
||||
)
|
||||
|
||||
await WebhookClient(timeout=1).send("https://example.com/hook", payload)
|
||||
|
||||
assert len(requests) == 1
|
||||
assert json.loads(requests[0].content) == payload.model_dump(mode="json")
|
||||
|
||||
|
||||
async def test_webhook_client_raises_for_server_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
transport = httpx2.MockTransport(lambda _: httpx2.Response(500))
|
||||
async_client = httpx2.AsyncClient
|
||||
monkeypatch.setattr(
|
||||
"app.processing.httpx2.AsyncClient",
|
||||
lambda **kwargs: async_client(transport=transport, **kwargs),
|
||||
)
|
||||
payload = PaymentWebhook(
|
||||
payment_id=uuid.uuid4(),
|
||||
amount=Decimal("1.00"),
|
||||
currency=Currency.EUR,
|
||||
status=PaymentStatus.FAILED,
|
||||
processed_at=datetime.now(UTC),
|
||||
metadata={},
|
||||
)
|
||||
|
||||
with pytest.raises(httpx2.HTTPStatusError):
|
||||
await WebhookClient(timeout=1).send("https://example.com/hook", payload)
|
||||
@@ -3,7 +3,6 @@ import pytest
|
||||
from app.processing import retry_async
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_uses_exponential_delay_and_returns_success() -> None:
|
||||
calls = 0
|
||||
delays: list[float] = []
|
||||
@@ -30,7 +29,6 @@ async def test_retry_uses_exponential_delay_and_returns_success() -> None:
|
||||
assert delays == [1, 2]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_raises_last_error_after_three_attempts() -> None:
|
||||
calls = 0
|
||||
|
||||
Reference in New Issue
Block a user