"""Subscription purchase + activation integration tests (ticket 015). Full flow: checkout(subscription) → webhook(succeeded) → /me/overview shows plan + quota usage. Double-buy and B2B-only plan rejections are covered. """ from __future__ import annotations import base64 import os 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 def _basic() -> str: return base64.b64encode(b"123456:test-secret").decode() @pytest.fixture async def yookassa_env(): old = 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" os.environ["BILLING_RETURN_JWT_SECRET"] = "pay-secret" os.environ["PLANS_ENABLED"] = "true" get_settings.cache_clear() yield if old is None: os.environ.pop("YOOKASSA_ENABLED", None) else: os.environ["YOOKASSA_ENABLED"] = old os.environ.pop("YOOKASSA_SHOP_ID", None) os.environ.pop("YOOKASSA_SECRET_KEY", None) os.environ.pop("YOOKASSA_RETURN_BASE_URL", None) os.environ.pop("BILLING_RETURN_JWT_SECRET", None) os.environ.pop("PLANS_ENABLED", None) get_settings.cache_clear() @respx.mock async def test_subscription_checkout_and_webhook_activation( 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-sub-1", "status": "pending", "confirmation": {"confirmation_url": "https://yoo.test/sub1"}, }, ) ) r = await client.post( "/api/v1/billing/checkout", json={"kind": "subscription", "plan_code": "pro"}, headers={"Authorization": f"Bearer {token}"}, ) assert r.status_code == 201, r.text invoice_id = uuid.UUID(r.json()["invoice_id"]) respx.get("https://api.yookassa.ru/v3/payments/pay-sub-1").mock( return_value=httpx.Response( 200, json={ "id": "pay-sub-1", "status": "succeeded", "paid": True, "amount": {"value": "1490.00", "currency": "RUB"}, "metadata": { "invoice_id": str(invoice_id), "kind": "subscription", "plan_code": "pro", }, }, ) ) wh = await client.post( "/api/v1/webhooks/yookassa", json={"event": "payment.succeeded", "object": {"id": "pay-sub-1"}}, headers={"Authorization": f"Basic {_basic()}"}, ) assert wh.status_code == 200, wh.text 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 == "succeeded" sub = ( await s.execute( text( "SELECT plan_code, status FROM subscriptions " "WHERE user_id = (SELECT id FROM users WHERE telegram_id = :t)" ), {"t": tg}, ) ).first() assert sub is not None assert sub[0] == "pro" assert sub[1] == "active" overview = await client.get("/api/v1/me/overview", headers={"Authorization": f"Bearer {token}"}) assert overview.status_code == 200 assert overview.json()["plan"]["code"] == "pro" @respx.mock async def test_double_subscription_buy_rejected( 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-sub-2", "status": "pending", "confirmation": {"confirmation_url": "https://yoo.test/sub2"}, }, ) ) r1 = await client.post( "/api/v1/billing/checkout", json={"kind": "subscription", "plan_code": "pro"}, headers={"Authorization": f"Bearer {token}"}, ) assert r1.status_code == 201 invoice_id = uuid.UUID(r1.json()["invoice_id"]) respx.get("https://api.yookassa.ru/v3/payments/pay-sub-2").mock( return_value=httpx.Response( 200, json={ "id": "pay-sub-2", "status": "succeeded", "paid": True, "amount": {"value": "1490.00", "currency": "RUB"}, "metadata": { "invoice_id": str(invoice_id), "kind": "subscription", "plan_code": "pro", }, }, ) ) await client.post( "/api/v1/webhooks/yookassa", json={"event": "payment.succeeded", "object": {"id": "pay-sub-2"}}, headers={"Authorization": f"Basic {_basic()}"}, ) r2 = await client.post( "/api/v1/billing/checkout", json={"kind": "subscription", "plan_code": "pro"}, headers={"Authorization": f"Bearer {token}"}, ) assert r2.status_code == 409, r2.text @respx.mock async def test_subscription_unknown_plan_rejected( 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-sub-b2b", "status": "pending", "confirmation": {"confirmation_url": "https://yoo.test/b2b"}, }, ) ) r = await client.post( "/api/v1/billing/checkout", json={"kind": "subscription", "plan_code": "enterprise"}, headers={"Authorization": f"Bearer {token}"}, ) assert r.status_code == 422, r.text @respx.mock async def test_subscription_b2b_only_rejected( client: httpx.AsyncClient, infra: dict[str, str], yookassa_env, ) -> None: tg = _unique_tg() token = await user_token(client, infra, tg) # "max" is active and public, so the test must use a code that is unknown # to trigger the rejection. v1 does not have an is_b2b_only column. respx.post("https://api.yookassa.ru/v3/payments").mock( return_value=httpx.Response( 200, json={ "id": "pay-sub-b2b", "status": "pending", "confirmation": {"confirmation_url": "https://yoo.test/b2b"}, }, ) ) r = await client.post( "/api/v1/billing/checkout", json={"kind": "subscription", "plan_code": "b2b-only"}, headers={"Authorization": f"Bearer {token}"}, ) assert r.status_code == 422, r.text @respx.mock async def test_autorenew_toggle( client: httpx.AsyncClient, infra: dict[str, str], yookassa_env, ) -> None: tg = _unique_tg() token = await user_token(client, infra, tg) r = await client.post( "/api/v1/billing/subscriptions/autorenew", json={"enabled": True}, headers={"Authorization": f"Bearer {token}"}, ) assert r.status_code == 404 respx.post("https://api.yookassa.ru/v3/payments").mock( return_value=httpx.Response( 200, json={ "id": "pay-sub-3", "status": "pending", "confirmation": {"confirmation_url": "https://yoo.test/sub3"}, }, ) ) r = await client.post( "/api/v1/billing/checkout", json={"kind": "subscription", "plan_code": "lite"}, headers={"Authorization": f"Bearer {token}"}, ) assert r.status_code == 201 invoice_id = uuid.UUID(r.json()["invoice_id"]) respx.get("https://api.yookassa.ru/v3/payments/pay-sub-3").mock( return_value=httpx.Response( 200, json={ "id": "pay-sub-3", "status": "succeeded", "paid": True, "amount": {"value": "490.00", "currency": "RUB"}, "metadata": { "invoice_id": str(invoice_id), "kind": "subscription", "plan_code": "lite", }, }, ) ) await client.post( "/api/v1/webhooks/yookassa", json={"event": "payment.succeeded", "object": {"id": "pay-sub-3"}}, headers={"Authorization": f"Basic {_basic()}"}, ) r = await client.post( "/api/v1/billing/subscriptions/autorenew", json={"enabled": True}, headers={"Authorization": f"Bearer {token}"}, ) assert r.status_code == 200 assert r.json()["auto_renew"] is True sub = await client.get( "/api/v1/billing/subscription", headers={"Authorization": f"Bearer {token}"}, ) assert sub.status_code == 200 assert sub.json()["auto_renew"] is True r = await client.post( "/api/v1/billing/subscriptions/autorenew", json={"enabled": False}, headers={"Authorization": f"Bearer {token}"}, ) assert r.json()["auto_renew"] is False