Files
remnawave-bedolaga-telegram…/tests/test_subscription_cart_integration.py
T
c0mrade 9a2aea038a chore: add uv package manager and ruff linter configuration
- Add pyproject.toml with uv and ruff configuration
- Pin Python version to 3.13 via .python-version
- Add Makefile commands: lint, format, fix
- Apply ruff formatting to entire codebase
- Remove unused imports (base64 in yookassa/simple_subscription)
- Update .gitignore for new config files
2026-01-24 17:45:27 +03:00

410 lines
18 KiB
Python

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('❌ Покупка отменена')