diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ac6315..795ec38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI pipeline on: push: - branches: [ 'dev-*', 'feature-*' ] + branches: [ 'dev-*', 'feature-*', 'fix-*' ] pull_request: - branches: [ 'dev-*', 'feature-*', 'main' ] + branches: [ 'dev-*', 'feature-*', 'fix-*', 'main' ] workflow_dispatch: jobs: @@ -35,7 +35,8 @@ jobs: python -m black . echo "🔍 Checking that repo is already formatted (no diff after isort+black)..." git diff --exit-code || ( - echo "❌ Code style drift. Locally run: isort . && black . && git add -A && git commit -m 'style: isort + black'" + echo "❌ Code style drift. From THIS repo root (telegram-helper-bot) run:" + echo " python -m isort . && python -m black . && git add -A && git commit -m 'style: isort + black'" exit 1 ) diff --git a/helper_bot/handlers/private/services.py b/helper_bot/handlers/private/services.py index 233198b..b369484 100644 --- a/helper_bot/handlers/private/services.py +++ b/helper_bot/handlers/private/services.py @@ -334,6 +334,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, @@ -530,6 +537,14 @@ class PostService: 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(), @@ -580,6 +595,14 @@ class PostService: 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( @@ -638,6 +661,14 @@ class PostService: 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( @@ -723,6 +754,14 @@ class PostService: 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( @@ -816,6 +855,14 @@ class PostService: 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 0db55f5..513a929 100644 --- a/helper_bot/services/scoring/rag_client.py +++ b/helper_bot/services/scoring/rag_client.py @@ -159,13 +159,28 @@ class RagApiClient: 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( diff --git a/helper_bot/utils/helper_func.py b/helper_bot/utils/helper_func.py index e83a45a..39aab76 100644 --- a/helper_bot/utils/helper_func.py +++ b/helper_bot/utils/helper_func.py @@ -203,6 +203,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 553075d..96cb8d7 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..fda702c --- /dev/null +++ b/tests/test_admin_dependencies.py @@ -0,0 +1,196 @@ +""" +Тесты для 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..2f807c4 --- /dev/null +++ b/tests/test_admin_handlers.py @@ -0,0 +1,287 @@ +""" +Тесты для 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..57e661d --- /dev/null +++ b/tests/test_admin_utils.py @@ -0,0 +1,198 @@ +""" +Тесты для 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("