Refactor code structure for improved readability and maintainability

This commit is contained in:
k1nq
2026-09-16 14:27:32 +05:00
parent 01028f440e
commit 2b35fa9a6d
17 changed files with 1475 additions and 3 deletions
+106
View File
@@ -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
+64
View File
@@ -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
+168
View File
@@ -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
+161
View File
@@ -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 == []
+122
View File
@@ -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
+115
View File
@@ -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()