123 lines
3.9 KiB
Python
123 lines
3.9 KiB
Python
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
|