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
+12
View File
@@ -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)
+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()
@@ -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",
+30
View File
@@ -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)
+89
View File
@@ -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
+31
View File
@@ -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,
)
+60
View File
@@ -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)
+86
View File
@@ -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