DealDocumentScreening/tests/integration/test_webhook_yookassa.py

257 lines
7.8 KiB
Python

"""ЮKassa webhook integration tests (ticket 013).
Mock provider responses with respx; drive the invoice state machine and
credit ledger idempotently.
"""
from __future__ import annotations
import base64
import uuid
import httpx
import pytest
import respx
from sqlalchemy import text
from contract_check.core.config import get_settings
from contract_check.core.db.session import create_session_factory
from tests.integration.conftest import user_token
pytestmark = pytest.mark.integration
def _unique_tg() -> int:
return abs(int(uuid.uuid4().int % 900000000000)) + 100000000000
@pytest.fixture
async def yookassa_env():
import os
old_enabled = os.environ.get("YOOKASSA_ENABLED")
os.environ["YOOKASSA_ENABLED"] = "true"
os.environ["YOOKASSA_SHOP_ID"] = "123456"
os.environ["YOOKASSA_SECRET_KEY"] = "test-secret"
os.environ["YOOKASSA_RETURN_BASE_URL"] = "https://example.com/pay/{invoice_id}"
os.environ["PRICE_PER_DOC_KOPECKS"] = "19900"
get_settings.cache_clear()
yield
if old_enabled is None:
os.environ.pop("YOOKASSA_ENABLED", None)
else:
os.environ["YOOKASSA_ENABLED"] = old_enabled
get_settings.cache_clear()
def _basic_header(shop_id: str = "123456", secret: str = "test-secret") -> dict[str, str]:
creds = base64.b64encode(f"{shop_id}:{secret}".encode()).decode()
return {"Authorization": f"Basic {creds}"}
@respx.mock
async def test_webhook_topup_succeeded_adds_credits(
client: httpx.AsyncClient,
infra: dict[str, str],
yookassa_env,
) -> None:
tg = _unique_tg()
token = await user_token(client, infra, tg)
# Checkout creates a pending invoice + payment.
respx.post("https://api.yookassa.ru/v3/payments").mock(
return_value=httpx.Response(
200,
json={
"id": "pay-webhook-1",
"status": "pending",
"confirmation": {"confirmation_url": "https://yoo.test/w1"},
},
)
)
r = await client.post(
"/api/v1/billing/checkout",
json={"kind": "topup", "credits": 5},
headers={"Authorization": f"Bearer {token}"},
)
assert r.status_code == 201, r.text
invoice_id = uuid.UUID(r.json()["invoice_id"])
# Re-fetch payment returns succeeded with matching amount.
respx.get("https://api.yookassa.ru/v3/payments/pay-webhook-1").mock(
return_value=httpx.Response(
200,
json={
"id": "pay-webhook-1",
"status": "succeeded",
"paid": True,
"amount": {"value": "995.00", "currency": "RUB"},
"metadata": {
"invoice_id": str(invoice_id),
"user_id": "00000000-0000-0000-0000-000000000000",
"kind": "topup",
},
},
)
)
webhook_body = {
"event": "payment.succeeded",
"object": {"id": "pay-webhook-1", "status": "succeeded"},
}
wh = await client.post(
"/api/v1/webhooks/yookassa",
json=webhook_body,
headers=_basic_header(),
)
assert wh.status_code == 200, wh.text
# Invoice succeeded and credits granted.
factory = create_session_factory()
async with factory() as s:
row = (
await s.execute(
text("SELECT status, credits_purchased FROM invoices WHERE id = :id"),
{"id": invoice_id},
)
).first()
assert row[0] == "succeeded"
credits = (
await s.execute(
text("SELECT credits_left FROM users WHERE telegram_id = :t"),
{"t": tg},
)
).scalar_one()
assert int(credits) == 5
events = (
await s.execute(
text(
"SELECT count(*) FROM credit_events WHERE kind = 'topup' AND invoice_id = :id"
),
{"id": invoice_id},
)
).scalar_one()
assert int(events) == 1
@respx.mock
async def test_webhook_replay_idempotent(
client: httpx.AsyncClient,
infra: dict[str, str],
yookassa_env,
) -> None:
tg = _unique_tg()
token = await user_token(client, infra, tg)
respx.post("https://api.yookassa.ru/v3/payments").mock(
return_value=httpx.Response(
200,
json={
"id": "pay-webhook-2",
"status": "pending",
"confirmation": {"confirmation_url": "https://yoo.test/w2"},
},
)
)
r = await client.post(
"/api/v1/billing/checkout",
json={"kind": "topup", "credits": 1},
headers={"Authorization": f"Bearer {token}"},
)
invoice_id = uuid.UUID(r.json()["invoice_id"])
respx.get("https://api.yookassa.ru/v3/payments/pay-webhook-2").mock(
return_value=httpx.Response(
200,
json={
"id": "pay-webhook-2",
"status": "succeeded",
"paid": True,
"amount": {"value": "199.00", "currency": "RUB"},
"metadata": {"invoice_id": str(invoice_id), "kind": "topup"},
},
)
)
body = {"event": "payment.succeeded", "object": {"id": "pay-webhook-2"}}
for _ in range(3):
await client.post("/api/v1/webhooks/yookassa", json=body, headers=_basic_header())
factory = create_session_factory()
async with factory() as s:
events = (
await s.execute(
text("SELECT count(*) FROM credit_events WHERE invoice_id = :id"),
{"id": invoice_id},
)
).scalar_one()
assert int(events) == 1
@respx.mock
async def test_webhook_amount_mismatch_no_credits(
client: httpx.AsyncClient,
infra: dict[str, str],
yookassa_env,
) -> None:
tg = _unique_tg()
token = await user_token(client, infra, tg)
respx.post("https://api.yookassa.ru/v3/payments").mock(
return_value=httpx.Response(
200,
json={
"id": "pay-webhook-3",
"status": "pending",
"confirmation": {"confirmation_url": "https://yoo.test/w3"},
},
)
)
r = await client.post(
"/api/v1/billing/checkout",
json={"kind": "topup", "credits": 1},
headers={"Authorization": f"Bearer {token}"},
)
invoice_id = uuid.UUID(r.json()["invoice_id"])
# Provider reports a different amount — we must not grant credits.
respx.get("https://api.yookassa.ru/v3/payments/pay-webhook-3").mock(
return_value=httpx.Response(
200,
json={
"id": "pay-webhook-3",
"status": "succeeded",
"paid": True,
"amount": {"value": "999.00", "currency": "RUB"},
"metadata": {"invoice_id": str(invoice_id), "kind": "topup"},
},
)
)
body = {"event": "payment.succeeded", "object": {"id": "pay-webhook-3"}}
wh = await client.post("/api/v1/webhooks/yookassa", json=body, headers=_basic_header())
assert wh.status_code == 200
factory = create_session_factory()
async with factory() as s:
status = (
await s.execute(text("SELECT status FROM invoices WHERE id = :id"), {"id": invoice_id})
).scalar_one()
assert status == "pending"
async def test_webhook_missing_auth_401(client: httpx.AsyncClient) -> None:
r = await client.post(
"/api/v1/webhooks/yookassa",
json={"event": "payment.succeeded", "object": {"id": "x"}},
)
assert r.status_code == 401
async def test_webhook_wrong_auth_401(client: httpx.AsyncClient) -> None:
r = await client.post(
"/api/v1/webhooks/yookassa",
json={"event": "payment.succeeded", "object": {"id": "x"}},
headers=_basic_header(shop_id="bad", secret="bad"),
)
assert r.status_code == 401