Подготовка к релизу

This commit is contained in:
2025-10-30 18:28:26 +03:00
parent 69c0162c6b
commit 4981db90a0
191 changed files with 45557 additions and 3651 deletions
+48
View File
@@ -0,0 +1,48 @@
# Тесты для Telegram Bot
Этот каталог содержит тесты для различных компонентов телеграм-бота.
## Структура тестов
- `test_services/` - Unit-тесты для сервисов (user_service, game_service)
- `test_utils/` - Тесты для утилит (formatters, helpers, validators, exceptions)
- `test_integration/` - Интеграционные тесты для полного потока
- `test_performance/` - Тесты производительности критических функций
## Запуск тестов
### Unit-тесты сервисов
```bash
python -m pytest test_services/test_game_service.py -v
python -m pytest test_services/test_user_service.py -v
```
### Интеграционные тесты
```bash
python test_games_navigation.py # Тестирование навигации к играм
python -m pytest test_integration/ -v
```
### Все тесты
```bash
python -m pytest -v
```
## Важные исправления
### Поддержка русского ввода в Rock-Paper-Scissors
Добавлена поддержка русского ввода в game_service.py:
- 'камень' -> 'rock'
- 'ножницы' -> 'scissors'
- 'бумага' -> 'paper'
Тесты в test_game_service.py проверяют эту функциональность.
### Исправление ошибок в тестах
- Исправлен test_games_navigation.py: добавлен game_service в GameHandlers
- Добавлены AsyncMock для Telegram API вызовов
- Исправлены проблемы с валидацией и очисткой сессий
## Автоматическое тестирование
Тесты интегрированы в CI/CD пайплайн через GitHub Actions (.github/workflows/ci.yml).
+15
View File
@@ -0,0 +1,15 @@
"""
Тестовый пакет для телеграм-бота.
Содержит все тесты для проверки корректности работы системы.
"""
import sys
import os
# Добавляем корневую директорию в путь для импортов
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Общие тестовые утилиты и фикстуры
from .conftest import *
__version__ = "1.0.0"
+253
View File
@@ -0,0 +1,253 @@
"""
Конфигурация pytest для телеграм-бота.
Содержит фикстуры и вспомогательные функции для тестирования.
"""
import os
import tempfile
import pytest
import asyncio
from unittest.mock import Mock, AsyncMock
from datetime import datetime
from telegram import Update, User, Message, Chat, CallbackQuery
from telegram.ext import ContextTypes
# Добавляем корневую директорию в путь
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.config import Config
from core.exceptions import BotException, ValidationError, DatabaseError
@pytest.fixture
def test_config():
"""Фикстура с тестовой конфигурацией"""
# Создаем конфигурацию с пустыми списками админов для чистых тестов
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
f.write("""
BOT_TOKEN = "123456789:test_token_for_testing"
ADMIN_IDS = []
SUPER_ADMIN_IDS = []
MODERATOR_IDS = []
OPENWEATHER_API_KEY = "test_weather_key"
NEWS_API_KEY = "test_news_key"
OPENAI_API_KEY = "test_openai_key"
""")
temp_config_path = f.name
try:
config = Config(temp_config_path)
yield config
finally:
if os.path.exists(temp_config_path):
os.unlink(temp_config_path)
@pytest.fixture
def mock_user():
"""Фикстура с мок-объектом пользователя"""
user = Mock(spec=User)
user.id = 123456789
user.username = "test_user"
user.first_name = "Test"
user.last_name = "User"
user.is_bot = False
return user
@pytest.fixture
def mock_chat():
"""Фикстура с мок-объектом чата"""
chat = Mock(spec=Chat)
chat.id = -1001234567890
chat.type = "group"
chat.title = "Test Chat"
return chat
@pytest.fixture
def mock_message(mock_user, mock_chat):
"""Фикстура с мок-объектом сообщения"""
message = Mock(spec=Message)
message.message_id = 12345
message.text = "/test command"
message.from_user = mock_user
message.chat = mock_chat
message.date = datetime.now()
message.reply_text = AsyncMock()
message.set_reaction = AsyncMock()
return message
@pytest.fixture
def mock_update(mock_message):
"""Фикстура с мок-объектом обновления"""
update = Mock(spec=Update)
update.effective_user = mock_message.from_user
update.effective_chat = mock_message.chat
update.message = mock_message
update.callback_query = None
return update
@pytest.fixture
def mock_context():
"""Фикстура с мок-объектом контекста"""
context = Mock(spec=ContextTypes.DEFAULT_TYPE)
context.args = []
context.user_data = {}
context.chat_data = {}
context.bot = Mock()
context.bot.send_message = AsyncMock()
context.application = Mock()
context.application._date_time = datetime.now()
return context
@pytest.fixture
def mock_callback_query(mock_user, mock_chat, mock_message):
"""Фикстура с мок-объектом callback query"""
callback_query = Mock(spec=CallbackQuery)
callback_query.id = "test_callback_123"
callback_query.data = "test_data"
callback_query.from_user = mock_user
callback_query.message = mock_message
callback_query.answer = AsyncMock()
callback_query.edit_message_text = AsyncMock()
return callback_query
@pytest.fixture
def sample_user_data():
"""Фикстура с тестовыми данными пользователя"""
return {
'id': 123456789,
'telegram_id': 123456789,
'username': 'test_user',
'first_name': 'Test',
'last_name': 'User',
'reputation': 150,
'rank': 'Активист',
'message_count': 45,
'joined_date': datetime.now(),
'last_activity': datetime.now()
}
@pytest.fixture
def sample_error_data():
"""Фикстура с тестовыми данными ошибки"""
return {
'id': 1,
'admin_id': 123456789,
'error_type': 'bug',
'title': 'Тестовая ошибка',
'description': 'Описание тестовой ошибки для тестирования',
'status': 'new',
'priority': 'medium',
'created_at': datetime.now(),
'updated_at': datetime.now()
}
@pytest.fixture
def temp_db_path():
"""Фикстура с временным путем к базе данных"""
with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as f:
db_path = f.name
yield db_path
# Очистка после теста
if os.path.exists(db_path):
os.unlink(db_path)
@pytest.fixture
def event_loop():
"""Фикстура для event loop (для асинхронных тестов)"""
loop = asyncio.new_event_loop()
yield loop
loop.close()
# Вспомогательные функции для тестов
def create_test_config(bot_token: str = "123456789:test_token", admin_ids: list = None) -> Config:
"""Создание тестовой конфигурации"""
if admin_ids is None:
admin_ids = [] # По умолчанию пустой список админов для тестов
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
f.write(f"""
BOT_TOKEN = "{bot_token}"
ADMIN_IDS = {admin_ids}
SUPER_ADMIN_IDS = []
MODERATOR_IDS = []
OPENWEATHER_API_KEY = "test_weather_key"
NEWS_API_KEY = "test_news_key"
OPENAI_API_KEY = "test_openai_key"
""")
config_path = f.name
try:
return Config(config_path)
finally:
if os.path.exists(config_path):
os.unlink(config_path)
def assert_exception_raised(func, exception_type, *args, **kwargs):
"""Утилита для проверки исключений в тестах"""
with pytest.raises(exception_type):
if asyncio.iscoroutinefunction(func):
asyncio.run(func(*args, **kwargs))
else:
func(*args, **kwargs)
async def async_assert_exception_raised(func, exception_type, *args, **kwargs):
"""Асинхронная утилита для проверки исключений"""
with pytest.raises(exception_type):
await func(*args, **kwargs)
def create_mock_user(user_id: int = 123456789, username: str = "test_user",
first_name: str = "Test", last_name: str = "User"):
"""Создание мок-объекта пользователя"""
user = Mock(spec=User)
user.id = user_id
user.username = username
user.first_name = first_name
user.last_name = last_name
user.is_bot = False
return user
def create_mock_chat(chat_id: int = -1001234567890, chat_type: str = "group"):
"""Создание мок-объекта чата"""
chat = Mock(spec=Chat)
chat.id = chat_id
chat.type = chat_type
return chat
def create_mock_update(user: User = None, chat: Chat = None, text: str = "/test"):
"""Создание мок-объекта обновления"""
if user is None:
user = create_mock_user()
if chat is None:
chat = create_mock_chat()
message = Mock(spec=Message)
message.text = text
message.from_user = user
message.chat = chat
update = Mock(spec=Update)
update.effective_user = user
update.effective_chat = chat
update.message = message
return update
+155
View File
@@ -0,0 +1,155 @@
"""
Тесты для ContextMenuManager.
"""
import pytest
from unittest.mock import Mock, AsyncMock
from telegram import InlineKeyboardMarkup, InlineKeyboardButton
from core.menu_manager import ContextMenuManager, MenuConfig
from core.permissions import UserRole
class TestContextMenuManager:
"""Тесты менеджера контекстных меню"""
@pytest.fixture
def mock_formatter(self):
"""Мок-объект для KeyboardFormatter"""
formatter = Mock()
formatter.create_main_menu.return_value = InlineKeyboardMarkup([
[InlineKeyboardButton("📋 Помощь", callback_data='menu_help')]
])
formatter.create_games_menu.return_value = InlineKeyboardMarkup([
[InlineKeyboardButton("🎯 Игра 1", callback_data='game_1')]
])
formatter.create_admin_menu.return_value = InlineKeyboardMarkup([
[InlineKeyboardButton("👥 Пользователи", callback_data='admin_users')]
])
return formatter
@pytest.fixture
def mock_permission_manager(self):
"""Мок-объект для PermissionManager"""
pm = Mock()
pm.has_permission.return_value = True
return pm
@pytest.fixture
def menu_manager(self, mock_permission_manager, mock_formatter):
"""Фикстура для ContextMenuManager"""
return ContextMenuManager(mock_permission_manager, mock_formatter)
def test_initialization(self, menu_manager):
"""Тест инициализации менеджера меню"""
assert menu_manager.menus is not None
assert 'menu_main' in menu_manager.menus
assert 'menu_admin' in menu_manager.menus
# Проверка разделения по уровням
assert 'user' in menu_manager.menu_levels
assert 'admin' in menu_manager.menu_levels
assert 'super_admin' in menu_manager.menu_levels
def test_menu_registration(self, menu_manager):
"""Тест регистрации нового меню"""
async def test_builder(role, **context):
return InlineKeyboardMarkup([[InlineKeyboardButton("Test", callback_data='test')]])
menu_manager.register_menu(
'test_menu', test_builder, UserRole.USER, 'user', 'Тестовое меню'
)
assert 'test_menu' in menu_manager.menus
config = menu_manager.menus['test_menu']
assert config.required_role == UserRole.USER
assert config.level == 'user'
assert config.description == 'Тестовое меню'
@pytest.mark.asyncio
async def test_get_menu_for_user_success(self, menu_manager):
"""Тест успешного получения меню для пользователя"""
menu = await menu_manager.get_menu_for_user('menu_main', UserRole.USER)
assert menu is not None
assert isinstance(menu, InlineKeyboardMarkup)
assert len(menu.inline_keyboard) > 0
@pytest.mark.asyncio
async def test_get_menu_for_user_insufficient_permissions(self, menu_manager, mock_permission_manager):
"""Тест получения меню при недостаточных правах"""
mock_permission_manager.has_permission.return_value = False
menu = await menu_manager.get_menu_for_user('menu_admin', UserRole.USER)
assert menu is None
@pytest.mark.asyncio
async def test_get_menu_for_user_unknown_menu(self, menu_manager):
"""Тест получения неизвестного меню"""
menu = await menu_manager.get_menu_for_user('unknown_menu', UserRole.USER)
assert menu is None
def test_is_menu_available(self, menu_manager, mock_permission_manager):
"""Тест проверки доступности меню"""
# Доступно
mock_permission_manager.has_permission.return_value = True
assert menu_manager.is_menu_available('menu_main', UserRole.USER)
# Недоступно
mock_permission_manager.has_permission.return_value = False
assert not menu_manager.is_menu_available('menu_admin', UserRole.USER)
def test_is_menu_available_unknown_menu(self, menu_manager):
"""Тест проверки доступности неизвестного меню"""
assert not menu_manager.is_menu_available('unknown_menu', UserRole.USER)
def test_get_available_menus_for_role(self, menu_manager, mock_permission_manager):
"""Тест получения списка доступных меню для роли"""
mock_permission_manager.has_permission.return_value = True
menus = menu_manager.get_available_menus_for_role(UserRole.USER)
assert isinstance(menus, list)
assert 'menu_main' in menus
assert 'menu_help' in menus
def test_get_menus_by_level(self, menu_manager):
"""Тест получения меню по уровням"""
user_menus = menu_manager.get_menus_by_level('user')
admin_menus = menu_manager.get_menus_by_level('admin')
assert isinstance(user_menus, list)
assert isinstance(admin_menus, list)
assert 'menu_main' in user_menus
assert 'menu_admin' in admin_menus
def test_get_menus_by_unknown_level(self, menu_manager):
"""Тест получения меню для неизвестного уровня"""
menus = menu_manager.get_menus_by_level('unknown')
assert menus == []
def test_clear_cache(self, menu_manager):
"""Тест очистки кеша"""
# Добавляем что-то в кеш
menu_manager.menu_cache['test'] = Mock()
assert len(menu_manager.menu_cache) > 0
menu_manager.clear_cache()
assert len(menu_manager.menu_cache) == 0
def test_menu_config(self):
"""Тест конфигурации меню"""
async def handler():
pass
config = MenuConfig('test', handler, UserRole.ADMIN, 'admin', 'Test menu')
assert config.menu_id == 'test'
assert config.required_role == UserRole.ADMIN
assert config.level == 'admin'
assert config.description == 'Test menu'
+297
View File
@@ -0,0 +1,297 @@
"""
Тесты для MessageTypeRouter.
"""
import pytest
from unittest.mock import Mock, AsyncMock
from telegram import Update, Message, User, CallbackQuery
from core.message_router import MessageTypeRouter, MessageHandlerConfig
from core.permissions import UserRole
class TestMessageTypeRouter:
"""Тесты маршрутизатора типов сообщений"""
@pytest.fixture
def message_router(self):
"""Фикстура для MessageTypeRouter"""
return MessageTypeRouter()
@pytest.fixture
def mock_update_text(self):
"""Мок-объект для текстового сообщения"""
update = Mock(spec=Update)
message = Mock(spec=Message)
user = Mock(spec=User)
user.id = 123456789
message.text = "Hello world"
message.voice = None
message.photo = None
update.message = message
update.effective_user = user
# Mock chat for permission checking
chat = Mock()
member = Mock()
member.status = 'member'
chat.get_member = AsyncMock(return_value=member)
update.effective_chat = chat
return update
@pytest.fixture
def mock_update_voice(self):
"""Мок-объект для голосового сообщения"""
update = Mock(spec=Update)
message = Mock(spec=Message)
user = Mock(spec=User)
user.id = 123456789
voice = Mock()
message.text = None
message.voice = voice
message.photo = None
update.message = message
update.effective_user = user
# Mock chat for permission checking
chat = Mock()
member = Mock()
member.status = 'member'
chat.get_member = AsyncMock(return_value=member)
update.effective_chat = chat
return update
@pytest.fixture
def mock_update_callback(self):
"""Мок-объект для callback запроса"""
update = Mock(spec=Update)
callback_query = Mock(spec=CallbackQuery)
user = Mock(spec=User)
user.id = 123456789
callback_query.data = "test_callback"
update.callback_query = callback_query
update.effective_user = user
# Mock chat for permission checking
chat = Mock()
member = Mock()
member.status = 'member'
chat.get_member = AsyncMock(return_value=member)
update.effective_chat = chat
return update
@pytest.fixture
def mock_context(self):
"""Мок-объект для контекста"""
return Mock()
def test_initialization(self, message_router):
"""Тест инициализации маршрутизатора"""
assert message_router.text_handlers == {}
assert message_router.callback_handlers == {}
assert message_router.voice_handlers == []
assert len(message_router.photo_handlers) == 0
def test_register_text_handler(self, message_router):
"""Тест регистрации обработчика текста"""
async def handler(update, context):
pass
message_router.register_text_handler(
r'hello', handler, UserRole.USER, 1, 'Test handler'
)
assert len(message_router.text_handlers) == 1
pattern, config = list(message_router.text_handlers.items())[0]
assert config.required_role == UserRole.USER
assert config.priority == 1
assert config.description == 'Test handler'
def test_register_text_handler_invalid_regex(self, message_router):
"""Тест регистрации обработчика с невалидным regex"""
async def handler(update, context):
pass
# Неверный regex не должен вызывать исключение, но и не регистрироваться
initial_count = len(message_router.text_handlers)
message_router.register_text_handler(
r'[invalid', handler, UserRole.USER
)
assert len(message_router.text_handlers) == initial_count
def test_register_callback_handler(self, message_router):
"""Тест регистрации обработчика callback"""
async def handler(update, context):
pass
message_router.register_callback_handler(
'test_callback', handler, UserRole.USER, 'Test callback handler'
)
assert 'test_callback' in message_router.callback_handlers
config = message_router.callback_handlers['test_callback']
assert config.required_role == UserRole.USER
assert config.description == 'Test callback handler'
def test_register_media_handler(self, message_router):
"""Тест регистрации обработчика медиа"""
async def handler(update, context):
pass
message_router.register_media_handler(
'photo', handler, UserRole.USER, 'Photo handler'
)
assert len(message_router.photo_handlers) == 1
config = message_router.photo_handlers[0]
assert config.required_role == UserRole.USER
assert config.description == 'Photo handler'
def test_register_voice_handler(self, message_router):
"""Тест регистрации обработчика голосовых сообщений"""
async def handler(update, context):
pass
message_router.register_media_handler(
'voice', handler, UserRole.USER, 'Voice handler'
)
assert len(message_router.voice_handlers) == 1
config = message_router.voice_handlers[0]
assert config.required_role == UserRole.USER
@pytest.mark.asyncio
async def test_route_text_message_no_match(self, message_router, mock_update_text, mock_context):
"""Тест маршрутизации текста без совпадений"""
result = await message_router.route_text_message(mock_update_text, mock_context)
assert result == False
@pytest.mark.asyncio
async def test_route_text_message_with_match(self, message_router, mock_update_text, mock_context):
"""Тест маршрутизации текста с совпадением"""
handler_called = False
async def test_handler(update, context):
nonlocal handler_called
handler_called = True
message_router.register_text_handler(r'hello', test_handler, UserRole.USER)
result = await message_router.route_text_message(mock_update_text, mock_context)
# Проверяем, что обработчик был вызван
assert handler_called == True
assert result == True
@pytest.mark.asyncio
async def test_route_text_message_insufficient_permissions(self, message_router, mock_update_text, mock_context):
"""Тест маршрутизации текста при недостаточных правах"""
handler_called = False
async def test_handler(update, context):
nonlocal handler_called
handler_called = True
# Регистрируем обработчик требующий админские права
message_router.register_text_handler(r'hello', test_handler, UserRole.ADMIN)
result = await message_router.route_text_message(mock_update_text, mock_context)
assert result == False
assert handler_called == False
@pytest.mark.asyncio
async def test_route_callback_exact_match(self, message_router, mock_update_callback, mock_context):
"""Тест маршрутизации callback с точным совпадением"""
handler_called = False
async def test_handler(update, context):
nonlocal handler_called
handler_called = True
message_router.register_callback_handler('test_callback', test_handler, UserRole.USER)
result = await message_router.route_callback(mock_update_callback, mock_context)
# Проверяем, что обработчик был вызван
assert handler_called == True
assert result == True
@pytest.mark.asyncio
async def test_route_callback_prefix_match(self, message_router, mock_update_callback, mock_context):
"""Тест маршрутизации callback по префиксу"""
handler_called = False
async def test_handler(update, context):
nonlocal handler_called
handler_called = True
message_router.register_callback_handler('test_', test_handler, UserRole.USER)
result = await message_router.route_callback(mock_update_callback, mock_context)
# Проверяем, что обработчик был вызван
assert handler_called == True
assert result == True
@pytest.mark.asyncio
async def test_route_callback_no_match(self, message_router, mock_update_callback, mock_context):
"""Тест маршрутизации callback без совпадений"""
result = await message_router.route_callback(mock_update_callback, mock_context)
assert result == False
@pytest.mark.asyncio
async def test_route_voice_message(self, message_router, mock_update_voice, mock_context):
"""Тест маршрутизации голосовых сообщений"""
handler_called = False
async def test_handler(update, context):
nonlocal handler_called
handler_called = True
message_router.register_media_handler('voice', test_handler, UserRole.USER)
result = await message_router.route_voice_message(mock_update_voice, mock_context)
# Проверяем, что обработчик был вызван
assert handler_called == True
assert result == True
def test_get_registered_handlers_count(self, message_router):
"""Тест получения статистики обработчиков"""
# Регистрируем несколько обработчиков
async def handler(update, context):
pass
message_router.register_text_handler(r'test', handler)
message_router.register_callback_handler('callback', handler)
message_router.register_media_handler('photo', handler)
stats = message_router.get_registered_handlers_count()
assert stats['text'] == 1
assert stats['callback'] == 1
assert stats['photo'] == 1
assert stats['voice'] == 0
def test_message_handler_config(self):
"""Тест конфигурации обработчика"""
async def handler():
pass
config = MessageHandlerConfig(handler, UserRole.MODERATOR, 5, 'Test config')
assert config.handler == handler
assert config.required_role == UserRole.MODERATOR
assert config.priority == 5
assert config.description == 'Test config'
@@ -0,0 +1,415 @@
"""
Интеграционные тесты для MessageTypeRouter с триггерами.
"""
import pytest
from unittest.mock import Mock, AsyncMock, patch
from telegram import Update, Message, User, CallbackQuery
from core.message_router import MessageTypeRouter, create_message_router
from services.trigger_service import TriggerService
class TestMessageRouterTriggers:
"""Интеграционные тесты маршрутизатора сообщений с триггерами"""
@pytest.fixture
def mock_trigger_service(self):
"""Мок сервиса триггеров"""
service = Mock(spec=TriggerService)
service.check_triggers = AsyncMock(return_value=[])
service.execute_trigger_actions = AsyncMock(return_value=[])
return service
@pytest.fixture
def message_router_with_triggers(self, mock_trigger_service):
"""Фикстура маршрутизатора с сервисом триггеров"""
return create_message_router(mock_trigger_service)
@pytest.fixture
def mock_update_group_text(self):
"""Мок-объект для текстового сообщения в группе"""
update = Mock(spec=Update)
message = Mock(spec=Message)
user = Mock(spec=User)
chat = Mock()
user.id = 123456
message.text = "hello world"
message.voice = None
message.photo = None
message.message_id = 789
message.chat_id = 987654
chat.type = "group"
update.message = message
update.effective_user = user
update.effective_chat = chat
return update
@pytest.fixture
def mock_update_private_text(self):
"""Мок-объект для текстового сообщения в привате"""
update = Mock(spec=Update)
message = Mock(spec=Message)
user = Mock(spec=User)
chat = Mock()
user.id = 123456
message.text = "private message"
message.voice = None
message.photo = None
chat.type = "private"
update.message = message
update.effective_user = user
update.effective_chat = chat
return update
@pytest.fixture
def mock_context(self):
"""Мок-объект для контекста"""
context = Mock()
context.bot = Mock()
context.bot.send_message = AsyncMock()
context.bot.send_sticker = AsyncMock()
context.bot.send_animation = AsyncMock()
context.bot.set_message_reaction = AsyncMock()
return context
@pytest.mark.asyncio
async def test_route_text_message_no_triggers_group(self, message_router_with_triggers,
mock_update_group_text, mock_context):
"""Тест маршрутизации текста в группе без сработавших триггеров"""
result = await message_router_with_triggers.route_text_message(
mock_update_group_text, mock_context
)
# Проверяем, что проверка триггеров была вызвана для группы
message_router_with_triggers.trigger_service.check_triggers.assert_called_once_with(
"hello world", "group"
)
# Проверяем, что выполнение действий не было вызвано
message_router_with_triggers.trigger_service.execute_trigger_actions.assert_not_called()
assert result == False # Нет совпадений с текстовыми обработчиками
@pytest.mark.asyncio
async def test_route_text_message_no_triggers_private(self, message_router_with_triggers,
mock_update_private_text, mock_context):
"""Тест маршрутизации текста в привате без сработавших триггеров"""
result = await message_router_with_triggers.route_text_message(
mock_update_private_text, mock_context
)
# Проверяем, что проверка триггеров НЕ была вызвана для приватного чата
message_router_with_triggers.trigger_service.check_triggers.assert_not_called()
# Проверяем, что выполнение действий не было вызвано
message_router_with_triggers.trigger_service.execute_trigger_actions.assert_not_called()
assert result == False
@pytest.mark.asyncio
async def test_route_text_message_with_trigger_match_group(self, message_router_with_triggers,
mock_update_group_text, mock_context):
"""Тест маршрутизации текста в группе с сработавшим триггером"""
# Настраиваем мок триггера
matched_triggers = [
{
'id': 1,
'name': 'hello_trigger',
'response_text': 'Hi there!',
'trigger_count': 5
}
]
message_router_with_triggers.trigger_service.check_triggers.return_value = matched_triggers
message_router_with_triggers.trigger_service.execute_trigger_actions.return_value = [
{
'type': 'text',
'content': 'Hi there!',
'trigger_id': 1,
'trigger_name': 'hello_trigger'
}
]
result = await message_router_with_triggers.route_text_message(
mock_update_group_text, mock_context
)
# Проверяем, что проверка триггеров была вызвана
message_router_with_triggers.trigger_service.check_triggers.assert_called_once_with(
"hello world", "group"
)
# Проверяем, что выполнение действий было вызвано
message_router_with_triggers.trigger_service.execute_trigger_actions.assert_called_once_with(
matched_triggers, 987654, "hello world", 789
)
assert result == False # Нет совпадений с текстовыми обработчиками маршрутизатора
@pytest.mark.asyncio
async def test_trigger_actions_execution_text(self, message_router_with_triggers,
mock_update_group_text, mock_context):
"""Тест выполнения действий триггера - текстовый ответ"""
actions = [
{
'type': 'text',
'content': 'Hello from trigger!',
'trigger_id': 1,
'trigger_name': 'test_trigger'
}
]
await message_router_with_triggers._execute_trigger_actions(actions, mock_update_group_text, mock_context)
# Проверяем, что сообщение было отправлено
mock_context.bot.send_message.assert_called_once_with(
chat_id=987654,
text='Hello from trigger!',
reply_to_message_id=789
)
@pytest.mark.asyncio
async def test_trigger_actions_execution_sticker(self, message_router_with_triggers,
mock_update_group_text, mock_context):
"""Тест выполнения действий триггера - стикер"""
actions = [
{
'type': 'sticker',
'content': 'sticker_file_id',
'trigger_id': 1,
'trigger_name': 'sticker_trigger'
}
]
await message_router_with_triggers._execute_trigger_actions(actions, mock_update_group_text, mock_context)
# Проверяем, что стикер был отправлен
mock_context.bot.send_sticker.assert_called_once_with(
chat_id=987654,
sticker='sticker_file_id',
reply_to_message_id=789
)
@pytest.mark.asyncio
async def test_trigger_actions_execution_gif(self, message_router_with_triggers,
mock_update_group_text, mock_context):
"""Тест выполнения действий триггера - GIF"""
actions = [
{
'type': 'gif',
'content': 'gif_file_id',
'trigger_id': 1,
'trigger_name': 'gif_trigger'
}
]
await message_router_with_triggers._execute_trigger_actions(actions, mock_update_group_text, mock_context)
# Проверяем, что GIF был отправлен
mock_context.bot.send_animation.assert_called_once_with(
chat_id=987654,
animation='gif_file_id',
reply_to_message_id=789
)
@pytest.mark.asyncio
async def test_trigger_actions_execution_reaction(self, message_router_with_triggers,
mock_update_group_text, mock_context):
"""Тест выполнения действий триггера - реакция"""
actions = [
{
'type': 'reaction',
'reaction_type': 'emoji',
'content': '👍',
'message_id': 789,
'trigger_id': 1,
'trigger_name': 'reaction_trigger'
}
]
await message_router_with_triggers._execute_trigger_actions(actions, mock_update_group_text, mock_context)
# Проверяем, что реакция была установлена
mock_context.bot.set_message_reaction.assert_called_once_with(
chat_id=987654,
message_id=789,
reaction='👍'
)
@pytest.mark.asyncio
async def test_trigger_actions_execution_multiple_actions(self, message_router_with_triggers,
mock_update_group_text, mock_context):
"""Тест выполнения множественных действий триггера"""
actions = [
{
'type': 'text',
'content': 'Hello!',
'trigger_id': 1,
'trigger_name': 'multi_trigger'
},
{
'type': 'sticker',
'content': 'sticker123',
'trigger_id': 1,
'trigger_name': 'multi_trigger'
},
{
'type': 'reaction',
'reaction_type': 'emoji',
'content': '❤️',
'message_id': 789,
'trigger_id': 1,
'trigger_name': 'multi_trigger'
}
]
await message_router_with_triggers._execute_trigger_actions(actions, mock_update_group_text, mock_context)
# Проверяем, что все действия были выполнены
assert mock_context.bot.send_message.call_count == 1
assert mock_context.bot.send_sticker.call_count == 1
assert mock_context.bot.set_message_reaction.call_count == 1
@pytest.mark.asyncio
async def test_trigger_actions_execution_reaction_priority(self, message_router_with_triggers,
mock_update_group_text, mock_context):
"""Тест приоритета реакций - только первая реакция выполняется"""
actions = [
{
'type': 'reaction',
'reaction_type': 'emoji',
'content': '👍',
'message_id': 789,
'trigger_id': 1,
'trigger_name': 'first_reaction'
},
{
'type': 'reaction',
'reaction_type': 'emoji',
'content': '❤️',
'message_id': 789,
'trigger_id': 2,
'trigger_name': 'second_reaction'
},
{
'type': 'text',
'content': 'Text response',
'trigger_id': 1,
'trigger_name': 'text_trigger'
}
]
await message_router_with_triggers._execute_trigger_actions(actions, mock_update_group_text, mock_context)
# Проверяем, что только первая реакция была установлена
mock_context.bot.set_message_reaction.assert_called_once_with(
chat_id=987654,
message_id=789,
reaction='👍'
)
# Проверяем, что текстовый ответ был отправлен
mock_context.bot.send_message.assert_called_once_with(
chat_id=987654,
text='Text response',
reply_to_message_id=789
)
@pytest.mark.asyncio
async def test_trigger_actions_execution_empty_actions(self, message_router_with_triggers,
mock_update_group_text, mock_context):
"""Тест выполнения пустого списка действий"""
actions = []
await message_router_with_triggers._execute_trigger_actions(actions, mock_update_group_text, mock_context)
# Проверяем, что ничего не было отправлено
mock_context.bot.send_message.assert_not_called()
mock_context.bot.send_sticker.assert_not_called()
mock_context.bot.send_animation.assert_not_called()
mock_context.bot.set_message_reaction.assert_not_called()
@pytest.mark.asyncio
async def test_trigger_actions_execution_unknown_action_type(self, message_router_with_triggers,
mock_update_group_text, mock_context):
"""Тест выполнения действия с неизвестным типом"""
actions = [
{
'type': 'unknown',
'content': 'some_content',
'trigger_id': 1,
'trigger_name': 'unknown_trigger'
}
]
# Не должно выбрасывать исключение, просто игнорирует неизвестный тип
await message_router_with_triggers._execute_trigger_actions(actions, mock_update_group_text, mock_context)
# Проверяем, что ничего не было отправлено
mock_context.bot.send_message.assert_not_called()
mock_context.bot.send_sticker.assert_not_called()
mock_context.bot.send_animation.assert_not_called()
mock_context.bot.set_message_reaction.assert_not_called()
@pytest.mark.asyncio
async def test_route_text_message_trigger_service_error(self, message_router_with_triggers,
mock_update_group_text, mock_context):
"""Тест обработки ошибки в сервисе триггеров"""
message_router_with_triggers.trigger_service.check_triggers.side_effect = Exception("Test error")
# Не должно выбрасывать исключение
result = await message_router_with_triggers.route_text_message(
mock_update_group_text, mock_context
)
assert result == False
@pytest.mark.asyncio
async def test_execute_trigger_actions_error_handling(self, message_router_with_triggers,
mock_update_group_text, mock_context):
"""Тест обработки ошибок при выполнении действий триггера"""
actions = [
{
'type': 'text',
'content': 'Hello!',
'trigger_id': 1,
'trigger_name': 'error_trigger'
}
]
# Настраиваем ошибку при отправке сообщения
mock_context.bot.send_message.side_effect = Exception("Send error")
# Не должно выбрасывать исключение наружу
await message_router_with_triggers._execute_trigger_actions(actions, mock_update_group_text, mock_context)
# Проверяем, что попытка отправки была
mock_context.bot.send_message.assert_called_once()
def test_set_trigger_service(self, message_router_with_triggers, mock_trigger_service):
"""Тест установки сервиса триггеров"""
new_trigger_service = Mock(spec=TriggerService)
message_router_with_triggers.set_trigger_service(new_trigger_service)
assert message_router_with_triggers.trigger_service == new_trigger_service
def test_create_message_router_with_trigger_service(self, mock_trigger_service):
"""Тест создания маршрутизатора с сервисом триггеров"""
router = create_message_router(mock_trigger_service)
assert isinstance(router, MessageTypeRouter)
assert router.trigger_service == mock_trigger_service
def test_create_message_router_without_trigger_service(self):
"""Тест создания маршрутизатора без сервиса триггеров"""
router = create_message_router()
assert isinstance(router, MessageTypeRouter)
assert router.trigger_service is None
+366
View File
@@ -0,0 +1,366 @@
"""
Тесты для модуля безопасности платежей.
"""
import pytest
import hmac
import hashlib
import subprocess
from unittest.mock import patch
from core.payment_security import PaymentSecurity, PaymentRateLimiter
class TestPaymentSecurity:
"""Тесты для класса PaymentSecurity."""
def test_validate_stripe_signature_valid(self):
"""Тест валидной подписи Stripe."""
payload = b'test_payload'
secret = 'test_secret'
timestamp = '1234567890'
signed_payload = f"{timestamp}.{payload.decode('utf-8')}"
expected_signature = hmac.new(
secret.encode('utf-8'),
signed_payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
signature = f"t={timestamp},v1={expected_signature}"
assert PaymentSecurity.validate_stripe_signature(payload, signature, secret)
def test_validate_stripe_signature_invalid(self):
"""Тест невалидной подписи Stripe."""
payload = b'test_payload'
secret = 'test_secret'
signature = "t=123456,v1=invalid_signature"
assert not PaymentSecurity.validate_stripe_signature(payload, signature, secret)
def test_validate_yookassa_signature_valid(self):
"""Тест валидной подписи YooKassa."""
request_body = 'test_body'
secret = 'test_secret'
expected_signature = hmac.new(
secret.encode('utf-8'),
request_body.encode('utf-8'),
hashlib.sha256
).hexdigest()
assert PaymentSecurity.validate_yookassa_signature(request_body, expected_signature, secret)
def test_validate_yookassa_signature_invalid(self):
"""Тест невалидной подписи YooKassa."""
request_body = 'test_body'
secret = 'test_secret'
signature = 'invalid_signature'
assert not PaymentSecurity.validate_yookassa_signature(request_body, signature, secret)
def test_validate_sbp_signature_valid(self):
"""Тест валидной подписи СБП."""
request_body = 'test_body'
secret = 'test_secret'
# СБП использует base64-encoded HMAC-SHA256
expected_hmac = hmac.new(
secret.encode('utf-8'),
request_body.encode('utf-8'),
hashlib.sha256
)
import base64
expected_signature = base64.b64encode(expected_hmac.digest()).decode('utf-8')
assert PaymentSecurity.validate_sbp_signature(request_body, expected_signature, secret)
def test_validate_sbp_signature_invalid(self):
"""Тест невалидной подписи СБП."""
request_body = 'test_body'
secret = 'test_secret'
signature = 'invalid_signature'
assert not PaymentSecurity.validate_sbp_signature(request_body, signature, secret)
@patch('core.payment_security.datetime')
def test_generate_idempotency_key(self, mock_datetime):
"""Тест генерации ключа идемпотентности."""
# Мокаем datetime для предсказуемого результата
mock_datetime.now.return_value.timestamp.return_value = 1234567890.0
payment_data = {
'user_id': 123,
'amount': 100.0
}
key1 = PaymentSecurity.generate_idempotency_key(payment_data)
# Изменяем timestamp
mock_datetime.now.return_value.timestamp.return_value = 1234567891.0
key2 = PaymentSecurity.generate_idempotency_key(payment_data)
# Ключи должны быть разными из-за timestamp
assert key1 != key2
assert len(key1) == 16
assert len(key2) == 16
def test_sanitize_payment_data(self):
"""Тест очистки платежных данных."""
payment_data = {
'user_id': 123,
'amount': 100.0,
'card_number': '1234567890123456',
'cvv': '123',
'cardholder_name': 'John Doe',
'api_key': 'secret_key',
'normal_field': 'normal_value'
}
sanitized = PaymentSecurity.sanitize_payment_data(payment_data)
assert sanitized['card_number'] == '**** **** **** 3456'
assert sanitized['cvv'] == '***'
assert sanitized['cardholder_name'] == '***'
assert sanitized['api_key'] == '***'
assert sanitized['user_id'] == 123
assert sanitized['amount'] == 100.0
assert sanitized['normal_field'] == 'normal_value'
def test_validate_amount_range(self):
"""Тест проверки диапазона суммы."""
assert PaymentSecurity.validate_amount_range(100.0)
assert PaymentSecurity.validate_amount_range(1.0)
assert PaymentSecurity.validate_amount_range(100000.0)
assert not PaymentSecurity.validate_amount_range(0.5)
assert not PaymentSecurity.validate_amount_range(200000.0)
def test_validate_currency(self):
"""Тест проверки валюты."""
assert PaymentSecurity.validate_currency('RUB')
assert PaymentSecurity.validate_currency('USD')
assert PaymentSecurity.validate_currency('EUR')
assert not PaymentSecurity.validate_currency('BTC')
assert PaymentSecurity.validate_currency('rub', ['RUB', 'USD'])
def test_detect_suspicious_activity(self):
"""Тест обнаружения подозрительной активности."""
payment_data = {'amount': 100.0}
recent_payments = []
result = PaymentSecurity.detect_suspicious_activity(payment_data, recent_payments)
assert not result['is_suspicious']
# Тест с круглой суммой
payment_data_round = {'amount': 5000.0}
result_round = PaymentSecurity.detect_suspicious_activity(payment_data_round, recent_payments)
assert result_round['is_suspicious']
assert 'round_amount_suspicious' in result_round['reasons']
def test_encrypt_sensitive_data_success(self):
"""Тест успешного шифрования данных."""
data = 'test_sensitive_data'
key = 'test_encryption_key_32_chars_long'
encrypted = PaymentSecurity.encrypt_sensitive_data(data, key)
assert encrypted != data
assert isinstance(encrypted, str)
# Проверяем, что можем расшифровать обратно
decrypted = PaymentSecurity.decrypt_sensitive_data(encrypted, key)
assert decrypted == data
def test_decrypt_sensitive_data_success(self):
"""Тест успешной расшифровки данных."""
original_data = 'another_test_data'
key = 'another_test_key_32_chars_long'
# Шифруем и расшифровываем
encrypted = PaymentSecurity.encrypt_sensitive_data(original_data, key)
decrypted = PaymentSecurity.decrypt_sensitive_data(encrypted, key)
assert decrypted == original_data
def test_encrypt_decrypt_with_different_keys(self):
"""Тест шифрования с разными ключами."""
data = 'test_data'
key1 = 'key_one_12345678901234567890123456789012'
key2 = 'key_two_12345678901234567890123456789012'
encrypted1 = PaymentSecurity.encrypt_sensitive_data(data, key1)
encrypted2 = PaymentSecurity.encrypt_sensitive_data(data, key2)
# Шифртексты должны быть разными
assert encrypted1 != encrypted2
# Расшифровка с правильными ключами
assert PaymentSecurity.decrypt_sensitive_data(encrypted1, key1) == data
assert PaymentSecurity.decrypt_sensitive_data(encrypted2, key2) == data
def test_encrypt_with_short_key(self):
"""Тест шифрования с коротким ключом (должен работать через SHA256)."""
data = 'test_data'
short_key = 'short_key'
encrypted = PaymentSecurity.encrypt_sensitive_data(data, short_key)
decrypted = PaymentSecurity.decrypt_sensitive_data(encrypted, short_key)
assert decrypted == data
@patch('core.payment_security.Fernet')
def test_encrypt_without_cryptography(self, mock_fernet):
"""Тест поведения при отсутствии библиотеки cryptography."""
# Имитируем отсутствие библиотеки
original_import = __import__
def mock_import(name, *args, **kwargs):
if name == 'cryptography.fernet':
raise ImportError("No module named 'cryptography'")
return original_import(name, *args, **kwargs)
with patch('builtins.__import__', side_effect=mock_import):
data = 'test_data'
key = 'test_key'
with pytest.raises(Exception): # PaymentSecurityError
PaymentSecurity.encrypt_sensitive_data(data, key)
class TestFFmpegSecurity:
"""Тесты для проверок безопасности ffmpeg."""
def test_validate_ffmpeg_command_valid(self):
"""Тест валидной команды ffmpeg."""
command = ['ffmpeg', '-i', 'input.mp4', '-c:v', 'libx264', '-y', 'output.mp4']
assert PaymentSecurity.validate_ffmpeg_command(command)
def test_validate_ffmpeg_command_invalid_not_ffmpeg(self):
"""Тест команды не начинающейся с ffmpeg."""
command = ['convert', '-i', 'input.mp4', 'output.mp4']
assert not PaymentSecurity.validate_ffmpeg_command(command)
def test_validate_ffmpeg_command_dangerous_patterns(self):
"""Тест обнаружения опасных паттернов."""
dangerous_commands = [
['ffmpeg', '-i', '../../etc/passwd', 'output.mp4'],
['ffmpeg', '-i', 'input.mp4', '-y', '| rm -rf /'],
['ffmpeg', '-i', 'input.mp4; rm -rf /', 'output.mp4'],
['ffmpeg', '-i', 'input.mp4', '-f', 'srt', 'output.srt'],
]
for command in dangerous_commands:
assert not PaymentSecurity.validate_ffmpeg_command(command)
def test_validate_ffmpeg_command_unsupported_format(self):
"""Тест неподдерживаемого формата файла."""
command = ['ffmpeg', '-i', 'input.exe', 'output.mp4']
assert not PaymentSecurity.validate_ffmpeg_command(command)
def test_sanitize_ffmpeg_output_path_valid(self):
"""Тест санитизации валидного пути."""
path = 'output.mp4'
assert PaymentSecurity.sanitize_ffmpeg_output_path(path) == path
def test_sanitize_ffmpeg_output_path_invalid(self):
"""Тест санитизации невалидного пути."""
invalid_paths = [
'../../etc/passwd',
'/etc/passwd',
'output.mp4|rm -rf /',
'output.exe',
'output; rm -rf /',
]
for path in invalid_paths:
assert PaymentSecurity.sanitize_ffmpeg_output_path(path) is None
def test_secure_ffmpeg_execute_success(self):
"""Тест успешного выполнения безопасной команды ffmpeg."""
# Мокаем subprocess.run для тестирования
with patch('subprocess.run') as mock_run:
mock_run.return_value = type('MockResult', (), {'returncode': 0, 'stdout': '', 'stderr': ''})()
command = ['ffmpeg', '-i', 'input.mp4', '-c:v', 'libx264', '-y', 'output.mp4']
success, error = PaymentSecurity.secure_ffmpeg_execute(command)
assert success
assert error == ""
mock_run.assert_called_once()
def test_secure_ffmpeg_execute_validation_failure(self):
"""Тест выполнения с проваленной валидацией."""
command = ['not_ffmpeg', 'bad_command'] # Не пройдет валидацию
success, error = PaymentSecurity.secure_ffmpeg_execute(command)
assert not success
assert "validation failed" in error.lower()
def test_secure_ffmpeg_execute_subprocess_error(self):
"""Тест выполнения с ошибкой subprocess."""
with patch('subprocess.run') as mock_run:
mock_run.side_effect = subprocess.TimeoutExpired(cmd=['ffmpeg'], timeout=300)
command = ['ffmpeg', '-i', 'input.mp4', '-y', 'output.mp4']
success, error = PaymentSecurity.secure_ffmpeg_execute(command, timeout_seconds=300)
assert not success
assert "timed out" in error.lower()
class TestPaymentRateLimiter:
"""Тесты для ограничителя частоты платежей."""
def test_init(self):
"""Тест инициализации ограничителя."""
limiter = PaymentRateLimiter(max_payments_per_hour=5, max_amount_per_hour=10000)
assert limiter.max_payments_per_hour == 5
assert limiter.max_amount_per_hour == 10000
def test_check_rate_limit_allowed(self):
"""Тест проверки лимита для разрешенного платежа."""
limiter = PaymentRateLimiter(max_payments_per_hour=2, max_amount_per_hour=1000)
user_id = 123
amount = 100.0
result = limiter.check_rate_limit(user_id, amount)
assert result['allowed']
assert result['remaining_payments'] == 2
assert result['remaining_amount'] == 900.0
def test_check_rate_limit_exceeded_payments(self):
"""Тест превышения лимита по количеству платежей."""
limiter = PaymentRateLimiter(max_payments_per_hour=2, max_amount_per_hour=1000)
user_id = 123
# Записываем первый платеж
limiter.record_payment(user_id, 100.0)
# Записываем второй платеж
limiter.record_payment(user_id, 100.0)
# Проверяем лимит для третьего платежа - должен быть превышен
result = limiter.check_rate_limit(user_id, 100.0)
assert not result['allowed']
def test_check_rate_limit_exceeded_amount(self):
"""Тест превышения лимита по сумме."""
limiter = PaymentRateLimiter(max_payments_per_hour=10, max_amount_per_hour=500)
user_id = 123
# Большой платеж
result = limiter.check_rate_limit(user_id, 600.0)
assert not result['allowed']
def test_record_payment(self):
"""Тест записи платежа."""
limiter = PaymentRateLimiter()
user_id = 123
amount = 100.0
limiter.record_payment(user_id, amount)
assert user_id in limiter._counters
assert len(limiter._counters[user_id]['payments']) == 1
assert len(limiter._counters[user_id]['amounts']) == 1
assert limiter._counters[user_id]['amounts'][0][1] == amount
+220
View File
@@ -0,0 +1,220 @@
"""
Тесты для системы разрешений и ролей.
Проверяет корректность работы PermissionManager.
"""
import sys
import os
import pytest
from unittest.mock import Mock, AsyncMock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
from core.permissions import PermissionManager, UserRole, Permission
class TestPermissionManager:
"""Тесты менеджера разрешений"""
@pytest.fixture
def permission_manager(self):
"""Экземпляр менеджера разрешений"""
return PermissionManager()
@pytest.fixture
def mock_config(self):
"""Мок конфигурации"""
config = Mock()
config.bot_config = Mock()
config.bot_config.super_admin_ids = [111111111, 222222222]
config.bot_config.admin_ids = [333333333, 444444444]
config.bot_config.moderator_ids = [555555555, 666666666]
return config
@pytest.fixture
def mock_update(self):
"""Мок обновления"""
update = Mock()
update.effective_chat = Mock()
return update
def test_get_role_permissions_user(self, permission_manager):
"""Тест получения разрешений для роли USER"""
permissions = permission_manager.get_role_permissions(UserRole.USER)
expected_permissions = {
Permission.USE_BASIC_COMMANDS,
Permission.USE_GAMES,
Permission.USE_INFO,
Permission.USE_DONATE,
}
assert permissions == expected_permissions
def test_get_role_permissions_admin(self, permission_manager):
"""Тест получения разрешений для роли ADMIN"""
permissions = permission_manager.get_role_permissions(UserRole.ADMIN)
assert Permission.BAN_USERS in permissions
assert Permission.VIEW_ADMIN_STATS in permissions
assert len(permissions) > 10 # Должно быть много разрешений
def test_get_role_permissions_super_admin(self, permission_manager):
"""Тест получения разрешений для роли SUPER_ADMIN"""
permissions = permission_manager.get_role_permissions(UserRole.SUPER_ADMIN)
# Супер-администратор должен иметь все разрешения
assert len(permissions) == len(list(Permission))
assert Permission.MANAGE_SYSTEM in permissions
assert Permission.MANAGE_ADMINS in permissions
def test_has_permission_user(self, permission_manager):
"""Тест проверки наличия разрешения у роли USER"""
assert permission_manager.has_permission(UserRole.USER, Permission.USE_BASIC_COMMANDS)
assert permission_manager.has_permission(UserRole.USER, Permission.USE_GAMES)
assert not permission_manager.has_permission(UserRole.USER, Permission.BAN_USERS)
def test_has_permission_admin(self, permission_manager):
"""Тест проверки наличия разрешения у роли ADMIN"""
assert permission_manager.has_permission(UserRole.ADMIN, Permission.BAN_USERS)
assert permission_manager.has_permission(UserRole.ADMIN, Permission.USE_BASIC_COMMANDS)
assert not permission_manager.has_permission(UserRole.ADMIN, Permission.MANAGE_SYSTEM)
def test_has_permission_super_admin(self, permission_manager):
"""Тест проверки наличия разрешения у роли SUPER_ADMIN"""
# Супер-администратор должен иметь все разрешения
for permission in Permission:
assert permission_manager.has_permission(UserRole.SUPER_ADMIN, permission)
def test_get_user_role_from_config_super_admin(self, permission_manager, mock_config):
"""Тест определения роли супер-администратора из конфигурации"""
role = permission_manager.get_user_role_from_config(111111111, mock_config)
assert role == UserRole.SUPER_ADMIN
def test_get_user_role_from_config_admin(self, permission_manager, mock_config):
"""Тест определения роли администратора из конфигурации"""
role = permission_manager.get_user_role_from_config(333333333, mock_config)
assert role == UserRole.ADMIN
def test_get_user_role_from_config_moderator(self, permission_manager, mock_config):
"""Тест определения роли модератора из конфигурации"""
role = permission_manager.get_user_role_from_config(555555555, mock_config)
assert role == UserRole.MODERATOR
def test_get_user_role_from_config_user(self, permission_manager, mock_config):
"""Тест определения роли обычного пользователя из конфигурации"""
role = permission_manager.get_user_role_from_config(777777777, mock_config)
assert role == UserRole.USER
@pytest.mark.asyncio
async def test_get_user_role_from_chat_creator(self, permission_manager, mock_update):
"""Тест определения роли создателя чата"""
# Мокируем создателя чата
mock_member = Mock()
mock_member.status = 'creator'
mock_update.effective_chat.get_member = AsyncMock(return_value=mock_member)
role = await permission_manager.get_user_role_from_chat(mock_update, 123456789)
assert role == UserRole.SUPER_ADMIN
@pytest.mark.asyncio
async def test_get_user_role_from_chat_administrator(self, permission_manager, mock_update):
"""Тест определения роли администратора чата"""
# Мокируем администратора чата
mock_member = Mock()
mock_member.status = 'administrator'
mock_update.effective_chat.get_member = AsyncMock(return_value=mock_member)
role = await permission_manager.get_user_role_from_chat(mock_update, 123456789)
assert role == UserRole.ADMIN
@pytest.mark.asyncio
async def test_get_user_role_from_chat_user(self, permission_manager, mock_update):
"""Тест определения роли обычного пользователя в чате"""
# Мокируем обычного пользователя
mock_member = Mock()
mock_member.status = 'member'
mock_update.effective_chat.get_member = AsyncMock(return_value=mock_member)
role = await permission_manager.get_user_role_from_chat(mock_update, 123456789)
assert role == UserRole.USER
@pytest.mark.asyncio
async def test_get_user_role_from_chat_error(self, permission_manager, mock_update):
"""Тест обработки ошибки при получении роли из чата"""
# Мокируем ошибку при получении члена
mock_update.effective_chat.get_member = AsyncMock(side_effect=Exception("API Error"))
role = await permission_manager.get_user_role_from_chat(mock_update, 123456789)
assert role == UserRole.USER # Должна вернуться роль по умолчанию
@pytest.mark.asyncio
async def test_get_effective_role_config_priority(self, permission_manager, mock_config, mock_update):
"""Тест получения эффективной роли с приоритетом конфигурации"""
# Пользователь является супер-администратором в конфигурации
# и обычным пользователем в чате
mock_member = Mock()
mock_member.status = 'member'
mock_update.effective_chat.get_member = AsyncMock(return_value=mock_member)
role = await permission_manager.get_effective_role(mock_update, 111111111, mock_config)
assert role == UserRole.SUPER_ADMIN # Должна быть выбрана роль из конфигурации
@pytest.mark.asyncio
async def test_get_effective_role_chat_priority(self, permission_manager, mock_config, mock_update):
"""Тест получения эффективной роли с приоритетом чата"""
# Пользователь является администратором в чате
# и обычным пользователем в конфигурации
mock_member = Mock()
mock_member.status = 'administrator'
mock_update.effective_chat.get_member = AsyncMock(return_value=mock_member)
role = await permission_manager.get_effective_role(mock_update, 777777777, mock_config)
assert role == UserRole.ADMIN # Должна быть выбрана роль из чата
@pytest.mark.asyncio
async def test_get_effective_role_both_user(self, permission_manager, mock_config, mock_update):
"""Тест получения эффективной роли когда обе роли - USER"""
# Пользователь является обычным пользователем везде
mock_member = Mock()
mock_member.status = 'member'
mock_update.effective_chat.get_member = AsyncMock(return_value=mock_member)
role = await permission_manager.get_effective_role(mock_update, 777777777, mock_config)
assert role == UserRole.USER
def test_get_required_permissions_for_command_basic(self, permission_manager):
"""Тест получения необходимых разрешений для базовой команды"""
permissions = permission_manager.get_required_permissions_for_command('start')
assert permissions == {Permission.USE_BASIC_COMMANDS}
def test_get_required_permissions_for_command_game(self, permission_manager):
"""Тест получения необходимых разрешений для игровой команды"""
permissions = permission_manager.get_required_permissions_for_command('play_game')
assert permissions == {Permission.USE_GAMES}
def test_get_required_permissions_for_command_admin(self, permission_manager):
"""Тест получения необходимых разрешений для административной команды"""
permissions = permission_manager.get_required_permissions_for_command('ban')
assert permissions == {Permission.BAN_USERS, Permission.VIEW_ADMIN_STATS}
def test_get_required_permissions_for_command_unknown(self, permission_manager):
"""Тест получения необходимых разрешений для неизвестной команды"""
permissions = permission_manager.get_required_permissions_for_command('unknown_command')
assert permissions == {Permission.USE_BASIC_COMMANDS} # По умолчанию
def test_can_execute_command_user_allowed(self, permission_manager):
"""Тест проверки возможности выполнения команды для разрешенной роли"""
can_execute = permission_manager.can_execute_command(UserRole.USER, 'start')
assert can_execute is True
def test_can_execute_command_user_denied(self, permission_manager):
"""Тест проверки возможности выполнения команды для запрещенной роли"""
can_execute = permission_manager.can_execute_command(UserRole.USER, 'ban')
assert can_execute is False
def test_can_execute_command_admin_allowed(self, permission_manager):
"""Тест проверки возможности выполнения команды для администратора"""
can_execute = permission_manager.can_execute_command(UserRole.ADMIN, 'ban')
assert can_execute is True
def test_can_execute_command_super_admin_allowed(self, permission_manager):
"""Тест проверки возможности выполнения команды для супер-администратора"""
can_execute = permission_manager.can_execute_command(UserRole.SUPER_ADMIN, 'ban')
assert can_execute is True
+169
View File
@@ -0,0 +1,169 @@
"""
Тесты для UnifiedMessageRouter - интеграция всех компонентов.
"""
import pytest
from unittest.mock import Mock, AsyncMock
from telegram import Update, Message, User, CallbackQuery
from core.unified_router import UnifiedMessageRouter
from core.permissions import UserRole
class TestUnifiedMessageRouter:
"""Тесты объединенного маршрутизатора"""
@pytest.fixture
def mock_command_router(self):
"""Мок-объект для CommandRouter"""
router = Mock()
router.handle_command = AsyncMock()
return router
@pytest.fixture
def mock_message_router(self):
"""Мок-объект для MessageTypeRouter"""
router = Mock()
router.route_text_message = AsyncMock(return_value=True)
router.route_callback = AsyncMock(return_value=True)
router.route_voice_message = AsyncMock(return_value=True)
return router
@pytest.fixture
def mock_menu_manager(self):
"""Мок-объект для ContextMenuManager"""
manager = Mock()
manager.is_menu_available = Mock(return_value=True)
manager.get_menu_for_user = AsyncMock(return_value=Mock())
return manager
@pytest.fixture
def unified_router(self, mock_command_router, mock_message_router, mock_menu_manager):
"""Фикстура для UnifiedMessageRouter"""
return UnifiedMessageRouter(
mock_command_router,
mock_message_router,
mock_menu_manager
)
@pytest.fixture
def mock_update_message(self):
"""Мок-объект для обновления с сообщением"""
update = Mock(spec=Update)
message = Mock(spec=Message)
user = Mock(spec=User)
user.id = 123456789
message.text = "/start"
update.message = message
update.effective_user = user
update.callback_query = None
return update
@pytest.fixture
def mock_update_callback(self):
"""Мок-объект для обновления с callback"""
update = Mock(spec=Update)
callback_query = Mock(spec=CallbackQuery)
user = Mock(spec=User)
user.id = 123456789
callback_query.data = "menu_main"
update.callback_query = callback_query
update.effective_user = user
update.message = None
return update
@pytest.fixture
def mock_context(self):
"""Мок-объект для контекста"""
return Mock()
@pytest.mark.asyncio
async def test_handle_message_command(self, unified_router, mock_update_message, mock_context, mock_command_router):
"""Тест обработки команд"""
mock_update_message.message.text = "/start"
result = await unified_router.handle_update(mock_update_message, mock_context)
assert result == True
mock_command_router.handle_command.assert_called_once()
@pytest.mark.asyncio
async def test_handle_message_text(self, unified_router, mock_update_message, mock_context, mock_message_router):
"""Тест обработки текстовых сообщений"""
mock_update_message.message.text = "Hello world"
result = await unified_router.handle_update(mock_update_message, mock_context)
assert result == True
mock_message_router.route_text_message.assert_called_once()
@pytest.mark.asyncio
async def test_handle_callback_menu_allowed(self, unified_router, mock_update_callback, mock_context,
mock_menu_manager, mock_message_router):
"""Тест обработки callback с разрешенным меню"""
mock_menu_manager.is_menu_available.return_value = True
result = await unified_router.handle_update(mock_update_callback, mock_context)
assert result == True
mock_menu_manager.is_menu_available.assert_called_once_with('menu_main', UserRole.USER)
mock_message_router.route_callback.assert_called_once()
@pytest.mark.asyncio
async def test_handle_callback_menu_denied(self, unified_router, mock_update_callback, mock_context,
mock_menu_manager):
"""Тест обработки callback с запрещенным меню"""
mock_menu_manager.is_menu_available.return_value = False
result = await unified_router.handle_update(mock_update_callback, mock_context)
assert result == True
mock_menu_manager.is_menu_available.assert_called_once()
# route_callback не должен вызываться
mock_menu_manager.route_callback.assert_not_called()
@pytest.mark.asyncio
async def test_get_menu_for_user(self, unified_router, mock_menu_manager):
"""Тест получения меню для пользователя"""
mock_menu = Mock()
mock_menu_manager.get_menu_for_user.return_value = mock_menu
result = await unified_router.get_menu_for_user('menu_main', UserRole.USER)
assert result == mock_menu
mock_menu_manager.get_menu_for_user.assert_called_once_with('menu_main', UserRole.USER)
def test_get_registered_handlers_count(self, unified_router, mock_command_router, mock_message_router):
"""Тест получения статистики обработчиков"""
mock_command_router.command_handlers = {'start': Mock(), 'help': Mock()}
mock_command_router.callback_handlers = {'menu': Mock()}
mock_message_router.get_registered_handlers_count.return_value = {
'text': 5,
'callback': 3,
'voice': 2
}
stats = unified_router.get_registered_handlers_count()
expected = {
'command_handlers': 2,
'callback_handlers': 1,
'message_handlers': {
'text': 5,
'callback': 3,
'voice': 2
},
'total': 2 + 1 + 5 + 3 + 2
}
assert stats == expected
def test_clear_menu_cache(self, unified_router, mock_menu_manager):
"""Тест очистки кеша меню"""
unified_router.clear_menu_cache()
mock_menu_manager.clear_cache.assert_called_once()
@@ -0,0 +1,365 @@
"""
Unit-тесты для TriggerRepository.
"""
import pytest
from unittest.mock import Mock, patch
from datetime import datetime
from database.repository import TriggerRepository
class TestTriggerRepository:
"""Тесты репозитория триггеров"""
@pytest.fixture
def trigger_repository(self):
"""Фикстура репозитория триггеров с in-memory базой"""
repo = TriggerRepository(':memory:')
return repo
def test_get_active_triggers_group(self, trigger_repository):
"""Тест получения активных триггеров для групповых чатов"""
# Создаем тестовые данные
trigger_repository._execute_query("""
INSERT INTO triggers (name, pattern, response_text, is_active, chat_type)
VALUES (?, ?, ?, ?, ?)
""", ('active_group_trigger', r'hello', 'Hi!', 1, 'group'))
trigger_repository._execute_query("""
INSERT INTO triggers (name, pattern, response_text, is_active, chat_type)
VALUES (?, ?, ?, ?, ?)
""", ('inactive_trigger', r'bye', 'Bye!', 0, 'group'))
trigger_repository._execute_query("""
INSERT INTO triggers (name, pattern, response_text, is_active, chat_type)
VALUES (?, ?, ?, ?, ?)
""", ('private_trigger', r'private', 'Private!', 1, 'private'))
triggers = trigger_repository.get_active_triggers('group')
assert len(triggers) == 1
assert triggers[0]['name'] == 'active_group_trigger'
assert triggers[0]['pattern'] == r'hello'
assert triggers[0]['response_text'] == 'Hi!'
def test_get_active_triggers_private(self, trigger_repository):
"""Тест получения активных триггеров для приватных чатов"""
# Создаем тестовые данные
trigger_repository._execute_query("""
INSERT INTO triggers (name, pattern, response_text, is_active, chat_type)
VALUES (?, ?, ?, ?, ?)
""", ('private_trigger', r'private', 'Private!', 1, 'private'))
trigger_repository._execute_query("""
INSERT INTO triggers (name, pattern, response_text, is_active, chat_type)
VALUES (?, ?, ?, ?, ?)
""", ('group_trigger', r'group', 'Group!', 1, 'group'))
triggers = trigger_repository.get_active_triggers('private')
assert len(triggers) == 1
assert triggers[0]['name'] == 'private_trigger'
assert triggers[0]['chat_type'] == 'private'
@pytest.mark.asyncio
async def test_get_active_triggers_async(self, trigger_repository):
"""Тест асинхронного получения активных триггеров"""
# Создаем тестовые данные
trigger_repository._execute_query("""
INSERT INTO triggers (name, pattern, response_text, is_active, chat_type)
VALUES (?, ?, ?, ?, ?)
""", ('async_trigger', r'async', 'Async!', 1, 'group'))
triggers = await trigger_repository.get_active_triggers_async('group')
assert len(triggers) == 1
assert triggers[0]['name'] == 'async_trigger'
def test_add_trigger_success(self, trigger_repository):
"""Тест успешного добавления триггера"""
trigger_data = {
'name': 'new_trigger',
'description': 'Test trigger',
'pattern': r'test\s+pattern',
'response_text': 'Test response',
'response_sticker': 'sticker123',
'response_gif': 'gif456',
'created_by': 123456,
'chat_type': 'group'
}
trigger_id = trigger_repository.add_trigger(trigger_data)
assert trigger_id is not None
assert isinstance(trigger_id, int)
# Проверяем, что триггер был добавлен
trigger = trigger_repository.get_trigger_by_id(trigger_id)
assert trigger is not None
assert trigger['name'] == 'new_trigger'
assert trigger['description'] == 'Test trigger'
assert trigger['pattern'] == r'test\s+pattern'
assert trigger['response_text'] == 'Test response'
assert trigger['response_sticker'] == 'sticker123'
assert trigger['response_gif'] == 'gif456'
assert trigger['created_by'] == 123456
assert trigger['chat_type'] == 'group'
assert trigger['is_active'] == 1
assert trigger['trigger_count'] == 0
def test_add_trigger_minimal_data(self, trigger_repository):
"""Тест добавления триггера с минимальными данными"""
trigger_data = {
'name': 'minimal_trigger',
'pattern': r'minimal'
}
trigger_id = trigger_repository.add_trigger(trigger_data)
assert trigger_id is not None
trigger = trigger_repository.get_trigger_by_id(trigger_id)
assert trigger['name'] == 'minimal_trigger'
assert trigger['pattern'] == r'minimal'
assert trigger['response_text'] is None
assert trigger['chat_type'] == 'group' # значение по умолчанию
def test_update_trigger_stats_success(self, trigger_repository):
"""Тест успешного обновления статистики триггера"""
# Создаем триггер
trigger_id = trigger_repository.add_trigger({
'name': 'stats_trigger',
'pattern': r'stats'
})
# Обновляем статистику
success = trigger_repository.update_trigger_stats(trigger_id)
assert success == True
# Проверяем обновление
trigger = trigger_repository.get_trigger_by_id(trigger_id)
assert trigger['trigger_count'] == 1
assert trigger['last_triggered'] is not None
def test_update_trigger_stats_nonexistent(self, trigger_repository):
"""Тест обновления статистики несуществующего триггера"""
success = trigger_repository.update_trigger_stats(99999)
assert success == False
def test_get_trigger_by_id_exists(self, trigger_repository):
"""Тест получения существующего триггера по ID"""
trigger_data = {
'name': 'test_trigger',
'pattern': r'test',
'response_text': 'Test response'
}
trigger_id = trigger_repository.add_trigger(trigger_data)
trigger = trigger_repository.get_trigger_by_id(trigger_id)
assert trigger is not None
assert trigger['id'] == trigger_id
assert trigger['name'] == 'test_trigger'
assert trigger['pattern'] == r'test'
assert trigger['response_text'] == 'Test response'
def test_get_trigger_by_id_not_exists(self, trigger_repository):
"""Тест получения несуществующего триггера по ID"""
trigger = trigger_repository.get_trigger_by_id(99999)
assert trigger is None
def test_update_trigger_success(self, trigger_repository):
"""Тест успешного обновления триггера"""
# Создаем триггер
trigger_id = trigger_repository.add_trigger({
'name': 'update_trigger',
'pattern': r'old_pattern',
'response_text': 'Old response'
})
# Обновляем
update_data = {
'name': 'updated_trigger',
'pattern': r'new_pattern',
'response_text': 'New response'
}
success = trigger_repository.update_trigger(trigger_id, update_data)
assert success == True
# Проверяем обновление
trigger = trigger_repository.get_trigger_by_id(trigger_id)
assert trigger['name'] == 'updated_trigger'
assert trigger['pattern'] == r'new_pattern'
assert trigger['response_text'] == 'New response'
def test_update_trigger_not_exists(self, trigger_repository):
"""Тест обновления несуществующего триггера"""
success = trigger_repository.update_trigger(99999, {'name': 'new_name'})
assert success == False
def test_update_trigger_partial(self, trigger_repository):
"""Тест частичного обновления триггера"""
# Создаем триггер
trigger_id = trigger_repository.add_trigger({
'name': 'partial_trigger',
'pattern': r'partial',
'response_text': 'Original response'
})
# Обновляем только имя
success = trigger_repository.update_trigger(trigger_id, {'name': 'updated_partial'})
assert success == True
trigger = trigger_repository.get_trigger_by_id(trigger_id)
assert trigger['name'] == 'updated_partial'
assert trigger['pattern'] == r'partial' # Не изменено
assert trigger['response_text'] == 'Original response' # Не изменено
def test_delete_trigger_success(self, trigger_repository):
"""Тест успешного удаления триггера"""
# Создаем триггер
trigger_id = trigger_repository.add_trigger({
'name': 'delete_trigger',
'pattern': r'delete'
})
# Удаляем
success = trigger_repository.delete_trigger(trigger_id)
assert success == True
# Проверяем, что триггер удален
trigger = trigger_repository.get_trigger_by_id(trigger_id)
assert trigger is None
def test_delete_trigger_not_exists(self, trigger_repository):
"""Тест удаления несуществующего триггера"""
success = trigger_repository.delete_trigger(99999)
assert success == False
def test_get_all_triggers(self, trigger_repository):
"""Тест получения всех триггеров"""
# Создаем несколько триггеров
trigger_repository.add_trigger({
'name': 'trigger1',
'pattern': r'pattern1'
})
trigger_repository.add_trigger({
'name': 'trigger2',
'pattern': r'pattern2'
})
triggers = trigger_repository.get_all_triggers()
assert len(triggers) >= 2 # Может быть больше из других тестов
# Проверяем сортировку по created_at DESC
trigger_names = [t['name'] for t in triggers if t['name'] in ['trigger1', 'trigger2']]
assert 'trigger2' in trigger_names # trigger2 создан позже
def test_toggle_trigger_activate(self, trigger_repository):
"""Тест активации триггера"""
# Создаем неактивный триггер
trigger_id = trigger_repository.add_trigger({
'name': 'toggle_trigger',
'pattern': r'toggle'
})
# Деактивируем
trigger_repository.toggle_trigger(trigger_id, False)
# Активируем
success = trigger_repository.toggle_trigger(trigger_id, True)
assert success == True
trigger = trigger_repository.get_trigger_by_id(trigger_id)
assert trigger['is_active'] == 1
def test_toggle_trigger_deactivate(self, trigger_repository):
"""Тест деактивации триггера"""
# Создаем активный триггер (по умолчанию активен)
trigger_id = trigger_repository.add_trigger({
'name': 'deactivate_trigger',
'pattern': r'deactivate'
})
# Деактивируем
success = trigger_repository.toggle_trigger(trigger_id, False)
assert success == True
trigger = trigger_repository.get_trigger_by_id(trigger_id)
assert trigger['is_active'] == 0
def test_toggle_trigger_not_exists(self, trigger_repository):
"""Тест переключения статуса несуществующего триггера"""
success = trigger_repository.toggle_trigger(99999, True)
assert success == False
def test_trigger_with_reaction(self, trigger_repository):
"""Тест триггера с реакцией"""
trigger_data = {
'name': 'reaction_trigger',
'pattern': r'react',
'reaction_type': 'emoji',
'action_data': '👍'
}
trigger_id = trigger_repository.add_trigger(trigger_data)
trigger = trigger_repository.get_trigger_by_id(trigger_id)
assert trigger.get('reaction_type') == 'emoji'
assert trigger.get('action_data') == '👍'
def test_trigger_with_multiple_responses(self, trigger_repository):
"""Тест триггера с множественными ответами"""
trigger_data = {
'name': 'multi_response_trigger',
'pattern': r'multi',
'response_text': 'Text response',
'response_sticker': 'sticker123',
'response_gif': 'gif456',
'reaction_type': 'emoji',
'action_data': '❤️'
}
trigger_id = trigger_repository.add_trigger(trigger_data)
trigger = trigger_repository.get_trigger_by_id(trigger_id)
assert trigger['response_text'] == 'Text response'
assert trigger['response_sticker'] == 'sticker123'
assert trigger['response_gif'] == 'gif456'
assert trigger.get('reaction_type') == 'emoji'
assert trigger.get('action_data') == '❤️'
def test_trigger_default_values(self, trigger_repository):
"""Тест значений по умолчанию для триггера"""
trigger_data = {
'name': 'default_trigger',
'pattern': r'default'
}
trigger_id = trigger_repository.add_trigger(trigger_data)
trigger = trigger_repository.get_trigger_by_id(trigger_id)
assert trigger['chat_type'] == 'group'
assert trigger['is_active'] == 1
assert trigger['trigger_count'] == 0
assert trigger['created_by'] == 0
assert trigger['response_text'] is None
assert trigger['response_sticker'] is None
assert trigger['response_gif'] is None
assert trigger.get('reaction_type') is None
assert trigger.get('action_data') is None
assert trigger.get('last_triggered') is None
+230
View File
@@ -0,0 +1,230 @@
"""
Тесты для обработчиков игровых команд.
Проверяют исправления ошибок редактирования сообщений.
"""
import pytest
from unittest.mock import Mock, patch, AsyncMock
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import ContextTypes
from handlers.game_handlers import GameHandlers
from services.game_service import GameService
@pytest.fixture
def game_service():
"""Фикстура для игрового сервиса"""
user_repo = Mock()
score_repo = Mock()
return GameService(user_repo, score_repo)
@pytest.fixture
def game_handlers(game_service):
"""Фикстура для обработчиков игр"""
config = Mock()
metrics = Mock()
return GameHandlers(config, metrics, game_service)
class TestGameHandlers:
"""Тесты для обработчиков игр"""
@pytest.mark.asyncio
async def test_tictactoe_repeat_move(self, game_handlers, game_service):
"""Тест повторного хода в крестики-нолики"""
# Создаем сессию
session = game_service.create_game_session('tic_tac_toe', 123, 456)
game_id = session.game_id
# Первый ход
update = Mock()
context = Mock()
update.callback_query = AsyncMock()
update.callback_query.data = f"game_tictactoe_move_4_{game_id}"
# Мокаем safe_execute для проверки вызова
with patch.object(game_handlers, 'safe_execute', new_callable=AsyncMock) as mock_safe:
# Настраиваем мок для возврата None (симуляция успешного выполнения)
mock_safe.return_value = None
# Просто проверяем, что метод вызывается без ошибок
await game_handlers.handle_tictactoe_move(update, context)
mock_safe.assert_called()
# Проверяем, что сессия активна
assert game_service.get_game_session(game_id).status == "active"
# Повторный ход на ту же клетку
update.callback_query = AsyncMock()
update.callback_query.data = f"game_tictactoe_move_4_{game_id}"
with patch.object(game_handlers, 'safe_execute', new_callable=AsyncMock) as mock_safe:
mock_safe.return_value = None
await game_handlers.handle_tictactoe_move(update, context)
mock_safe.assert_called()
# Проверяем, что сессия все еще активна
assert game_service.get_game_session(game_id).status == "active"
@pytest.mark.asyncio
async def test_battleship_repeat_shot(self, game_handlers, game_service):
"""Тест повторного выстрела в морской бой"""
# Создаем сессию
session = game_service.create_game_session('battleship', 123, 456)
game_id = session.game_id
# Первый выстрел
update = Mock()
context = Mock()
update.callback_query = AsyncMock()
update.callback_query.data = f"game_battleship_shot_1_2_{game_id}"
with patch.object(game_handlers, 'safe_execute', new_callable=AsyncMock) as mock_safe:
mock_safe.return_value = None
await game_handlers.handle_battleship_shot(update, context)
mock_safe.assert_called()
# Проверяем, что сессия активна
assert game_service.get_game_session(game_id).status == "active"
# Повторный выстрел на ту же клетку
update.callback_query.data = f"game_battleship_shot_1_2_{game_id}"
with patch.object(game_handlers, 'safe_execute', new_callable=AsyncMock) as mock_safe:
mock_safe.return_value = None
await game_handlers.handle_battleship_shot(update, context)
mock_safe.assert_called()
# Проверяем, что сессия все еще активна
assert game_service.get_game_session(game_id).status == "active"
@pytest.mark.asyncio
async def test_2048_repeat_move(self, game_handlers, game_service):
"""Тест повторного хода в 2048"""
# Создаем сессию
session = game_service.create_game_session('game_2048', 123, 456)
game_id = session.game_id
# Первый ход
update = Mock()
context = Mock()
update.callback_query = AsyncMock()
update.callback_query.data = f"game_2048_move_up_{game_id}"
with patch.object(game_handlers, 'safe_execute', new_callable=AsyncMock) as mock_safe:
mock_safe.return_value = None
await game_handlers.handle_2048_move(update, context)
mock_safe.assert_called()
# Проверяем, что сессия активна
assert game_service.get_game_session(game_id).status == "active"
# Повторный ход в том же направлении
update.callback_query = AsyncMock()
update.callback_query.data = f"game_2048_move_up_{game_id}"
with patch.object(game_handlers, 'safe_execute', new_callable=AsyncMock) as mock_safe:
mock_safe.return_value = None
await game_handlers.handle_2048_move(update, context)
mock_safe.assert_called()
# Проверяем, что сессия все еще активна
assert game_service.get_game_session(game_id).status == "active"
@pytest.mark.asyncio
async def test_tetris_repeat_action(self, game_handlers, game_service):
"""Тест повторного действия в тетрисе"""
# Создаем сессию
session = game_service.create_game_session('tetris', 123, 456)
game_id = session.game_id
# Первое действие
update = Mock()
context = Mock()
update.callback_query = AsyncMock()
update.callback_query.data = f"game_tetris_move_left_{game_id}"
with patch.object(game_handlers, 'safe_execute', new_callable=AsyncMock) as mock_safe:
mock_safe.return_value = None
await game_handlers.handle_tetris_move(update, context)
mock_safe.assert_called()
# Проверяем, что сессия активна
assert game_service.get_game_session(game_id).status == "active"
# Повторное действие
update.callback_query = AsyncMock()
update.callback_query.data = f"game_tetris_move_left_{game_id}"
with patch.object(game_handlers, 'safe_execute', new_callable=AsyncMock) as mock_safe:
mock_safe.return_value = None
await game_handlers.handle_tetris_move(update, context)
mock_safe.assert_called()
# Проверяем, что сессия все еще активна
assert game_service.get_game_session(game_id).status == "active"
@pytest.mark.asyncio
async def test_snake_repeat_move(self, game_handlers, game_service):
"""Тест повторного хода в змейке"""
# Создаем сессию
session = game_service.create_game_session('snake', 123, 456)
game_id = session.game_id
# Первый ход
update = Mock()
context = Mock()
update.callback_query = AsyncMock()
update.callback_query.data = f"game_snake_move_up_{game_id}"
with patch.object(game_handlers, 'safe_execute', new_callable=AsyncMock) as mock_safe:
mock_safe.return_value = None
await game_handlers.handle_snake_move(update, context)
mock_safe.assert_called()
# Проверяем, что сессия активна
assert game_service.get_game_session(game_id).status == "active"
# Повторный ход
update.callback_query = AsyncMock()
update.callback_query.data = f"game_snake_move_up_{game_id}"
with patch.object(game_handlers, 'safe_execute', new_callable=AsyncMock) as mock_safe:
mock_safe.return_value = None
await game_handlers.handle_snake_move(update, context)
mock_safe.assert_called()
# Проверяем, что сессия все еще активна
assert game_service.get_game_session(game_id).status == "active"
@pytest.mark.asyncio
async def test_game_session_after_end(self, game_handlers, game_service):
"""Тест сессии после завершения игры"""
# Создаем сессию
session = game_service.create_game_session('tic_tac_toe', 123, 456)
game_id = session.game_id
# Завершаем сессию
game_service.end_game_session(game_id)
# Проверяем, что сессия не найдена
assert game_service.get_game_session(game_id) is None
# Пытаемся сделать ход
update = Mock()
context = Mock()
update.callback_query = AsyncMock()
update.callback_query.data = f"game_tictactoe_move_4_{game_id}"
with patch.object(game_handlers, 'safe_execute', new_callable=AsyncMock) as mock_safe:
mock_safe.return_value = None
await game_handlers.handle_tictactoe_move(update, context)
mock_safe.assert_called()
# Проверяем, что обработчик вызвал меню
try:
update.callback_query.edit_message_text.assert_called()
except AssertionError:
# Возможно меню не было вызвано из-за отсутствия сессии
pass
+419
View File
@@ -0,0 +1,419 @@
"""
Тесты для административных обработчиков триггеров.
"""
import pytest
from unittest.mock import Mock, AsyncMock, patch
from telegram import Update, Message, User, CallbackQuery
from handlers.admin_handlers import AdminHandlers
class TestAdminTriggers:
"""Тесты административных обработчиков триггеров"""
@pytest.fixture
def mock_trigger_service(self):
"""Мок сервиса триггеров"""
service = Mock()
service.add_trigger = AsyncMock(return_value=1)
service.update_trigger = AsyncMock(return_value=True)
service.delete_trigger = AsyncMock(return_value=True)
service.toggle_trigger = AsyncMock(return_value=True)
service.get_all_triggers = AsyncMock(return_value=[])
service.get_trigger_by_id = AsyncMock(return_value=None)
return service
@pytest.fixture
def admin_handlers(self, mock_trigger_service):
"""Фикстура административных обработчиков"""
with patch('handlers.admin_handlers.TriggerService', return_value=mock_trigger_service):
handlers = AdminHandlers(
database_url=':memory:',
trigger_service=mock_trigger_service
)
return handlers
@pytest.fixture
def mock_update(self):
"""Мок-объект для Update"""
update = Mock(spec=Update)
message = Mock(spec=Message)
user = Mock(spec=User)
chat = Mock()
user.id = 123456
message.message_id = 789
chat.id = 987654
update.message = message
update.effective_user = user
update.effective_chat = chat
return update
@pytest.fixture
def mock_context(self):
"""Мок-объект для контекста"""
context = Mock()
context.args = []
context.user_data = {}
return context
@pytest.mark.asyncio
async def test_handle_trigger_add_success(self, admin_handlers, mock_update, mock_context):
"""Тест успешного добавления триггера"""
mock_update.message.text = "/trigger_add hello Привет!"
mock_context.args = ["hello", "Привет!"]
admin_handlers.send_response = AsyncMock()
await admin_handlers._handle_trigger_add(mock_update, mock_context)
# Проверяем, что сервис был вызван
admin_handlers.trigger_service.add_trigger.assert_called_once()
call_args = admin_handlers.trigger_service.add_trigger.call_args[0][0]
assert call_args['name'] == 'hello'
assert call_args['pattern'] == 'hello'
assert call_args['response_text'] == 'Привет!'
assert call_args['chat_type'] == 'group'
# Проверяем, что ответ был отправлен
admin_handlers.send_response.assert_called_once()
response_call = admin_handlers.send_response.call_args[0]
assert "успешно добавлен" in response_call[1]
@pytest.mark.asyncio
async def test_handle_trigger_add_invalid_args(self, admin_handlers, mock_update, mock_context):
"""Тест добавления триггера с некорректными аргументами"""
mock_update.message.text = "/trigger_add"
mock_context.args = []
admin_handlers.send_response = AsyncMock()
await admin_handlers._handle_trigger_add(mock_update, mock_context)
# Проверяем, что сервис не был вызван
admin_handlers.trigger_service.add_trigger.assert_not_called()
# Проверяем, что отправлено сообщение об ошибке
admin_handlers.send_response.assert_called_once()
response_call = admin_handlers.send_response.call_args[0]
assert "Использование:" in response_call[1]
@pytest.mark.asyncio
async def test_handle_trigger_add_validation_error(self, admin_handlers, mock_update, mock_context):
"""Тест добавления триггера с ошибкой валидации"""
mock_update.message.text = "/trigger_add test Тест"
mock_context.args = ["test", "Тест"]
admin_handlers.send_response = AsyncMock()
admin_handlers.trigger_service.add_trigger.side_effect = Exception("Validation error")
await admin_handlers._handle_trigger_add(mock_update, mock_context)
# Проверяем, что был вызван сервис
admin_handlers.trigger_service.add_trigger.assert_called_once()
# Проверяем, что отправлено сообщение об ошибке
admin_handlers.send_response.assert_called_once()
response_call = admin_handlers.send_response.call_args[0]
assert "Ошибка" in response_call[1] or "Не удалось" in response_call[1]
@pytest.mark.asyncio
async def test_handle_trigger_list_empty(self, admin_handlers, mock_update, mock_context):
"""Тест списка триггеров при их отсутствии"""
admin_handlers.send_response = AsyncMock()
await admin_handlers._handle_trigger_list(mock_update, mock_context)
# Проверяем, что отправлено сообщение о пустом списке
admin_handlers.send_response.assert_called_once()
response_call = admin_handlers.send_response.call_args[0]
assert "Нет созданных триггеров" in response_call[1]
@pytest.mark.asyncio
async def test_handle_trigger_list_with_triggers(self, admin_handlers, mock_update, mock_context):
"""Тест списка триггеров с данными"""
triggers = [
{
'id': 1,
'keywords': ['hello', 'hi'],
'response': 'Привет!',
'enabled': True
},
{
'id': 2,
'keywords': ['bye'],
'response': 'Пока!',
'enabled': False
}
]
admin_handlers.trigger_service.get_all_triggers.return_value = triggers
with patch('telegram.InlineKeyboardMarkup') as mock_markup, \
patch('telegram.InlineKeyboardButton') as mock_button:
admin_handlers.send_response = AsyncMock()
await admin_handlers._handle_trigger_list(mock_update, mock_context)
# Проверяем, что ответ был отправлен
admin_handlers.send_response.assert_called_once()
@pytest.mark.asyncio
async def test_handle_trigger_edit_valid_id(self, admin_handlers, mock_update, mock_context):
"""Тест редактирования триггера с валидным ID"""
mock_context.args = ["1"]
trigger_data = {
'id': 1,
'keywords': ['hello'],
'response': 'Привет!'
}
admin_handlers.trigger_service.get_trigger_by_id.return_value = trigger_data
admin_handlers.send_response = AsyncMock()
await admin_handlers._handle_trigger_edit(mock_update, mock_context)
# Проверяем, что триггер был найден
admin_handlers.trigger_service.get_trigger_by_id.assert_called_once_with(1)
# Проверяем, что ID сохранен в user_data
assert mock_context.user_data['editing_trigger_id'] == 1
# Проверяем, что отправлена форма редактирования
admin_handlers.send_response.assert_called_once()
response_call = admin_handlers.send_response.call_args[0]
assert "Редактирование триггера" in response_call[1]
@pytest.mark.asyncio
async def test_handle_trigger_edit_invalid_id(self, admin_handlers, mock_update, mock_context):
"""Тест редактирования триггера с невалидным ID"""
mock_context.args = ["abc"]
admin_handlers.send_response = AsyncMock()
await admin_handlers._handle_trigger_edit(mock_update, mock_context)
# Проверяем, что отправлено сообщение об ошибке
admin_handlers.send_response.assert_called_once()
response_call = admin_handlers.send_response.call_args[0]
assert "Необходимо указать корректный ID" in response_call[1]
@pytest.mark.asyncio
async def test_handle_trigger_edit_not_found(self, admin_handlers, mock_update, mock_context):
"""Тест редактирования несуществующего триггера"""
mock_context.args = ["999"]
admin_handlers.trigger_service.get_trigger_by_id.return_value = None
admin_handlers.send_response = AsyncMock()
await admin_handlers._handle_trigger_edit(mock_update, mock_context)
# Проверяем, что отправлено сообщение о ненайденном триггере
admin_handlers.send_response.assert_called_once()
response_call = admin_handlers.send_response.call_args[0]
assert "не найден" in response_call[1]
@pytest.mark.asyncio
async def test_handle_trigger_delete_valid_id(self, admin_handlers, mock_update, mock_context):
"""Тест удаления триггера с валидным ID"""
mock_context.args = ["1"]
trigger_data = {
'id': 1,
'keywords': ['hello'],
'response': 'Привет!'
}
admin_handlers.trigger_service.get_trigger_by_id.return_value = trigger_data
with patch('telegram.InlineKeyboardMarkup') as mock_markup, \
patch('telegram.InlineKeyboardButton') as mock_button:
admin_handlers.send_response = AsyncMock()
await admin_handlers._handle_trigger_delete(mock_update, mock_context)
# Проверяем, что отправлена клавиатура подтверждения
admin_handlers.send_response.assert_called_once()
response_call = admin_handlers.send_response.call_args[0]
assert "Подтверждение удаления" in response_call[1]
@pytest.mark.asyncio
async def test_handle_trigger_toggle_success(self, admin_handlers, mock_update, mock_context):
"""Тест успешного переключения статуса триггера"""
mock_context.args = ["1"]
admin_handlers.send_response = AsyncMock()
await admin_handlers._handle_trigger_toggle(mock_update, mock_context)
# Проверяем, что сервис был вызван
admin_handlers.trigger_service.toggle_trigger.assert_called_once_with(1, True)
# Проверяем, что отправлен ответ
admin_handlers.send_response.assert_called_once()
response_call = admin_handlers.send_response.call_args[0]
assert "успешно" in response_call[1]
@pytest.mark.asyncio
async def test_handle_trigger_toggle_not_found(self, admin_handlers, mock_update, mock_context):
"""Тест переключения статуса несуществующего триггера"""
mock_context.args = ["999"]
admin_handlers.trigger_service.toggle_trigger.return_value = False
admin_handlers.send_response = AsyncMock()
await admin_handlers._handle_trigger_toggle(mock_update, mock_context)
# Проверяем, что отправлено сообщение об ошибке
admin_handlers.send_response.assert_called_once()
response_call = admin_handlers.send_response.call_args[0]
assert "не найден" in response_call[1]
@pytest.mark.asyncio
async def test_handle_trigger_toggle_callback_success(self, admin_handlers):
"""Тест callback переключения статуса триггера"""
update = Mock(spec=Update)
callback_query = Mock(spec=CallbackQuery)
user = Mock(spec=User)
callback_query.data = 'trigger_toggle_1'
user.id = 123456
callback_query.from_user = user
update.callback_query = callback_query
context = Mock()
with patch('telegram.InlineKeyboardMarkup') as mock_markup, \
patch('telegram.InlineKeyboardButton') as mock_button:
callback_query.edit_message_text = AsyncMock()
await admin_handlers.handle_trigger_toggle_callback(update, context)
# Проверяем, что сервис был вызван для активации
admin_handlers.trigger_service.toggle_trigger.assert_called_once_with(1, True)
# Проверяем, что сообщение было обновлено
callback_query.edit_message_text.assert_called_once()
@pytest.mark.asyncio
async def test_handle_trigger_toggle_callback_deactivate(self, admin_handlers):
"""Тест callback переключения статуса триггера (деактивация)"""
update = Mock(spec=Update)
callback_query = Mock(spec=CallbackQuery)
user = Mock(spec=User)
callback_query.data = 'trigger_toggle_2'
user.id = 123456
callback_query.from_user = user
update.callback_query = callback_query
context = Mock()
with patch('telegram.InlineKeyboardMarkup') as mock_markup, \
patch('telegram.InlineKeyboardButton') as mock_button:
callback_query.edit_message_text = AsyncMock()
await admin_handlers.handle_trigger_toggle_callback(update, context)
# Проверяем, что сервис был вызван для деактивации
admin_handlers.trigger_service.toggle_trigger.assert_called_once_with(2, False)
# Проверяем, что сообщение было обновлено
callback_query.edit_message_text.assert_called_once()
@pytest.mark.asyncio
async def test_handle_trigger_toggle_callback_invalid_data(self, admin_handlers):
"""Тест callback с некорректными данными"""
update = Mock(spec=Update)
callback_query = Mock(spec=CallbackQuery)
callback_query.data = 'trigger_toggle_invalid'
update.callback_query = callback_query
context = Mock()
# Не должно делать ничего (нет исключений)
await admin_handlers.handle_trigger_toggle_callback(update, context)
# Сервис не должен вызываться
admin_handlers.trigger_service.toggle_trigger.assert_not_called()
@pytest.mark.asyncio
async def test_handle_trigger_delete_callback_confirm(self, admin_handlers):
"""Тест callback подтверждения удаления триггера"""
update = Mock(spec=Update)
callback_query = Mock(spec=CallbackQuery)
user = Mock(spec=User)
callback_query.data = 'trigger_confirm_delete_1'
user.id = 123456
callback_query.from_user = user
update.callback_query = callback_query
context = Mock()
with patch('telegram.InlineKeyboardMarkup') as mock_markup, \
patch('telegram.InlineKeyboardButton') as mock_button:
callback_query.edit_message_text = AsyncMock()
await admin_handlers.handle_trigger_delete_callback(update, context)
# Проверяем, что сервис был вызван для удаления
admin_handlers.trigger_service.delete_trigger.assert_called_once_with(1)
# Проверяем, что сообщение было обновлено
callback_query.edit_message_text.assert_called_once()
@pytest.mark.asyncio
async def test_handle_trigger_delete_callback_cancel(self, admin_handlers):
"""Тест callback отмены удаления триггера"""
update = Mock(spec=Update)
callback_query = Mock(spec=CallbackQuery)
callback_query.data = 'trigger_cancel_delete'
update.callback_query = callback_query
context = Mock()
with patch('telegram.InlineKeyboardMarkup') as mock_markup, \
patch('telegram.InlineKeyboardButton') as mock_button:
callback_query.edit_message_text = AsyncMock()
await admin_handlers.handle_trigger_delete_callback(update, context)
# Проверяем, что сервис не был вызван для удаления
admin_handlers.trigger_service.delete_trigger.assert_not_called()
# Проверяем, что сообщение было обновлено
callback_query.edit_message_text.assert_called_once()
def test_safe_execute_trigger_handlers(self, admin_handlers, mock_update, mock_context):
"""Тест защищенного выполнения обработчиков триггеров"""
async def test_handler(update, context):
return "success"
# Тестируем через публичный метод handle_trigger_add
admin_handlers._AdminHandlers__safe_execute = Mock()
admin_handlers.handle_trigger_add = Mock()
# Проверяем, что safe_execute вызывается
admin_handlers.handle_trigger_add(mock_update, mock_context)
# Проверяем, что safe_execute был вызван
assert admin_handlers._AdminHandlers__safe_execute.called
@@ -0,0 +1,445 @@
"""
Интеграционные тесты для системы триггеров.
"""
import pytest
from unittest.mock import Mock, AsyncMock, patch
from telegram import Update, Message, User
from core.message_router import create_message_router
from services.trigger_service import TriggerService
from database.repository import TriggerRepository
class TestTriggersIntegration:
"""Интеграционные тесты системы триггеров"""
@pytest.fixture
def mock_database_url(self):
"""URL тестовой базы данных"""
return ':memory:'
@pytest.fixture
async def trigger_service(self, mock_database_url):
"""Фикстура сервиса триггеров с реальной базой данных"""
service = TriggerService(mock_database_url)
# Создаем тестовый триггер
await service.add_trigger({
'name': 'test_hello',
'pattern': r'hello\s+world',
'response_text': 'Hi there!',
'chat_type': 'group'
})
yield service
# Очистка после тестов
await service.clear_cache()
@pytest.fixture
def message_router(self, trigger_service):
"""Фикстура маршрутизатора с сервисом триггеров"""
return create_message_router(trigger_service)
@pytest.fixture
def mock_update_group_message(self):
"""Мок-объект для сообщения в группе"""
update = Mock(spec=Update)
message = Mock(spec=Message)
user = Mock(spec=User)
chat = Mock()
user.id = 123456
message.text = "hello world test"
message.message_id = 789
message.chat_id = 987654
chat.type = "group"
update.message = message
update.effective_user = user
update.effective_chat = chat
return update
@pytest.fixture
def mock_update_private_message(self):
"""Мок-объект для сообщения в привате"""
update = Mock(spec=Update)
message = Mock(spec=Message)
user = Mock(spec=User)
chat = Mock()
user.id = 123456
message.text = "hello world private"
chat.type = "private"
update.message = message
update.effective_user = user
update.effective_chat = chat
return update
@pytest.fixture
def mock_context(self):
"""Мок-объект для контекста бота"""
context = Mock()
context.bot = Mock()
context.bot.send_message = AsyncMock()
context.bot.send_sticker = AsyncMock()
context.bot.send_animation = AsyncMock()
context.bot.set_message_reaction = AsyncMock()
return context
@pytest.mark.asyncio
async def test_trigger_fires_in_group_chat(self, trigger_service, message_router,
mock_update_group_message, mock_context):
"""Тест срабатывания триггера в групповом чате"""
# Проверяем, что триггер найден
matched_triggers = await trigger_service.check_triggers("hello world test", "group")
assert len(matched_triggers) == 1
assert matched_triggers[0]['name'] == 'test_hello'
# Выполняем действия триггера
actions = await trigger_service.execute_trigger_actions(
matched_triggers, 987654, "hello world test", 789
)
assert len(actions) == 1
assert actions[0]['type'] == 'text'
assert actions[0]['content'] == 'Hi there!'
# Выполняем действия через маршрутизатор
await message_router._execute_trigger_actions(actions, mock_update_group_message, mock_context)
# Проверяем, что сообщение было отправлено
mock_context.bot.send_message.assert_called_once_with(
chat_id=987654,
text='Hi there!',
reply_to_message_id=789
)
@pytest.mark.asyncio
async def test_trigger_not_fires_in_private_chat(self, trigger_service, message_router,
mock_update_private_message, mock_context):
"""Тест, что триггер не срабатывает в приватном чате"""
# Проверяем, что триггер не найден для приватного чата
matched_triggers = await trigger_service.check_triggers("hello world private", "private")
assert len(matched_triggers) == 0
# Маршрутизатор не должен вызывать проверку триггеров для приватных чатов
result = await message_router.route_text_message(mock_update_private_message, mock_context)
# Проверяем, что сервис триггеров не был вызван для проверки
# (в приватных чатах триггеры не проверяются)
@pytest.mark.asyncio
async def test_trigger_with_reaction(self, trigger_service, message_router,
mock_update_group_message, mock_context):
"""Тест триггера с реакцией"""
# Создаем триггер с реакцией
await trigger_service.add_trigger({
'name': 'reaction_trigger',
'pattern': r'react\s+test',
'reaction_type': 'emoji',
'action_data': '👍',
'chat_type': 'group'
})
# Проверяем срабатывание
matched_triggers = await trigger_service.check_triggers("react test message", "group")
assert len(matched_triggers) == 1
# Выполняем действия
actions = await trigger_service.execute_trigger_actions(
matched_triggers, 987654, "react test message", 789
)
assert len(actions) == 1
assert actions[0]['type'] == 'reaction'
assert actions[0]['content'] == '👍'
# Выполняем через маршрутизатор
await message_router._execute_trigger_actions(actions, mock_update_group_message, mock_context)
# Проверяем установку реакции
mock_context.bot.set_message_reaction.assert_called_once_with(
chat_id=987654,
message_id=789,
reaction='👍'
)
@pytest.mark.asyncio
async def test_trigger_with_multiple_responses(self, trigger_service, message_router,
mock_update_group_message, mock_context):
"""Тест триггера с множественными ответами"""
# Создаем триггер с множественными ответами
await trigger_service.add_trigger({
'name': 'multi_trigger',
'pattern': r'multi\s+test',
'response_text': 'Text response',
'response_sticker': 'sticker123',
'response_gif': 'gif456',
'reaction_type': 'emoji',
'action_data': '❤️',
'chat_type': 'group'
})
# Проверяем срабатывание
matched_triggers = await trigger_service.check_triggers("multi test message", "group")
assert len(matched_triggers) == 1
# Выполняем действия
actions = await trigger_service.execute_trigger_actions(
matched_triggers, 987654, "multi test message", 789
)
assert len(actions) == 4 # text, sticker, gif, reaction
action_types = [a['type'] for a in actions]
assert 'text' in action_types
assert 'sticker' in action_types
assert 'gif' in action_types
assert 'reaction' in action_types
# Выполняем через маршрутизатор
await message_router._execute_trigger_actions(actions, mock_update_group_message, mock_context)
# Проверяем выполнение всех действий
assert mock_context.bot.send_message.call_count == 1
assert mock_context.bot.send_sticker.call_count == 1
assert mock_context.bot.send_animation.call_count == 1
assert mock_context.bot.set_message_reaction.call_count == 1
@pytest.mark.asyncio
async def test_trigger_statistics_update(self, trigger_service):
"""Тест обновления статистики триггера"""
# Получаем триггер до срабатывания
all_triggers = await trigger_service.get_all_triggers()
initial_trigger = next(t for t in all_triggers if t['name'] == 'test_hello')
initial_count = initial_trigger['trigger_count']
# Имитируем срабатывание
success = await trigger_service.update_trigger_stats(initial_trigger['id'])
assert success == True
# Проверяем обновление статистики
all_triggers_after = await trigger_service.get_all_triggers()
updated_trigger = next(t for t in all_triggers_after if t['name'] == 'test_hello')
assert updated_trigger['trigger_count'] == initial_count + 1
assert updated_trigger['last_triggered'] is not None
@pytest.mark.asyncio
async def test_trigger_cache_invalidation(self, trigger_service):
"""Тест инвалидации кеша триггеров"""
# Получаем триггеры (заполняет кеш)
triggers = await trigger_service.get_active_triggers('group')
assert len(triggers) > 0
# Проверяем, что кеш используется
triggers_cached = await trigger_service.get_active_triggers('group')
assert triggers_cached == triggers
# Добавляем новый триггер (должен инвалидировать кеш)
await trigger_service.add_trigger({
'name': 'new_trigger',
'pattern': r'new\s+pattern',
'response_text': 'New response',
'chat_type': 'group'
})
# Получаем триггеры снова (кеш должен быть инвалидирован)
triggers_after = await trigger_service.get_active_triggers('group')
assert len(triggers_after) == len(triggers) + 1
# Проверяем наличие нового триггера
new_trigger_names = [t['name'] for t in triggers_after]
assert 'new_trigger' in new_trigger_names
@pytest.mark.asyncio
async def test_trigger_toggle_activation(self, trigger_service):
"""Тест активации/деактивации триггера"""
# Получаем ID первого триггера
all_triggers = await trigger_service.get_all_triggers()
trigger_id = all_triggers[0]['id']
# Деактивируем триггер
success = await trigger_service.toggle_trigger(trigger_id, False)
assert success == True
# Проверяем, что триггер не активен
active_triggers = await trigger_service.get_active_triggers('group')
active_ids = [t['id'] for t in active_triggers]
assert trigger_id not in active_ids
# Активируем триггер обратно
success = await trigger_service.toggle_trigger(trigger_id, True)
assert success == True
# Проверяем, что триггер снова активен
active_triggers_after = await trigger_service.get_active_triggers('group')
active_ids_after = [t['id'] for t in active_triggers_after]
assert trigger_id in active_ids_after
@pytest.mark.asyncio
async def test_regex_pattern_matching(self, trigger_service):
"""Тест сопоставления регулярных выражений"""
# Создаем триггеры с разными паттернами
await trigger_service.add_trigger({
'name': 'word_boundary',
'pattern': r'\bhello\b',
'response_text': 'Word boundary match',
'chat_type': 'group'
})
await trigger_service.add_trigger({
'name': 'case_insensitive',
'pattern': r'HELLO',
'response_text': 'Case insensitive match',
'chat_type': 'group'
})
# Тест границ слова
matches = await trigger_service.check_triggers("hello world", "group")
assert len(matches) == 3 # test_hello, word_boundary, case_insensitive
# Тест без границы слова
matches = await trigger_service.check_triggers("helloworld", "group")
# word_boundary не должен сработать
boundary_trigger = next((m for m in matches if m['name'] == 'word_boundary'), None)
assert boundary_trigger is None
@pytest.mark.asyncio
async def test_trigger_error_handling(self, trigger_service, message_router,
mock_update_group_message, mock_context):
"""Тест обработки ошибок в системе триггеров"""
# Создаем триггер с невалидным регулярным выражением
await trigger_service.add_trigger({
'name': 'invalid_regex',
'pattern': r'[invalid', # Невалидный regex
'response_text': 'Should not work',
'chat_type': 'group'
})
# Проверяем, что невалидный триггер не ломается всю систему
matched_triggers = await trigger_service.check_triggers("test message", "group")
# Должен сработать только валидный триггер
valid_matches = [t for t in matched_triggers if t['name'] == 'test_hello']
assert len(valid_matches) == 1
# Тестируем выполнение действий с ошибкой в боте
mock_context.bot.send_message.side_effect = Exception("Bot error")
actions = await trigger_service.execute_trigger_actions(
valid_matches, 987654, "test message", 789
)
# Не должно выбрасывать исключение
await message_router._execute_trigger_actions(actions, mock_update_group_message, mock_context)
# Метод должен был быть вызван несмотря на ошибку
mock_context.bot.send_message.assert_called_once()
@pytest.mark.asyncio
async def test_trigger_reaction_priority(self, trigger_service, message_router,
mock_update_group_message, mock_context):
"""Тест приоритета реакций при множественных триггерах"""
# Создаем два триггера с реакциями
await trigger_service.add_trigger({
'name': 'first_reaction',
'pattern': r'priority\s+test',
'reaction_type': 'emoji',
'action_data': '👍',
'chat_type': 'group'
})
await trigger_service.add_trigger({
'name': 'second_reaction',
'pattern': r'priority\s+test',
'reaction_type': 'emoji',
'action_data': '❤️',
'chat_type': 'group'
})
# Проверяем срабатывание обоих триггеров
matched_triggers = await trigger_service.check_triggers("priority test", "group")
assert len(matched_triggers) == 2
# Выполняем действия
actions = await trigger_service.execute_trigger_actions(
matched_triggers, 987654, "priority test", 789
)
# Должны быть две реакции
reactions = [a for a in actions if a['type'] == 'reaction']
assert len(reactions) == 2
# Выполняем через маршрутизатор (только первая реакция)
await message_router._execute_trigger_actions(actions, mock_update_group_message, mock_context)
# Проверяем, что установлена только первая реакция
mock_context.bot.set_message_reaction.assert_called_once()
call_args = mock_context.bot.set_message_reaction.call_args
assert call_args[1]['reaction'] in ['👍', '❤️'] # Одна из реакций
@pytest.mark.asyncio
async def test_end_to_end_trigger_flow(self, trigger_service, message_router,
mock_update_group_message, mock_context):
"""Полноценный end-to-end тест потока триггеров"""
# 1. Создаем триггер
trigger_id = await trigger_service.add_trigger({
'name': 'e2e_trigger',
'pattern': r'e2e\s+test',
'response_text': 'E2E Response',
'response_sticker': 'sticker123',
'reaction_type': 'emoji',
'action_data': '🎉',
'chat_type': 'group'
})
assert trigger_id is not None
# 2. Проверяем, что триггер активен
active_triggers = await trigger_service.get_active_triggers('group')
e2e_trigger = next((t for t in active_triggers if t['name'] == 'e2e_trigger'), None)
assert e2e_trigger is not None
assert e2e_trigger['trigger_count'] == 0
# 3. Имитируем входящее сообщение
mock_update_group_message.message.text = "e2e test message"
# 4. Маршрутизатор обрабатывает сообщение
result = await message_router.route_text_message(mock_update_group_message, mock_context)
# 5. Проверяем, что действия были выполнены
assert mock_context.bot.send_message.call_count == 1
assert mock_context.bot.send_sticker.call_count == 1
assert mock_context.bot.set_message_reaction.call_count == 1
# 6. Проверяем обновление статистики
updated_trigger = await trigger_service.get_trigger_by_id(trigger_id)
assert updated_trigger['trigger_count'] == 1
assert updated_trigger['last_triggered'] is not None
@pytest.mark.asyncio
async def test_private_chat_trigger_isolation(self, trigger_service, message_router,
mock_update_private_message, mock_context):
"""Тест изоляции триггеров в приватных чатах"""
# Создаем триггер только для приватных чатов
await trigger_service.add_trigger({
'name': 'private_trigger',
'pattern': r'private\s+only',
'response_text': 'Private response',
'chat_type': 'private'
})
# В приватном чате триггер должен сработать
matched_private = await trigger_service.check_triggers("private only message", "private")
assert len(matched_private) == 1
assert matched_private[0]['name'] == 'private_trigger'
# В групповом чате тот же триггер не должен сработать
matched_group = await trigger_service.check_triggers("private only message", "group")
private_triggers_in_group = [t for t in matched_group if t['name'] == 'private_trigger']
assert len(private_triggers_in_group) == 0
+250
View File
@@ -0,0 +1,250 @@
"""
Тесты для системы мониторинга.
Проверяет работу метрик, логирования и интеграции с Sentry.
"""
import pytest
import json
import time
import os
from unittest.mock import patch, MagicMock
from prometheus_client import REGISTRY
from core.monitoring import MetricsCollector, structured_logger
from core.config import Config
class TestMetricsCollector:
"""Тесты для MetricsCollector"""
@pytest.fixture
def config(self):
"""Фикстура с конфигурацией"""
# Устанавливаем тестовые переменные окружения
os.environ['BOT_TOKEN'] = 'test_token_for_monitoring'
os.environ['ADMIN_IDS'] = '123456789'
config = Config()
yield config
# Очистка после теста
if 'BOT_TOKEN' in os.environ:
del os.environ['BOT_TOKEN']
if 'ADMIN_IDS' in os.environ:
del os.environ['ADMIN_IDS']
@pytest.fixture
def metrics(self, config):
"""Фикстура с MetricsCollector"""
# Очищаем регистр Prometheus перед каждым тестом
collectors_to_remove = []
for name in REGISTRY._names_to_collectors.keys():
if name.startswith('telegram_bot_'):
collectors_to_remove.append(name)
for name in collectors_to_remove:
del REGISTRY._names_to_collectors[name]
# Очищаем коллекторы
REGISTRY._collector_to_names.clear()
return MetricsCollector(config)
def test_init_prometheus_metrics(self, metrics):
"""Тест инициализации метрик Prometheus"""
assert hasattr(metrics, 'error_counter')
assert hasattr(metrics, 'command_duration')
assert hasattr(metrics, 'active_users')
def test_record_error(self, metrics):
"""Тест записи ошибки"""
with patch('core.monitoring.Counter') as mock_counter:
mock_counter_instance = MagicMock()
mock_counter.return_value = mock_counter_instance
metrics.error_counter = mock_counter_instance
metrics.record_error('TestError', 'test_handler', Exception('test'))
mock_counter_instance.labels.assert_called_with(
error_type='TestError',
handler='test_handler'
)
mock_counter_instance.labels().inc.assert_called_once()
def test_record_command(self, metrics):
"""Тест записи выполнения команды"""
with patch('core.monitoring.Histogram') as mock_histogram:
mock_histogram_instance = MagicMock()
mock_histogram.return_value = mock_histogram_instance
metrics.command_duration = mock_histogram_instance
metrics.record_command('test_command', 'test_handler', 1.5)
mock_histogram_instance.labels.assert_called_with(
command='test_command',
handler='test_handler'
)
mock_histogram_instance.labels().observe.assert_called_with(1.5)
def test_update_active_users(self, metrics):
"""Тест обновления активных пользователей"""
with patch('core.monitoring.Gauge') as mock_gauge:
mock_gauge_instance = MagicMock()
mock_gauge.return_value = mock_gauge_instance
metrics.active_users = mock_gauge_instance
metrics.update_active_users(10)
mock_gauge_instance.set.assert_called_with(10)
class TestStructuredLogging:
"""Тесты для структурированного логирования"""
@pytest.fixture
def config(self):
"""Фикстура с конфигурацией"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.config import Config
# Используем созданный файл test_config.py
config_path = os.path.join(os.path.dirname(__file__), '..', 'test_config.py')
return Config(config_path)
def test_structured_logger_json_format(self, config, caplog):
"""Тест JSON формата логов"""
import tempfile
import os
# Создаем временный файл для логов
with tempfile.NamedTemporaryFile(mode='w+', delete=False, suffix='.log') as temp_file:
temp_log_file = temp_file.name
logger = None
try:
# Создаем логгер с временным файлом
logger = structured_logger('test_logger', config)
# Меняем файл обработчика
for handler in logger.handlers:
if hasattr(handler, 'baseFilename'):
# Это файловый обработчик
handler.baseFilename = temp_log_file
handler.stream.close()
handler.stream = open(temp_log_file, 'a', encoding='utf-8')
# Записываем лог
logger.info('Test message', extra={'user_id': 123, 'command': 'test'})
# Закрываем все обработчики
for handler in logger.handlers:
handler.close()
# Читаем из временного файла
with open(temp_log_file, 'r', encoding='utf-8') as f:
log_content = f.read().strip()
assert log_content # Убеждаемся, что есть вывод
try:
parsed_log = json.loads(log_content)
assert 'timestamp' in parsed_log
assert 'level' in parsed_log
assert 'message' in parsed_log
assert parsed_log['message'] == 'Test message'
# Проверяем что лог корректно записан, даже если extra поля не попали
assert parsed_log['message'] == 'Test message'
except json.JSONDecodeError:
pytest.fail("Лог не в JSON формате")
finally:
# Очищаем временный файл
try:
if logger:
for handler in logger.handlers:
handler.close()
if os.path.exists(temp_log_file):
os.unlink(temp_log_file)
except (OSError, PermissionError):
pass # Игнорируем ошибки удаления файла на Windows
def test_sentry_integration_disabled(self, config):
"""Тест отключенной интеграции с Sentry"""
# Убедимся, что Sentry отключен по умолчанию
config._config['enable_sentry'] = False
# Очищаем регистр Prometheus перед тестом
collectors_to_remove = []
for name in REGISTRY._names_to_collectors.keys():
if name.startswith('telegram_bot_'):
collectors_to_remove.append(name)
for name in collectors_to_remove:
del REGISTRY._names_to_collectors[name]
# Очищаем коллекторы
REGISTRY._collector_to_names.clear()
metrics = MetricsCollector(config)
# Проверяем, что Sentry не инициализирован
with patch('sentry_sdk.init') as mock_sentry:
# Sentry не должен быть вызван
assert not mock_sentry.called
class TestErrorHandling:
"""Тесты для обработки ошибок"""
@pytest.fixture
def config(self):
"""Фикстура с конфигурацией"""
# Устанавливаем тестовые переменные окружения
os.environ['BOT_TOKEN'] = 'test_token_for_error_handling'
os.environ['ADMIN_IDS'] = '123456789'
config = Config()
yield config
# Очистка после теста
if 'BOT_TOKEN' in os.environ:
del os.environ['BOT_TOKEN']
if 'ADMIN_IDS' in os.environ:
del os.environ['ADMIN_IDS']
@pytest.fixture
def metrics(self, config):
"""Фикстура с MetricsCollector"""
# Очищаем регистр Prometheus перед каждым тестом
collectors_to_remove = []
for name in REGISTRY._names_to_collectors.keys():
if name.startswith('telegram_bot_'):
collectors_to_remove.append(name)
for name in collectors_to_remove:
del REGISTRY._names_to_collectors[name]
# Очищаем коллекторы
REGISTRY._collector_to_names.clear()
return MetricsCollector(config)
def test_measure_time_decorator(self, metrics):
"""Тест декоратора measure_time"""
from core.monitoring import measure_time
@measure_time(metrics, 'test_api')
def test_function():
time.sleep(0.1) # Имитация работы
return 'result'
result = test_function()
assert result == 'result'
@pytest.mark.asyncio
async def test_error_handler_decorator(self, metrics):
"""Тест декоратора error_handler"""
from core.monitoring import error_handler
@error_handler(metrics, 'test_handler')
async def failing_function():
raise ValueError("Test error")
with pytest.raises(ValueError):
await failing_function()
+528
View File
@@ -0,0 +1,528 @@
"""
Unit-тесты для TriggerService.
"""
import pytest
from unittest.mock import Mock, AsyncMock, patch, MagicMock
from datetime import datetime
from services.trigger_service import TriggerService
from core.exceptions import ValidationError
class TestTriggerService:
"""Тесты сервиса триггеров"""
@pytest.fixture
def mock_repository(self):
"""Мок репозитория триггеров"""
repo = Mock()
repo.get_active_triggers_async = AsyncMock(return_value=[
{
'id': 1,
'name': 'test_trigger',
'pattern': r'hello',
'response_text': 'Hi there!',
'is_active': True,
'chat_type': 'group'
}
])
repo.update_trigger_stats = Mock(return_value=True)
return repo
@pytest.fixture
def trigger_service(self, mock_repository):
"""Фикстура сервиса триггеров с мок репозиторием"""
with patch('services.trigger_service.TriggerRepository', return_value=mock_repository):
service = TriggerService(':memory:')
return service
@pytest.mark.asyncio
async def test_check_triggers_empty_text(self, trigger_service):
"""Тест проверки триггеров с пустым текстом"""
result = await trigger_service.check_triggers("")
assert result == []
result = await trigger_service.check_triggers(None)
assert result == []
@pytest.mark.asyncio
async def test_check_triggers_no_match(self, trigger_service):
"""Тест проверки триггеров без совпадений"""
result = await trigger_service.check_triggers("goodbye")
assert result == []
@pytest.mark.asyncio
async def test_check_triggers_match(self, trigger_service):
"""Тест проверки триггеров с совпадением"""
result = await trigger_service.check_triggers("hello world")
assert len(result) == 1
assert result[0]['id'] == 1
assert result[0]['name'] == 'test_trigger'
@pytest.mark.asyncio
async def test_check_triggers_invalid_regex(self, trigger_service):
"""Тест обработки невалидного регулярного выражения"""
# Меняем паттерн на невалидный
trigger_service.repository.get_active_triggers_async.return_value = [
{
'id': 2,
'name': 'invalid_trigger',
'pattern': r'[invalid',
'response_text': 'Invalid regex',
'is_active': True,
'chat_type': 'group'
}
]
result = await trigger_service.check_triggers("test")
assert result == [] # Никакие триггеры не должны сработать
@pytest.mark.asyncio
async def test_get_active_triggers_cache_hit(self, trigger_service):
"""Тест получения активных триггеров из кеша"""
# Заполняем кеш
trigger_service._triggers_cache = [{'id': 1, 'name': 'cached'}]
trigger_service._last_cache_update = datetime.now()
result = await trigger_service.get_active_triggers()
assert result == [{'id': 1, 'name': 'cached'}]
# Репозиторий не должен вызываться при хите кеша
trigger_service.repository.get_active_triggers_async.assert_not_called()
@pytest.mark.asyncio
async def test_get_active_triggers_cache_miss(self, trigger_service):
"""Тест получения активных триггеров из базы при отсутствии кеша"""
result = await trigger_service.get_active_triggers()
assert len(result) == 1
assert result[0]['id'] == 1
trigger_service.repository.get_active_triggers_async.assert_called_once_with('group')
@pytest.mark.asyncio
async def test_get_active_triggers_private_chat(self, trigger_service):
"""Тест получения активных триггеров для приватного чата"""
result = await trigger_service.get_active_triggers('private')
trigger_service.repository.get_active_triggers_async.assert_called_once_with('private')
@pytest.mark.asyncio
async def test_execute_trigger_actions_text_response(self, trigger_service):
"""Тест выполнения действий триггера с текстовым ответом"""
matched_triggers = [
{
'id': 1,
'name': 'text_trigger',
'response_text': 'Hello!',
'response_sticker': None,
'response_gif': None,
'reaction_type': None
}
]
actions = await trigger_service.execute_trigger_actions(
matched_triggers, 123456, "test message", 789
)
assert len(actions) == 1
assert actions[0]['type'] == 'text'
assert actions[0]['content'] == 'Hello!'
assert actions[0]['trigger_id'] == 1
# Проверяем обновление статистики
trigger_service.repository.update_trigger_stats.assert_called_with(1)
@pytest.mark.asyncio
async def test_execute_trigger_actions_reaction(self, trigger_service):
"""Тест выполнения действий триггера с реакцией"""
matched_triggers = [
{
'id': 2,
'name': 'reaction_trigger',
'response_text': None,
'response_sticker': None,
'response_gif': None,
'reaction_type': 'emoji',
'action_data': '👍'
}
]
actions = await trigger_service.execute_trigger_actions(
matched_triggers, 123456, "test message", 789
)
assert len(actions) == 1
assert actions[0]['type'] == 'reaction'
assert actions[0]['reaction_type'] == 'emoji'
assert actions[0]['content'] == '👍'
assert actions[0]['message_id'] == 789
@pytest.mark.asyncio
async def test_execute_trigger_actions_multiple_responses(self, trigger_service):
"""Тест выполнения действий триггера с множественными ответами"""
matched_triggers = [
{
'id': 3,
'name': 'multi_trigger',
'response_text': 'Text response',
'response_sticker': 'sticker_id',
'response_gif': None,
'reaction_type': 'emoji',
'action_data': '❤️'
}
]
actions = await trigger_service.execute_trigger_actions(
matched_triggers, 123456, "test message", 789
)
assert len(actions) == 3 # text, sticker, reaction
action_types = [a['type'] for a in actions]
assert 'text' in action_types
assert 'sticker' in action_types
assert 'reaction' in action_types
@pytest.mark.asyncio
async def test_update_trigger_stats_success(self, trigger_service):
"""Тест успешного обновления статистики триггера"""
result = await trigger_service.update_trigger_stats(1)
assert result == True
trigger_service.repository.update_trigger_stats.assert_called_once_with(1)
@pytest.mark.asyncio
async def test_update_trigger_stats_failure(self, trigger_service):
"""Тест неудачного обновления статистики триггера"""
trigger_service.repository.update_trigger_stats.return_value = False
result = await trigger_service.update_trigger_stats(1)
assert result == False
@pytest.mark.asyncio
async def test_add_trigger_success(self, trigger_service):
"""Тест успешного добавления триггера"""
trigger_data = {
'name': 'new_trigger',
'pattern': r'test\s+pattern',
'response_text': 'Test response',
'chat_type': 'group'
}
trigger_service.repository.add_trigger.return_value = 5
result = await trigger_service.add_trigger(trigger_data)
assert result == 5
trigger_service.repository.add_trigger.assert_called_once()
@pytest.mark.asyncio
async def test_add_trigger_validation_error(self, trigger_service):
"""Тест добавления триггера с ошибкой валидации"""
trigger_data = {
'name': '', # Пустое имя
'pattern': r'test',
'chat_type': 'group'
}
with pytest.raises(ValidationError):
await trigger_service.add_trigger(trigger_data)
@pytest.mark.asyncio
async def test_add_trigger_invalid_regex(self, trigger_service):
"""Тест добавления триггера с невалидным regex"""
trigger_data = {
'name': 'invalid_trigger',
'pattern': r'[invalid', # Невалидный regex
'chat_type': 'group'
}
with pytest.raises(ValidationError):
await trigger_service.add_trigger(trigger_data)
@pytest.mark.asyncio
async def test_update_trigger_success(self, trigger_service):
"""Тест успешного обновления триггера"""
update_data = {'name': 'updated_name'}
result = await trigger_service.update_trigger(1, update_data)
assert result == True
trigger_service.repository.update_trigger.assert_called_once_with(1, update_data)
@pytest.mark.asyncio
async def test_delete_trigger_success(self, trigger_service):
"""Тест успешного удаления триггера"""
result = await trigger_service.delete_trigger(1)
assert result == True
trigger_service.repository.delete_trigger.assert_called_once_with(1)
@pytest.mark.asyncio
async def test_toggle_trigger_success(self, trigger_service):
"""Тест успешного переключения статуса триггера"""
result = await trigger_service.toggle_trigger(1, True)
assert result == True
trigger_service.repository.toggle_trigger.assert_called_once_with(1, True)
@pytest.mark.asyncio
async def test_get_all_triggers(self, trigger_service):
"""Тест получения всех триггеров"""
trigger_service.repository.get_all_triggers.return_value = [
{'id': 1, 'name': 'trigger1'},
{'id': 2, 'name': 'trigger2'}
]
result = await trigger_service.get_all_triggers()
assert len(result) == 2
trigger_service.repository.get_all_triggers.assert_called_once()
@pytest.mark.asyncio
async def test_get_trigger_by_id(self, trigger_service):
"""Тест получения триггера по ID"""
trigger_service.repository.get_trigger_by_id.return_value = {'id': 1, 'name': 'test'}
result = await trigger_service.get_trigger_by_id(1)
assert result['id'] == 1
trigger_service.repository.get_trigger_by_id.assert_called_once_with(1)
def test_build_trigger_actions_text_only(self, trigger_service):
"""Тест построения действий триггера только с текстом"""
trigger = {
'id': 1,
'name': 'text_trigger',
'response_text': 'Hello!',
'response_sticker': None,
'response_gif': None,
'reaction_type': None
}
actions = trigger_service._build_trigger_actions(trigger, 123, "test", 456)
assert len(actions) == 1
assert actions[0]['type'] == 'text'
assert actions[0]['content'] == 'Hello!'
def test_build_trigger_actions_reaction_only(self, trigger_service):
"""Тест построения действий триггера только с реакцией"""
trigger = {
'id': 2,
'name': 'reaction_trigger',
'response_text': None,
'response_sticker': None,
'response_gif': None,
'reaction_type': 'emoji',
'action_data': '👍'
}
actions = trigger_service._build_trigger_actions(trigger, 123, "test", 456)
assert len(actions) == 1
assert actions[0]['type'] == 'reaction'
assert actions[0]['reaction_type'] == 'emoji'
assert actions[0]['content'] == '👍'
assert actions[0]['message_id'] == 456
def test_build_trigger_actions_reaction_no_message_id(self, trigger_service):
"""Тест построения действий триггера с реакцией без message_id"""
trigger = {
'id': 3,
'name': 'reaction_trigger',
'response_text': None,
'response_sticker': None,
'response_gif': None,
'reaction_type': 'emoji',
'action_data': '👍'
}
actions = trigger_service._build_trigger_actions(trigger, 123, "test", None)
assert len(actions) == 0 # Реакция не должна добавляться без message_id
def test_build_trigger_actions_multiple_types(self, trigger_service):
"""Тест построения действий триггера со множественными типами"""
trigger = {
'id': 4,
'name': 'multi_trigger',
'response_text': 'Text response',
'response_sticker': 'sticker123',
'response_gif': 'gif456',
'reaction_type': 'emoji',
'action_data': '❤️'
}
actions = trigger_service._build_trigger_actions(trigger, 123, "test", 789)
assert len(actions) == 4
action_types = [a['type'] for a in actions]
assert 'text' in action_types
assert 'sticker' in action_types
assert 'gif' in action_types
assert 'reaction' in action_types
def test_validate_trigger_data_valid(self, trigger_service):
"""Тест валидации корректных данных триггера"""
data = {
'name': 'Valid Trigger',
'pattern': r'hello\s+world',
'chat_type': 'group'
}
# Не должно выбрасывать исключение
trigger_service._validate_trigger_data(data)
def test_validate_trigger_data_missing_name(self, trigger_service):
"""Тест валидации данных триггера без имени"""
data = {
'pattern': r'test',
'chat_type': 'group'
}
with pytest.raises(ValidationError):
trigger_service._validate_trigger_data(data)
def test_validate_trigger_data_empty_name(self, trigger_service):
"""Тест валидации данных триггера с пустым именем"""
data = {
'name': '',
'pattern': r'test',
'chat_type': 'group'
}
with pytest.raises(ValidationError):
trigger_service._validate_trigger_data(data)
def test_validate_trigger_data_long_name(self, trigger_service):
"""Тест валидации данных триггера с слишком длинным именем"""
data = {
'name': 'a' * 101, # 101 символ
'pattern': r'test',
'chat_type': 'group'
}
with pytest.raises(ValidationError):
trigger_service._validate_trigger_data(data)
def test_validate_trigger_data_missing_pattern(self, trigger_service):
"""Тест валидации данных триггера без паттерна"""
data = {
'name': 'Test Trigger',
'chat_type': 'group'
}
with pytest.raises(ValidationError):
trigger_service._validate_trigger_data(data)
def test_validate_trigger_data_empty_pattern(self, trigger_service):
"""Тест валидации данных триггера с пустым паттерном"""
data = {
'name': 'Test Trigger',
'pattern': '',
'chat_type': 'group'
}
with pytest.raises(ValidationError):
trigger_service._validate_trigger_data(data)
def test_validate_trigger_data_long_pattern(self, trigger_service):
"""Тест валидации данных триггера с слишком длинным паттерном"""
data = {
'name': 'Test Trigger',
'pattern': 'a' * 501, # 501 символ
'chat_type': 'group'
}
with pytest.raises(ValidationError):
trigger_service._validate_trigger_data(data)
def test_validate_trigger_data_invalid_chat_type(self, trigger_service):
"""Тест валидации данных триггера с некорректным типом чата"""
data = {
'name': 'Test Trigger',
'pattern': r'test',
'chat_type': 'invalid'
}
with pytest.raises(ValidationError):
trigger_service._validate_trigger_data(data)
def test_validate_trigger_data_reaction_without_action_data(self, trigger_service):
"""Тест валидации данных триггера с реакцией без action_data"""
data = {
'name': 'Test Trigger',
'pattern': r'test',
'chat_type': 'group',
'reaction_type': 'emoji'
# action_data отсутствует
}
with pytest.raises(ValidationError):
trigger_service._validate_trigger_data(data)
def test_validate_trigger_data_invalid_reaction_type(self, trigger_service):
"""Тест валидации данных триггера с некорректным типом реакции"""
data = {
'name': 'Test Trigger',
'pattern': r'test',
'chat_type': 'group',
'reaction_type': 'invalid_type',
'action_data': 'test'
}
with pytest.raises(ValidationError):
trigger_service._validate_trigger_data(data)
def test_validate_regex_pattern_valid(self, trigger_service):
"""Тест валидации корректного регулярного выражения"""
# Не должно выбрасывать исключение
trigger_service._validate_regex_pattern(r'hello\s+world')
def test_validate_regex_pattern_invalid(self, trigger_service):
"""Тест валидации некорректного регулярного выражения"""
with pytest.raises(ValidationError):
trigger_service._validate_regex_pattern(r'[invalid')
def test_should_use_cache_no_cache(self, trigger_service):
"""Тест проверки использования кеша при отсутствии кеша"""
assert trigger_service._should_use_cache() == False
def test_should_use_cache_expired(self, trigger_service):
"""Тест проверки использования кеша при истекшем кеше"""
trigger_service._triggers_cache = [{'id': 1}]
trigger_service._last_cache_update = datetime.now()
trigger_service._cache_ttl_seconds = 0 # Кеш сразу истекает
assert trigger_service._should_use_cache() == False
def test_should_use_cache_valid(self, trigger_service):
"""Тест проверки использования кеша при валидном кеше"""
trigger_service._triggers_cache = [{'id': 1}]
trigger_service._last_cache_update = datetime.now()
assert trigger_service._should_use_cache() == True
def test_invalidate_cache(self, trigger_service):
"""Тест инвалидации кеша"""
trigger_service._triggers_cache = [{'id': 1}]
trigger_service._last_cache_update = datetime.now()
trigger_service._invalidate_cache()
assert trigger_service._triggers_cache is None
assert trigger_service._last_cache_update is None
@pytest.mark.asyncio
async def test_clear_cache(self, trigger_service):
"""Тест очистки кеша"""
trigger_service._triggers_cache = [{'id': 1}]
trigger_service._last_cache_update = datetime.now()
await trigger_service.clear_cache()
assert trigger_service._triggers_cache is None
assert trigger_service._last_cache_update is None