Merge pull request #16 from KerradKerridi/fix-1

Переписал почти все тесты
This commit was merged in pull request #16.
This commit is contained in:
ANDREY KATYKHIN
2026-02-03 23:45:52 +03:00
committed by GitHub
28 changed files with 4817 additions and 6 deletions

View File

@@ -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
)

View File

@@ -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,

View File

@@ -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(

View File

@@ -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%})"

View File

@@ -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

View File

@@ -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"}}

View File

@@ -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")

198
tests/test_admin_utils.py Normal file
View File

@@ -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("<script>") == "&lt;script&gt;"
def test_escaping_ampersand(self):
"""Экранирование &."""
assert escape_html("a & b") == "a &amp; b"
def test_escaping_quotes(self):
"""Экранирование кавычек."""
assert escape_html('"test"') == "&quot;test&quot;"
@pytest.mark.unit
class TestFormatUserInfo:
"""Тесты для format_user_info."""
def test_formats_all_fields(self):
"""Все поля подставляются и экранируются."""
result = format_user_info(
user_id=123,
username="user_name",
full_name="Иван Иванов",
)
assert "123" in result
assert "user_name" in result
assert "Иван Иванов" in result
assert "<b>Выбран пользователь:</b>" in result
assert "<b>ID:</b>" in result
assert "<b>Username:</b>" in result
assert "<b>Имя:</b>" in result
def test_escapes_username_and_full_name(self):
"""username и full_name экранируются через escape_html."""
result = format_user_info(
user_id=1,
username="<script>",
full_name="<b>Bold</b>",
)
assert "&lt;script&gt;" in result
assert "&lt;b&gt;Bold&lt;/b&gt;" in result
@pytest.mark.unit
class TestFormatBanConfirmation:
"""Тесты для format_ban_confirmation."""
def test_ban_forever(self):
"""При ban_days=None отображается 'Навсегда'."""
result = format_ban_confirmation(user_id=456, reason="Спам", ban_days=None)
assert "Навсегда" in result
assert "456" in result
assert "Спам" in result
assert "<b>Необходимо подтверждение:</b>" in result
def test_ban_with_days(self):
"""При указании срока отображается количество дней."""
result = format_ban_confirmation(user_id=789, reason="Оскорбления", ban_days=7)
assert "7 дней" in result
assert "Оскорбления" in result
def test_escapes_reason(self):
"""Причина бана экранируется."""
result = format_ban_confirmation(user_id=1, reason="<html>", ban_days=1)
assert "&lt;html&gt;" in result
@pytest.mark.unit
@pytest.mark.asyncio
class TestReturnToAdminMenu:
"""Тесты для return_to_admin_menu."""
async def test_sets_state_and_sends_menu(self):
"""Устанавливается состояние ADMIN и отправляется меню."""
message = MagicMock()
message.from_user.id = 111
message.answer = AsyncMock()
state = MagicMock()
state.set_data = AsyncMock()
state.set_state = AsyncMock()
with patch(
"helper_bot.handlers.admin.utils.get_reply_keyboard_admin"
) as mock_kb:
mock_kb.return_value = "keyboard_markup"
await return_to_admin_menu(message, state)
state.set_data.assert_called_once_with({})
state.set_state.assert_called_once_with("ADMIN")
mock_kb.assert_called_once()
assert message.answer.call_count == 1
message.answer.assert_called_with(
"Вернулись в меню", reply_markup="keyboard_markup"
)
async def test_additional_message_sent_first(self):
"""При additional_message сначала отправляется оно, затем меню."""
message = MagicMock()
message.from_user.id = 222
message.answer = AsyncMock()
state = MagicMock()
state.set_data = AsyncMock()
state.set_state = AsyncMock()
with patch(
"helper_bot.handlers.admin.utils.get_reply_keyboard_admin",
return_value="keyboard_markup",
):
await return_to_admin_menu(
message, state, additional_message="Дополнительный текст"
)
assert message.answer.call_count == 2
message.answer.assert_any_call("Дополнительный текст")
message.answer.assert_any_call(
"Вернулись в меню", reply_markup="keyboard_markup"
)
@pytest.mark.unit
@pytest.mark.asyncio
class TestHandleAdminError:
"""Тесты для handle_admin_error."""
async def test_admin_error_sends_error_text(self):
"""При AdminError отправляется текст ошибки и возврат в меню."""
message = MagicMock()
message.from_user.id = 333
message.answer = AsyncMock()
state = MagicMock()
state.set_data = AsyncMock()
state.set_state = AsyncMock()
error = AdminError("Конкретная ошибка")
with patch(
"helper_bot.handlers.admin.utils.get_reply_keyboard_admin",
return_value="keyboard_markup",
):
await handle_admin_error(message, error, state, "test_context")
message.answer.assert_any_call("Ошибка: Конкретная ошибка")
state.set_data.assert_called_once_with({})
state.set_state.assert_called_once_with("ADMIN")
async def test_generic_error_sends_internal_message(self):
"""При любой другой ошибке отправляется общее сообщение."""
message = MagicMock()
message.from_user.id = 444
message.answer = AsyncMock()
state = MagicMock()
state.set_data = AsyncMock()
state.set_state = AsyncMock()
with patch(
"helper_bot.handlers.admin.utils.get_reply_keyboard_admin",
return_value="keyboard_markup",
):
await handle_admin_error(
message, ValueError("Что-то пошло не так"), state, "test"
)
message.answer.assert_any_call("Произошла внутренняя ошибка. Попробуйте позже.")
state.set_state.assert_called_once_with("ADMIN")

View File

@@ -0,0 +1,130 @@
"""
Тесты для helper_bot.middlewares.album_middleware.
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from helper_bot.middlewares.album_middleware import AlbumGetter, AlbumMiddleware
@pytest.mark.unit
@pytest.mark.asyncio
class TestAlbumGetter:
"""Тесты для AlbumGetter."""
async def test_get_album_returns_collected_album_after_event_set(self):
"""get_album возвращает собранную медиагруппу после set()."""
album_data = {"group_1": {"collected_album": [MagicMock(), MagicMock()]}}
event = asyncio.Event()
event.set()
getter = AlbumGetter(album_data, "group_1", event)
result = await getter.get_album(timeout=1.0)
assert result is not None
assert len(result) == 2
async def test_get_album_returns_none_on_timeout(self):
"""get_album возвращает None при таймауте."""
album_data = {}
event = asyncio.Event()
getter = AlbumGetter(album_data, "group_1", event)
result = await getter.get_album(timeout=0.01)
assert result is None
async def test_get_album_returns_none_if_media_group_id_removed(self):
"""get_album возвращает None если media_group_id уже удалён из album_data."""
album_data = {}
event = asyncio.Event()
event.set()
getter = AlbumGetter(album_data, "missing_group", event)
result = await getter.get_album(timeout=0.1)
assert result is None
@pytest.mark.unit
@pytest.mark.asyncio
class TestAlbumMiddleware:
"""Тесты для AlbumMiddleware."""
@pytest.fixture
def middleware(self):
"""Middleware с короткой latency для тестов."""
return AlbumMiddleware(latency=0.05)
@pytest.fixture
def mock_handler(self):
"""Мок handler."""
return AsyncMock(return_value="ok")
async def test_no_media_group_id_calls_handler_immediately(
self, middleware, mock_handler
):
"""Сообщение без media_group_id передаётся в handler сразу."""
event = MagicMock()
event.media_group_id = None
event.message_id = 1
data = {}
result = await middleware(mock_handler, event, data)
mock_handler.assert_called_once_with(event, data)
assert result == "ok"
assert "album_getter" not in data
async def test_first_media_group_message_creates_album_getter_and_calls_handler(
self, middleware, mock_handler
):
"""Первое сообщение медиагруппы: создаётся album_getter, handler вызывается."""
event = MagicMock()
event.media_group_id = "group_123"
event.message_id = 10
data = {}
result = await middleware(mock_handler, event, data)
mock_handler.assert_called_once_with(event, data)
assert "album_getter" in data
assert isinstance(data["album_getter"], AlbumGetter)
assert data["album_getter"].media_group_id == "group_123"
assert result == "ok"
async def test_second_media_group_message_does_not_call_handler(
self, middleware, mock_handler
):
"""Второе сообщение той же медиагруппы: handler не вызывается."""
event1 = MagicMock()
event1.media_group_id = "group_456"
event1.message_id = 1
data1 = {}
await middleware(mock_handler, event1, data1)
event2 = MagicMock()
event2.media_group_id = "group_456"
event2.message_id = 2
data2 = {}
result = await middleware(mock_handler, event2, data2)
assert mock_handler.call_count == 1
assert result is None
def test_collect_album_messages_returns_count(self, middleware):
"""collect_album_messages возвращает количество сообщений в группе."""
event = MagicMock()
event.media_group_id = "g1"
assert middleware.collect_album_messages(event) == 1
assert middleware.collect_album_messages(event) == 2
def test_collect_album_messages_no_media_group_returns_zero(self, middleware):
"""Без media_group_id возвращается 0."""
event = MagicMock()
event.media_group_id = None
assert middleware.collect_album_messages(event) == 0

View File

@@ -0,0 +1,112 @@
"""
Тесты для helper_bot.middlewares.blacklist_middleware.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from aiogram.types import CallbackQuery, Message
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware
@pytest.mark.unit
@pytest.mark.asyncio
class TestBlacklistMiddleware:
"""Тесты для BlacklistMiddleware."""
@pytest.fixture
def middleware(self):
"""Экземпляр middleware."""
return BlacklistMiddleware()
@pytest.fixture
def mock_handler(self):
"""Мок handler."""
return AsyncMock(return_value="handler_ok")
@patch("helper_bot.middlewares.blacklist_middleware.BotDB", new_callable=MagicMock)
async def test_user_not_in_blacklist_calls_handler(
self, mock_bot_db, middleware, mock_handler
):
"""Пользователь не в блэклисте — handler вызывается."""
mock_bot_db.check_user_in_blacklist = AsyncMock(return_value=False)
event = MagicMock(spec=Message)
event.from_user = MagicMock()
event.from_user.id = 123
event.from_user.username = "user1"
data = {}
result = await middleware(mock_handler, event, data)
mock_bot_db.check_user_in_blacklist.assert_called_once_with(123)
mock_handler.assert_called_once_with(event, data)
assert result == "handler_ok"
@patch("helper_bot.middlewares.blacklist_middleware.BotDB", new_callable=MagicMock)
async def test_user_in_blacklist_message_sends_answer_and_returns_false(
self, mock_bot_db, middleware, mock_handler
):
"""Пользователь в блэклисте (Message) — отправляется ответ, handler не вызывается."""
mock_bot_db.check_user_in_blacklist = AsyncMock(return_value=True)
mock_bot_db.get_blacklist_users_by_id = AsyncMock(
return_value=(123, "Спам", 1735689600) # user_id, reason, date_unban_ts
)
event = MagicMock(spec=Message)
event.from_user = MagicMock()
event.from_user.id = 123
event.from_user.username = "banned"
event.answer = AsyncMock()
data = {}
result = await middleware(mock_handler, event, data)
mock_handler.assert_not_called()
event.answer.assert_called_once()
call_text = event.answer.call_args[0][0]
assert "Ты заблокирован" in call_text
assert "Спам" in call_text
assert result is False
@patch("helper_bot.middlewares.blacklist_middleware.BotDB", new_callable=MagicMock)
async def test_user_in_blacklist_callback_sends_alert(
self, mock_bot_db, middleware, mock_handler
):
"""Пользователь в блэклисте (CallbackQuery) — answer с show_alert=True."""
mock_bot_db.check_user_in_blacklist = AsyncMock(return_value=True)
mock_bot_db.get_blacklist_users_by_id = AsyncMock(
return_value=(456, "Нарушение", None)
)
event = MagicMock(spec=CallbackQuery)
event.from_user = MagicMock()
event.from_user.id = 456
event.from_user.username = "banned_cb"
event.answer = AsyncMock()
data = {}
result = await middleware(mock_handler, event, data)
mock_handler.assert_not_called()
event.answer.assert_called_once()
call_args = event.answer.call_args
assert call_args[0][0].startswith("<b>Ты заблокирован")
assert call_args[1].get("show_alert") is True
assert result is False
@patch("helper_bot.middlewares.blacklist_middleware.BotDB", new_callable=MagicMock)
async def test_event_without_user_passes_through(
self, mock_bot_db, middleware, mock_handler
):
"""Событие без user — handler вызывается (user = None)."""
event = MagicMock()
# Объект без from_user или from_user = None — в коде user = event.from_user
event.from_user = None
data = {}
result = await middleware(mock_handler, event, data)
mock_handler.assert_called_once_with(event, data)
assert result == "handler_ok"

View File

@@ -0,0 +1,109 @@
"""
Тесты для helper_bot.handlers.callback.dependency_factory.
"""
from unittest.mock import MagicMock, patch
import pytest
from helper_bot.handlers.callback.dependency_factory import (
get_ban_service,
get_post_publish_service,
)
from helper_bot.handlers.callback.services import BanService, PostPublishService
@pytest.mark.unit
class TestGetPostPublishService:
"""Тесты для get_post_publish_service."""
def test_returns_post_publish_service_with_dependencies_from_factory(self):
"""Возвращается PostPublishService с db, settings, s3_storage, scoring_manager из get_global_instance."""
mock_db = MagicMock()
mock_settings = {
"Telegram": {
"group_for_posts": "-100",
"main_public": "@ch",
"important_logs": "-200",
}
}
mock_s3 = MagicMock()
mock_scoring = MagicMock()
mock_bdf = MagicMock()
mock_bdf.get_db.return_value = mock_db
mock_bdf.settings = mock_settings
mock_bdf.get_s3_storage.return_value = mock_s3
mock_bdf.get_scoring_manager.return_value = mock_scoring
with patch(
"helper_bot.handlers.callback.dependency_factory.get_global_instance",
return_value=mock_bdf,
):
service = get_post_publish_service()
assert isinstance(service, PostPublishService)
assert service.bot is None
assert service.db is mock_db
assert service.settings is mock_settings
assert service.s3_storage is mock_s3
assert service.scoring_manager is mock_scoring
mock_bdf.get_db.assert_called_once()
mock_bdf.get_s3_storage.assert_called_once()
mock_bdf.get_scoring_manager.assert_called_once()
def test_post_publish_service_get_bot_from_message_when_bot_none(self):
"""PostPublishService._get_bot возвращает message.bot когда self.bot is None."""
mock_db = MagicMock()
mock_settings = {
"Telegram": {
"group_for_posts": "-100",
"main_public": "@ch",
"important_logs": "-200",
}
}
mock_bdf = MagicMock()
mock_bdf.get_db.return_value = mock_db
mock_bdf.settings = mock_settings
mock_bdf.get_s3_storage.return_value = None
mock_bdf.get_scoring_manager.return_value = None
with patch(
"helper_bot.handlers.callback.dependency_factory.get_global_instance",
return_value=mock_bdf,
):
service = get_post_publish_service()
message = MagicMock()
message.bot = MagicMock()
bot = service._get_bot(message)
assert bot is message.bot
@pytest.mark.unit
class TestGetBanService:
"""Тесты для get_ban_service."""
def test_returns_ban_service_with_dependencies_from_factory(self):
"""Возвращается BanService с db и settings из get_global_instance."""
mock_db = MagicMock()
mock_settings = {
"Telegram": {
"group_for_posts": "-100",
"important_logs": "-200",
}
}
mock_bdf = MagicMock()
mock_bdf.get_db.return_value = mock_db
mock_bdf.settings = mock_settings
with patch(
"helper_bot.handlers.callback.dependency_factory.get_global_instance",
return_value=mock_bdf,
):
service = get_ban_service()
assert isinstance(service, BanService)
assert service.bot is None
assert service.db is mock_db
assert service.settings is mock_settings
mock_bdf.get_db.assert_called_once()

View File

