from unittest.mock import AsyncMock, MagicMock, patch import pytest from aiogram.fsm.context import FSMContext from aiogram.types import ( CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, ) from sqlalchemy.ext.asyncio import AsyncSession from app.database.models import User from app.handlers.subscription.autopay import handle_subscription_cancel from app.handlers.subscription.purchase import clear_saved_cart, return_to_saved_cart, save_cart_and_redirect_to_topup @pytest.fixture def mock_callback_query(): callback = AsyncMock(spec=CallbackQuery) callback.message = AsyncMock(spec=Message) callback.message.edit_text = AsyncMock() callback.answer = AsyncMock() callback.data = 'subscription_confirm' return callback @pytest.fixture def mock_user(): user = AsyncMock(spec=User) user.id = 12345 user.telegram_id = 12345 user.language = 'ru' user.balance_kopeks = 10000 user.subscription = None user.has_had_paid_subscription = False user.promo_group_id = None user.get_primary_promo_group = MagicMock(return_value=None) user.get_promo_discount = MagicMock(return_value=0) user.promo_offer_discount_percent = 0 user.promo_offer_discount_expires_at = None return user @pytest.fixture def mock_db(): db = AsyncMock(spec=AsyncSession) return db @pytest.fixture def mock_state(): state = AsyncMock(spec=FSMContext) state.get_data = AsyncMock( return_value={'period_days': 30, 'countries': ['ru'], 'devices': 2, 'traffic_gb': 10, 'total_price': 50000} ) state.set_data = AsyncMock() state.update_data = AsyncMock() state.set_state = AsyncMock() state.clear = AsyncMock() return state async def test_save_cart_and_redirect_to_topup(mock_callback_query, mock_state, mock_user, mock_db): """Тест сохранения корзины и перенаправления к пополнению""" # Мокаем все зависимости with ( patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, patch('app.handlers.subscription.purchase.get_payment_methods_keyboard_with_cart') as mock_keyboard_func, patch('app.localization.texts.get_texts') as mock_get_texts, ): # Подготовим моки mock_cart_service.save_user_cart = AsyncMock(return_value=True) mock_keyboard = InlineKeyboardMarkup( inline_keyboard=[[InlineKeyboardButton(text='✅', callback_data='confirm')]] ) mock_keyboard_func.return_value = mock_keyboard # Подготовим тексты mock_texts = AsyncMock() mock_texts.format_price = lambda x: f'{x / 100:.0f} ₽' mock_get_texts.return_value = mock_texts missing_amount = 40000 # 50000 - 10000 = 40000 # Вызываем функцию await save_cart_and_redirect_to_topup(mock_callback_query, mock_state, mock_user, missing_amount) # Проверяем, что данные были сохранены в корзину mock_cart_service.save_user_cart.assert_called_once() args, kwargs = mock_cart_service.save_user_cart.call_args saved_user_id, saved_cart_data = args assert saved_user_id == mock_user.id assert saved_cart_data['period_days'] == 30 assert saved_cart_data['countries'] == ['ru'] assert saved_cart_data['devices'] == 2 assert saved_cart_data['traffic_gb'] == 10 assert saved_cart_data['total_price'] == 50000 assert saved_cart_data['saved_cart'] is True assert saved_cart_data['missing_amount'] == missing_amount assert saved_cart_data['return_to_cart'] is True assert saved_cart_data['user_id'] == mock_user.id # Проверяем, что сообщение было отредактировано mock_callback_query.message.edit_text.assert_called_once() # В этой функции нет вызова callback.answer() # mock_callback_query.answer не должен быть вызван mock_callback_query.answer.assert_not_called() async def test_return_to_saved_cart_success(mock_callback_query, mock_state, mock_user, mock_db): """Тест возврата к сохраненной корзине с достаточным балансом""" # Подготовим данные корзины cart_data = { 'period_days': 30, 'countries': ['ru', 'us'], 'devices': 3, 'traffic_gb': 20, 'total_price': 30000, # Меньше, чем баланс пользователя (50000) 'saved_cart': True, 'user_id': mock_user.id, } # Мокаем все зависимости with ( patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, patch('app.handlers.subscription.purchase._get_available_countries') as mock_get_countries, patch('app.handlers.subscription.purchase.format_period_description') as mock_format_period, patch('app.localization.texts.get_texts') as mock_get_texts, patch('app.handlers.subscription.purchase.get_subscription_confirm_keyboard_with_cart') as mock_keyboard_func, patch('app.handlers.subscription.purchase._prepare_subscription_summary') as mock_prepare_summary, ): # Подготовим моки mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data) mock_cart_service.save_user_cart = AsyncMock(return_value=True) mock_prepare_summary.return_value = ('summary', {}) mock_get_countries.return_value = [{'uuid': 'ru', 'name': 'Russia'}, {'uuid': 'us', 'name': 'USA'}] mock_format_period.return_value = '30 дней' mock_keyboard = InlineKeyboardMarkup( inline_keyboard=[[InlineKeyboardButton(text='✅', callback_data='confirm')]] ) mock_keyboard_func.return_value = mock_keyboard # Подготовим тексты mock_texts = AsyncMock() mock_texts.format_price = lambda x: f'{x / 100:.0f} ₽' mock_get_texts.return_value = mock_texts # Увеличиваем баланс пользователя, чтобы его хватило mock_user.balance_kopeks = 50000 # Вызываем функцию await return_to_saved_cart(mock_callback_query, mock_state, mock_user, mock_db) # Проверяем, что корзина была загружена mock_cart_service.get_user_cart.assert_called_once_with(mock_user.id) # Проверяем, что сообщение было отредактировано mock_callback_query.message.edit_text.assert_called_once() # В успешном сценарии вызывается callback.answer() mock_callback_query.answer.assert_called_once() async def test_return_to_saved_cart_skips_edit_when_message_matches( mock_callback_query, mock_state, mock_user, mock_db, ): cart_data = { 'period_days': 60, 'countries': ['ru', 'us'], 'devices': 3, 'traffic_gb': 40, 'total_price': 44000, 'saved_cart': True, 'user_id': mock_user.id, } confirm_keyboard = InlineKeyboardMarkup( inline_keyboard=[[InlineKeyboardButton(text='Подтвердить', callback_data='confirm')]] ) existing_keyboard = InlineKeyboardMarkup( inline_keyboard=[[InlineKeyboardButton(text='Подтвердить', callback_data='confirm')]] ) with ( patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, patch('app.handlers.subscription.purchase._get_available_countries') as mock_get_countries, patch('app.handlers.subscription.purchase.format_period_description') as mock_format_period, patch('app.localization.texts.get_texts') as mock_get_texts, patch('app.handlers.subscription.purchase.get_subscription_confirm_keyboard_with_cart') as mock_keyboard_func, patch('app.handlers.subscription.purchase.settings') as mock_settings, ): mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data) mock_cart_service.save_user_cart = AsyncMock() mock_get_countries.return_value = [ {'uuid': 'ru', 'name': 'Russia'}, {'uuid': 'us', 'name': 'USA'}, ] mock_format_period.return_value = '60 дней' mock_keyboard_func.return_value = confirm_keyboard mock_texts = AsyncMock() mock_texts.format_price = lambda x: f'{x / 100:.0f} ₽' mock_get_texts.return_value = mock_texts mock_settings.is_devices_selection_enabled.return_value = True mock_settings.is_traffic_fixed.return_value = False mock_user.balance_kopeks = 50000 summary_text = ( '🛒 Восстановленная корзина\n\n' '📅 Период: 60 дней\n' '📊 Трафик: 40 ГБ\n' '🌍 Страны: Russia, USA\n' '📱 Устройства: 3\n\n' '💎 Общая стоимость: 440 ₽\n\n' 'Подтверждаете покупку?' ) mock_callback_query.message.text = summary_text mock_callback_query.message.reply_markup = existing_keyboard await return_to_saved_cart(mock_callback_query, mock_state, mock_user, mock_db) mock_callback_query.message.edit_text.assert_not_called() mock_callback_query.answer.assert_called_once_with('✅ Корзина восстановлена!') mock_state.set_data.assert_called_once_with(cart_data) mock_state.set_state.assert_called_once() mock_cart_service.save_user_cart.assert_not_called() async def test_return_to_saved_cart_normalizes_devices_when_disabled( mock_callback_query, mock_state, mock_user, mock_db, ): cart_data = { 'period_days': 30, 'countries': ['ru', 'us'], 'devices': 5, 'traffic_gb': 20, 'total_price': 45000, 'total_devices_price': 15000, 'saved_cart': True, 'user_id': mock_user.id, } sanitized_summary_data = { 'period_days': 30, 'countries': ['ru', 'us'], 'devices': 3, 'traffic_gb': 20, 'total_price': 30000, 'total_devices_price': 0, } with ( patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, patch('app.handlers.subscription.purchase._get_available_countries') as mock_get_countries, patch('app.handlers.subscription.purchase.format_period_description') as mock_format_period, patch('app.localization.texts.get_texts') as mock_get_texts, patch('app.handlers.subscription.purchase.get_subscription_confirm_keyboard_with_cart') as mock_keyboard_func, patch('app.handlers.subscription.purchase.settings') as mock_settings, patch( 'app.handlers.subscription.pricing._prepare_subscription_summary', new=AsyncMock(return_value=('ignored', sanitized_summary_data)), ), ): mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data) mock_cart_service.save_user_cart = AsyncMock() mock_get_countries.return_value = [{'uuid': 'ru', 'name': 'Russia'}, {'uuid': 'us', 'name': 'USA'}] mock_format_period.return_value = '30 дней' mock_keyboard = InlineKeyboardMarkup( inline_keyboard=[[InlineKeyboardButton(text='✅', callback_data='confirm')]] ) mock_keyboard_func.return_value = mock_keyboard mock_texts = AsyncMock() mock_texts.format_price = lambda x: f'{x / 100:.0f} ₽' mock_texts.t = lambda key, default=None: default or '' mock_get_texts.return_value = mock_texts mock_settings.is_devices_selection_enabled.return_value = False mock_settings.DEFAULT_DEVICE_LIMIT = 3 mock_settings.is_traffic_fixed.return_value = False mock_settings.get_fixed_traffic_limit.return_value = 0 mock_user.balance_kopeks = 60000 await return_to_saved_cart(mock_callback_query, mock_state, mock_user, mock_db) mock_cart_service.save_user_cart.assert_called_once() _, saved_payload = mock_cart_service.save_user_cart.call_args[0] assert saved_payload['devices'] == 3 assert saved_payload['total_price'] == 30000 assert saved_payload['saved_cart'] is True mock_state.set_data.assert_called_once() normalized_data = mock_state.set_data.call_args[0][0] assert normalized_data['devices'] == 3 assert normalized_data['total_price'] == 30000 assert normalized_data['saved_cart'] is True edited_text = mock_callback_query.message.edit_text.call_args[0][0] assert '📱' not in edited_text mock_callback_query.answer.assert_called_once() async def test_return_to_saved_cart_insufficient_funds(mock_callback_query, mock_state, mock_user, mock_db): """Тест возврата к сохраненной корзине с недостаточным балансом""" # Подготовим данные корзины cart_data = { 'period_days': 30, 'countries': ['ru', 'us'], 'devices': 3, 'traffic_gb': 20, 'total_price': 50000, # Больше, чем баланс пользователя (10000) 'saved_cart': True, 'user_id': mock_user.id, } # Мокаем все зависимости with ( patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, patch('app.localization.texts.get_texts') as mock_get_texts, patch('app.handlers.subscription.purchase.get_insufficient_balance_keyboard_with_cart') as mock_keyboard_func, ): # Подготовим моки mock_cart_service.get_user_cart = AsyncMock(return_value=cart_data) mock_cart_service.save_user_cart = AsyncMock(return_value=True) mock_keyboard = InlineKeyboardMarkup( inline_keyboard=[[InlineKeyboardButton(text='Пополнить', callback_data='topup')]] ) mock_keyboard_func.return_value = mock_keyboard # Подготовим тексты mock_texts = AsyncMock() mock_texts.format_price = lambda x: f'{x / 100:.0f} ₽' mock_texts.t = lambda key, default: default mock_get_texts.return_value = mock_texts # Баланс пользователя меньше стоимости подписки mock_user.balance_kopeks = 10000 # Вызываем функцию await return_to_saved_cart(mock_callback_query, mock_state, mock_user, mock_db) # Проверяем, что FSM не был изменен (данные не установлены) mock_state.set_data.assert_not_called() # Проверяем, что сообщение было отредактировано с сообщением о недостатке средств mock_callback_query.message.edit_text.assert_called_once() # В этой функции в сценарии недостатка средств вызова callback.answer() не происходит # (ответ отправляется через return до вызова callback.answer()) mock_callback_query.answer.assert_not_called() async def test_clear_saved_cart(mock_callback_query, mock_state, mock_user, mock_db): """Тест очистки сохраненной корзины""" # Мокаем все зависимости with ( patch('app.handlers.subscription.purchase.user_cart_service') as mock_cart_service, patch('app.handlers.menu.show_main_menu') as mock_show_main_menu, ): mock_cart_service.delete_user_cart = AsyncMock(return_value=True) mock_show_main_menu.return_value = AsyncMock() # Вызываем функцию await clear_saved_cart(mock_callback_query, mock_state, mock_user, mock_db) # Проверяем, что корзина удалена из сервиса mock_cart_service.delete_user_cart.assert_called_once_with(mock_user.id) # Проверяем, что FSM очищен mock_state.clear.assert_called_once() # Проверяем, что вызван answer mock_callback_query.answer.assert_called_once() async def test_handle_subscription_cancel_clears_saved_cart(mock_callback_query, mock_state, mock_user, mock_db): """Отмена покупки должна очищать сохраненную корзину""" mock_clear_draft = AsyncMock() mock_show_main_menu = AsyncMock() with ( patch('app.handlers.subscription.autopay.user_cart_service') as mock_cart_service, patch('app.handlers.subscription.autopay.clear_subscription_checkout_draft', new=mock_clear_draft), patch('app.localization.texts.get_texts', return_value=MagicMock()) as _, patch('app.handlers.menu.show_main_menu', new=mock_show_main_menu), ): mock_cart_service.delete_user_cart = AsyncMock(return_value=True) await handle_subscription_cancel(mock_callback_query, mock_state, mock_user, mock_db) mock_state.clear.assert_called_once() mock_clear_draft.assert_awaited_once_with(mock_user.id) mock_cart_service.delete_user_cart.assert_awaited_once_with(mock_user.id) mock_show_main_menu.assert_awaited_once_with(mock_callback_query, mock_user, mock_db) mock_callback_query.answer.assert_called_once_with('❌ Покупка отменена')