Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user