@@ -5,7 +5,11 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from helper_bot.handlers.callback.callback_handlers import (
change_page,
delete_voice_message,
process_ban_user,
process_unlock_user,
return_to_main_menu,
save_voice_message,
)
from helper_bot.handlers.voice.constants import CALLBACK_DELETE, CALLBACK_SAVE
@@ -384,5 +388,278 @@ class TestCallbackHandlersEdgeCases:
mock_bot_db.delete_audio_moderate_record.assert_called_once_with(99999)
@pytest.mark.unit
@pytest.mark.asyncio
class TestReturnToMainMenu:
"""Тесты для return_to_main_menu."""
@pytest.fixture
def mock_call(self):
call = Mock()
call.message = Mock()
call.message.message_id = 1
call.message.from_user = Mock()
call.message.from_user.id = 123
call.message.delete = AsyncMock()
call.message.answer = AsyncMock()
return call
@patch("helper_bot.handlers.callback.callback_handlers.get_reply_keyboard_admin")
async def test_return_to_main_menu_deletes_and_answers(
self, mock_keyboard, mock_call
):
"""return_to_main_menu удаляет сообщение и отправляет приветствие."""
mock_keyboard.return_value = MagicMock()
await return_to_main_menu(mock_call)
mock_call.message.delete.assert_called_once()
mock_call.message.answer.assert_called_once()
assert (
"админк" in mock_call.message.answer.call_args[0][0].lower()
or "добро" in mock_call.message.answer.call_args[0][0].lower()
)
@pytest.mark.unit
@pytest.mark.asyncio
class TestChangePage:
"""Тесты для change_page."""
@pytest.fixture
def mock_bot_db_for_page(self):
"""Мок БД для change_page."""
db = Mock()
db.get_last_users = AsyncMock(return_value=[("U1", 1), ("U2", 2)])
return db
@pytest.fixture
def mock_call_list_users(self):
call = Mock()
call.data = "page_2"
call.message = Mock()
call.message.text = "Список пользователей которые последними обращались к боту"
call.message.chat = Mock()
call.message.chat.id = 1
call.message.message_id = 10
call.bot = Mock()
call.bot.edit_message_reply_markup = AsyncMock()
call.answer = AsyncMock()
return call
@pytest.fixture
def mock_bot_db(self):
db = Mock()
db.get_last_users = AsyncMock(return_value=[("U1", 1), ("U2", 2)])
return db
@patch(
"helper_bot.handlers.callback.callback_handlers.create_keyboard_with_pagination"
)
async def test_change_page_list_users_edits_markup(
self, mock_keyboard, mock_call_list_users, mock_bot_db_for_page
):
"""change_page для списка пользователей редактирует reply_markup."""
mock_keyboard.return_value = MagicMock()
await change_page(mock_call_list_users, bot_db=mock_bot_db_for_page)
mock_bot_db_for_page.get_last_users.assert_awaited_once_with(30)
mock_call_list_users.bot.edit_message_reply_markup.assert_awaited_once()
@patch(
"helper_bot.handlers.callback.callback_handlers.get_banned_users_buttons",
new_callable=AsyncMock,
)
@patch(
"helper_bot.handlers.callback.callback_handlers.get_banned_users_list",
new_callable=AsyncMock,
)
@patch(
"helper_bot.handlers.callback.callback_handlers.create_keyboard_with_pagination"
)
async def test_change_page_banned_list_edits_text_and_markup(
self, mock_keyboard, mock_get_list, mock_get_buttons, mock_bot_db_for_page
):
"""change_page для списка забаненных редактирует текст и клавиатуру."""
mock_get_list.return_value = "Текст страницы"
mock_get_buttons.return_value = []
call = Mock()
call.data = "page_1"
call.message = Mock()
call.message.text = "Заблокированные пользователи"
call.message.chat = Mock()
call.message.chat.id = 1
call.message.message_id = 10
call.bot = Mock()
call.bot.edit_message_text = AsyncMock()
call.bot.edit_message_reply_markup = AsyncMock()
call.answer = AsyncMock()
mock_keyboard.return_value = MagicMock()
await change_page(call, bot_db=mock_bot_db_for_page)
mock_get_list.assert_awaited_once()
mock_get_buttons.assert_awaited_once()
call.bot.edit_message_text.assert_awaited_once()
call.bot.edit_message_reply_markup.assert_awaited_once()
async def test_change_page_invalid_page_number_answers_error(
self, mock_bot_db_for_page
):
"""change_page при некорректном номере страницы отвечает ошибкой."""
call = Mock()
call.data = "page_abc"
call.answer = AsyncMock()
await change_page(call, bot_db=mock_bot_db_for_page)
call.answer.assert_awaited_once_with(
text="Ошибка: некорректный номер страницы", show_alert=True, cache_time=3
)
@pytest.mark.unit
@pytest.mark.asyncio
class TestProcessBanUser:
"""Тесты для process_ban_user."""
@pytest.fixture
def mock_bot_db_ban(self):
"""Мок БД для process_ban_user."""
db = Mock()
db.get_full_name_by_id = AsyncMock(return_value="Full Name")
return db
@pytest.fixture
def mock_call(self):
call = Mock()
call.data = "ban_123456"
call.from_user = Mock()
call.message = Mock()
call.message.answer = AsyncMock()
call.answer = AsyncMock()
return call
@pytest.fixture
def mock_state(self):
state = Mock()
state.update_data = AsyncMock()
state.set_state = AsyncMock()
return state
@patch(
"helper_bot.handlers.callback.callback_handlers.create_keyboard_for_ban_reason"
)
@patch("helper_bot.handlers.callback.callback_handlers.format_user_info")
@patch("helper_bot.handlers.callback.callback_handlers.get_ban_service")
async def test_process_ban_user_success_sets_state_await_details(
self,
mock_get_ban,
mock_format,
mock_keyboard,
mock_call,
mock_state,
mock_bot_db_ban,
):
"""process_ban_user при успехе переводит в AWAIT_BAN_DETAILS."""
mock_ban = Mock()
mock_ban.ban_user = AsyncMock(return_value="username")
mock_get_ban.return_value = mock_ban
mock_format.return_value = "User info"
mock_keyboard.return_value = MagicMock()
await process_ban_user(mock_call, mock_state, bot_db=mock_bot_db_ban)
mock_state.update_data.assert_awaited_once()
mock_state.set_state.assert_awaited_once_with("AWAIT_BAN_DETAILS")
mock_call.message.answer.assert_awaited_once()
@patch("helper_bot.handlers.callback.callback_handlers.get_reply_keyboard_admin")
@patch("helper_bot.handlers.callback.callback_handlers.get_ban_service")
async def test_process_ban_user_not_found_returns_to_admin(
self, mock_get_ban, mock_keyboard, mock_call, mock_state, mock_bot_db_ban
):
"""process_ban_user при UserNotFoundError возвращает в админ-меню."""
from helper_bot.handlers.callback.exceptions import UserNotFoundError
mock_ban = Mock()
mock_ban.ban_user = AsyncMock(side_effect=UserNotFoundError("not found"))
mock_get_ban.return_value = mock_ban
mock_keyboard.return_value = MagicMock()
await process_ban_user(mock_call, mock_state, bot_db=mock_bot_db_ban)
mock_call.message.answer.assert_awaited_once()
mock_state.set_state.assert_awaited_once_with("ADMIN")
async def test_process_ban_user_invalid_user_id_answers_error(
self, mock_call, mock_state, mock_bot_db_ban
):
"""process_ban_user при некорректном user_id отвечает ошибкой."""
mock_call.data = "ban_abc"
await process_ban_user(mock_call, mock_state, bot_db=mock_bot_db_ban)
mock_call.answer.assert_awaited_once_with(
text="Ошибка: некорректный ID пользователя", show_alert=True, cache_time=3
)
@pytest.mark.unit
@pytest.mark.asyncio
class TestProcessUnlockUser:
"""Тесты для process_unlock_user."""
@patch("helper_bot.handlers.callback.callback_handlers.get_ban_service")
async def test_process_unlock_user_success_answers_unlocked(self, mock_get_ban):
"""process_unlock_user при успехе отвечает сообщением о разблокировке."""
call = Mock()
call.data = "unlock_123"
call.answer = AsyncMock()
mock_ban = Mock()
mock_ban.unlock_user = AsyncMock(return_value="username")
mock_get_ban.return_value = mock_ban
await process_unlock_user(call)
mock_ban.unlock_user.assert_awaited_once_with("123")
call.answer.assert_awaited_once()
assert (
"username" in call.answer.call_args[0][0]
or "разблокирован" in call.answer.call_args[0][0].lower()
)
@patch("helper_bot.handlers.callback.callback_handlers.get_ban_service")
async def test_process_unlock_user_not_found_answers_error(self, mock_get_ban):
"""process_unlock_user при UserNotFoundError отвечает что пользователь не найден."""
from helper_bot.handlers.callback.exceptions import UserNotFoundError
call = Mock()
call.data = "unlock_999"
call.answer = AsyncMock()
mock_ban = Mock()
mock_ban.unlock_user = AsyncMock(side_effect=UserNotFoundError("not found"))
mock_get_ban.return_value = mock_ban
await process_unlock_user(call)
call.answer.assert_awaited_once_with(
text="Пользователь не найден в базе", show_alert=True, cache_time=3
)
async def test_process_unlock_user_invalid_user_id_answers_error(self):
"""process_unlock_user при некорректном user_id отвечает ошибкой."""
call = Mock()
call.data = "unlock_abc"
call.answer = AsyncMock()
await process_unlock_user(call)
call.answer.assert_awaited_once_with(
text="Ошибка: некорректный ID пользователя", show_alert=True, cache_time=3
)
if __name__ == "__main__":
pytest.main([__file__])

View File

