import pytest from datetime import datetime, timedelta from unittest.mock import AsyncMock, MagicMock from app.config import settings from app.database.models import User from app.services.subscription_auto_purchase_service import auto_purchase_saved_cart_after_topup from app.services.subscription_purchase_service import ( PurchaseDevicesConfig, PurchaseOptionsContext, PurchasePeriodConfig, PurchasePricingResult, PurchaseSelection, PurchaseServersConfig, PurchaseTrafficConfig, ) from sqlalchemy.ext.asyncio import AsyncSession class DummyTexts: def t(self, key: str, default: str): return default def format_price(self, value: int) -> str: return f"{value / 100:.0f} ₽" async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch): monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True) user = MagicMock(spec=User) user.id = 42 user.telegram_id = 4242 user.balance_kopeks = 200_000 user.language = "ru" user.subscription = None user.get_primary_promo_group = MagicMock(return_value=None) cart_data = { "period_days": 30, "countries": ["ru"], "traffic_gb": 0, "devices": 1, } traffic_config = PurchaseTrafficConfig( selectable=False, mode="fixed", options=[], default_value=0, current_value=0, ) servers_config = PurchaseServersConfig( options=[], min_selectable=0, max_selectable=0, default_selection=["ru"], ) devices_config = PurchaseDevicesConfig( minimum=1, maximum=5, default=1, current=1, price_per_device=0, discounted_price_per_device=0, price_label="0 ₽", ) period_config = PurchasePeriodConfig( id="days:30", days=30, months=1, label="30 дней", base_price=100_000, base_price_label="1000 ₽", base_price_original=100_000, base_price_original_label=None, discount_percent=0, per_month_price=100_000, per_month_price_label="1000 ₽", traffic=traffic_config, servers=servers_config, devices=devices_config, ) context = PurchaseOptionsContext( user=user, subscription=None, currency="RUB", balance_kopeks=user.balance_kopeks, periods=[period_config], default_period=period_config, period_map={"days:30": period_config}, server_uuid_to_id={"ru": 1}, payload={}, ) base_pricing = PurchasePricingResult( selection=PurchaseSelection( period=period_config, traffic_value=0, servers=["ru"], devices=1, ), server_ids=[1], server_prices_for_period=[100_000], base_original_total=100_000, discounted_total=100_000, promo_discount_value=0, promo_discount_percent=0, final_total=100_000, months=1, details={"servers_individual_prices": [100_000]}, ) class DummyMiniAppService: async def build_options(self, db, user): return context async def calculate_pricing(self, db, ctx, selection): return PurchasePricingResult( selection=selection, server_ids=base_pricing.server_ids, server_prices_for_period=base_pricing.server_prices_for_period, base_original_total=base_pricing.base_original_total, discounted_total=base_pricing.discounted_total, promo_discount_value=base_pricing.promo_discount_value, promo_discount_percent=base_pricing.promo_discount_percent, final_total=base_pricing.final_total, months=base_pricing.months, details=base_pricing.details, ) async def submit_purchase(self, db, prepared_context, pricing): return { "subscription": MagicMock(), "transaction": MagicMock(), "was_trial_conversion": False, "message": "🎉 Subscription purchased", } monkeypatch.setattr( "app.services.subscription_auto_purchase_service.MiniAppSubscriptionPurchaseService", lambda: DummyMiniAppService(), ) 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_purchase_notification = AsyncMock() monkeypatch.setattr( "app.services.subscription_auto_purchase_service.AdminNotificationService", lambda bot: admin_service_mock, ) # Мокаем get_user_by_id чтобы вернуть того же user monkeypatch.setattr( "app.services.subscription_auto_purchase_service.get_user_by_id", AsyncMock(return_value=user), ) bot = AsyncMock() db_session = AsyncMock(spec=AsyncSession) result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot) assert result is True delete_cart_mock.assert_awaited_once_with(user.id) clear_draft_mock.assert_awaited_once_with(user.id) bot.send_message.assert_awaited() admin_service_mock.send_subscription_purchase_notification.assert_awaited() 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 user.get_primary_promo_group = MagicMock(return_value=None) 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, ) # Мок для get_subscription_by_user_id monkeypatch.setattr( "app.database.crud.subscription.get_subscription_by_user_id", AsyncMock(return_value=subscription), ) 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() async def test_auto_purchase_trial_preserved_on_insufficient_balance(monkeypatch): """Тест: триал сохраняется, если не хватает денег для автопокупки""" monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True) subscription = MagicMock() subscription.id = 123 subscription.is_trial = True # Триальная подписка! subscription.status = "active" subscription.end_date = datetime.utcnow() + timedelta(days=2) # Осталось 2 дня subscription.device_limit = 1 subscription.traffic_limit_gb = 10 subscription.connected_squads = [] user = MagicMock(spec=User) user.id = 99 user.telegram_id = 9999 # ИСПРАВЛЕНО: Баланс достаточный для первой проверки (строка 243), # но subtract_user_balance вернёт False (симуляция неудачи списания) user.balance_kopeks = 60_000 user.language = "ru" user.subscription = subscription user.get_primary_promo_group = MagicMock(return_value=None) cart_data = { "cart_mode": "extend", "subscription_id": subscription.id, "period_days": 30, "total_price": 50_000, "description": "Продление на 30 дней", "device_limit": 1, "traffic_limit_gb": 100, "squad_uuid": None, "consume_promo_offer": False, } # Mock: недостаточно денег, списание не удалось subtract_mock = AsyncMock(return_value=False) monkeypatch.setattr( "app.services.subscription_auto_purchase_service.subtract_user_balance", subtract_mock, ) monkeypatch.setattr( "app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart", AsyncMock(return_value=cart_data), ) 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} дней", ) monkeypatch.setattr( "app.services.subscription_auto_purchase_service.format_local_datetime", lambda dt, fmt: dt.strftime(fmt) if dt else "", ) 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, ) # Мок для get_subscription_by_user_id monkeypatch.setattr( "app.database.crud.subscription.get_subscription_by_user_id", AsyncMock(return_value=subscription), ) db_session = AsyncMock(spec=AsyncSession) bot = AsyncMock() result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot) # Проверки assert result is False # Автопокупка не удалась assert subscription.is_trial is True # ТРИАЛ СОХРАНЁН! subtract_mock.assert_awaited_once() async def test_auto_purchase_trial_converted_after_successful_extension(monkeypatch): """Тест: триал конвертируется в платную подписку ТОЛЬКО после успешного продления""" monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True) subscription = MagicMock() subscription.id = 456 subscription.is_trial = True # Триальная подписка! subscription.status = "active" subscription.end_date = datetime.utcnow() + timedelta(days=1) subscription.device_limit = 1 subscription.traffic_limit_gb = 10 subscription.connected_squads = [] user = MagicMock(spec=User) user.id = 88 user.telegram_id = 8888 user.balance_kopeks = 200_000 # Достаточно денег user.language = "ru" user.subscription = subscription user.get_primary_promo_group = MagicMock(return_value=None) cart_data = { "cart_mode": "extend", "subscription_id": subscription.id, "period_days": 30, "total_price": 100_000, "description": "Продление на 30 дней", "device_limit": 2, "traffic_limit_gb": 500, "squad_uuid": None, "consume_promo_offer": False, } # Mock: деньги списались успешно subtract_mock = AsyncMock(return_value=True) monkeypatch.setattr( "app.services.subscription_auto_purchase_service.subtract_user_balance", subtract_mock, ) # 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), ) monkeypatch.setattr( "app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart", AsyncMock(), ) monkeypatch.setattr( "app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft", AsyncMock(), ) 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} дней", ) # ИСПРАВЛЕНО: Добавлен мок для format_local_datetime monkeypatch.setattr( "app.services.subscription_auto_purchase_service.format_local_datetime", lambda dt, fmt: dt.strftime(fmt) if dt else "", ) 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, ) # Мок для get_subscription_by_user_id monkeypatch.setattr( "app.database.crud.subscription.get_subscription_by_user_id", AsyncMock(return_value=subscription), ) db_session = AsyncMock(spec=AsyncSession) db_session.commit = AsyncMock() # Важно! Отслеживаем commit db_session.refresh = AsyncMock() # ИСПРАВЛЕНО: Добавлен мок для refresh bot = AsyncMock() result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot) # Проверки assert result is True # Автопокупка успешна assert subscription.is_trial is False # ТРИАЛ КОНВЕРТИРОВАН! assert subscription.status == "active" db_session.commit.assert_awaited() # Commit был вызван async def test_auto_purchase_trial_preserved_on_extension_failure(monkeypatch): """Тест: триал НЕ конвертируется и вызывается rollback при ошибке в extend_subscription""" monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True) subscription = MagicMock() subscription.id = 789 subscription.is_trial = True # Триальная подписка! subscription.status = "active" subscription.end_date = datetime.utcnow() + timedelta(days=3) subscription.device_limit = 1 subscription.traffic_limit_gb = 10 subscription.connected_squads = [] user = MagicMock(spec=User) user.id = 77 user.telegram_id = 7777 user.balance_kopeks = 200_000 # Достаточно денег user.language = "ru" user.subscription = subscription user.get_primary_promo_group = MagicMock(return_value=None) cart_data = { "cart_mode": "extend", "subscription_id": subscription.id, "period_days": 30, "total_price": 100_000, "description": "Продление на 30 дней", "device_limit": 1, "traffic_limit_gb": 100, "squad_uuid": None, "consume_promo_offer": False, } # Mock: деньги списались успешно subtract_mock = AsyncMock(return_value=True) monkeypatch.setattr( "app.services.subscription_auto_purchase_service.subtract_user_balance", subtract_mock, ) # Mock: extend_subscription выбрасывает ошибку! async def extend_error(db, current_subscription, days): raise Exception("Database connection error") monkeypatch.setattr( "app.services.subscription_auto_purchase_service.extend_subscription", extend_error, ) monkeypatch.setattr( "app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart", AsyncMock(return_value=cart_data), ) # ИСПРАВЛЕНО: Добавлены недостающие моки 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} дней", ) monkeypatch.setattr( "app.services.subscription_auto_purchase_service.format_local_datetime", lambda dt, fmt: dt.strftime(fmt) if dt else "", ) 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, ) # Мок для get_subscription_by_user_id monkeypatch.setattr( "app.database.crud.subscription.get_subscription_by_user_id", AsyncMock(return_value=subscription), ) db_session = AsyncMock(spec=AsyncSession) db_session.rollback = AsyncMock() # Важно! Отслеживаем rollback db_session.refresh = AsyncMock() # ИСПРАВЛЕНО: Добавлен мок для refresh bot = AsyncMock() result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot) # Проверки assert result is False # Автопокупка не удалась assert subscription.is_trial is True # ТРИАЛ СОХРАНЁН! db_session.rollback.assert_awaited() # ROLLBACK БЫЛ ВЫЗВАН! async def test_auto_purchase_trial_remaining_days_transferred(monkeypatch): """Тест: остаток триала переносится на платную подписку при TRIAL_ADD_REMAINING_DAYS_TO_PAID=True""" monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True) monkeypatch.setattr(settings, "TRIAL_ADD_REMAINING_DAYS_TO_PAID", True) # Включено! now = datetime.utcnow() trial_end = now + timedelta(days=2) # Осталось 2 дня триала subscription = MagicMock() subscription.id = 321 subscription.is_trial = True subscription.status = "active" subscription.end_date = trial_end subscription.start_date = now - timedelta(days=1) # Триал начался вчера subscription.device_limit = 1 subscription.traffic_limit_gb = 10 subscription.connected_squads = [] user = MagicMock(spec=User) user.id = 66 user.telegram_id = 6666 user.balance_kopeks = 200_000 user.language = "ru" user.subscription = subscription user.get_primary_promo_group = MagicMock(return_value=None) cart_data = { "cart_mode": "extend", "subscription_id": subscription.id, "period_days": 30, # Покупает 30 дней "total_price": 100_000, "description": "Продление на 30 дней", "device_limit": 1, "traffic_limit_gb": 100, "squad_uuid": None, "consume_promo_offer": False, } subtract_mock = AsyncMock(return_value=True) monkeypatch.setattr( "app.services.subscription_auto_purchase_service.subtract_user_balance", subtract_mock, ) # Mock: extend_subscription с логикой переноса бонусных дней # Имитируем нашу новую логику из extend_subscription() async def extend_with_bonus(db, current_subscription, days): # Вычисляем бонусные дни (как в нашем коде) bonus_days = 0 if current_subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID: if current_subscription.end_date and current_subscription.end_date > now: remaining = current_subscription.end_date - now if remaining.total_seconds() > 0: bonus_days = max(0, remaining.days) total_days = days + bonus_days current_subscription.end_date = current_subscription.end_date + timedelta(days=total_days) return current_subscription monkeypatch.setattr( "app.services.subscription_auto_purchase_service.extend_subscription", extend_with_bonus, ) 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), ) monkeypatch.setattr( "app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart", AsyncMock(), ) monkeypatch.setattr( "app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft", AsyncMock(), ) 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} дней", ) monkeypatch.setattr( "app.services.subscription_auto_purchase_service.format_local_datetime", lambda dt, fmt: dt.strftime(fmt), ) 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, ) # Мок для get_subscription_by_user_id monkeypatch.setattr( "app.database.crud.subscription.get_subscription_by_user_id", AsyncMock(return_value=subscription), ) db_session = AsyncMock(spec=AsyncSession) db_session.commit = AsyncMock() db_session.refresh = AsyncMock() # ИСПРАВЛЕНО: Добавлен мок для refresh bot = AsyncMock() result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot) # Проверки assert result is True assert subscription.is_trial is False # Триал конвертирован # Проверяем, что подписка продлена на 32 дня (30 + 2 бонусных) # end_date должна быть примерно на 32 дня от оригинального trial_end expected_end = trial_end + timedelta(days=32) # trial_end + (30 + 2) actual_delta = (subscription.end_date - trial_end).days assert actual_delta == 32, f"Expected 32 days extension (30 + 2 bonus), got {actual_delta}"