DealDocumentScreening/tests/integration/test_subscription_purchase.py

325 lines
9.7 KiB
Python

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