@@ -0,0 +1,810 @@
"""
Тесты для helper_bot.handlers.callback.services: PostPublishService, BanService.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from aiogram.types import CallbackQuery, Message
from helper_bot.handlers.callback.constants import CONTENT_TYPE_MEDIA_GROUP
from helper_bot.handlers.callback.exceptions import (
PostNotFoundError,
PublishError,
UserBlockedBotError,
UserNotFoundError,
)
from helper_bot.handlers.callback.services import BanService, PostPublishService
@pytest.mark.unit
@pytest.mark.asyncio
class TestPostPublishService:
"""Тесты для PostPublishService."""
@pytest.fixture
def mock_bot(self):
bot = MagicMock()
bot.delete_message = AsyncMock()
return bot
@pytest.fixture
def mock_db(self):
db = MagicMock()
db.get_author_id_by_message_id = AsyncMock(return_value=123)
db.update_status_by_message_id = AsyncMock(return_value=1)
db.get_post_text_and_anonymity_by_message_id = AsyncMock(
return_value=("text", False)
)
db.get_user_by_id = AsyncMock(
return_value=MagicMock(first_name="U", username="u")
)
db.update_published_message_id = AsyncMock()
db.get_post_content_by_message_id = AsyncMock(return_value=[("/path", "photo")])
db.add_published_post_content = AsyncMock(return_value=True)
db.get_post_text_by_message_id = AsyncMock(return_value="post text")
return db
@pytest.fixture
def settings(self):
return {
"Telegram": {
"group_for_posts": "-100",
"main_public": "-200",
"important_logs": "-300",
}
}
@pytest.fixture
def service(self, mock_bot, mock_db, settings):
return PostPublishService(mock_bot, mock_db, settings)
def test_get_bot_returns_bot_when_set(self, service, mock_bot):
"""_get_bot при установленном bot возвращает его."""
message = MagicMock()
assert service._get_bot(message) is mock_bot
def test_get_bot_returns_message_bot_when_bot_none(self, mock_db, settings):
"""_get_bot при bot=None возвращает message.bot."""
service = PostPublishService(None, mock_db, settings)
message = MagicMock()
message.bot = MagicMock()
assert service._get_bot(message) is message.bot
@pytest.fixture
def mock_call_text(self):
call = MagicMock(spec=CallbackQuery)
call.message = MagicMock(spec=Message)
call.message.message_id = 1
call.message.text = None
call.message.media_group_id = None
call.message.content_type = "text"
call.message.from_user = MagicMock()
call.message.from_user.full_name = "User"
call.message.from_user.id = 123
return call
@patch("helper_bot.handlers.callback.services.send_text_message")
@patch("helper_bot.handlers.callback.services.get_text_message")
async def test_publish_post_text_success(
self, mock_get_text, mock_send_text, service, mock_call_text, mock_db
):
"""publish_post для текстового поста вызывает _publish_text_post и отправляет в канал."""
mock_get_text.return_value = "Formatted"
sent = MagicMock()
sent.message_id = 999
mock_send_text.return_value = sent
await service.publish_post(mock_call_text)
mock_db.update_status_by_message_id.assert_awaited_once_with(1, "approved")
assert mock_send_text.await_count >= 1
mock_db.update_published_message_id.assert_awaited_once()
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_get_author_id_raises_when_not_found(
self, mock_send, service, mock_db
):
"""_get_author_id при отсутствии автора выбрасывает PostNotFoundError."""
mock_db.get_author_id_by_message_id = AsyncMock(return_value=None)
with pytest.raises(PostNotFoundError):
await service._get_author_id(1)
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_get_author_id_returns_author_id(self, mock_send, service, mock_db):
"""_get_author_id возвращает ID автора."""
mock_db.get_author_id_by_message_id = AsyncMock(return_value=456)
result = await service._get_author_id(1)
assert result == 456
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_get_author_id_for_media_group_returns_from_helper(
self, mock_send, service, mock_db
):
"""_get_author_id_for_media_group при нахождении по helper_id возвращает author_id."""
mock_db.get_author_id_by_helper_message_id = AsyncMock(return_value=789)
result = await service._get_author_id_for_media_group(100)
assert result == 789
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_train_on_published_skips_when_no_scoring_manager(
self, mock_send, service, mock_db
):
"""_train_on_published при отсутствии scoring_manager ничего не делает."""
service.scoring_manager = None
await service._train_on_published(1)
mock_db.get_post_text_by_message_id.assert_not_called()
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_train_on_published_calls_on_post_published(
self, mock_send, service, mock_db
):
"""_train_on_published при наличии scoring_manager вызывает on_post_published."""
mock_scoring = MagicMock()
mock_scoring.on_post_published = AsyncMock()
service.scoring_manager = mock_scoring
mock_db.get_post_text_by_message_id = AsyncMock(return_value="post text")
await service._train_on_published(1)
mock_scoring.on_post_published.assert_awaited_once_with("post text")
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_train_on_declined_skips_when_no_scoring_manager(
self, mock_send, service
):
"""_train_on_declined при отсутствии scoring_manager ничего не делает."""
service.scoring_manager = None
await service._train_on_declined(1)
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_save_published_post_content_copies_path(
self, mock_send, service, mock_db
):
"""_save_published_post_content копирует путь контента в published."""
published_message = MagicMock()
mock_db.get_post_content_by_message_id = AsyncMock(
return_value=[("/path/file", "photo")]
)
mock_db.add_published_post_content = AsyncMock(return_value=True)
await service._save_published_post_content(published_message, 100, 1)
mock_db.add_published_post_content.assert_awaited_once_with(
published_message_id=100, content_path="/path/file", content_type="photo"
)
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_save_published_post_content_empty_content(
self, mock_send, service, mock_db
):
"""_save_published_post_content при пустом контенте не падает."""
published_message = MagicMock()
mock_db.get_post_content_by_message_id = AsyncMock(return_value=[])
await service._save_published_post_content(published_message, 100, 1)
mock_db.add_published_post_content.assert_not_called()
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_save_published_post_content_add_fails(
self, mock_send, service, mock_db
):
"""_save_published_post_content при add_published_post_content=False не падает."""
published_message = MagicMock()
mock_db.get_post_content_by_message_id = AsyncMock(
return_value=[("/path", "photo")]
)
mock_db.add_published_post_content = AsyncMock(return_value=False)
await service._save_published_post_content(published_message, 100, 1)
mock_db.add_published_post_content.assert_awaited_once()
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_publish_post_unsupported_content_raises(
self, mock_send, service, mock_call_text
):
"""publish_post при неподдерживаемом типе контента выбрасывает PublishError."""
mock_call_text.message.content_type = "document"
with pytest.raises(PublishError, match="Неподдерживаемый тип контента"):
await service.publish_post(mock_call_text)
@patch("helper_bot.handlers.callback.services.send_photo_message")
@patch("helper_bot.handlers.callback.services.send_text_message")
@patch("helper_bot.handlers.callback.services.get_text_message")
async def test_publish_post_photo_success(
self, mock_get_text, mock_send_text, mock_send_photo, service, mock_db
):
"""publish_post для фото вызывает _publish_photo_post."""
mock_get_text.return_value = "Formatted"
call = MagicMock(spec=CallbackQuery)
call.message = MagicMock(spec=Message)
call.message.message_id = 1
call.message.text = None
call.message.media_group_id = None
call.message.content_type = "photo"
call.message.photo = [MagicMock(), MagicMock(file_id="fid")]
call.message.from_user = MagicMock(full_name="U", id=1)
sent = MagicMock()
sent.message_id = 999
mock_send_photo.return_value = sent
await service.publish_post(call)
mock_send_photo.assert_awaited_once()
mock_db.update_published_message_id.assert_awaited_once()
@patch("helper_bot.handlers.callback.services.send_video_message")
@patch("helper_bot.handlers.callback.services.send_text_message")
@patch("helper_bot.handlers.callback.services.get_text_message")
async def test_publish_post_video_success(
self, mock_get_text, mock_send_text, mock_send_video, service, mock_db
):
"""publish_post для видео вызывает _publish_video_post."""
mock_get_text.return_value = "Formatted"
call = MagicMock(spec=CallbackQuery)
call.message = MagicMock(spec=Message)
call.message.message_id = 1
call.message.text = None
call.message.media_group_id = None
call.message.content_type = "video"
call.message.video = MagicMock(file_id="vid")
call.message.from_user = MagicMock(full_name="U", id=1)
sent = MagicMock()
sent.message_id = 999
mock_send_video.return_value = sent
await service.publish_post(call)
mock_send_video.assert_awaited_once()
@patch("helper_bot.handlers.callback.services.send_video_note_message")
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_publish_post_video_note_success(
self, mock_send_text, mock_send_vn, service, mock_db
):
"""publish_post для кружка вызывает _publish_video_note_post."""
call = MagicMock(spec=CallbackQuery)
call.message = MagicMock(spec=Message)
call.message.message_id = 1
call.message.text = None
call.message.media_group_id = None
call.message.content_type = "video_note"
call.message.video_note = MagicMock(file_id="vnid")
call.message.from_user = MagicMock(full_name="U", id=1)
sent = MagicMock()
sent.message_id = 999
mock_send_vn.return_value = sent
await service.publish_post(call)
mock_send_vn.assert_awaited_once()
@patch("helper_bot.handlers.callback.services.send_audio_message")
@patch("helper_bot.handlers.callback.services.send_text_message")
@patch("helper_bot.handlers.callback.services.get_text_message")
async def test_publish_post_audio_success(
self, mock_get_text, mock_send_text, mock_send_audio, service, mock_db
):
"""publish_post для аудио вызывает _publish_audio_post."""
mock_get_text.return_value = "Formatted"
call = MagicMock(spec=CallbackQuery)
call.message = MagicMock(spec=Message)
call.message.message_id = 1
call.message.text = None
call.message.media_group_id = None
call.message.content_type = "audio"
call.message.audio = MagicMock(file_id="aid")
call.message.from_user = MagicMock(full_name="U", id=1)
sent = MagicMock()
sent.message_id = 999
mock_send_audio.return_value = sent
await service.publish_post(call)
mock_send_audio.assert_awaited_once()
@patch("helper_bot.handlers.callback.services.send_voice_message")
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_publish_post_voice_success(
self, mock_send_text, mock_send_voice, service, mock_db
):
"""publish_post для войса вызывает _publish_voice_post."""
call = MagicMock(spec=CallbackQuery)
call.message = MagicMock(spec=Message)
call.message.message_id = 1
call.message.text = None
call.message.media_group_id = None
call.message.content_type = "voice"
call.message.voice = MagicMock(file_id="vid")
call.message.from_user = MagicMock(full_name="U", id=1)
sent = MagicMock()
sent.message_id = 999
mock_send_voice.return_value = sent
await service.publish_post(call)
mock_send_voice.assert_awaited_once()
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_publish_text_post_updated_rows_zero_raises(
self, mock_send, service, mock_call_text, mock_db
):
"""_publish_text_post при updated_rows=0 выбрасывает PostNotFoundError."""
mock_db.update_status_by_message_id = AsyncMock(return_value=0)
with pytest.raises(PostNotFoundError, match="не найден в базе данных"):
await service._publish_text_post(mock_call_text)
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_publish_text_post_user_none_raises(
self, mock_send, service, mock_call_text, mock_db
):
"""_publish_text_post при отсутствии пользователя выбрасывает PostNotFoundError."""
mock_db.get_user_by_id = AsyncMock(return_value=None)
with pytest.raises(PostNotFoundError, match="не найден в базе данных"):
await service._publish_text_post(mock_call_text)
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_publish_text_post_raw_text_none_uses_empty(
self, mock_send, service, mock_call_text, mock_db
):
"""_publish_text_post при raw_text=None использует пустую строку."""
mock_db.get_post_text_and_anonymity_by_message_id = AsyncMock(
return_value=(None, False)
)
sent = MagicMock()
sent.message_id = 999
mock_send.return_value = sent
await service._publish_text_post(mock_call_text)
mock_db.update_status_by_message_id.assert_awaited_once_with(1, "approved")
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_delete_post_and_notify_author_user_blocked_raises(
self, mock_send, service, mock_call_text
):
"""_delete_post_and_notify_author при заблокированном боте выбрасывает UserBlockedBotError."""
from helper_bot.handlers.callback.constants import ERROR_BOT_BLOCKED
mock_send.side_effect = Exception(ERROR_BOT_BLOCKED)
with pytest.raises(UserBlockedBotError):
await service._delete_post_and_notify_author(mock_call_text, 123)
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_train_on_published_skips_empty_text(
self, mock_send, service, mock_db
):
"""_train_on_published пропускает пустой текст."""
mock_scoring = MagicMock()
mock_scoring.on_post_published = AsyncMock()
service.scoring_manager = mock_scoring
mock_db.get_post_text_by_message_id = AsyncMock(return_value=" ")
await service._train_on_published(1)
mock_scoring.on_post_published.assert_not_called()
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_train_on_published_skips_caret(self, mock_send, service, mock_db):
"""_train_on_published пропускает текст '^'."""
mock_scoring = MagicMock()
mock_scoring.on_post_published = AsyncMock()
service.scoring_manager = mock_scoring
mock_db.get_post_text_by_message_id = AsyncMock(return_value="^")
await service._train_on_published(1)
mock_scoring.on_post_published.assert_not_called()
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_train_on_declined_calls_on_post_declined(
self, mock_send, service, mock_db
):
"""_train_on_declined вызывает on_post_declined."""
mock_scoring = MagicMock()
mock_scoring.on_post_declined = AsyncMock()
service.scoring_manager = mock_scoring
mock_db.get_post_text_by_message_id = AsyncMock(return_value="declined text")
await service._train_on_declined(1)
mock_scoring.on_post_declined.assert_awaited_once_with("declined text")
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_get_author_id_for_media_group_fallback_via_post_ids(
self, mock_send, service, mock_db
):
"""_get_author_id_for_media_group fallback через get_post_ids_from_telegram_by_last_id."""
mock_db.get_author_id_by_helper_message_id = AsyncMock(return_value=None)
mock_db.get_post_ids_from_telegram_by_last_id = AsyncMock(return_value=[50, 51])
mock_db.get_author_id_by_message_id = AsyncMock(side_effect=[None, 777])
result = await service._get_author_id_for_media_group(100)
assert result == 777
mock_db.get_author_id_by_message_id.assert_any_call(50)
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_get_author_id_for_media_group_fallback_direct(
self, mock_send, service, mock_db
):
"""_get_author_id_for_media_group fallback напрямую по message_id."""
mock_db.get_author_id_by_helper_message_id = AsyncMock(return_value=None)
mock_db.get_post_ids_from_telegram_by_last_id = AsyncMock(return_value=[])
mock_db.get_author_id_by_message_id = AsyncMock(return_value=888)
result = await service._get_author_id_for_media_group(100)
assert result == 888
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_get_author_id_for_media_group_raises_when_not_found(
self, mock_send, service, mock_db
):
"""_get_author_id_for_media_group при отсутствии автора выбрасывает PostNotFoundError."""
mock_db.get_author_id_by_helper_message_id = AsyncMock(return_value=None)
mock_db.get_post_ids_from_telegram_by_last_id = AsyncMock(return_value=[])
mock_db.get_author_id_by_message_id = AsyncMock(return_value=None)
with pytest.raises(PostNotFoundError, match="Автор не найден для медиагруппы"):
await service._get_author_id_for_media_group(100)
@patch("helper_bot.handlers.callback.services.send_media_group_to_channel")
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_publish_post_media_group_by_media_group_id(
self, mock_send_text, mock_send_media, service, mock_db
):
"""publish_post при media_group_id идёт в _publish_media_group."""
call = MagicMock(spec=CallbackQuery)
call.message = MagicMock(spec=Message)
call.message.message_id = 10
call.message.text = None
call.message.media_group_id = "mg_123"
call.message.from_user = MagicMock()
mock_db.get_post_ids_by_helper_id = AsyncMock(return_value=[1])
mock_db.get_post_content_by_helper_id = AsyncMock(
return_value=[("/p", "photo")]
)
mock_db.get_post_text_and_anonymity_by_helper_id = AsyncMock(
return_value=("", False)
)
mock_db.get_author_id_by_helper_message_id = AsyncMock(return_value=123)
mock_db.get_user_by_id = AsyncMock(
return_value=MagicMock(first_name="U", username="u")
)
mock_db.update_published_message_id = AsyncMock()
mock_db.update_status_for_media_group_by_helper_id = AsyncMock()
mock_db.get_post_content_by_message_id = AsyncMock(
return_value=[("/path", "photo")]
)
mock_db.add_published_post_content = AsyncMock(return_value=True)
bot = MagicMock()
bot.delete_messages = AsyncMock()
bot.delete_message = AsyncMock()
call.message.bot = bot
service.bot = bot
mock_send_media.return_value = [MagicMock(message_id=101)]
mock_send_text.return_value = MagicMock()
await service.publish_post(call)
mock_send_media.assert_awaited_once()
@patch("helper_bot.handlers.callback.services.send_media_group_to_channel")
@patch("helper_bot.handlers.callback.services.send_text_message")
@patch("helper_bot.handlers.callback.services.get_text_message")
async def test_publish_media_group_success(
self, mock_get_text, mock_send_text, mock_send_media, service, mock_db
):
"""_publish_media_group успешно публикует медиагруппу."""
mock_get_text.return_value = "Formatted"
call = MagicMock(spec=CallbackQuery)
call.message = MagicMock(spec=Message)
call.message.message_id = 10
call.message.text = (
CONTENT_TYPE_MEDIA_GROUP # маршрутизация в _publish_media_group
)
call.message.from_user = MagicMock()
mock_db.get_post_ids_by_helper_id = AsyncMock(return_value=[1, 2])
mock_db.get_post_content_by_helper_id = AsyncMock(
return_value=[("/p1", "photo"), ("/p2", "photo")]
)
mock_db.get_post_text_and_anonymity_by_helper_id = AsyncMock(
return_value=("text", False)
)
mock_db.get_author_id_by_helper_message_id = AsyncMock(return_value=123)
mock_db.get_user_by_id = AsyncMock(
return_value=MagicMock(first_name="U", username="u")
)
mock_db.update_published_message_id = AsyncMock()
mock_db.update_status_for_media_group_by_helper_id = AsyncMock()
mock_db.get_post_content_by_message_id = AsyncMock(
return_value=[("/path", "photo")]
)
mock_db.add_published_post_content = AsyncMock(return_value=True)
bot = MagicMock()
bot.delete_messages = AsyncMock()
bot.delete_message = AsyncMock()
call.message.bot = bot
service.bot = bot
sent_msgs = [MagicMock(message_id=101), MagicMock(message_id=102)]
mock_send_media.return_value = sent_msgs
mock_send_text.return_value = MagicMock()
await service.publish_post(call)
mock_send_media.assert_awaited_once()
mock_db.update_status_for_media_group_by_helper_id.assert_awaited_once_with(
10, "approved"
)
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_publish_media_group_empty_ids_raises(
self, mock_send, service, mock_db
):
"""_publish_media_group при пустых media_group_message_ids выбрасывает PublishError."""
call = MagicMock(spec=CallbackQuery)
call.message = MagicMock(spec=Message)
call.message.message_id = 10
call.message.text = CONTENT_TYPE_MEDIA_GROUP
call.message.from_user = MagicMock()
mock_db.get_post_ids_by_helper_id = AsyncMock(return_value=[])
with pytest.raises(PublishError, match="Не найдены message_id медиагруппы"):
await service.publish_post(call)
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_decline_post_media_group_calls_decline_media_group(
self, mock_send, service, mock_db
):
"""decline_post для медиагруппы вызывает _decline_media_group."""
call = MagicMock(spec=CallbackQuery)
call.message = MagicMock(spec=Message)
call.message.message_id = 10
call.message.text = CONTENT_TYPE_MEDIA_GROUP
call.message.content_type = "text"
call.message.from_user = MagicMock(full_name="A", id=1)
mock_db.get_author_id_by_helper_message_id = AsyncMock(return_value=123)
mock_db.update_status_for_media_group_by_helper_id = AsyncMock()
mock_db.get_post_ids_by_helper_id = AsyncMock(return_value=[1, 2])
bot = MagicMock()
bot.delete_messages = AsyncMock()
call.message.bot = bot
service.bot = bot
mock_send.return_value = MagicMock()
await service.decline_post(call)
mock_db.update_status_for_media_group_by_helper_id.assert_awaited_once_with(
10, "declined"
)
mock_send.assert_awaited_once()
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_decline_post_unsupported_type_raises(
self, mock_send, service, mock_call_text
):
"""decline_post при неподдерживаемом типе выбрасывает PublishError."""
mock_call_text.message.text = None
mock_call_text.message.content_type = "document"
with pytest.raises(
PublishError, match="Неподдерживаемый тип контента для отклонения"
):
await service.decline_post(mock_call_text)
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_decline_single_post_post_not_found_raises(
self, mock_send, service, mock_call_text, mock_db
):
"""_decline_single_post при updated_rows=0 выбрасывает PostNotFoundError."""
mock_db.update_status_by_message_id = AsyncMock(return_value=0)
with pytest.raises(PostNotFoundError, match="не найден в базе данных"):
await service._decline_single_post(mock_call_text)
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_decline_single_post_user_blocked_raises(
self, mock_send, service, mock_call_text, mock_db
):
"""_decline_single_post при заблокированном боте выбрасывает UserBlockedBotError."""
from helper_bot.handlers.callback.constants import ERROR_BOT_BLOCKED
mock_send.side_effect = Exception(ERROR_BOT_BLOCKED)
with pytest.raises(UserBlockedBotError):
await service._decline_single_post(mock_call_text)
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_decline_media_group_user_blocked_raises(
self, mock_send, service, mock_db
):
"""_decline_media_group при заблокированном боте выбрасывает UserBlockedBotError."""
from helper_bot.handlers.callback.constants import ERROR_BOT_BLOCKED
call = MagicMock(spec=CallbackQuery)
call.message = MagicMock(spec=Message)
call.message.message_id = 10
call.message.from_user = MagicMock()
mock_db.update_status_for_media_group_by_helper_id = AsyncMock()
mock_db.get_post_ids_by_helper_id = AsyncMock(return_value=[1])
mock_db.get_author_id_by_helper_message_id = AsyncMock(return_value=123)
bot = MagicMock()
bot.delete_messages = AsyncMock()
call.message.bot = bot
service.bot = bot
mock_send.side_effect = Exception(ERROR_BOT_BLOCKED)
with pytest.raises(UserBlockedBotError):
await service._decline_media_group(call)
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_delete_media_group_and_notify_author_success(
self, mock_send, service, mock_db
):
"""_delete_media_group_and_notify_author удаляет сообщения и уведомляет автора."""
call = MagicMock(spec=CallbackQuery)
call.message = MagicMock(spec=Message)
call.message.message_id = 10
mock_db.get_post_ids_by_helper_id = AsyncMock(return_value=[1, 2])
bot = MagicMock()
bot.delete_messages = AsyncMock()
call.message.bot = bot
service.bot = bot
mock_send.return_value = MagicMock()
await service._delete_media_group_and_notify_author(call, 123)
bot.delete_messages.assert_awaited_once()
mock_send.assert_awaited_once()
@pytest.mark.unit
@pytest.mark.asyncio
class TestBanService:
"""Тесты для BanService."""
@pytest.fixture
def mock_db(self):
db = MagicMock()
db.get_author_id_by_message_id = AsyncMock(return_value=111)
db.get_author_id_by_helper_message_id = AsyncMock(return_value=None)
db.set_user_blacklist = AsyncMock()
db.update_status_by_message_id = AsyncMock(return_value=1)
db.update_status_for_media_group_by_helper_id = AsyncMock(return_value=1)
db.get_username = AsyncMock(return_value="user")
return db
@pytest.fixture
def settings(self):
return {"Telegram": {"group_for_posts": "-100", "important_logs": "-200"}}
@pytest.fixture
def ban_service(self, mock_db, settings):
bot = MagicMock()
bot.delete_message = AsyncMock()
return BanService(bot, mock_db, settings)
def test_get_bot_returns_bot_when_set(self, ban_service):
"""_get_bot при установленном bot возвращает его."""
message = MagicMock()
assert ban_service._get_bot(message) is ban_service.bot
@pytest.fixture
def mock_call(self):
call = MagicMock(spec=CallbackQuery)
call.message = MagicMock()
call.message.message_id = 1
call.message.text = None
call.from_user = MagicMock()
call.from_user.id = 999
call.bot = MagicMock()
call.bot.delete_message = AsyncMock()
return call
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_ban_user_from_post_success(
self, mock_send, ban_service, mock_call, mock_db
):
"""ban_user_from_post устанавливает blacklist и обновляет статус поста."""
mock_call.message.text = None
await ban_service.ban_user_from_post(mock_call)
mock_db.set_user_blacklist.assert_awaited_once()
mock_db.update_status_by_message_id.assert_awaited_once_with(1, "declined")
mock_send.assert_awaited_once()
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_ban_user_from_post_media_group(
self, mock_send, ban_service, mock_call, mock_db
):
"""ban_user_from_post для медиагруппы обновляет статус по helper_id."""
mock_call.message.text = CONTENT_TYPE_MEDIA_GROUP
mock_call.message.message_id = 10
mock_db.get_author_id_by_helper_message_id = AsyncMock(return_value=111)
mock_db.get_author_id_by_message_id = AsyncMock(return_value=None)
mock_db.update_status_for_media_group_by_helper_id = AsyncMock(return_value=1)
await ban_service.ban_user_from_post(mock_call)
mock_db.set_user_blacklist.assert_awaited_once()
mock_db.update_status_for_media_group_by_helper_id.assert_awaited_once_with(
10, "declined"
)
mock_send.assert_awaited_once()
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_ban_user_from_post_user_blocked_raises(
self, mock_send, ban_service, mock_call, mock_db
):
"""ban_user_from_post при заблокированном боте выбрасывает UserBlockedBotError."""
from helper_bot.handlers.callback.constants import ERROR_BOT_BLOCKED
mock_send.side_effect = Exception(ERROR_BOT_BLOCKED)
with pytest.raises(UserBlockedBotError):
await ban_service.ban_user_from_post(mock_call)
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_ban_user_from_post_author_not_found_raises(
self, mock_send, ban_service, mock_call, mock_db
):
"""ban_user_from_post при отсутствии автора выбрасывает UserNotFoundError."""
mock_db.get_author_id_by_message_id = AsyncMock(return_value=None)
mock_db.get_author_id_by_helper_message_id = AsyncMock(return_value=None)
with pytest.raises(UserNotFoundError, match="Автор не найден"):
await ban_service.ban_user_from_post(mock_call)
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_ban_user_raises_when_not_found(
self, mock_send, ban_service, mock_db
):
"""ban_user при отсутствии пользователя выбрасывает UserNotFoundError."""
mock_db.get_username = AsyncMock(return_value=None)
with pytest.raises(UserNotFoundError):
await ban_service.ban_user("999", "")
@patch("helper_bot.handlers.callback.services.send_text_message")
async def test_ban_user_returns_username(self, mock_send, ban_service, mock_db):
"""ban_user возвращает username пользователя."""
mock_db.get_username = AsyncMock(return_value="found_user")
result = await ban_service.ban_user("123", "")
assert result == "found_user"
@patch(
"helper_bot.handlers.callback.services.delete_user_blacklist",
new_callable=AsyncMock,
)
async def test_unlock_user_raises_when_not_found(
self, mock_delete, ban_service, mock_db
):
"""unlock_user при отсутствии пользователя выбрасывает UserNotFoundError."""
mock_db.get_username = AsyncMock(return_value=None)
with pytest.raises(UserNotFoundError):
await ban_service.unlock_user("999")
@patch(
"helper_bot.handlers.callback.services.delete_user_blacklist",
new_callable=AsyncMock,
)
async def test_unlock_user_returns_username(
self, mock_delete, ban_service, mock_db
):
"""unlock_user удаляет из blacklist и возвращает username."""
mock_db.get_username = AsyncMock(return_value="unlocked_user")
result = await ban_service.unlock_user("123")
mock_delete.assert_awaited_once()
assert result == "unlocked_user"

172
tests/test_decorators.py Normal file
View File

@@ -0,0 +1,172 @@
"""
Тесты для декораторов group и private handlers (error_handler).
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from aiogram import types
from helper_bot.handlers.group.decorators import error_handler as group_error_handler
from helper_bot.handlers.private.decorators import (
error_handler as private_error_handler,
)
class FakeMessage:
"""Класс-маркер, чтобы мок проходил isinstance(..., types.Message) в декораторе."""
pass
@pytest.mark.unit
@pytest.mark.asyncio
class TestGroupErrorHandler:
"""Тесты для error_handler из group/decorators."""
async def test_success_returns_result(self):
"""При успешном выполнении возвращается результат функции."""
@group_error_handler
async def sample_handler():
return "ok"
result = await sample_handler()
assert result == "ok"
async def test_exception_is_reraised(self):
"""При исключении оно пробрасывается дальше."""
@group_error_handler
async def failing_handler():
raise ValueError("test error")
with pytest.raises(ValueError, match="test error"):
await failing_handler()
@patch("helper_bot.handlers.group.decorators.logger")
async def test_exception_is_logged(self, mock_logger):
"""При исключении вызывается logger.error."""
@group_error_handler
async def failing_handler():
raise RuntimeError("logged error")
with pytest.raises(RuntimeError):
await failing_handler()
mock_logger.error.assert_called_once()
assert "logged error" in mock_logger.error.call_args[0][0]
assert "failing_handler" in mock_logger.error.call_args[0][0]
@patch("helper_bot.handlers.group.decorators.types")
@patch("helper_bot.utils.base_dependency_factory.get_global_instance")
@patch("helper_bot.handlers.group.decorators.logger")
async def test_exception_sends_to_important_logs_when_message_has_bot(
self, mock_logger, mock_get_global, mock_types
):
"""При исключении и наличии message с bot отправляется сообщение в important_logs."""
mock_types.Message = FakeMessage
message = MagicMock()
message.__class__ = FakeMessage
message.bot = MagicMock()
message.bot.send_message = AsyncMock()
mock_bdf = MagicMock()
mock_bdf.settings = {"Telegram": {"important_logs": "-100123"}}
mock_get_global.return_value = mock_bdf
@group_error_handler
async def failing_handler(msg):
assert msg is message
raise ValueError("error for logs")
with pytest.raises(ValueError):
await failing_handler(message)
mock_get_global.assert_called_once()
message.bot.send_message.assert_called_once()
call_kwargs = message.bot.send_message.call_args[1]
assert call_kwargs["chat_id"] == "-100123"
call_text = call_kwargs["text"]
assert "error for logs" in call_text
assert "failing_handler" in call_text
assert "Traceback" in call_text
@pytest.mark.unit
@pytest.mark.asyncio
class TestPrivateErrorHandler:
"""Тесты для error_handler из private/decorators."""
async def test_success_returns_result(self):
"""При успешном выполнении возвращается результат функции."""
@private_error_handler
async def sample_handler():
return 42
result = await sample_handler()
assert result == 42
async def test_exception_is_reraised(self):
"""При исключении оно пробрасывается дальше."""
@private_error_handler
async def failing_handler():
raise TypeError("private error")
with pytest.raises(TypeError, match="private error"):
await failing_handler()
@patch("helper_bot.handlers.private.decorators.logger")
async def test_exception_is_logged(self, mock_logger):
"""При исключении вызывается logger.error."""
@private_error_handler
async def failing_handler():
raise KeyError("key missing")
with pytest.raises(KeyError):
await failing_handler()
mock_logger.error.assert_called_once()
assert "key missing" in mock_logger.error.call_args[0][0]
@patch("helper_bot.handlers.private.decorators.types")
@patch("helper_bot.utils.base_dependency_factory.get_global_instance")
async def test_exception_sends_to_important_logs_when_message_has_bot(
self, mock_get_global, mock_types
):
"""При исключении и наличии message с bot отправляется сообщение в important_logs."""
mock_types.Message = FakeMessage
message = MagicMock()
message.__class__ = FakeMessage
message.bot = MagicMock()
message.bot.send_message = AsyncMock()
mock_bdf = MagicMock()
mock_bdf.settings = {"Telegram": {"important_logs": "-100456"}}
mock_get_global.return_value = mock_bdf
@private_error_handler
async def failing_handler(msg):
raise RuntimeError("private runtime")
with pytest.raises(RuntimeError):
await failing_handler(message)
mock_get_global.assert_called_once()
message.bot.send_message.assert_called_once()
call_kwargs = message.bot.send_message.call_args[1]
assert call_kwargs["chat_id"] == "-100456"
call_text = call_kwargs["text"]
assert "private runtime" in call_text
assert "failing_handler" in call_text
async def test_no_message_in_args_no_send(self):
"""Если в args нет Message, send_message не вызывается (только логирование)."""
@private_error_handler
async def failing_handler():
raise ValueError("no message")
with pytest.raises(ValueError):
await failing_handler()
# get_global_instance не должен вызываться, т.к. message не найден в args

