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