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
+77
View File
@@ -0,0 +1,77 @@
import uuid
from collections.abc import AsyncIterator
import httpx2
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,
base_url="http://testserver",
) as test_client:
yield test_client
async def test_get_payment_requires_api_key(client: httpx2.AsyncClient) -> None:
response = await client.get(f"/api/v1/payments/{uuid.uuid4()}")
assert response.status_code == 401
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",
headers={"X-API-Key": "change-me"},
json={
"amount": "100.00",
"currency": "RUB",
"description": "Test payment",
"metadata": {},
"webhook_url": "https://example.com/webhook",
},
)
assert response.status_code == 422
assert response.json()["detail"][0]["loc"] == ["header", "Idempotency-Key"]
async def test_create_payment_rejects_unsupported_currency(
client: httpx2.AsyncClient,
) -> None:
response = await client.post(
"/api/v1/payments",
headers={
"X-API-Key": "change-me",
"Idempotency-Key": "test-key",
},
json={
"amount": "100.00",
"currency": "GBP",
"description": "Test payment",
"metadata": {},
"webhook_url": "https://example.com/webhook",
},
)
assert response.status_code == 422
+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)
+51
View File
@@ -0,0 +1,51 @@
import pytest
from app.processing import retry_async
async def test_retry_uses_exponential_delay_and_returns_success() -> None:
calls = 0
delays: list[float] = []
async def operation() -> str:
nonlocal calls
calls += 1
if calls < 3:
raise RuntimeError("temporary failure")
return "processed"
async def record_sleep(delay: float) -> None:
delays.append(delay)
result = await retry_async(
operation,
attempts=3,
base_delay=1,
sleep=record_sleep,
)
assert result == "processed"
assert calls == 3
assert delays == [1, 2]
async def test_retry_raises_last_error_after_three_attempts() -> None:
calls = 0
async def operation() -> None:
nonlocal calls
calls += 1
raise RuntimeError(f"failure {calls}")
async def no_sleep(_: float) -> None:
pass
with pytest.raises(RuntimeError, match="failure 3"):
await retry_async(
operation,
attempts=3,
base_delay=1,
sleep=no_sleep,
)
assert calls == 3