View File

@@ -0,0 +1,125 @@
"""
Тесты для helper_bot.services.scoring.deepseek_service (DeepSeekService).
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from helper_bot.services.scoring.deepseek_service import DeepSeekService
from helper_bot.services.scoring.exceptions import (
DeepSeekAPIError,
ScoringError,
TextTooShortError,
)
@pytest.mark.unit
class TestDeepSeekServiceInit:
"""Тесты инициализации DeepSeekService."""
def test_init_with_api_key_enabled(self):
"""При переданном api_key сервис включён."""
with patch(
"helper_bot.services.scoring.deepseek_service.httpx.AsyncClient", None
):
service = DeepSeekService(api_key="key")
assert service.is_enabled is True
assert service.source_name == "deepseek"
def test_init_without_api_key_disabled(self):
"""Без api_key сервис отключён."""
service = DeepSeekService(api_key=None)
assert service.is_enabled is False
def test_init_default_url_and_model(self):
"""Используются DEFAULT_API_URL и DEFAULT_MODEL."""
service = DeepSeekService(api_key="k")
assert service.api_url == DeepSeekService.DEFAULT_API_URL
assert service.model == DeepSeekService.DEFAULT_MODEL
@pytest.mark.unit
class TestDeepSeekServiceHelpers:
"""Тесты _clean_text и _parse_score_response."""
def test_clean_text_strips_and_collapses_whitespace(self):
"""_clean_text убирает лишние пробелы и переносы."""
service = DeepSeekService(api_key="k")
assert service._clean_text(" a b \n c ") == "a b c"
def test_clean_text_empty_returns_empty(self):
"""_clean_text для пустой строки возвращает ''."""
service = DeepSeekService(api_key="k")
assert service._clean_text("") == ""
assert service._clean_text(" ") == ""
def test_parse_score_response_valid_number(self):
"""_parse_score_response парсит число."""
service = DeepSeekService(api_key="k")
assert service._parse_score_response("0.75") == 0.75
assert service._parse_score_response("1.0") == 1.0
assert service._parse_score_response("0") == 0.0
def test_parse_score_response_clamps_to_range(self):
"""_parse_score_response ограничивает значение 0.01.0."""
service = DeepSeekService(api_key="k")
assert service._parse_score_response("1.5") == 1.0
assert service._parse_score_response("-0.1") == 0.0
def test_parse_score_response_invalid_raises(self):
"""_parse_score_response при невалидном ответе выбрасывает DeepSeekAPIError."""
service = DeepSeekService(api_key="k")
with pytest.raises(DeepSeekAPIError, match="распарсить"):
service._parse_score_response("not a number")
@pytest.mark.unit
@pytest.mark.asyncio
class TestDeepSeekServiceCalculateScore:
"""Тесты calculate_score."""
@pytest.fixture
def service(self):
"""Сервис с api_key."""
return DeepSeekService(api_key="key", min_text_length=3)
async def test_disabled_raises_scoring_error(self, service):
"""При отключённом сервисе — ScoringError."""
service._enabled = False
with pytest.raises(ScoringError, match="отключен"):
await service.calculate_score("достаточно длинный текст")
async def test_text_too_short_raises(self, service):
"""Текст короче min_text_length — TextTooShortError."""
with pytest.raises(TextTooShortError, match="короткий"):
await service.calculate_score("ab")
async def test_success_returns_scoring_result(self, service):
"""Успешный запрос возвращает ScoringResult."""
with patch.object(
service,
"_make_api_request",
new_callable=AsyncMock,
return_value=0.82,
):
result = await service.calculate_score("Текст поста для оценки")
assert result.score == 0.82
assert result.source == "deepseek"
assert result.model == service.model
@pytest.mark.unit
class TestDeepSeekServiceStats:
"""Тесты get_stats."""
def test_get_stats_returns_dict(self):
"""get_stats возвращает словарь с enabled, model, api_url, timeout, max_retries."""
service = DeepSeekService(api_key="k", timeout=60, max_retries=5)
stats = service.get_stats()
assert stats["enabled"] is True
assert stats["model"] == service.model
assert stats["api_url"] == service.api_url
assert stats["timeout"] == 60
assert stats["max_retries"] == 5

View File

@@ -0,0 +1,63 @@
"""
Тесты для helper_bot.middlewares.dependencies_middleware.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware
@pytest.mark.unit
@pytest.mark.asyncio
class TestDependenciesMiddleware:
"""Тесты для DependenciesMiddleware."""
@pytest.fixture
def middleware(self):
"""Экземпляр middleware."""
return DependenciesMiddleware()
@pytest.fixture
def mock_handler(self):
"""Мок handler."""
return AsyncMock(return_value="ok")
@patch("helper_bot.middlewares.dependencies_middleware.get_global_instance")
async def test_injects_bot_db_and_settings_into_data(
self, mock_get_global, middleware, mock_handler
):
"""В data подставляются bot_db и settings из get_global_instance."""
mock_bdf = MagicMock()
mock_db = MagicMock()
mock_settings = {"Telegram": {}}
mock_bdf.get_db.return_value = mock_db
mock_bdf.settings = mock_settings
mock_get_global.return_value = mock_bdf
event = MagicMock()
data = {}
result = await middleware(mock_handler, event, data)
mock_get_global.assert_called_once()
assert data["bot_db"] is mock_db
assert data["settings"] is mock_settings
mock_handler.assert_called_once_with(event, data)
assert result == "ok"
@patch("helper_bot.middlewares.dependencies_middleware.get_global_instance")
async def test_exception_does_not_break_chain(
self, mock_get_global, middleware, mock_handler
):
"""При исключении в get_global_instance handler всё равно вызывается."""
mock_get_global.side_effect = RuntimeError("No global instance")
event = MagicMock()
data = {}
result = await middleware(mock_handler, event, data)
mock_handler.assert_called_once_with(event, data)
assert result == "ok"

184
tests/test_main.py Normal file
View File

