diff --git a/helper_bot/handlers/private/services.py b/helper_bot/handlers/private/services.py index 5943d34..5d96348 100644 --- a/helper_bot/handlers/private/services.py +++ b/helper_bot/handlers/private/services.py @@ -257,6 +257,13 @@ class PostService: # Формируем текст/caption с учетом скоров post_text = "" if text_for_post or content_type == "text": + logger.debug( + f"PostService._process_post_background: Передача скоров в get_text_message - " + f"rag_score={rag_score} (type: {type(rag_score).__name__ if rag_score is not None else 'None'}), " + f"rag_score_pos_only={rag_score_pos_only} (type: {type(rag_score_pos_only).__name__ if rag_score_pos_only is not None else 'None'}), " + f"rag_confidence={rag_confidence} (type: {type(rag_confidence).__name__ if rag_confidence is not None else 'None'}), " + f"content_type={content_type}, message_id={message.message_id}" + ) post_text = get_text_message( text_for_post.lower() if text_for_post else "", first_name, @@ -406,6 +413,14 @@ class PostService: # Получаем скоры для текста deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json = await self._get_scores(raw_text) + logger.debug( + f"PostService.handle_text_post: Передача скоров в get_text_message - " + f"rag_score={rag_score} (type: {type(rag_score).__name__ if rag_score is not None else 'None'}), " + f"rag_score_pos_only={rag_score_pos_only} (type: {type(rag_score_pos_only).__name__ if rag_score_pos_only is not None else 'None'}), " + f"rag_confidence={rag_confidence} (type: {type(rag_confidence).__name__ if rag_confidence is not None else 'None'}), " + f"message_id={message.message_id}" + ) + # Формируем текст с учетом скоров post_text = get_text_message( message.text.lower(), @@ -446,6 +461,14 @@ class PostService: # Получаем скоры для текста deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json = await self._get_scores(raw_caption) + logger.debug( + f"PostService.handle_photo_post: Передача скоров в get_text_message - " + f"rag_score={rag_score} (type: {type(rag_score).__name__ if rag_score is not None else 'None'}), " + f"rag_score_pos_only={rag_score_pos_only} (type: {type(rag_score_pos_only).__name__ if rag_score_pos_only is not None else 'None'}), " + f"rag_confidence={rag_confidence} (type: {type(rag_confidence).__name__ if rag_confidence is not None else 'None'}), " + f"message_id={message.message_id}" + ) + post_caption = "" if message.caption: post_caption = get_text_message( @@ -490,6 +513,14 @@ class PostService: # Получаем скоры для текста deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json = await self._get_scores(raw_caption) + logger.debug( + f"PostService.handle_video_post: Передача скоров в get_text_message - " + f"rag_score={rag_score} (type: {type(rag_score).__name__ if rag_score is not None else 'None'}), " + f"rag_score_pos_only={rag_score_pos_only} (type: {type(rag_score_pos_only).__name__ if rag_score_pos_only is not None else 'None'}), " + f"rag_confidence={rag_confidence} (type: {type(rag_confidence).__name__ if rag_confidence is not None else 'None'}), " + f"message_id={message.message_id}" + ) + post_caption = "" if message.caption: post_caption = get_text_message( @@ -559,6 +590,14 @@ class PostService: # Получаем скоры для текста deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json = await self._get_scores(raw_caption) + logger.debug( + f"PostService.handle_audio_post: Передача скоров в get_text_message - " + f"rag_score={rag_score} (type: {type(rag_score).__name__ if rag_score is not None else 'None'}), " + f"rag_score_pos_only={rag_score_pos_only} (type: {type(rag_score_pos_only).__name__ if rag_score_pos_only is not None else 'None'}), " + f"rag_confidence={rag_confidence} (type: {type(rag_confidence).__name__ if rag_confidence is not None else 'None'}), " + f"message_id={message.message_id}" + ) + post_caption = "" if message.caption: post_caption = get_text_message( @@ -634,6 +673,14 @@ class PostService: # Получаем скоры для текста deepseek_score, rag_score, rag_confidence, rag_score_pos_only, ml_scores_json = await self._get_scores(raw_caption) + logger.debug( + f"PostService.handle_media_group_post: Передача скоров в get_text_message - " + f"rag_score={rag_score} (type: {type(rag_score).__name__ if rag_score is not None else 'None'}), " + f"rag_score_pos_only={rag_score_pos_only} (type: {type(rag_score_pos_only).__name__ if rag_score_pos_only is not None else 'None'}), " + f"rag_confidence={rag_confidence} (type: {type(rag_confidence).__name__ if rag_confidence is not None else 'None'}), " + f"message_id={message.message_id}" + ) + post_caption = get_text_message( album[0].caption.lower(), first_name, diff --git a/helper_bot/services/scoring/rag_client.py b/helper_bot/services/scoring/rag_client.py index 689e5e6..45f5f3a 100644 --- a/helper_bot/services/scoring/rag_client.py +++ b/helper_bot/services/scoring/rag_client.py @@ -149,13 +149,20 @@ class RagApiClient: # Парсим ответ score = float(data.get("rag_score", 0.0)) confidence = float(data.get("rag_confidence", 0.0)) if data.get("rag_confidence") is not None else None + rag_score_pos_only_raw = data.get("rag_score_pos_only") + rag_score_pos_only = float(rag_score_pos_only_raw) if rag_score_pos_only_raw is not None else None # Форматируем confidence для логирования confidence_str = f"{confidence:.4f}" if confidence is not None else "None" + rag_score_pos_only_str = f"{rag_score_pos_only:.4f}" if rag_score_pos_only is not None else "None" logger.info( - f"RagApiClient: Скор успешно получен " - f"(score={score:.4f}, confidence={confidence_str})" + f"RagApiClient: Скор успешно получен из API - " + f"rag_score={score:.4f} (type: {type(score).__name__}), " + f"rag_confidence={confidence_str}, " + f"rag_score_pos_only={rag_score_pos_only_str}, " + f"raw_response_rag_score={data.get('rag_score')}, " + f"raw_response_rag_score_pos_only={rag_score_pos_only_raw}" ) return ScoringResult( @@ -164,7 +171,7 @@ class RagApiClient: model=data.get("meta", {}).get("model", "rag-service"), confidence=confidence, metadata={ - "rag_score_pos_only": float(data.get("rag_score_pos_only", 0.0)) if data.get("rag_score_pos_only") is not None else None, + "rag_score_pos_only": rag_score_pos_only, "positive_examples": data.get("meta", {}).get("positive_examples"), "negative_examples": data.get("meta", {}).get("negative_examples"), } diff --git a/helper_bot/utils/helper_func.py b/helper_bot/utils/helper_func.py index 2350a47..7d3e881 100644 --- a/helper_bot/utils/helper_func.py +++ b/helper_bot/utils/helper_func.py @@ -172,6 +172,13 @@ def get_text_message( if deepseek_score is not None: scores_lines.append(f"DeepSeek: {deepseek_score:.2f}") if rag_score is not None: + logger.debug( + f"get_text_message: Форматирование rag_score - " + f"rag_score={rag_score} (type: {type(rag_score).__name__}), " + f"rag_score_pos_only={rag_score_pos_only} (type: {type(rag_score_pos_only).__name__ if rag_score_pos_only is not None else 'None'}), " + f"rag_confidence={rag_confidence} (type: {type(rag_confidence).__name__ if rag_confidence is not None else 'None'}), " + f"formatted_value={rag_score:.2f}" + ) rag_line = f"RAG neg/pos: {rag_score:.2f}" if rag_confidence is not None: rag_line += f" (уверенность: {rag_confidence:.0%})" diff --git a/tests/conftest.py b/tests/conftest.py index 702d2b2..c9fa084 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,15 @@ import asyncio import os import sys +from pathlib import Path from unittest.mock import AsyncMock, Mock, patch +# Корень проекта (каталог с helper_bot и database) — в sys.path для импортов +_conftest_dir = Path(__file__).resolve().parent +_project_root = _conftest_dir.parent +if str(_project_root) not in sys.path: + sys.path.insert(0, str(_project_root)) + import pytest from aiogram.fsm.context import FSMContext from aiogram.types import Chat, Message, User diff --git a/tests/test_admin_dependencies.py b/tests/test_admin_dependencies.py new file mode 100644 index 0000000..51694b5 --- /dev/null +++ b/tests/test_admin_dependencies.py @@ -0,0 +1,176 @@ +""" +Тесты для helper_bot.handlers.admin.dependencies: AdminAccessMiddleware, get_bot_db, get_settings. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from helper_bot.handlers.admin.dependencies import ( + AdminAccessMiddleware, + get_bot_db, + get_settings, +) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestAdminAccessMiddleware: + """Тесты для AdminAccessMiddleware.""" + + @pytest.fixture + def middleware(self): + """Экземпляр middleware.""" + return AdminAccessMiddleware() + + @pytest.fixture + def mock_handler(self): + """Мок handler.""" + return AsyncMock(return_value="handler_result") + + @patch("helper_bot.handlers.admin.dependencies.check_access", new_callable=AsyncMock) + async def test_access_granted_calls_handler(self, mock_check_access, middleware, mock_handler): + """При доступе разрешён вызывается handler с event и data.""" + mock_check_access.return_value = True + event = MagicMock() + event.from_user = MagicMock() + event.from_user.id = 123 + event.from_user.username = "admin" + data = {"bot_db": MagicMock()} + + result = await middleware(mock_handler, event, data) + + mock_check_access.assert_awaited_once_with(123, data["bot_db"]) + mock_handler.assert_awaited_once_with(event, data) + assert result == "handler_result" + + @patch("helper_bot.handlers.admin.dependencies.check_access", new_callable=AsyncMock) + async def test_access_denied_answers_and_does_not_call_handler( + self, mock_check_access, middleware, mock_handler + ): + """При доступе запрещён отправляется ответ и handler не вызывается.""" + mock_check_access.return_value = False + event = MagicMock() + event.from_user = MagicMock() + event.from_user.id = 456 + event.from_user.username = "user" + event.answer = AsyncMock() + data = {"bot_db": MagicMock()} + + result = await middleware(mock_handler, event, data) + + mock_check_access.assert_awaited_once() + event.answer.assert_awaited_once_with("Доступ запрещен!") + mock_handler.assert_not_awaited() + assert result is None + + @patch("helper_bot.handlers.admin.dependencies.check_access", new_callable=AsyncMock) + @patch("helper_bot.handlers.admin.dependencies.get_global_instance") + async def test_fallback_get_db_from_global_when_bot_db_missing( + self, mock_get_global, mock_check_access, middleware, mock_handler + ): + """Если bot_db нет в data, берётся из get_global_instance().get_db().""" + mock_check_access.return_value = True + mock_bdf = MagicMock() + mock_bdf.get_db.return_value = MagicMock() + mock_get_global.return_value = mock_bdf + event = MagicMock() + event.from_user = MagicMock() + event.from_user.id = 1 + event.from_user.username = "u" + data = {} + + await middleware(mock_handler, event, data) + + mock_get_global.assert_called_once() + mock_bdf.get_db.assert_called_once() + mock_check_access.assert_awaited_once_with(1, mock_bdf.get_db.return_value) + + @patch("helper_bot.handlers.admin.dependencies.check_access", new_callable=AsyncMock) + async def test_event_without_from_user_calls_handler(self, mock_check_access, middleware, mock_handler): + """Если у event нет from_user, handler вызывается (проверка доступа не выполняется).""" + class EventWithoutUser: + pass + event = EventWithoutUser() + data = {} + + result = await middleware(mock_handler, event, data) + + mock_check_access.assert_not_awaited() + mock_handler.assert_awaited_once_with(event, data) + assert result == "handler_result" + + @patch("helper_bot.handlers.admin.dependencies.check_access", new_callable=AsyncMock) + async def test_handler_typeerror_missing_data_calls_handler_without_data( + self, mock_check_access, middleware + ): + """При TypeError из-за отсутствия data вызывается handler(event) без data.""" + mock_check_access.return_value = True + call_count = 0 + + async def handler(event, data=None): + nonlocal call_count + call_count += 1 + if call_count == 1 and data is not None: + raise TypeError("missing 1 required positional argument: 'data'") + return "ok" + + handler.__name__ = "test_handler" + event = MagicMock() + event.from_user = MagicMock() + event.from_user.id = 1 + event.from_user.username = "u" + data = {"bot_db": MagicMock()} + + result = await middleware(handler, event, data) + + assert call_count == 2 + assert result == "ok" + + @patch("helper_bot.handlers.admin.dependencies.check_access", new_callable=AsyncMock) + async def test_handler_other_exception_reraises(self, mock_check_access, middleware): + """При другом исключении в handler оно пробрасывается.""" + mock_check_access.return_value = True + + async def handler(event, data): + raise ValueError("other error") + + event = MagicMock() + event.from_user = MagicMock() + event.from_user.id = 1 + event.from_user.username = "u" + data = {"bot_db": MagicMock()} + + with pytest.raises(ValueError, match="other error"): + await middleware(handler, event, data) + + +@pytest.mark.unit +class TestDependencyProviders: + """Тесты для get_bot_db и get_settings.""" + + @patch("helper_bot.handlers.admin.dependencies.get_global_instance") + def test_get_bot_db_returns_bdf_get_db(self, mock_get_global): + """get_bot_db возвращает bdf.get_db().""" + mock_bdf = MagicMock() + mock_db = MagicMock() + mock_bdf.get_db.return_value = mock_db + mock_get_global.return_value = mock_bdf + + result = get_bot_db() + + mock_get_global.assert_called_once() + mock_bdf.get_db.assert_called_once() + assert result is mock_db + + @patch("helper_bot.handlers.admin.dependencies.get_global_instance") + def test_get_settings_returns_bdf_settings(self, mock_get_global): + """get_settings возвращает bdf.settings.""" + mock_bdf = MagicMock() + mock_bdf.settings = {"Telegram": {"bot_token": "x"}} + mock_get_global.return_value = mock_bdf + + result = get_settings() + + mock_get_global.assert_called_once() + assert result == {"Telegram": {"bot_token": "x"}} diff --git a/tests/test_admin_handlers.py b/tests/test_admin_handlers.py new file mode 100644 index 0000000..081520a --- /dev/null +++ b/tests/test_admin_handlers.py @@ -0,0 +1,235 @@ +""" +Тесты для helper_bot.handlers.admin.admin_handlers: хендлеры админ-панели с моками. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from aiogram import types +from aiogram.fsm.context import FSMContext + +from helper_bot.handlers.admin.admin_handlers import ( + admin_panel, + cancel_ban_process, + confirm_ban, + get_banned_users, + get_last_users, + get_ml_stats, + process_ban_duration, + process_ban_reason, + process_ban_target, + start_ban_process, +) +from helper_bot.handlers.admin.services import User as AdminUser + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestAdminHandlers: + """Тесты хендлеров admin_handlers с моками.""" + + @pytest.fixture + def mock_message(self): + """Мок сообщения.""" + msg = MagicMock(spec=types.Message) + msg.from_user = MagicMock() + msg.from_user.id = 123 + msg.from_user.full_name = "Admin" + msg.text = "Бан по нику" + msg.answer = AsyncMock() + msg.reply = AsyncMock() + return msg + + @pytest.fixture + def mock_state(self): + """Мок FSMContext.""" + state = MagicMock(spec=FSMContext) + state.set_state = AsyncMock() + state.get_state = AsyncMock(return_value="ADMIN") + state.get_data = AsyncMock(return_value={}) + state.update_data = AsyncMock() + return state + + @pytest.fixture + def mock_bot_db(self): + """Мок БД.""" + db = MagicMock() + db.get_last_users = AsyncMock(return_value=[("User One", 1), ("User Two", 2)]) + db.get_banned_users_from_db = AsyncMock(return_value=[]) + db.get_username = AsyncMock(return_value="user") + db.get_full_name_by_id = AsyncMock(return_value="Full Name") + return db + + @patch("helper_bot.handlers.admin.admin_handlers.get_reply_keyboard_admin") + async def test_admin_panel_sets_state_and_answers(self, mock_keyboard, mock_message, mock_state): + """admin_panel устанавливает состояние ADMIN и отправляет приветствие.""" + mock_keyboard.return_value = MagicMock() + + await admin_panel(mock_message, mock_state) + + mock_state.set_state.assert_awaited_once_with("ADMIN") + mock_message.answer.assert_awaited_once() + assert "админк" in mock_message.answer.call_args[0][0].lower() or "добро" in mock_message.answer.call_args[0][0].lower() + + @patch("helper_bot.handlers.admin.admin_handlers.return_to_admin_menu", new_callable=AsyncMock) + async def test_cancel_ban_process_returns_to_menu(self, mock_return, mock_message, mock_state): + """cancel_ban_process вызывает return_to_admin_menu.""" + mock_state.get_state = AsyncMock(return_value="AWAIT_BAN_TARGET") + + await cancel_ban_process(mock_message, mock_state) + + mock_return.assert_awaited_once_with(mock_message, mock_state) + + @patch("helper_bot.handlers.admin.admin_handlers.create_keyboard_with_pagination") + @patch("helper_bot.handlers.admin.admin_handlers.AdminService") + async def test_get_last_users_answers_with_keyboard( + self, mock_service_cls, mock_keyboard, mock_message, mock_state, mock_bot_db + ): + """get_last_users получает пользователей и отправляет клавиатуру.""" + mock_service = MagicMock() + mock_service.get_last_users = AsyncMock( + return_value=[ + AdminUser(1, "u1", "User One"), + AdminUser(2, "u2", "User Two"), + ] + ) + mock_service_cls.return_value = mock_service + mock_keyboard.return_value = MagicMock() + + await get_last_users(mock_message, mock_state, bot_db=mock_bot_db) + + mock_service.get_last_users.assert_awaited_once() + mock_keyboard.assert_called_once() + mock_message.answer.assert_awaited_once() + assert "Список пользователей" in mock_message.answer.call_args[1]["text"] or "пользователей" in mock_message.answer.call_args[1]["text"] + + @patch("helper_bot.handlers.admin.admin_handlers.create_keyboard_with_pagination") + @patch("helper_bot.handlers.admin.admin_handlers.AdminService") + async def test_get_banned_users_empty_answers_no_list( + self, mock_service_cls, mock_keyboard, mock_message, mock_state, mock_bot_db + ): + """get_banned_users при пустом списке отправляет сообщение 'никого нет'.""" + mock_service = MagicMock() + mock_service.get_banned_users_for_display = AsyncMock(return_value=("Текст", [])) + mock_service_cls.return_value = mock_service + + await get_banned_users(mock_message, mock_state, bot_db=mock_bot_db) + + mock_message.answer.assert_awaited_once() + assert "никого нет" in mock_message.answer.call_args[1]["text"] or "заблокированных" in mock_message.answer.call_args[1]["text"] + + @patch("helper_bot.handlers.admin.admin_handlers.get_global_instance") + async def test_get_ml_stats_disabled_answers_message(self, mock_get_global, mock_message, mock_state): + """get_ml_stats при отключённом scoring_manager отправляет сообщение об отключении.""" + mock_bdf = MagicMock() + mock_bdf.get_scoring_manager.return_value = None + mock_get_global.return_value = mock_bdf + + await get_ml_stats(mock_message, mock_state) + + mock_message.answer.assert_awaited_once() + assert "ML" in mock_message.answer.call_args[0][0] or "RAG" in mock_message.answer.call_args[0][0] or "отключен" in mock_message.answer.call_args[0][0].lower() + + @patch("helper_bot.handlers.admin.admin_handlers.get_global_instance") + async def test_get_ml_stats_with_rag_and_deepseek(self, mock_get_global, mock_message, mock_state): + """get_ml_stats при включённом scoring возвращает статистику.""" + mock_scoring = MagicMock() + mock_scoring.get_stats = AsyncMock(return_value={ + "rag": {"model_loaded": True, "vector_store": {"positive_count": 1, "negative_count": 0, "total_count": 1}}, + "deepseek": {"enabled": True, "model": "test", "timeout": 30}, + }) + mock_bdf = MagicMock() + mock_bdf.get_scoring_manager.return_value = mock_scoring + mock_get_global.return_value = mock_bdf + + await get_ml_stats(mock_message, mock_state) + + mock_message.answer.assert_awaited_once() + text = mock_message.answer.call_args[0][0] + assert "ML" in text or "RAG" in text or "DeepSeek" in text + + async def test_start_ban_process_by_nick_sets_state_await_target(self, mock_message, mock_state): + """start_ban_process при 'Бан по нику' устанавливает ban_type username и AWAIT_BAN_TARGET.""" + mock_message.text = "Бан по нику" + + await start_ban_process(mock_message, mock_state) + + mock_state.update_data.assert_awaited_once() + call_kw = mock_state.update_data.call_args[1] + assert call_kw.get("ban_type") == "username" + mock_state.set_state.assert_awaited_once_with("AWAIT_BAN_TARGET") + mock_message.answer.assert_awaited_once() + assert "username" in mock_message.answer.call_args[0][0].lower() or "ник" in mock_message.answer.call_args[0][0].lower() + + async def test_start_ban_process_by_id_sets_ban_type_id(self, mock_message, mock_state): + """start_ban_process при 'Бан по ID' устанавливает ban_type id.""" + mock_message.text = "Бан по ID" + + await start_ban_process(mock_message, mock_state) + + call_kw = mock_state.update_data.call_args[1] + assert call_kw.get("ban_type") == "id" + + @patch("helper_bot.handlers.admin.admin_handlers.create_keyboard_for_ban_reason") + @patch("helper_bot.handlers.admin.admin_handlers.format_user_info") + @patch("helper_bot.handlers.admin.admin_handlers.return_to_admin_menu", new_callable=AsyncMock) + @patch("helper_bot.handlers.admin.admin_handlers.AdminService") + async def test_process_ban_target_user_not_found_returns_to_menu( + self, mock_service_cls, mock_return, mock_format, mock_keyboard, + mock_message, mock_state, mock_bot_db + ): + """process_ban_target при ненайденном пользователе по username возвращает в меню.""" + mock_service = MagicMock() + mock_service.get_user_by_username = AsyncMock(return_value=None) + mock_service_cls.return_value = mock_service + mock_state.get_data = AsyncMock(return_value={"ban_type": "username"}) + mock_message.text = "unknown_user" + mock_format.return_value = "User info" + mock_keyboard.return_value = MagicMock() + + await process_ban_target(mock_message, mock_state, bot_db=mock_bot_db) + + mock_message.answer.assert_called() + mock_return.assert_awaited_once() + + @patch("helper_bot.handlers.admin.admin_handlers.create_keyboard_for_ban_reason") + @patch("helper_bot.handlers.admin.admin_handlers.format_user_info") + @patch("helper_bot.handlers.admin.admin_handlers.AdminService") + async def test_process_ban_reason_sets_state_await_duration( + self, mock_service_cls, mock_format, mock_keyboard, + mock_message, mock_state + ): + """process_ban_reason сохраняет причину и переводит в AWAIT_BAN_DURATION.""" + mock_state.get_state = AsyncMock(return_value="AWAIT_BAN_DETAILS") + mock_state.get_data = AsyncMock(return_value={}) + mock_message.text = "Спам" + mock_format.return_value = "Спам" + mock_keyboard.return_value = MagicMock() + + await process_ban_reason(mock_message, mock_state) + + mock_state.update_data.assert_awaited_once_with(ban_reason="Спам") + mock_state.set_state.assert_awaited_once_with("AWAIT_BAN_DURATION") + mock_message.answer.assert_awaited_once() + + @patch("helper_bot.handlers.admin.admin_handlers.create_keyboard_for_approve_ban") + @patch("helper_bot.handlers.admin.admin_handlers.format_ban_confirmation") + async def test_process_ban_duration_forever_sets_ban_days_none( + self, mock_format, mock_keyboard, mock_message, mock_state + ): + """process_ban_duration при 'Навсегда' устанавливает ban_days=None.""" + mock_state.get_data = AsyncMock(return_value={ + "target_user_id": 1, + "target_username": "u", + "target_full_name": "U", + "ban_reason": "Спам", + }) + mock_message.text = "Навсегда" + mock_format.return_value = "Подтверждение" + mock_keyboard.return_value = MagicMock() + + await process_ban_duration(mock_message, mock_state) + + mock_state.update_data.assert_awaited_once() + assert mock_state.update_data.call_args[1].get("ban_days") is None + mock_state.set_state.assert_awaited_once_with("BAN_CONFIRMATION") diff --git a/tests/test_admin_utils.py b/tests/test_admin_utils.py new file mode 100644 index 0000000..f7c90a3 --- /dev/null +++ b/tests/test_admin_utils.py @@ -0,0 +1,196 @@ +""" +Тесты для helper_bot.handlers.admin.utils. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from helper_bot.handlers.admin.exceptions import AdminError +from helper_bot.handlers.admin.utils import ( + escape_html, + format_ban_confirmation, + format_user_info, + handle_admin_error, + return_to_admin_menu, +) + + +@pytest.mark.unit +class TestEscapeHtml: + """Тесты для escape_html.""" + + def test_empty_string(self): + """Пустая строка возвращает пустую строку.""" + assert escape_html("") == "" + + def test_none_returns_empty(self): + """None возвращает пустую строку.""" + assert escape_html(None) == "" + + def test_plain_text_unchanged(self): + """Обычный текст не меняется.""" + assert escape_html("Hello world") == "Hello world" + + def test_escaping_angle_brackets(self): + """Экранирование < и >.""" + assert escape_html("