mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 03:40:26 +00:00
Fix auto-purchase subscription refresh after YooKassa top-up
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
"""Тесты для Telegram Stars-сценариев внутри PaymentService."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
import sys
|
||||
@@ -25,12 +27,17 @@ class DummyBot:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[Dict[str, Any]] = []
|
||||
self.sent_messages: list[Dict[str, Any]] = []
|
||||
|
||||
async def create_invoice_link(self, **kwargs: Any) -> str:
|
||||
"""Эмулируем создание платежной ссылки и сохраняем параметры вызова."""
|
||||
self.calls.append(kwargs)
|
||||
return "https://t.me/invoice/stars"
|
||||
|
||||
async def send_message(self, **kwargs: Any) -> None:
|
||||
"""Фиксируем отправленные сообщения пользователю."""
|
||||
self.sent_messages.append(kwargs)
|
||||
|
||||
|
||||
def _make_service(bot: Optional[DummyBot]) -> PaymentService:
|
||||
"""Создаёт экземпляр PaymentService без выполнения полного конструктора."""
|
||||
@@ -41,6 +48,80 @@ def _make_service(bot: Optional[DummyBot]) -> PaymentService:
|
||||
return service
|
||||
|
||||
|
||||
class DummySession:
|
||||
"""Минимальная заглушка AsyncSession для проверки сценариев Stars."""
|
||||
|
||||
def __init__(self, pending_subscription: "DummySubscription") -> None:
|
||||
self.pending_subscription = pending_subscription
|
||||
self.commits: int = 0
|
||||
self.refreshed: list[Any] = []
|
||||
|
||||
async def execute(self, *_args: Any, **_kwargs: Any) -> Any:
|
||||
class _Result:
|
||||
def __init__(self, subscription: "DummySubscription") -> None:
|
||||
self._subscription = subscription
|
||||
|
||||
def scalar_one_or_none(self) -> "DummySubscription":
|
||||
return self._subscription
|
||||
|
||||
return _Result(self.pending_subscription)
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
async def refresh(self, obj: Any) -> None:
|
||||
self.refreshed.append(obj)
|
||||
|
||||
|
||||
class DummySubscription:
|
||||
"""Упрощённая модель подписки для тестов."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
subscription_id: int,
|
||||
*,
|
||||
traffic_limit_gb: int = 0,
|
||||
device_limit: int = 1,
|
||||
period_days: int = 30,
|
||||
) -> None:
|
||||
self.id = subscription_id
|
||||
self.traffic_limit_gb = traffic_limit_gb
|
||||
self.device_limit = device_limit
|
||||
self.status = "pending"
|
||||
self.start_date = datetime(2024, 1, 1)
|
||||
self.end_date = self.start_date + timedelta(days=period_days)
|
||||
|
||||
|
||||
class DummyUser:
|
||||
"""Минимальные данные пользователя для тестов Stars-покупки."""
|
||||
|
||||
def __init__(self, user_id: int = 501, telegram_id: int = 777) -> None:
|
||||
self.id = user_id
|
||||
self.telegram_id = telegram_id
|
||||
self.language = "ru"
|
||||
self.balance_kopeks = 0
|
||||
self.has_made_first_topup = False
|
||||
self.promo_group = None
|
||||
self.subscription = None
|
||||
|
||||
|
||||
class DummyTransaction:
|
||||
"""Локальная транзакция, созданная в тестах."""
|
||||
|
||||
def __init__(self, external_id: str) -> None:
|
||||
self.external_id = external_id
|
||||
|
||||
|
||||
class DummySubscriptionService:
|
||||
"""Заглушка SubscriptionService, запоминающая вызовы."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[Any, Any]] = []
|
||||
|
||||
async def create_remnawave_user(self, db: Any, subscription: Any) -> object:
|
||||
self.calls.append((db, subscription))
|
||||
return object()
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_create_stars_invoice_calculates_stars(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Количество звёзд должно рассчитываться по курсу с округлением вниз и нижним порогом 1."""
|
||||
@@ -140,3 +221,127 @@ async def test_create_stars_invoice_requires_bot() -> None:
|
||||
amount_kopeks=1000,
|
||||
description="Пополнение",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio("asyncio")
|
||||
async def test_process_stars_payment_simple_subscription_success(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Оплата простой подписки через Stars активирует pending подписку и уведомляет пользователя."""
|
||||
|
||||
bot = DummyBot()
|
||||
service = _make_service(bot)
|
||||
|
||||
pending_subscription = DummySubscription(subscription_id=321, device_limit=2)
|
||||
db = DummySession(pending_subscription)
|
||||
user = DummyUser(user_id=900, telegram_id=123456)
|
||||
activated_subscription = DummySubscription(subscription_id=321, device_limit=2)
|
||||
|
||||
transaction_holder: Dict[str, DummyTransaction] = {}
|
||||
|
||||
async def fake_create_transaction(**kwargs: Any) -> DummyTransaction:
|
||||
transaction = DummyTransaction(external_id=kwargs.get("external_id", ""))
|
||||
transaction_holder["value"] = transaction
|
||||
return transaction
|
||||
|
||||
async def fake_get_user_by_id(_db: Any, _user_id: int) -> DummyUser:
|
||||
return user
|
||||
|
||||
async def fake_activate_pending_subscription(
|
||||
db: Any,
|
||||
user_id: int,
|
||||
period_days: Optional[int] = None,
|
||||
) -> DummySubscription:
|
||||
activated_subscription.start_date = pending_subscription.start_date
|
||||
activated_subscription.end_date = activated_subscription.start_date + timedelta(
|
||||
days=period_days or 30
|
||||
)
|
||||
return activated_subscription
|
||||
|
||||
subscription_service_stub = DummySubscriptionService()
|
||||
admin_calls: list[Dict[str, Any]] = []
|
||||
|
||||
class AdminNotificationStub:
|
||||
def __init__(self, _bot: Any) -> None:
|
||||
self.bot = _bot
|
||||
|
||||
async def send_subscription_purchase_notification(
|
||||
self,
|
||||
db: Any,
|
||||
user_obj: Any,
|
||||
subscription: Any,
|
||||
transaction: Any,
|
||||
period_days: int,
|
||||
was_trial_conversion: bool,
|
||||
) -> None:
|
||||
admin_calls.append(
|
||||
{
|
||||
"user": user_obj,
|
||||
"subscription": subscription,
|
||||
"transaction": transaction,
|
||||
"period": period_days,
|
||||
"was_trial": was_trial_conversion,
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.payment.stars.create_transaction",
|
||||
fake_create_transaction,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.payment.stars.get_user_by_id",
|
||||
fake_get_user_by_id,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.database.crud.subscription.activate_pending_subscription",
|
||||
fake_activate_pending_subscription,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_service.SubscriptionService",
|
||||
lambda: subscription_service_stub,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.admin_notification_service.AdminNotificationService",
|
||||
AdminNotificationStub,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
type(settings),
|
||||
"format_price",
|
||||
lambda self, amount: f"{amount / 100:.0f}₽",
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
settings,
|
||||
"SIMPLE_SUBSCRIPTION_PERIOD_DAYS",
|
||||
30,
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.payment.stars.TelegramStarsService.calculate_rubles_from_stars",
|
||||
lambda stars: Decimal("100"),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
payload = f"simple_sub_{user.id}_{pending_subscription.id}_30"
|
||||
result = await service.process_stars_payment(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
stars_amount=5,
|
||||
payload=payload,
|
||||
telegram_payment_charge_id="charge12345",
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert user.balance_kopeks == 0, "Баланс не должен меняться при оплате подписки"
|
||||
assert subscription_service_stub.calls == [(db, activated_subscription)]
|
||||
assert len(admin_calls) == 1
|
||||
assert admin_calls[0]["subscription"] is activated_subscription
|
||||
assert admin_calls[0]["period"] == 30
|
||||
assert bot.sent_messages, "Пользователь должен получить уведомление"
|
||||
assert "Подписка успешно активирована" in bot.sent_messages[0]["text"]
|
||||
assert transaction_holder["value"].external_id == "charge12345"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from app.config import settings
|
||||
@@ -130,7 +131,6 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch):
|
||||
details=base_pricing.details,
|
||||
)
|
||||
|
||||
class DummyPurchaseService:
|
||||
async def submit_purchase(self, db, prepared_context, pricing):
|
||||
return {
|
||||
"subscription": MagicMock(),
|
||||
@@ -143,10 +143,6 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch):
|
||||
"app.services.subscription_auto_purchase_service.MiniAppSubscriptionPurchaseService",
|
||||
lambda: DummyMiniAppService(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.SubscriptionPurchaseService",
|
||||
lambda: DummyPurchaseService(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart",
|
||||
AsyncMock(return_value=cart_data),
|
||||
@@ -187,3 +183,118 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch):
|
||||
clear_draft_mock.assert_awaited_once_with(user.id)
|
||||
bot.send_message.assert_awaited()
|
||||
admin_service_mock.send_subscription_purchase_notification.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_purchase_saved_cart_after_topup_extension(monkeypatch):
|
||||
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
|
||||
|
||||
subscription = MagicMock()
|
||||
subscription.id = 99
|
||||
subscription.is_trial = False
|
||||
subscription.status = "active"
|
||||
subscription.end_date = datetime.utcnow()
|
||||
subscription.device_limit = 1
|
||||
subscription.traffic_limit_gb = 100
|
||||
subscription.connected_squads = ["squad-a"]
|
||||
|
||||
user = MagicMock(spec=User)
|
||||
user.id = 7
|
||||
user.telegram_id = 7007
|
||||
user.balance_kopeks = 200_000
|
||||
user.language = "ru"
|
||||
user.subscription = subscription
|
||||
|
||||
cart_data = {
|
||||
"cart_mode": "extend",
|
||||
"subscription_id": subscription.id,
|
||||
"period_days": 30,
|
||||
"total_price": 31_000,
|
||||
"description": "Продление подписки на 30 дней",
|
||||
"device_limit": 2,
|
||||
"traffic_limit_gb": 500,
|
||||
"squad_uuid": "squad-b",
|
||||
"consume_promo_offer": True,
|
||||
}
|
||||
|
||||
subtract_mock = AsyncMock(return_value=True)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.subtract_user_balance",
|
||||
subtract_mock,
|
||||
)
|
||||
|
||||
async def extend_stub(db, current_subscription, days):
|
||||
current_subscription.end_date = current_subscription.end_date + timedelta(days=days)
|
||||
return current_subscription
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.extend_subscription",
|
||||
extend_stub,
|
||||
)
|
||||
|
||||
create_transaction_mock = AsyncMock(return_value=MagicMock())
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.create_transaction",
|
||||
create_transaction_mock,
|
||||
)
|
||||
|
||||
service_mock = MagicMock()
|
||||
service_mock.update_remnawave_user = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.SubscriptionService",
|
||||
lambda: service_mock,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart",
|
||||
AsyncMock(return_value=cart_data),
|
||||
)
|
||||
delete_cart_mock = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart",
|
||||
delete_cart_mock,
|
||||
)
|
||||
clear_draft_mock = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft",
|
||||
clear_draft_mock,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.get_texts",
|
||||
lambda lang: DummyTexts(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.format_period_description",
|
||||
lambda days, lang: f"{days} дней",
|
||||
)
|
||||
|
||||
admin_service_mock = MagicMock()
|
||||
admin_service_mock.send_subscription_extension_notification = AsyncMock()
|
||||
monkeypatch.setattr(
|
||||
"app.services.subscription_auto_purchase_service.AdminNotificationService",
|
||||
lambda bot: admin_service_mock,
|
||||
)
|
||||
|
||||
bot = AsyncMock()
|
||||
db_session = AsyncMock(spec=AsyncSession)
|
||||
|
||||
result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot)
|
||||
|
||||
assert result is True
|
||||
subtract_mock.assert_awaited_once_with(
|
||||
db_session,
|
||||
user,
|
||||
cart_data["total_price"],
|
||||
cart_data["description"],
|
||||
consume_promo_offer=True,
|
||||
)
|
||||
assert subscription.device_limit == 2
|
||||
assert subscription.traffic_limit_gb == 500
|
||||
assert "squad-b" in subscription.connected_squads
|
||||
delete_cart_mock.assert_awaited_once_with(user.id)
|
||||
clear_draft_mock.assert_awaited_once_with(user.id)
|
||||
admin_service_mock.send_subscription_extension_notification.assert_awaited()
|
||||
bot.send_message.assert_awaited()
|
||||
service_mock.update_remnawave_user.assert_awaited()
|
||||
create_transaction_mock.assert_awaited()
|
||||
|
||||
Reference in New Issue
Block a user