@@ -0,0 +1,184 @@
"""
Тесты для helper_bot.main: start_bot_with_retry, start_bot.
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from helper_bot.main import start_bot, start_bot_with_retry
@pytest.mark.unit
@pytest.mark.asyncio
class TestStartBotWithRetry:
"""Тесты для start_bot_with_retry."""
async def test_success_on_first_try_exits_immediately(
self, mock_bot, mock_dispatcher
):
"""При успешном start_polling с первой попытки цикл завершается без повторов."""
mock_dispatcher.start_polling = AsyncMock()
await start_bot_with_retry(mock_bot, mock_dispatcher, max_retries=3)
mock_dispatcher.start_polling.assert_awaited_once_with(
mock_bot, skip_updates=True
)
@patch("helper_bot.main.asyncio.sleep", new_callable=AsyncMock)
async def test_network_error_retries_then_succeeds(
self, mock_sleep, mock_bot, mock_dispatcher
):
"""При сетевой ошибке выполняется повтор с задержкой, затем успех."""
mock_dispatcher.start_polling = AsyncMock(
side_effect=[ConnectionError("connection reset"), None]
)
await start_bot_with_retry(
mock_bot, mock_dispatcher, max_retries=3, base_delay=0.1
)
assert mock_dispatcher.start_polling.await_count == 2
mock_sleep.assert_awaited_once()
# base_delay * (2 ** 0) = 0.1
mock_sleep.assert_awaited_with(0.1)
async def test_non_network_error_raises_immediately(
self, mock_bot, mock_dispatcher
):
"""При не-сетевой ошибке исключение пробрасывается без повторов."""
mock_dispatcher.start_polling = AsyncMock(side_effect=ValueError("critical"))
with pytest.raises(ValueError, match="critical"):
await start_bot_with_retry(mock_bot, mock_dispatcher, max_retries=3)
mock_dispatcher.start_polling.assert_awaited_once()
@patch("helper_bot.main.asyncio.sleep", new_callable=AsyncMock)
async def test_max_retries_exceeded_raises(
self, mock_sleep, mock_bot, mock_dispatcher
):
"""При исчерпании попыток из-за сетевых ошибок исключение пробрасывается."""
mock_dispatcher.start_polling = AsyncMock(
side_effect=ConnectionError("network error")
)
with pytest.raises(ConnectionError, match="network error"):
await start_bot_with_retry(
mock_bot, mock_dispatcher, max_retries=2, base_delay=0.01
)
assert mock_dispatcher.start_polling.await_count == 2
assert mock_sleep.await_count == 1
async def test_timeout_error_triggers_retry(self, mock_bot, mock_dispatcher):
"""Ошибка с 'timeout' в сообщении считается сетевой и даёт повтор."""
call_count = 0
async def polling(*args, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
raise TimeoutError("timeout while connecting")
return None
mock_dispatcher.start_polling = AsyncMock(side_effect=polling)
with patch("helper_bot.main.asyncio.sleep", new_callable=AsyncMock):
await start_bot_with_retry(
mock_bot, mock_dispatcher, max_retries=3, base_delay=0.01
)
assert call_count == 2
@pytest.mark.unit
@pytest.mark.asyncio
class TestStartBot:
"""Тесты для start_bot с моками Bot, Dispatcher, start_metrics_server и т.д."""
@pytest.fixture
def mock_bdf(self, test_settings):
"""Мок фабрики зависимостей (bdf) с настройками и scoring_manager."""
bdf = MagicMock()
bdf.settings = {
**test_settings,
"Metrics": {"host": "127.0.0.1", "port": 9090},
}
scoring_manager = MagicMock()
scoring_manager.close = AsyncMock()
bdf.get_scoring_manager = MagicMock(return_value=scoring_manager)
return bdf
@patch("helper_bot.main.stop_metrics_server", new_callable=AsyncMock)
@patch("helper_bot.main.start_bot_with_retry", new_callable=AsyncMock)
@patch("helper_bot.main.start_metrics_server", new_callable=AsyncMock)
@patch("helper_bot.main.VoiceHandlers")
@patch("helper_bot.main.Dispatcher")
@patch("helper_bot.main.Bot")
async def test_start_bot_calls_metrics_server_and_polling(
self,
mock_bot_cls,
mock_dp_cls,
mock_voice_handlers_cls,
mock_start_metrics,
mock_start_retry,
mock_stop_metrics,
mock_bdf,
):
"""start_bot создаёт Bot и Dispatcher, запускает метрики, delete_webhook, start_bot_with_retry; в finally — stop_metrics и закрытие ресурсов."""
mock_bot = MagicMock()
mock_bot.delete_webhook = AsyncMock()
mock_bot.session = MagicMock()
mock_bot.session.close = AsyncMock()
mock_bot_cls.return_value = mock_bot
mock_dp = MagicMock()
mock_dp.update = MagicMock()
mock_dp.update.outer_middleware = MagicMock(return_value=None)
mock_dp.include_routers = MagicMock()
mock_dp.shutdown = MagicMock(return_value=lambda f: None)
mock_dp_cls.return_value = mock_dp
mock_voice_router = MagicMock()
mock_voice_handlers_cls.return_value.router = mock_voice_router
result = await start_bot(mock_bdf)
mock_bot_cls.assert_called_once()
mock_dp_cls.assert_called_once()
mock_bot.delete_webhook.assert_awaited_once_with(drop_pending_updates=True)
mock_start_metrics.assert_awaited_once_with("127.0.0.1", 9090)
mock_start_retry.assert_awaited_once()
mock_stop_metrics.assert_awaited_once()
mock_bdf.get_scoring_manager.return_value.close.assert_awaited()
mock_bot.session.close.assert_awaited()
assert result is mock_bot
@patch("helper_bot.main.stop_metrics_server", new_callable=AsyncMock)
@patch("helper_bot.main.start_bot_with_retry", new_callable=AsyncMock)
@patch("helper_bot.main.start_metrics_server", new_callable=AsyncMock)
@patch("helper_bot.main.VoiceHandlers")
@patch("helper_bot.main.Dispatcher")
@patch("helper_bot.main.Bot")
async def test_start_bot_uses_default_metrics_host_port_when_not_in_settings(
self,
mock_bot_cls,
mock_dp_cls,
mock_voice_handlers_cls,
mock_start_metrics,
mock_start_retry,
mock_stop_metrics,
mock_bdf,
test_settings,
):
"""Если в настройках нет Metrics, используются host 0.0.0.0 и port 8080."""
mock_bdf.settings = test_settings
mock_bot = MagicMock()
mock_bot.delete_webhook = AsyncMock()
mock_bot.session = MagicMock()
mock_bot.session.close = AsyncMock()
mock_bot_cls.return_value = mock_bot
mock_dp = MagicMock()
mock_dp.update = MagicMock()
mock_dp.update.outer_middleware = MagicMock(return_value=None)
mock_dp.include_routers = MagicMock()
mock_dp.shutdown = MagicMock(return_value=lambda f: None)
mock_dp_cls.return_value = mock_dp
mock_voice_handlers_cls.return_value.router = MagicMock()
await start_bot(mock_bdf)
mock_start_metrics.assert_awaited_once_with("0.0.0.0", 8080)

View File

@@ -0,0 +1,375 @@
"""
Тесты для helper_bot.middlewares.metrics_middleware.
"""
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from aiogram.types import Message
from helper_bot.middlewares.metrics_middleware import (
DatabaseMetricsMiddleware,
ErrorMetricsMiddleware,
MetricsMiddleware,
)
@pytest.mark.unit
@pytest.mark.asyncio
class TestMetricsMiddleware:
"""Тесты для MetricsMiddleware."""
@pytest.fixture
def middleware(self):
"""Экземпляр middleware с отключённым периодическим обновлением активных пользователей."""
m = MetricsMiddleware()
m.last_active_users_update = time.time()
return m
@pytest.fixture
def mock_handler(self):
"""Мок handler."""
async def sample_handler(event, data):
return "result"
sample_handler.__name__ = "sample_handler"
return sample_handler
@patch("helper_bot.middlewares.metrics_middleware.metrics")
async def test_handler_success_records_metrics(
self, mock_metrics, middleware, mock_handler
):
"""При успешном выполнении handler вызываются record_method_duration и record_middleware."""
event = MagicMock(spec=Message)
event.message = None
event.callback_query = None
event.text = "привет"
event.from_user = MagicMock()
event.chat = MagicMock()
event.chat.type = "private"
event.photo = None
event.video = None
event.audio = None
event.document = None
event.voice = None
event.sticker = None
event.animation = None
data = {}
result = await middleware(mock_handler, event, data)
assert result == "result"
mock_metrics.record_method_duration.assert_called()
mock_metrics.record_middleware.assert_called_once()
call_args = mock_metrics.record_middleware.call_args[0]
assert call_args[0] == "MetricsMiddleware"
assert call_args[2] == "success"
@patch("helper_bot.middlewares.metrics_middleware.metrics")
async def test_handler_exception_records_error_and_reraises(
self, mock_metrics, middleware
):
"""При исключении в handler записываются метрики ошибки и исключение пробрасывается."""
async def failing_handler(event, data):
raise ValueError("test error")
failing_handler.__name__ = "failing_handler"
event = MagicMock(spec=Message)
event.message = None
event.callback_query = None
event.text = "text"
event.from_user = MagicMock()
event.chat = MagicMock()
event.chat.type = "private"
event.photo = None
event.video = None
event.audio = None
event.document = None
event.voice = None
event.sticker = None
event.animation = None
data = {}
with pytest.raises(ValueError, match="test error"):
await middleware(failing_handler, event, data)
mock_metrics.record_error.assert_called_once()
call_args = mock_metrics.record_error.call_args[0]
assert call_args[0] == "ValueError"
mock_metrics.record_middleware.assert_called_once()
def test_get_handler_name_returns_function_name(self, middleware):
"""_get_handler_name возвращает __name__ функции."""
def named_handler():
pass
assert middleware._get_handler_name(named_handler) == "named_handler"
def test_get_handler_name_for_lambda_returns_qualname_or_unknown(self, middleware):
"""_get_handler_name для lambda возвращает qualname (содержит 'lambda') или 'unknown'."""
lambda_handler = lambda e, d: None
name = middleware._get_handler_name(lambda_handler)
assert "lambda" in name or name == "unknown"
@pytest.mark.asyncio
@patch("helper_bot.middlewares.metrics_middleware.metrics")
async def test_record_comprehensive_message_metrics_photo(
self, mock_metrics, middleware
):
"""_record_comprehensive_message_metrics для сообщения с фото записывает message_type photo."""
message = MagicMock()
message.photo = [MagicMock()]
message.video = None
message.audio = None
message.document = None
message.voice = None
message.sticker = None
message.animation = None
message.chat = MagicMock()
message.chat.type = "private"
message.from_user = MagicMock()
message.from_user.id = 1
message.from_user.is_bot = False
result = await middleware._record_comprehensive_message_metrics(message)
mock_metrics.record_message.assert_called_once_with(
"photo", "private", "message_handler"
)
assert result["message_type"] == "photo"
assert result["chat_type"] == "private"
@pytest.mark.asyncio
@patch("helper_bot.middlewares.metrics_middleware.metrics")
async def test_record_comprehensive_message_metrics_voice(
self, mock_metrics, middleware
):
"""_record_comprehensive_message_metrics для voice записывает message_type voice."""
message = MagicMock()
message.photo = None
message.video = None
message.audio = None
message.document = None
message.voice = MagicMock()
message.sticker = None
message.animation = None
message.chat = MagicMock()
message.chat.type = "supergroup"
message.from_user = MagicMock()
message.from_user.id = 2
message.from_user.is_bot = False
result = await middleware._record_comprehensive_message_metrics(message)
mock_metrics.record_message.assert_called_once_with(
"voice", "supergroup", "message_handler"
)
assert result["message_type"] == "voice"
@pytest.mark.asyncio
@patch("helper_bot.middlewares.metrics_middleware.metrics")
async def test_record_comprehensive_callback_metrics(
self, mock_metrics, middleware
):
"""_record_comprehensive_callback_metrics записывает callback_query и возвращает данные."""
callback = MagicMock()
callback.data = "publish"
callback.from_user = MagicMock()
callback.from_user.id = 10
callback.from_user.is_bot = False
result = await middleware._record_comprehensive_callback_metrics(callback)
mock_metrics.record_message.assert_called_once_with(
"callback_query", "callback", "callback_handler"
)
assert result["callback_data"] == "publish"
assert result["user_id"] == 10
@pytest.mark.asyncio
@patch("helper_bot.middlewares.metrics_middleware.metrics")
async def test_record_unknown_event_metrics(self, mock_metrics, middleware):
"""_record_unknown_event_metrics записывает unknown и возвращает event_type."""
event = MagicMock()
event.__str__ = lambda self: "custom_event"
result = await middleware._record_unknown_event_metrics(event)
mock_metrics.record_message.assert_called_once_with(
"unknown", "unknown", "unknown_handler"
)
assert "event_type" in result
def test_extract_command_info_slash_command_returns_mapping(self, middleware):
"""_extract_command_info_with_fallback для слеш-команды возвращает command info."""
message = MagicMock()
message.text = "/start"
message.from_user = MagicMock()
result = middleware._extract_command_info_with_fallback(message)
assert result is not None
assert "command" in result
assert "handler_type" in result
def test_extract_command_info_no_text_returns_none(self, middleware):
"""_extract_command_info_with_fallback при отсутствии text возвращает None."""
message = MagicMock()
message.text = None
result = middleware._extract_command_info_with_fallback(message)
assert result is None
def test_extract_command_info_empty_string_returns_none(self, middleware):
"""_extract_command_info_with_fallback при пустом text возвращает None или fallback."""
message = MagicMock()
message.text = ""
message.from_user = None
result = middleware._extract_command_info_with_fallback(message)
assert result is None or (result is not None and "command" in result)
def test_extract_callback_command_info_no_data_returns_none(self, middleware):
"""_extract_callback_command_info_with_fallback при отсутствии data возвращает None."""
callback = MagicMock()
callback.data = None
callback.from_user = MagicMock()
result = middleware._extract_callback_command_info_with_fallback(callback)
assert result is None
def test_extract_callback_command_info_ban_pattern_returns_callback_ban(
self, middleware
):
"""_extract_callback_command_info_with_fallback для ban_123 возвращает callback_ban."""
callback = MagicMock()
callback.data = "ban_123456"
callback.from_user = MagicMock()
result = middleware._extract_callback_command_info_with_fallback(callback)
assert result is not None
assert result["command"] == "callback_ban" or "ban" in result["command"]
assert "handler_type" in result
def test_extract_callback_command_info_page_pattern_returns_callback_page(
self, middleware
):
"""_extract_callback_command_info_with_fallback для page_2 возвращает callback_page."""
callback = MagicMock()
callback.data = "page_2"
callback.from_user = MagicMock()
result = middleware._extract_callback_command_info_with_fallback(callback)
assert result is not None
assert result["command"] == "callback_page" or "page" in result["command"]
@pytest.mark.asyncio
@patch("helper_bot.middlewares.metrics_middleware.metrics")
@patch("helper_bot.utils.base_dependency_factory.get_global_instance")
async def test_update_active_users_metric_sets_metrics(
self, mock_get_global, mock_metrics, middleware
):
"""_update_active_users_metric вызывает fetch_one и устанавливает метрики."""
mock_bdf = MagicMock()
mock_db = MagicMock()
mock_db.fetch_one = AsyncMock(
side_effect=[
{"total": 100},
{"daily": 10},
]
)
mock_bdf.get_db.return_value = mock_db
mock_get_global.return_value = mock_bdf
await middleware._update_active_users_metric()
assert mock_metrics.set_active_users.called
assert mock_metrics.set_total_users.called
mock_metrics.set_active_users.assert_called_with(10, "daily")
mock_metrics.set_total_users.assert_called_with(100)
@pytest.mark.asyncio
@patch("helper_bot.middlewares.metrics_middleware.metrics")
@patch("helper_bot.utils.base_dependency_factory.get_global_instance")
async def test_update_active_users_metric_on_exception_sets_fallback(
self, mock_get_global, mock_metrics, middleware
):
"""_update_active_users_metric при исключении устанавливает fallback 1."""
mock_get_global.side_effect = RuntimeError("no bdf")
await middleware._update_active_users_metric()
mock_metrics.set_active_users.assert_called_with(1, "daily")
mock_metrics.set_total_users.assert_called_with(1)
@pytest.mark.unit
@pytest.mark.asyncio
class TestDatabaseMetricsMiddleware:
"""Тесты для DatabaseMetricsMiddleware."""
@patch("helper_bot.middlewares.metrics_middleware.metrics")
async def test_success_records_middleware(self, mock_metrics):
"""При успешном handler вызывается record_middleware с success."""
middleware = DatabaseMetricsMiddleware()
handler = AsyncMock(return_value="ok")
handler.__name__ = "test_handler"
event = MagicMock()
data = {}
result = await middleware(handler, event, data)
assert result == "ok"
mock_metrics.record_middleware.assert_called_once()
assert mock_metrics.record_middleware.call_args[0][2] == "success"
@patch("helper_bot.middlewares.metrics_middleware.metrics")
async def test_exception_records_error_and_reraises(self, mock_metrics):
"""При исключении записывается ошибка и исключение пробрасывается."""
middleware = DatabaseMetricsMiddleware()
handler = AsyncMock(side_effect=RuntimeError("db error"))
handler.__name__ = "db_handler"
event = MagicMock()
data = {}
with pytest.raises(RuntimeError, match="db error"):
await middleware(handler, event, data)
mock_metrics.record_middleware.assert_called_once()
assert mock_metrics.record_middleware.call_args[0][2] == "error"
mock_metrics.record_error.assert_called_once()
@pytest.mark.unit
@pytest.mark.asyncio
class TestErrorMetricsMiddleware:
"""Тесты для ErrorMetricsMiddleware."""
@patch("helper_bot.middlewares.metrics_middleware.metrics")
async def test_success_records_middleware(self, mock_metrics):
"""При успешном handler вызывается record_middleware с success."""
middleware = ErrorMetricsMiddleware()
handler = AsyncMock(return_value="ok")
handler.__name__ = "test_handler"
event = MagicMock()
data = {}
result = await middleware(handler, event, data)
assert result == "ok"
mock_metrics.record_middleware.assert_called_once()
assert mock_metrics.record_middleware.call_args[0][2] == "success"
@patch("helper_bot.middlewares.metrics_middleware.metrics")
async def test_exception_records_error_and_reraises(self, mock_metrics):
"""При исключении записывается ошибка и исключение пробрасывается."""
middleware = ErrorMetricsMiddleware()
handler = AsyncMock(side_effect=TypeError("error"))
handler.__name__ = "err_handler"
event = MagicMock()
data = {}
with pytest.raises(TypeError, match="error"):
await middleware(handler, event, data)
mock_metrics.record_middleware.assert_called_once()
assert mock_metrics.record_middleware.call_args[0][2] == "error"
mock_metrics.record_error.assert_called_once()

206
tests/test_rag_client.py Normal file
View File

@@ -0,0 +1,206 @@
"""
Тесты для helper_bot.services.scoring.rag_client (RagApiClient).
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from helper_bot.services.scoring.exceptions import (
InsufficientExamplesError,
ScoringError,
TextTooShortError,
)
from helper_bot.services.scoring.rag_client import RagApiClient
@pytest.mark.unit
class TestRagApiClientInit:
"""Тесты инициализации RagApiClient."""
def test_init_strips_trailing_slash(self):
"""api_url без trailing slash."""
with patch("helper_bot.services.scoring.rag_client.httpx.AsyncClient"):
client = RagApiClient(api_url="http://api/v1/", api_key="key")
assert client.api_url == "http://api/v1"
def test_source_name_is_rag(self):
"""source_name возвращает 'rag'."""
with patch("helper_bot.services.scoring.rag_client.httpx.AsyncClient"):
client = RagApiClient(api_url="http://api", api_key="key")
assert client.source_name == "rag"
def test_is_enabled_true_by_default(self):
"""is_enabled True по умолчанию."""
with patch("helper_bot.services.scoring.rag_client.httpx.AsyncClient"):
client = RagApiClient(api_url="http://api", api_key="key")
assert client.is_enabled is True
def test_is_enabled_false_when_disabled(self):
"""is_enabled False при enabled=False."""
with patch("helper_bot.services.scoring.rag_client.httpx.AsyncClient"):
client = RagApiClient(api_url="http://api", api_key="key", enabled=False)
assert client.is_enabled is False
@pytest.mark.unit
@pytest.mark.asyncio
class TestRagApiClientCalculateScore:
"""Тесты calculate_score."""
@pytest.fixture
def client(self):
"""Клиент с замоканным _client."""
with patch("helper_bot.services.scoring.rag_client.httpx.AsyncClient"):
c = RagApiClient(api_url="http://rag/api", api_key="key")
c._client = MagicMock()
return c
async def test_disabled_raises_scoring_error(self, client):
"""При отключённом клиенте выбрасывается ScoringError."""
client._enabled = False
with pytest.raises(ScoringError, match="отключен"):
await client.calculate_score("text")
async def test_empty_text_raises_text_too_short(self, client):
"""Пустой текст — TextTooShortError."""
with pytest.raises(TextTooShortError, match="пустой"):
await client.calculate_score(" ")
async def test_success_200_returns_scoring_result(self, client):
"""Успешный ответ 200 возвращает ScoringResult."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"rag_score": 0.85,
"rag_confidence": 0.9,
"rag_score_pos_only": 0.82,
"meta": {"model": "test-model"},
}
client._client.post = AsyncMock(return_value=mock_response)
result = await client.calculate_score("Post text")
assert result.score == 0.85
assert result.source == "rag"
assert result.model == "test-model"
assert result.confidence == 0.9
assert result.metadata["rag_score_pos_only"] == 0.82
async def test_400_insufficient_raises_insufficient_examples(self, client):
"""400 с 'недостаточно' в detail — InsufficientExamplesError."""
mock_response = MagicMock()
mock_response.status_code = 400
mock_response.json.return_value = {"detail": "Недостаточно примеров"}
client._client.post = AsyncMock(return_value=mock_response)
with pytest.raises(InsufficientExamplesError):
await client.calculate_score("text")
async def test_400_short_raises_text_too_short(self, client):
"""400 с 'коротк' в detail — TextTooShortError."""
mock_response = MagicMock()
mock_response.status_code = 400
mock_response.json.return_value = {"detail": "Текст слишком короткий"}
client._client.post = AsyncMock(return_value=mock_response)
with pytest.raises(TextTooShortError):
await client.calculate_score("ab")
async def test_401_raises_scoring_error(self, client):
"""401 — ScoringError про аутентификацию."""
mock_response = MagicMock()
mock_response.status_code = 401
client._client.post = AsyncMock(return_value=mock_response)
with pytest.raises(ScoringError, match="аутентификации"):
await client.calculate_score("text")
async def test_500_raises_scoring_error(self, client):
"""5xx — ScoringError."""
mock_response = MagicMock()
mock_response.status_code = 500
client._client.post = AsyncMock(return_value=mock_response)
with pytest.raises(ScoringError, match="сервера"):
await client.calculate_score("text")
async def test_timeout_raises_scoring_error(self, client):
"""Таймаут запроса — ScoringError."""
class FakeTimeoutException(Exception):
pass
with patch("helper_bot.services.scoring.rag_client.httpx") as mock_httpx:
mock_httpx.TimeoutException = FakeTimeoutException
client._client.post = AsyncMock(side_effect=FakeTimeoutException("timeout"))
with pytest.raises(ScoringError, match="Таймаут"):
await client.calculate_score("text")
@pytest.mark.unit
@pytest.mark.asyncio
class TestRagApiClientExamplesAndStats:
"""Тесты add_positive_example, add_negative_example, get_stats, get_stats_sync."""
@pytest.fixture
def client(self):
"""Клиент с замоканным _client."""
with patch("helper_bot.services.scoring.rag_client.httpx.AsyncClient"):
c = RagApiClient(api_url="http://rag/api", api_key="key")
c._client = MagicMock()
return c
async def test_add_positive_example_disabled_returns_early(self, client):
"""При отключённом клиенте add_positive_example ничего не делает."""
client._enabled = False
await client.add_positive_example("text")
client._client.post.assert_not_called()
async def test_add_positive_example_success(self, client):
"""Успешное добавление положительного примера."""
mock_response = MagicMock()
mock_response.status_code = 200
client._client.post = AsyncMock(return_value=mock_response)
await client.add_positive_example("Good post")
client._client.post.assert_called_once()
call_kwargs = client._client.post.call_args[1]
assert call_kwargs["json"] == {"text": "Good post"}
async def test_add_positive_example_test_mode_sends_header(self):
"""При test_mode=True отправляется заголовок X-Test-Mode."""
with patch("helper_bot.services.scoring.rag_client.httpx.AsyncClient"):
c = RagApiClient(api_url="http://rag", api_key="k", test_mode=True)
c._client = MagicMock()
c._client.post = AsyncMock(return_value=MagicMock(status_code=200))
await c.add_positive_example("t")
call_kwargs = c._client.post.call_args[1]
assert call_kwargs.get("headers", {}).get("X-Test-Mode") == "true"
async def test_get_stats_200_returns_json(self, client):
"""get_stats при 200 возвращает json."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"total": 10}
client._client.get = AsyncMock(return_value=mock_response)
result = await client.get_stats()
assert result == {"total": 10}
async def test_get_stats_disabled_returns_empty(self, client):
"""При отключённом клиенте get_stats возвращает {}."""
client._enabled = False
result = await client.get_stats()
assert result == {}
def test_get_stats_sync_returns_dict(self):
"""get_stats_sync возвращает словарь с enabled, api_url, timeout."""
with patch("helper_bot.services.scoring.rag_client.httpx.AsyncClient"):
client = RagApiClient(api_url="http://api", api_key="k", timeout=15)
result = client.get_stats_sync()
assert result["enabled"] is True
assert result["api_url"] == "http://api"
assert result["timeout"] == 15

