Files
remnawave-bedolaga-telegram…/tests/test_subscription_cart_integration.py
Pavel Stryuk d30d1e2a29 1) Отображение скидки на кнопках (красивое!)
2) У промогрупп появится приоритет
3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом
4) Промокоды с промогруппой
5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке
6) Исправить баг исчезновения триалки при пополнении
7) Исправить падающие тесты и добавить новых
8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
9) При попытке изменить промогруппу "Пользователь не найден" - исправил
2025-11-04 20:52:17 +01:00

395 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from aiogram.fsm.context import FSMContext
from aiogram.types import (
CallbackQuery,
User as TgUser,
Message,
InlineKeyboardButton,
InlineKeyboardMarkup,
)
from sqlalchemy.ext.asyncio import AsyncSession
from app.handlers.subscription.purchase import save_cart_and_redirect_to_topup, return_to_saved_cart, clear_saved_cart
from app.handlers.subscription.autopay import handle_subscription_cancel
from app.database.models import User, Subscription
@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("❌ Покупка отменена")