"""Ю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