View File

@@ -0,0 +1,108 @@
"""
Тесты для helper_bot.middlewares.rate_limit_middleware.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from aiogram.types import CallbackQuery, Message, Update
from helper_bot.middlewares.rate_limit_middleware import RateLimitMiddleware
@pytest.mark.unit
@pytest.mark.asyncio
class TestRateLimitMiddleware:
"""Тесты для RateLimitMiddleware."""
@pytest.fixture
def middleware(self):
"""Экземпляр middleware."""
return RateLimitMiddleware()
@pytest.fixture
def mock_handler(self):
"""Мок handler."""
return AsyncMock(return_value="handler_result")
async def test_event_with_message_calls_rate_limiter(
self, middleware, mock_handler
):
"""При событии с message вызывается rate_limiter.send_with_rate_limit."""
event = MagicMock(spec=Message)
event.message = None
event.chat = MagicMock()
event.chat.id = 12345
data = {}
with patch.object(
middleware.rate_limiter,
"send_with_rate_limit",
new_callable=AsyncMock,
return_value="rate_limited_result",
) as mock_send:
result = await middleware(mock_handler, event, data)
mock_send.assert_called_once()
call_args = mock_send.call_args
assert call_args[0][1] == 12345 # chat_id
# Вызываем переданный rate_limited_handler
await call_args[0][0]()
mock_handler.assert_called_once_with(event, data)
assert result == "rate_limited_result"
async def test_update_with_message_calls_rate_limiter(
self, middleware, mock_handler
):
"""При Update с message извлекается chat_id и вызывается rate_limiter."""
message = MagicMock(spec=Message)
message.chat = MagicMock()
message.chat.id = 99999
event = MagicMock(spec=Update)
event.message = message
data = {}
with patch.object(
middleware.rate_limiter,
"send_with_rate_limit",
new_callable=AsyncMock,
return_value="ok",
) as mock_send:
await middleware(mock_handler, event, data)
mock_send.assert_called_once()
assert mock_send.call_args[0][1] == 99999
async def test_event_without_message_calls_handler_directly(
self, middleware, mock_handler
):
"""При событии без message (например CallbackQuery) handler вызывается напрямую."""
event = MagicMock(spec=CallbackQuery)
event.message = None
data = {}
result = await middleware(mock_handler, event, data)
mock_handler.assert_called_once_with(event, data)
assert result == "handler_result"
async def test_exception_from_handler_propagates(self, middleware, mock_handler):
"""Исключение из handler пробрасывается через rate_limiter."""
event = MagicMock(spec=Message)
event.chat = MagicMock()
event.chat.id = 1
data = {}
mock_handler.side_effect = ValueError("test error")
with patch.object(
middleware.rate_limiter,
"send_with_rate_limit",
new_callable=AsyncMock,
) as mock_send:
async def call_passed_handler(inner_handler, chat_id):
return await inner_handler()
mock_send.side_effect = call_passed_handler
with pytest.raises(ValueError, match="test error"):
await middleware(mock_handler, event, data)

View File

@@ -0,0 +1,263 @@
"""
Тесты для helper_bot.utils.rate_limit_monitor.
"""
import time
from collections import deque
from unittest.mock import patch
import pytest
from helper_bot.utils.rate_limit_monitor import (
RateLimitMonitor,
RateLimitStats,
get_rate_limit_summary,
record_rate_limit_request,
)
@pytest.mark.unit
class TestRateLimitStats:
"""Тесты для RateLimitStats."""
def test_success_rate_zero_requests(self):
"""При нуле запросов success_rate равен 1.0."""
stats = RateLimitStats(chat_id=1)
assert stats.success_rate == 1.0
def test_success_rate_all_success(self):
"""При всех успешных запросах success_rate равен 1.0."""
stats = RateLimitStats(chat_id=1, total_requests=5, successful_requests=5)
assert stats.success_rate == 1.0
def test_success_rate_partial(self):
"""Частичный успех: 3 из 5."""
stats = RateLimitStats(chat_id=1, total_requests=5, successful_requests=3)
assert stats.success_rate == 0.6
def test_error_rate(self):
"""error_rate = 1 - success_rate."""
stats = RateLimitStats(chat_id=1, total_requests=10, successful_requests=7)
assert stats.error_rate == pytest.approx(0.3)
def test_average_wait_time_zero_requests(self):
"""При нуле запросов average_wait_time равен 0."""
stats = RateLimitStats(chat_id=1)
assert stats.average_wait_time == 0.0
def test_average_wait_time(self):
"""Среднее время ожидания считается корректно."""
stats = RateLimitStats(chat_id=1, total_requests=4, total_wait_time=2.0)
assert stats.average_wait_time == 0.5
def test_requests_per_minute_empty(self):
"""При пустом request_times возвращается 0."""
stats = RateLimitStats(chat_id=1)
assert stats.requests_per_minute == 0.0
def test_requests_per_minute_recent(self):
"""Подсчёт запросов за последнюю минуту."""
now = time.time()
stats = RateLimitStats(
chat_id=1, request_times=deque([now, now - 30], maxlen=100)
)
assert stats.requests_per_minute == 2
def test_requests_per_minute_old_ignored(self):
"""Запросы старше минуты не учитываются."""
now = time.time()
stats = RateLimitStats(
chat_id=1,
request_times=deque([now, now - 90], maxlen=100),
)
assert stats.requests_per_minute == 1
@pytest.mark.unit
class TestRateLimitMonitor:
"""Тесты для RateLimitMonitor."""
def test_init(self):
"""Инициализация с дефолтными и кастомными параметрами."""
monitor = RateLimitMonitor(max_history_size=500)
assert monitor.max_history_size == 500
assert monitor.global_stats.chat_id == 0
assert len(monitor.stats) == 0
assert len(monitor.error_history) == 0
def test_record_request_success(self):
"""Запись успешного запроса обновляет счётчики."""
monitor = RateLimitMonitor()
monitor.record_request(chat_id=123, success=True, wait_time=1.5)
chat_stats = monitor.get_chat_stats(123)
assert chat_stats is not None
assert chat_stats.total_requests == 1
assert chat_stats.successful_requests == 1
assert chat_stats.failed_requests == 0
assert chat_stats.total_wait_time == 1.5
global_stats = monitor.get_global_stats()
assert global_stats.total_requests == 1
assert global_stats.successful_requests == 1
def test_record_request_failure_retry_after(self):
"""Запись ошибки RetryAfter увеличивает retry_after_errors."""
monitor = RateLimitMonitor()
monitor.record_request(chat_id=456, success=False, error_type="RetryAfter")
chat_stats = monitor.get_chat_stats(456)
assert chat_stats.failed_requests == 1
assert chat_stats.retry_after_errors == 1
assert chat_stats.other_errors == 0
assert len(monitor.error_history) == 1
assert monitor.error_history[0]["error_type"] == "RetryAfter"
def test_record_request_failure_other(self):
"""Запись другой ошибки увеличивает other_errors."""
monitor = RateLimitMonitor()
monitor.record_request(chat_id=789, success=False, error_type="Timeout")
chat_stats = monitor.get_chat_stats(789)
assert chat_stats.other_errors == 1
assert chat_stats.retry_after_errors == 0
def test_get_chat_stats_missing(self):
"""Для неизвестного чата возвращается None."""
monitor = RateLimitMonitor()
assert monitor.get_chat_stats(999) is None
def test_get_top_chats_by_requests(self):
"""Топ чатов по количеству запросов."""
monitor = RateLimitMonitor()
monitor.record_request(1, True)
monitor.record_request(1, True)
monitor.record_request(2, True)
monitor.record_request(3, True)
monitor.record_request(3, True)
monitor.record_request(3, True)
top = monitor.get_top_chats_by_requests(limit=2)
assert len(top) == 2
assert top[0][0] == 3
assert top[0][1].total_requests == 3
assert top[1][0] == 1
assert top[1][1].total_requests == 2
def test_get_chats_with_high_error_rate(self):
"""Чаты с высоким процентом ошибок (и более 5 запросов)."""
monitor = RateLimitMonitor()
for _ in range(6):
monitor.record_request(100, True)
for _ in range(4):
monitor.record_request(100, False, error_type="Other")
# 4/10 = 40% ошибок
for _ in range(6):
monitor.record_request(200, True)
for _ in range(2):
monitor.record_request(200, False, error_type="Other")
# 2/8 < 20%, но порог 0.1 — попадёт если error_rate > 0.1
high = monitor.get_chats_with_high_error_rate(threshold=0.2)
assert len(high) >= 1
chat_ids = [c[0] for c in high]
assert 100 in chat_ids
def test_get_recent_errors(self):
"""Недавние ошибки за указанный период."""
monitor = RateLimitMonitor()
monitor.record_request(1, False, error_type="RetryAfter")
recent = monitor.get_recent_errors(minutes=60)
assert len(recent) == 1
assert recent[0]["error_type"] == "RetryAfter"
assert recent[0]["chat_id"] == 1
def test_get_recent_errors_empty_old_window(self):
"""При окне 0 минут недавних ошибок нет (все старше)."""
monitor = RateLimitMonitor()
monitor.record_request(1, False, error_type="RetryAfter")
recent = monitor.get_recent_errors(minutes=0)
assert len(recent) == 0
def test_get_error_summary(self):
"""Сводка ошибок по типам."""
monitor = RateLimitMonitor()
monitor.record_request(1, False, error_type="RetryAfter")
monitor.record_request(1, False, error_type="RetryAfter")
monitor.record_request(2, False, error_type="Timeout")
summary = monitor.get_error_summary(minutes=60)
assert summary["RetryAfter"] == 2
assert summary["Timeout"] == 1
def test_reset_stats_all(self):
"""Сброс всей статистики."""
monitor = RateLimitMonitor()
monitor.record_request(1, True)
monitor.record_request(2, False, error_type="RetryAfter")
monitor.reset_stats()
assert monitor.get_chat_stats(1) is None
assert monitor.get_global_stats().total_requests == 0
assert len(monitor.error_history) == 0
def test_reset_stats_single_chat(self):
"""Сброс статистики для одного чата."""
monitor = RateLimitMonitor()
monitor.record_request(1, True)
monitor.record_request(2, True)
monitor.reset_stats(chat_id=1)
assert monitor.get_chat_stats(1) is None
assert monitor.get_chat_stats(2) is not None
assert monitor.get_global_stats().total_requests == 2
def test_reset_stats_nonexistent_chat(self):
"""Сброс несуществующего чата не падает."""
monitor = RateLimitMonitor()
monitor.reset_stats(chat_id=999)
@patch("helper_bot.utils.rate_limit_monitor.logger")
def test_log_statistics(self, mock_logger):
"""log_statistics вызывает logger с нужным уровнем."""
monitor = RateLimitMonitor()
monitor.record_request(1, True)
monitor.log_statistics(log_level="info")
mock_logger.info.assert_called()
mock_logger.reset_mock()
monitor.log_statistics(log_level="warning")
mock_logger.warning.assert_called()
mock_logger.reset_mock()
monitor.log_statistics(log_level="error")
mock_logger.error.assert_called()
@pytest.mark.unit
class TestModuleFunctions:
"""Тесты для функций модуля record_rate_limit_request и get_rate_limit_summary."""
def test_record_rate_limit_request(self):
"""record_rate_limit_request делегирует в глобальный монитор."""
monitor = RateLimitMonitor()
with patch("helper_bot.utils.rate_limit_monitor.rate_limit_monitor", monitor):
record_rate_limit_request(chat_id=111, success=True, wait_time=0.5)
stats = monitor.get_chat_stats(111)
assert stats is not None
assert stats.total_requests == 1
assert stats.total_wait_time == 0.5
def test_get_rate_limit_summary(self):
"""get_rate_limit_summary возвращает словарь с ожидаемыми ключами."""
monitor = RateLimitMonitor()
monitor.record_request(1, True)
with patch("helper_bot.utils.rate_limit_monitor.rate_limit_monitor", monitor):
summary = get_rate_limit_summary()
assert "total_requests" in summary
assert "success_rate" in summary
assert "error_rate" in summary
assert "recent_errors_count" in summary
assert "active_chats" in summary
assert "requests_per_minute" in summary
assert "average_wait_time" in summary
assert summary["total_requests"] == 1
assert summary["active_chats"] == 1

View File

@@ -223,6 +223,61 @@ class TestAdminService:
# Assert
self.mock_db.delete_user_blacklist.assert_called_once_with(user_id)
@pytest.mark.asyncio
async def test_get_banned_users_success(self):
"""Тест успешного получения списка забаненных пользователей."""
self.mock_db.get_banned_users_from_db = AsyncMock(
return_value=[(1, "спам", None), (2, "оскорбления", "2025-02-01")]
)
self.mock_db.get_username = AsyncMock(side_effect=["user1", "user2"])
self.mock_db.get_full_name_by_id = AsyncMock(
side_effect=["Name One", "Name Two"]
)
result = await self.admin_service.get_banned_users()
assert len(result) == 2
assert result[0].user_id == 1
assert result[0].reason == "спам"
assert result[0].unban_date is None
assert result[1].user_id == 2
assert result[1].reason == "оскорбления"
@pytest.mark.asyncio
async def test_get_banned_users_uses_user_id_fallback(self):
"""get_banned_users при отсутствии username/full_name использует User_{id}."""
self.mock_db.get_banned_users_from_db = AsyncMock(
return_value=[(99, "reason", None)]
)
self.mock_db.get_username = AsyncMock(return_value=None)
self.mock_db.get_full_name_by_id = AsyncMock(return_value=None)
result = await self.admin_service.get_banned_users()
assert len(result) == 1
assert result[0].username == "User_99"
@pytest.mark.asyncio
async def test_get_banned_users_for_display_success(self):
"""Тест успешного получения данных для отображения забаненных."""
with patch(
"helper_bot.handlers.admin.services.get_banned_users_list",
new_callable=AsyncMock,
) as mock_list:
with patch(
"helper_bot.handlers.admin.services.get_banned_users_buttons",
new_callable=AsyncMock,
) as mock_buttons:
mock_list.return_value = "Список забаненных"
mock_buttons.return_value = []
text, buttons = await self.admin_service.get_banned_users_for_display(0)
assert text == "Список забаненных"
assert buttons == []
mock_list.assert_awaited_once_with(0, self.mock_db)
mock_buttons.assert_awaited_once_with(self.mock_db)
class TestUser:
"""Тесты для модели User"""

View File

@@ -1,6 +1,6 @@
"""Tests for refactored private handlers"""
from unittest.mock import AsyncMock, MagicMock, Mock
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from aiogram import types
@@ -28,6 +28,7 @@ class TestPrivateHandlers:
db.add_post = AsyncMock()
db.add_message = AsyncMock()
db.update_helper_message = AsyncMock()
db.update_user_activity = AsyncMock()
return db
@pytest.fixture
@@ -58,6 +59,7 @@ class TestPrivateHandlers:
message.from_user = from_user
message.text = "test message"
message.message_id = 1
# Создаем мок для chat
chat = Mock()
@@ -122,6 +124,21 @@ class TestPrivateHandlers:
chat_id=mock_settings.group_for_logs
)
@pytest.mark.asyncio
async def test_handle_emoji_message_no_emoji(
self, mock_db, mock_settings, mock_message, mock_state
):
"""handle_emoji_message при user_emoji=None не отправляет ответ с эмодзи."""
handlers = create_private_handlers(mock_db, mock_settings)
with pytest.MonkeyPatch().context() as m:
m.setattr(
"helper_bot.handlers.private.private_handlers.check_user_emoji",
AsyncMock(return_value=None),
)
await handlers.handle_emoji_message(mock_message, mock_state)
mock_state.set_state.assert_called_once_with(FSM_STATES["START"])
mock_message.answer.assert_not_called()
@pytest.mark.asyncio
async def test_handle_start_message(
self, mock_db, mock_settings, mock_message, mock_state
@@ -155,6 +172,151 @@ class TestPrivateHandlers:
mock_db.add_user.assert_called_once()
mock_db.update_user_date.assert_called_once()
@pytest.mark.asyncio
async def test_handle_restart_message(
self, mock_db, mock_settings, mock_message, mock_state
):
"""handle_restart_message перезапускает состояние и отправляет сообщение."""
handlers = create_private_handlers(mock_db, mock_settings)
with pytest.MonkeyPatch().context() as m:
m.setattr(
"helper_bot.handlers.private.private_handlers.get_reply_keyboard",
AsyncMock(return_value=Mock()),
)
m.setattr(
"helper_bot.handlers.private.private_handlers.update_user_info",
AsyncMock(),
)
m.setattr(
"helper_bot.handlers.private.private_handlers.check_user_emoji",
AsyncMock(),
)
await handlers.handle_restart_message(mock_message, mock_state)
mock_state.set_state.assert_called_once_with(FSM_STATES["START"])
mock_message.answer.assert_called_once()
@pytest.mark.asyncio
async def test_suggest_post(self, mock_db, mock_settings, mock_message, mock_state):
"""suggest_post переводит в состояние SUGGEST и отправляет текст."""
handlers = create_private_handlers(mock_db, mock_settings)
with pytest.MonkeyPatch().context() as m:
m.setattr(
"helper_bot.handlers.private.private_handlers.messages.get_message",
lambda x, y: "Suggest text",
)
await handlers.suggest_post(mock_message, mock_state)
mock_state.set_state.assert_called_once_with(FSM_STATES["SUGGEST"])
mock_message.answer.assert_called_once()
@pytest.mark.asyncio
async def test_end_message(self, mock_db, mock_settings, mock_message, mock_state):
"""end_message отправляет прощание и переводит в START."""
handlers = create_private_handlers(mock_db, mock_settings)
with pytest.MonkeyPatch().context() as m:
m.setattr(
"helper_bot.handlers.private.private_handlers.messages.get_message",
lambda x, y: "Bye",
)
await handlers.end_message(mock_message, mock_state)
mock_state.set_state.assert_called_once_with(FSM_STATES["START"])
assert mock_message.answer.await_count >= 1
@pytest.mark.asyncio
async def test_stickers(self, mock_db, mock_settings, mock_message, mock_state):
"""stickers обновляет инфо о стикерах и отправляет ссылку."""
handlers = create_private_handlers(mock_db, mock_settings)
with pytest.MonkeyPatch().context() as m:
m.setattr(
"helper_bot.handlers.private.private_handlers.get_reply_keyboard",
AsyncMock(return_value=Mock()),
)
await handlers.stickers(mock_message, mock_state)
mock_db.update_stickers_info.assert_awaited_once_with(mock_message.from_user.id)
mock_state.set_state.assert_called_once_with(FSM_STATES["START"])
mock_message.answer.assert_called_once()
@pytest.mark.asyncio
async def test_connect_with_admin(
self, mock_db, mock_settings, mock_message, mock_state
):
"""connect_with_admin переводит в PRE_CHAT и отправляет сообщение."""
handlers = create_private_handlers(mock_db, mock_settings)
with pytest.MonkeyPatch().context() as m:
m.setattr(
"helper_bot.handlers.private.private_handlers.messages.get_message",
lambda x, y: "Admin contact",
)
await handlers.connect_with_admin(mock_message, mock_state)
mock_state.set_state.assert_called_once_with(FSM_STATES["PRE_CHAT"])
mock_message.answer.assert_called_once()
@pytest.mark.asyncio
async def test_resend_message_in_group_pre_chat(
self, mock_db, mock_settings, mock_message, mock_state
):
"""resend_message_in_group при PRE_CHAT переводит в START и отправляет question."""
handlers = create_private_handlers(mock_db, mock_settings)
mock_state.get_state = AsyncMock(return_value=FSM_STATES["PRE_CHAT"])
with pytest.MonkeyPatch().context() as m:
m.setattr(
"helper_bot.handlers.private.private_handlers.get_reply_keyboard",
AsyncMock(return_value=Mock()),
)
m.setattr(
"helper_bot.handlers.private.private_handlers.messages.get_message",
lambda x, y: "Question?",
)
await handlers.resend_message_in_group_for_message(mock_message, mock_state)
mock_message.forward.assert_called_once_with(
chat_id=mock_settings.group_for_message
)
mock_state.set_state.assert_called_once_with(FSM_STATES["START"])
@pytest.mark.asyncio
async def test_resend_message_in_group_chat(
self, mock_db, mock_settings, mock_message, mock_state
):
"""resend_message_in_group при CHAT оставляет в CHAT и отправляет question с leave markup."""
handlers = create_private_handlers(mock_db, mock_settings)
mock_state.get_state = AsyncMock(return_value=FSM_STATES["CHAT"])
with pytest.MonkeyPatch().context() as m:
m.setattr(
"helper_bot.handlers.private.private_handlers.get_reply_keyboard_leave_chat",
lambda: Mock(),
)
m.setattr(
"helper_bot.handlers.private.private_handlers.messages.get_message",
lambda x, y: "Question?",
)
await handlers.resend_message_in_group_for_message(mock_message, mock_state)
mock_message.forward.assert_called_once_with(
chat_id=mock_settings.group_for_message
)
mock_message.answer.assert_called()
@pytest.mark.asyncio
async def test_suggest_router_answers_and_schedules_background(
self, mock_db, mock_settings, mock_message, mock_state
):
"""suggest_router сразу отвечает и планирует фоновую обработку."""
mock_message.media_group_id = None
handlers = create_private_handlers(mock_db, mock_settings)
with pytest.MonkeyPatch().context() as m:
m.setattr(
"helper_bot.handlers.private.private_handlers.get_reply_keyboard",
AsyncMock(return_value=Mock()),
)
m.setattr(
"helper_bot.handlers.private.private_handlers.messages.get_message",
lambda x, y: "Success",
)
with patch.object(
handlers.post_service, "process_post", new_callable=AsyncMock
):
await handlers.suggest_router(mock_message, mock_state)
mock_state.set_state.assert_called_once_with(FSM_STATES["START"])
mock_message.answer.assert_called_once()
class TestBotSettings:
"""Test class for BotSettings dataclass"""

222
tests/test_s3_storage.py Normal file
View File

@@ -0,0 +1,222 @@
"""
Тесты для helper_bot.utils.s3_storage (S3StorageService).
"""
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from helper_bot.utils.s3_storage import S3StorageService
@pytest.mark.unit
class TestS3StorageServiceInit:
"""Тесты инициализации S3StorageService."""
def test_init_stores_params(self):
"""Параметры сохраняются в атрибутах."""
with patch("helper_bot.utils.s3_storage.aioboto3.Session"):
service = S3StorageService(
endpoint_url="http://s3",
access_key="ak",
secret_key="sk",
bucket_name="bucket",
region="eu-west-1",
)
assert service.endpoint_url == "http://s3"
assert service.bucket_name == "bucket"
assert service.region == "eu-west-1"
@pytest.mark.unit
class TestS3StorageServiceGenerateS3Key:
"""Тесты generate_s3_key."""
@pytest.fixture
def service(self):
"""Сервис без реального session."""
with patch("helper_bot.utils.s3_storage.aioboto3.Session"):
return S3StorageService(
endpoint_url="http://s3",
access_key="ak",
secret_key="sk",
bucket_name="b",
)
def test_photo_key(self, service):
"""Ключ для photo — photos/{id}.jpg."""
key = service.generate_s3_key("photo", "file_123")
assert key == "photos/file_123.jpg"
def test_video_key(self, service):
"""Ключ для video — videos/{id}.mp4."""
key = service.generate_s3_key("video", "vid_1")
assert key == "videos/vid_1.mp4"
def test_audio_key(self, service):
"""Ключ для audio — music/{id}.mp3."""
key = service.generate_s3_key("audio", "a1")
assert key == "music/a1.mp3"
def test_voice_key(self, service):
"""Ключ для voice — voice/{id}.ogg."""
key = service.generate_s3_key("voice", "v1")
assert key == "voice/v1.ogg"
def test_other_key(self, service):
"""Неизвестный тип — other/{id}.bin."""
key = service.generate_s3_key("other", "x")
assert key == "other/x.bin"
@pytest.mark.unit
@pytest.mark.asyncio
class TestS3StorageServiceUploadDownload:
"""Тесты upload_file, download_file, file_exists, delete_file через мок session.client."""
@pytest.fixture
def service(self):
"""Сервис с замоканной session."""
mock_session = MagicMock()
mock_context = AsyncMock()
mock_s3 = MagicMock()
mock_s3.upload_file = AsyncMock()
mock_s3.upload_fileobj = AsyncMock()
mock_s3.download_file = AsyncMock()
mock_s3.head_object = AsyncMock()
mock_s3.delete_object = AsyncMock()
mock_context.__aenter__.return_value = mock_s3
mock_context.__aexit__.return_value = None
mock_session.client.return_value = mock_context
with patch(
"helper_bot.utils.s3_storage.aioboto3.Session", return_value=mock_session
):
s = S3StorageService(
endpoint_url="http://s3",
access_key="ak",
secret_key="sk",
bucket_name="bucket",
)
s._mock_s3 = mock_s3
return s
async def test_upload_file_success(self, service):
"""upload_file при успехе возвращает True."""
result = await service.upload_file("/tmp/f", "key")
assert result is True
service._mock_s3.upload_file.assert_called_once()
async def test_upload_file_with_content_type(self, service):
"""upload_file с content_type передаёт ExtraArgs."""
await service.upload_file("/tmp/f", "key", content_type="image/jpeg")
call_kwargs = service._mock_s3.upload_file.call_args[1]
assert call_kwargs.get("ExtraArgs", {}).get("ContentType") == "image/jpeg"
async def test_upload_file_exception_returns_false(self, service):
"""При исключении upload_file возвращает False."""
service._mock_s3.upload_file = AsyncMock(side_effect=Exception("network error"))
result = await service.upload_file("/tmp/f", "key")
assert result is False
async def test_download_file_success(self, service):
"""download_file при успехе возвращает True."""
with patch("os.makedirs"):
result = await service.download_file("key", "/tmp/out")
assert result is True
service._mock_s3.download_file.assert_called_once()
async def test_download_file_exception_returns_false(self, service):
"""При исключении download_file возвращает False."""
service._mock_s3.download_file = AsyncMock(side_effect=Exception("error"))
with patch("os.makedirs"):
result = await service.download_file("key", "/tmp/out")
assert result is False
async def test_upload_fileobj_success(self, service):
"""upload_fileobj при успехе возвращает True."""
f = MagicMock()
result = await service.upload_fileobj(f, "key")
assert result is True
service._mock_s3.upload_fileobj.assert_called_once()
async def test_file_exists_true(self, service):
"""file_exists при успешном head_object возвращает True."""
result = await service.file_exists("key")
assert result is True
async def test_file_exists_false_on_exception(self, service):
"""file_exists при исключении возвращает False."""
service._mock_s3.head_object = AsyncMock(side_effect=Exception())
result = await service.file_exists("key")
assert result is False
async def test_delete_file_success(self, service):
"""delete_file при успехе возвращает True."""
result = await service.delete_file("key")
assert result is True
service._mock_s3.delete_object.assert_called_once_with(
Bucket="bucket", Key="key"
)
async def test_delete_file_exception_returns_false(self, service):
"""При исключении delete_file возвращает False."""
service._mock_s3.delete_object = AsyncMock(side_effect=Exception())
result = await service.delete_file("key")
assert result is False
@pytest.mark.unit
@pytest.mark.asyncio
class TestS3StorageServiceDownloadToTemp:
"""Тесты download_to_temp."""
async def test_download_to_temp_success_returns_path(self):
"""При успешном download возвращается путь к временному файлу."""
mock_session = MagicMock()
mock_context = AsyncMock()
mock_s3 = MagicMock()
mock_s3.download_file = AsyncMock()
mock_context.__aenter__.return_value = mock_s3
mock_context.__aexit__.return_value = None
mock_session.client.return_value = mock_context
with patch(
"helper_bot.utils.s3_storage.aioboto3.Session", return_value=mock_session
):
service = S3StorageService(
endpoint_url="http://s3",
access_key="ak",
secret_key="sk",
bucket_name="b",
)
with patch("os.makedirs"):
path = await service.download_to_temp("photos/1.jpg")
if path:
assert Path(path).suffix in (".jpg", "")
try:
Path(path).unlink(missing_ok=True)
except Exception:
pass
async def test_download_to_temp_failure_returns_none(self):
"""При неуспешном download возвращается None."""
mock_session = MagicMock()
mock_context = AsyncMock()
mock_s3 = MagicMock()
mock_s3.download_file = AsyncMock()
mock_context.__aenter__.return_value = mock_s3
mock_context.__aexit__.return_value = None
mock_session.client.return_value = mock_context
with patch(
"helper_bot.utils.s3_storage.aioboto3.Session", return_value=mock_session
):
service = S3StorageService(
endpoint_url="http://s3",
access_key="ak",
secret_key="sk",
bucket_name="b",
)
with patch.object(service, "download_file", AsyncMock(return_value=False)):
path = await service.download_to_temp("key")
assert path is None

View File

@@ -0,0 +1,249 @@
"""
Тесты для helper_bot.server_prometheus: MetricsServer, start_metrics_server, stop_metrics_server.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from aiohttp import web
from helper_bot.server_prometheus import (
MetricsServer,
start_metrics_server,
stop_metrics_server,
)
@pytest.mark.unit
@pytest.mark.asyncio
class TestMetricsServer:
"""Тесты для класса MetricsServer."""
def test_init_sets_host_port_and_routes(self):
"""При инициализации задаются host, port и маршруты /metrics, /health."""
server = MetricsServer(host="127.0.0.1", port=9090)
assert server.host == "127.0.0.1"
assert server.port == 9090
assert server.runner is None
assert server.site is None
paths = []
for res in server.app.router.resources():
info = res.get_info()
path = info.get("path") or info.get("formatter")
if path:
paths.append(path)
assert "/metrics" in paths
assert "/health" in paths
@patch("helper_bot.server_prometheus.metrics")
async def test_metrics_handler_success_returns_prometheus_content(
self, mock_metrics_module
):
"""metrics_handler при успехе возвращает 200 и данные метрик."""
mock_metrics_module.get_metrics.return_value = (
b"# TYPE bot_commands_total counter"
)
server = MetricsServer(host="0.0.0.0", port=8080)
request = MagicMock(spec=web.Request)
response = await server.metrics_handler(request)
assert response.status == 200
assert response.body == b"# TYPE bot_commands_total counter"
assert "text/plain" in response.content_type
mock_metrics_module.get_metrics.assert_called_once()
@patch("helper_bot.server_prometheus.metrics", None)
async def test_metrics_handler_when_metrics_none_returns_500(self):
"""metrics_handler при недоступности metrics возвращает 500."""
server = MetricsServer(host="0.0.0.0", port=8080)
request = MagicMock(spec=web.Request)
response = await server.metrics_handler(request)
assert response.status == 500
assert "Metrics not available" in response.text
@patch("helper_bot.server_prometheus.metrics")
async def test_metrics_handler_on_exception_returns_500(self, mock_metrics_module):
"""metrics_handler при исключении в get_metrics возвращает 500."""
mock_metrics_module.get_metrics.side_effect = RuntimeError("metrics error")
server = MetricsServer(host="0.0.0.0", port=8080)
request = MagicMock(spec=web.Request)
response = await server.metrics_handler(request)
assert response.status == 500
assert "Error generating metrics" in response.text
@patch("helper_bot.server_prometheus.metrics")
async def test_health_handler_success_returns_ok(self, mock_metrics_module):
"""health_handler при успехе возвращает 200 OK."""
mock_metrics_module.get_metrics.return_value = b"some_metrics_data"
server = MetricsServer(host="0.0.0.0", port=8080)
request = MagicMock(spec=web.Request)
response = await server.health_handler(request)
assert response.status == 200
assert response.text == "OK"
@patch("helper_bot.server_prometheus.metrics", None)
async def test_health_handler_when_metrics_none_returns_503(self):
"""health_handler при недоступности metrics возвращает 503."""
server = MetricsServer(host="0.0.0.0", port=8080)
request = MagicMock(spec=web.Request)
response = await server.health_handler(request)
assert response.status == 503
assert "Metrics not available" in response.text
@patch("helper_bot.server_prometheus.metrics")
async def test_health_handler_empty_metrics_returns_503(self, mock_metrics_module):
"""health_handler при пустых метриках возвращает 503."""
mock_metrics_module.get_metrics.return_value = b""
server = MetricsServer(host="0.0.0.0", port=8080)
request = MagicMock(spec=web.Request)
response = await server.health_handler(request)
assert response.status == 503
assert "Empty metrics" in response.text
@patch("helper_bot.server_prometheus.metrics")
async def test_health_handler_get_metrics_raises_returns_503(
self, mock_metrics_module
):
"""health_handler при исключении get_metrics возвращает 503."""
mock_metrics_module.get_metrics.side_effect = ValueError("gen failed")
server = MetricsServer(host="0.0.0.0", port=8080)
request = MagicMock(spec=web.Request)
response = await server.health_handler(request)
assert response.status == 503
assert "Metrics generation failed" in response.text
@patch("helper_bot.server_prometheus.web.AppRunner")
@patch("helper_bot.server_prometheus.web.TCPSite")
async def test_start_creates_runner_and_site(
self, mock_tcp_site_cls, mock_app_runner_cls
):
"""start() создаёт AppRunner и TCPSite и запускает сервер."""
mock_runner = MagicMock()
mock_runner.setup = AsyncMock()
mock_app_runner_cls.return_value = mock_runner
mock_site = MagicMock()
mock_site.start = AsyncMock()
mock_tcp_site_cls.return_value = mock_site
server = MetricsServer(host="0.0.0.0", port=19998)
await server.start()
mock_app_runner_cls.assert_called_once_with(server.app)
mock_runner.setup.assert_awaited_once()
mock_tcp_site_cls.assert_called_once_with(mock_runner, "0.0.0.0", 19998)
mock_site.start.assert_awaited_once()
assert server.runner is mock_runner
assert server.site is mock_site
async def test_stop_stops_site_and_cleans_runner(self):
"""stop() останавливает site и очищает runner."""
server = MetricsServer(host="0.0.0.0", port=8080)
server.site = MagicMock()
server.site.stop = AsyncMock()
server.runner = MagicMock()
server.runner.cleanup = AsyncMock()
await server.stop()
server.site.stop.assert_awaited_once()
server.runner.cleanup.assert_awaited_once()
async def test_stop_when_site_none_does_not_raise(self):
"""stop() при site=None не падает."""
server = MetricsServer(host="0.0.0.0", port=8080)
server.site = None
server.runner = None
await server.stop()
@patch.object(MetricsServer, "start", new_callable=AsyncMock)
@patch.object(MetricsServer, "stop", new_callable=AsyncMock)
async def test_context_manager_enters_and_exits(self, mock_stop, mock_start):
"""Использование как async context manager вызывает start и stop."""
mock_start.return_value = None
server = MetricsServer(host="0.0.0.0", port=8080)
async with server:
pass
mock_start.assert_awaited_once()
mock_stop.assert_awaited_once()
@patch.object(MetricsServer, "start", new_callable=AsyncMock)
@patch.object(MetricsServer, "stop", new_callable=AsyncMock)
async def test_context_manager_exit_calls_stop_on_exception(
self, mock_stop, mock_start
):
"""При исключении внутри контекста stop всё равно вызывается."""
mock_start.return_value = None
server = MetricsServer(host="0.0.0.0", port=8080)
with pytest.raises(ValueError):
async with server:
raise ValueError("test")
mock_stop.assert_awaited_once()
@pytest.mark.unit
@pytest.mark.asyncio
class TestStartStopMetricsServer:
"""Тесты для start_metrics_server и stop_metrics_server."""
@patch("helper_bot.server_prometheus.MetricsServer")
async def test_start_metrics_server_creates_and_starts_server(
self, mock_server_cls
):
"""start_metrics_server создаёт MetricsServer и вызывает start()."""
mock_instance = MagicMock()
mock_instance.start = AsyncMock()
mock_server_cls.return_value = mock_instance
result = await start_metrics_server("0.0.0.0", 8080)
mock_server_cls.assert_called_once_with("0.0.0.0", 8080)
mock_instance.start.assert_awaited_once()
assert result is mock_instance
@patch("helper_bot.server_prometheus.MetricsServer")
async def test_stop_metrics_server_when_running_stops_and_clears_global(
self, mock_server_cls
):
"""stop_metrics_server при запущенном сервере останавливает его и обнуляет глобальную переменную."""
import helper_bot.server_prometheus as mod
mock_instance = MagicMock()
mock_instance.stop = AsyncMock()
old_server = mod.metrics_server
mod.metrics_server = mock_instance
try:
await stop_metrics_server()
mock_instance.stop.assert_awaited_once()
assert mod.metrics_server is None
finally:
mod.metrics_server = old_server
async def test_stop_metrics_server_when_none_does_not_raise(self):
"""stop_metrics_server при metrics_server=None не падает."""
import helper_bot.server_prometheus as mod
old_server = mod.metrics_server
mod.metrics_server = None
try:
await stop_metrics_server()
assert mod.metrics_server is None
finally:
mod.metrics_server = old_server

View File

@@ -0,0 +1,91 @@
"""
Тесты для helper_bot.middlewares.text_middleware (BulkTextMiddleware).
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from helper_bot.middlewares.text_middleware import BulkTextMiddleware
@pytest.mark.unit
@pytest.mark.asyncio
class TestBulkTextMiddleware:
"""Тесты для BulkTextMiddleware."""
@pytest.fixture
def middleware(self):
"""Middleware с минимальной latency для быстрых тестов."""
return BulkTextMiddleware(latency=0.001)
@pytest.fixture
def mock_handler(self):
"""Мок handler."""
return AsyncMock(return_value="ok")
async def test_no_text_passes_immediately(self, middleware, mock_handler):
"""Сообщение без text передаётся в handler сразу."""
event = MagicMock()
event.chat = MagicMock()
event.chat.id = 1
event.from_user = MagicMock()
event.from_user.id = 10
event.text = None
event.message_id = 1
data = {}
result = await middleware(mock_handler, event, data)
mock_handler.assert_called_once_with(event, data)
assert result == "ok"
async def test_single_text_after_latency_calls_handler_with_concatenated_text(
self, middleware, mock_handler
):
"""Одно текстовое сообщение после latency передаётся в data['texts'] и handler вызывается."""
event = MagicMock()
event.chat = MagicMock()
event.chat.id = 100
event.from_user = MagicMock()
event.from_user.id = 200
event.text = "Hello"
event.message_id = 5
data = {}
result = await middleware(mock_handler, event, data)
mock_handler.assert_called_once()
assert data["texts"] == "Hello"
assert result == "ok"
async def test_two_messages_same_key_concatenated(self, middleware, mock_handler):
"""Два сообщения с одним ключом (chat_id, user_id) за latency конкатенируются."""
# Первое сообщение
event1 = MagicMock()
event1.chat = MagicMock()
event1.chat.id = 1
event1.from_user = MagicMock()
event1.from_user.id = 2
event1.text = "A"
event1.message_id = 1
data1 = {}
# Запускаем без ожидания второго сообщения — после latency будет одно
result1 = await middleware(mock_handler, event1, data1)
assert data1["texts"] == "A"
assert result1 == "ok"
async def test_messages_sorted_by_message_id(self, middleware, mock_handler):
"""Сообщения в data['texts'] отсортированы по message_id."""
event = MagicMock()
event.chat = MagicMock()
event.chat.id = 5
event.from_user = MagicMock()
event.from_user.id = 5
event.text = "Only"
event.message_id = 42
data = {}
await middleware(mock_handler, event, data)
assert data["texts"] == "Only"

View File

@@ -145,6 +145,228 @@ class TestVoiceHandler:
# Проверяем, что роутер содержит обработчики
assert len(voice_handler.router.message.handlers) > 0
@pytest.mark.asyncio
async def test_restart_function_sets_state_and_answers(
self, voice_handler, mock_message, mock_state, mock_db, mock_settings
):
"""restart_function пересылает в логи, обновляет инфо и отправляет клавиатуру."""
with patch(
"helper_bot.handlers.voice.voice_handler.update_user_info",
new_callable=AsyncMock,
):
with patch(
"helper_bot.handlers.voice.voice_handler.check_user_emoji",
new_callable=AsyncMock,
):
with patch(
"helper_bot.handlers.voice.voice_handler.get_main_keyboard"
) as mock_keyboard:
mock_keyboard.return_value = MagicMock()
await voice_handler.restart_function(
mock_message, mock_state, mock_db, mock_settings
)
mock_message.forward.assert_awaited_once_with(
chat_id=mock_settings["Telegram"]["group_for_logs"]
)
mock_state.set_state.assert_called_once_with(STATE_START)
mock_message.answer.assert_called_once()
assert (
"Записывайся" in mock_message.answer.call_args[1]["text"]
or "слушай" in mock_message.answer.call_args[1]["text"]
)
@pytest.mark.asyncio
async def test_start_sets_state_and_sends_welcome(
self, voice_handler, mock_message, mock_state, mock_db, mock_settings
):
"""start устанавливает состояние и отправляет приветствие через VoiceBotService."""
mock_db.mark_voice_bot_welcome_received = AsyncMock()
with patch(
"helper_bot.handlers.voice.voice_handler.update_user_info",
new_callable=AsyncMock,
):
with patch(
"helper_bot.handlers.voice.voice_handler.get_user_emoji_safe",
new_callable=AsyncMock,
return_value="😊",
):
with patch(
"helper_bot.handlers.voice.voice_handler.VoiceBotService"
) as mock_svc_cls:
mock_svc = MagicMock()
mock_svc.send_welcome_messages = AsyncMock()
mock_svc_cls.return_value = mock_svc
await voice_handler.start(
mock_message, mock_state, mock_db, mock_settings
)
mock_state.set_state.assert_called_once_with(STATE_START)
mock_svc.send_welcome_messages.assert_awaited_once()
mock_db.mark_voice_bot_welcome_received.assert_awaited_once_with(
123
)
@pytest.mark.asyncio
async def test_help_function_answers_help_message(
self, voice_handler, mock_message, mock_state, mock_settings
):
"""help_function пересылает в логи и отправляет HELP_MESSAGE."""
with patch(
"helper_bot.handlers.voice.voice_handler.update_user_info",
new_callable=AsyncMock,
):
with patch(
"helper_bot.handlers.voice.voice_handler.messages.get_message"
) as mock_get:
mock_get.return_value = "Help text"
await voice_handler.help_function(
mock_message, mock_state, mock_settings
)
mock_message.forward.assert_awaited_once_with(
chat_id=mock_settings["Telegram"]["group_for_logs"]
)
mock_message.answer.assert_called_once_with(
text="Help text",
disable_web_page_preview=not mock_settings["Telegram"][
"preview_link"
],
)
mock_state.set_state.assert_called_once_with(STATE_START)
@pytest.mark.asyncio
async def test_cancel_handler_returns_to_menu(
self, voice_handler, mock_message, mock_state, mock_db, mock_settings
):
"""cancel_handler пересылает в логи и возвращает в главное меню."""
with patch(
"helper_bot.handlers.voice.voice_handler.update_user_info",
new_callable=AsyncMock,
):
with patch(
"helper_bot.handlers.voice.voice_handler.get_reply_keyboard",
new_callable=AsyncMock,
) as mock_kb:
mock_kb.return_value = MagicMock()
await voice_handler.cancel_handler(
mock_message, mock_state, mock_db, mock_settings
)
mock_message.forward.assert_awaited_once()
mock_message.answer.assert_called_once()
assert (
"Добро пожаловать" in mock_message.answer.call_args[1]["text"]
or "меню" in mock_message.answer.call_args[1]["text"]
)
mock_state.set_state.assert_called_once()
@pytest.mark.asyncio
async def test_refresh_listen_function_clears_listenings(
self, voice_handler, mock_message, mock_state, mock_db, mock_settings
):
"""refresh_listen_function очищает прослушивания и отправляет сообщение."""
with patch(
"helper_bot.handlers.voice.voice_handler.update_user_info",
new_callable=AsyncMock,
):
with patch(
"helper_bot.handlers.voice.voice_handler.get_main_keyboard"
) as mock_keyboard:
with patch(
"helper_bot.handlers.voice.voice_handler.VoiceBotService"
) as mock_svc_cls:
mock_svc = MagicMock()
mock_svc.clear_user_listenings = AsyncMock()
mock_svc_cls.return_value = mock_svc
with patch(
"helper_bot.handlers.voice.voice_handler.messages.get_message"
) as mock_get:
mock_get.return_value = "Прослушивания сброшены"
await voice_handler.refresh_listen_function(
mock_message, mock_state, mock_db, mock_settings
)
mock_svc.clear_user_listenings.assert_awaited_once_with(123)
mock_message.answer.assert_called_once()
mock_state.set_state.assert_called_once_with(STATE_START)
@pytest.mark.asyncio
async def test_suggest_voice_valid_sends_to_group_and_saves(
self, voice_handler, mock_message, mock_state, mock_db, mock_settings
):
"""suggest_voice при валидном голосовом отправляет в группу и сохраняет message_id."""
mock_message.voice = MagicMock()
mock_message.voice.file_id = "voice_123"
with patch(
"helper_bot.handlers.voice.voice_handler.validate_voice_message",
new_callable=AsyncMock,
return_value=True,
):
with patch(
"helper_bot.handlers.voice.voice_handler.send_voice_message",
new_callable=AsyncMock,
) as mock_send:
sent = MagicMock()
sent.message_id = 999
mock_send.return_value = sent
mock_db.set_user_id_and_message_id_for_voice_bot = AsyncMock()
with patch(
"helper_bot.handlers.voice.voice_handler.get_reply_keyboard_for_voice"
) as mock_kb:
mock_kb.return_value = MagicMock()
with patch(
"helper_bot.handlers.voice.voice_handler.messages.get_message"
) as mock_get:
mock_get.return_value = "Голос сохранён"
await voice_handler.suggest_voice(
mock_message, mock_state, mock_db, mock_settings
)
mock_send.assert_awaited_once()
mock_db.set_user_id_and_message_id_for_voice_bot.assert_awaited_once_with(
999, 123
)
mock_message.answer.assert_called_once()
mock_state.set_state.assert_called_once_with(STATE_START)
@pytest.mark.asyncio
async def test_suggest_voice_invalid_keeps_state_standup_write(
self, voice_handler, mock_message, mock_state, mock_db, mock_settings
):
"""suggest_voice при невалидном голосовом оставляет состояние STANDUP_WRITE."""
with patch(
"helper_bot.handlers.voice.voice_handler.validate_voice_message",
new_callable=AsyncMock,
return_value=False,
):
with patch(
"helper_bot.handlers.voice.voice_handler.get_main_keyboard"
) as mock_keyboard:
mock_keyboard.return_value = MagicMock()
with patch(
"helper_bot.handlers.voice.voice_handler.messages.get_message"
) as mock_get:
mock_get.return_value = "Неверный контент"
await voice_handler.suggest_voice(
mock_message, mock_state, mock_db, mock_settings
)
mock_message.answer.assert_called()
mock_state.set_state.assert_called_once_with(STATE_STANDUP_WRITE)
@pytest.mark.asyncio
async def test_handle_emoji_message_answers_emoji(
self, voice_handler, mock_message, mock_state, mock_settings
):
"""handle_emoji_message пересылает в логи и отвечает эмодзи или ничего."""
with patch(
"helper_bot.handlers.voice.voice_handler.check_user_emoji",
new_callable=AsyncMock,
return_value="😊",
):
await voice_handler.handle_emoji_message(
mock_message, mock_state, mock_settings
)
mock_message.forward.assert_awaited_once()
mock_state.set_state.assert_called_once_with(STATE_START)
mock_message.answer.assert_called_once_with(
f"Твоя эмодзя - 😊", parse_mode="HTML"
)
if __name__ == "__main__":
pytest.main([__file__])

View File

@@ -274,6 +274,124 @@ class TestVoiceBotService:
assert hasattr(voice_service, "get_remaining_audio_count")
assert hasattr(voice_service, "send_welcome_messages")
@pytest.mark.asyncio
async def test_get_welcome_sticker_exception_returns_none(
self, voice_service, mock_settings
):
"""get_welcome_sticker при исключении возвращает None."""
with patch("pathlib.Path.rglob") as mock_rglob:
mock_rglob.side_effect = OSError("Permission denied")
sticker = await voice_service.get_welcome_sticker()
assert sticker is None
@pytest.mark.asyncio
async def test_get_welcome_sticker_exception_sends_to_logs_when_enabled(
self, voice_service, mock_settings
):
"""get_welcome_sticker при исключении и logs=True отправляет ошибку в логи."""
voice_service.settings = {"Settings": {"logs": True}, "Telegram": {}}
with patch("pathlib.Path.rglob", side_effect=OSError("err")):
with patch.object(
voice_service, "_send_error_to_logs", new_callable=AsyncMock
) as mock_send_logs:
await voice_service.get_welcome_sticker()
mock_send_logs.assert_awaited_once()
@pytest.mark.asyncio
async def test_get_random_audio_exception_raises(self, voice_service, mock_bot_db):
"""get_random_audio при исключении выбрасывает AudioProcessingError."""
mock_bot_db.check_listen_audio = AsyncMock(side_effect=Exception("DB error"))
with pytest.raises(
AudioProcessingError, match="Не удалось получить случайное аудио"
):
await voice_service.get_random_audio(123)
@pytest.mark.asyncio
async def test_mark_audio_as_listened_exception_raises(
self, voice_service, mock_bot_db
):
"""mark_audio_as_listened при исключении выбрасывает DatabaseError."""
from helper_bot.handlers.voice.exceptions import DatabaseError
mock_bot_db.mark_listened_audio = AsyncMock(side_effect=Exception("DB error"))
with pytest.raises(DatabaseError, match="Не удалось пометить аудио"):
await voice_service.mark_audio_as_listened("file", 123)
@pytest.mark.asyncio
async def test_clear_user_listenings_exception_raises(
self, voice_service, mock_bot_db
):
"""clear_user_listenings при исключении выбрасывает DatabaseError."""
from helper_bot.handlers.voice.exceptions import DatabaseError
mock_bot_db.delete_listen_count_for_user = AsyncMock(
side_effect=Exception("DB error")
)
with pytest.raises(DatabaseError, match="Не удалось очистить прослушивания"):
await voice_service.clear_user_listenings(123)
@pytest.mark.asyncio
async def test_get_remaining_audio_count_exception_raises(
self, voice_service, mock_bot_db
):
"""get_remaining_audio_count при исключении выбрасывает DatabaseError."""
from helper_bot.handlers.voice.exceptions import DatabaseError
mock_bot_db.check_listen_audio = AsyncMock(side_effect=Exception("DB error"))
with pytest.raises(DatabaseError, match="Не удалось получить количество аудио"):
await voice_service.get_remaining_audio_count(123)
@pytest.mark.asyncio
async def test_send_welcome_messages_exception_raises(
self, voice_service, mock_bot_db, mock_settings
):
"""send_welcome_messages при исключении выбрасывает VoiceMessageError."""
mock_message = Mock()
mock_message.answer = AsyncMock(side_effect=Exception("Network error"))
mock_message.answer_sticker = AsyncMock()
with patch.object(
voice_service,
"get_welcome_sticker",
new_callable=AsyncMock,
return_value=None,
):
with patch.object(voice_service, "_get_main_keyboard", return_value=Mock()):
with pytest.raises(
VoiceMessageError,
match="Не удалось отправить приветственные сообщения",
):
await voice_service.send_welcome_messages(mock_message, "😊")
def test_get_main_keyboard_returns_keyboard(self, voice_service):
"""_get_main_keyboard возвращает клавиатуру."""
with patch("helper_bot.keyboards.keyboards.get_main_keyboard") as mock_kb:
mock_kb.return_value = Mock()
result = voice_service._get_main_keyboard()
mock_kb.assert_called_once()
assert result is not None
@pytest.mark.asyncio
async def test_send_error_to_logs_handles_exception(
self, voice_service, mock_settings
):
"""_send_error_to_logs при ошибке отправки логирует и не падает."""
voice_service.settings = {
"Settings": {},
"Telegram": {"important_logs": "-123"},
}
with patch(
"helper_bot.utils.helper_func.send_voice_message", new_callable=AsyncMock
) as mock_send:
mock_send.side_effect = Exception("Send failed")
await voice_service._send_error_to_logs("Test error")
mock_send.assert_awaited_once()
if __name__ == "__main__":
pytest.main([__file__])