6 Commits

Author SHA1 Message Date
ANDREY KATYKHIN
e2a6944ed8 Merge pull request #16 from KerradKerridi/fix-1
Переписал почти все тесты
2026-02-03 23:45:52 +03:00
73c36061c7 one more fix 2026-02-02 00:54:23 +03:00
d87d4e492e fix linter, fix ci, fix tests 2026-02-02 00:46:44 +03:00
68041037bd Merge remote-tracking branch 'origin/master' into fix-1 2026-02-02 00:41:51 +03:00
ANDREY KATYKHIN
3933259674 Merge pull request #15 from KerradKerridi/dev-13
Dev 13
2026-02-02 00:29:07 +03:00
a5faa4bdc6 Переписал почти все тесты
feat: улучшено логирование и обработка скорингов в PostService и RagApiClient

- Добавлены отладочные сообщения для передачи скорингов в функции обработки постов.
- Обновлено логирование успешного получения скорингов из RAG API с дополнительной информацией.
- Оптимизирована обработка скорингов в функции get_text_message для улучшения отладки.
- Обновлены тесты для проверки новых функциональных возможностей и обработки ошибок.
2026-01-30 00:55:47 +03:00
28 changed files with 4817 additions and 6 deletions

View File

@@ -2,9 +2,9 @@ name: CI pipeline
on: on:
push: push:
branches: [ 'dev-*', 'feature-*' ] branches: [ 'dev-*', 'feature-*', 'fix-*' ]
pull_request: pull_request:
branches: [ 'dev-*', 'feature-*', 'main' ] branches: [ 'dev-*', 'feature-*', 'fix-*', 'main' ]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -35,7 +35,8 @@ jobs:
python -m black . python -m black .
echo "🔍 Checking that repo is already formatted (no diff after isort+black)..." echo "🔍 Checking that repo is already formatted (no diff after isort+black)..."
git diff --exit-code || ( 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 exit 1
) )

View File

@@ -334,6 +334,13 @@ class PostService:
# Формируем текст/caption с учетом скоров # Формируем текст/caption с учетом скоров
post_text = "" post_text = ""
if text_for_post or content_type == "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( post_text = get_text_message(
text_for_post.lower() if text_for_post else "", text_for_post.lower() if text_for_post else "",
first_name, first_name,
@@ -530,6 +537,14 @@ class PostService:
ml_scores_json, ml_scores_json,
) = await self._get_scores(raw_text) ) = 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( post_text = get_text_message(
message.text.lower(), message.text.lower(),
@@ -580,6 +595,14 @@ class PostService:
ml_scores_json, ml_scores_json,
) = await self._get_scores(raw_caption) ) = 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 = "" post_caption = ""
if message.caption: if message.caption:
post_caption = get_text_message( post_caption = get_text_message(
@@ -638,6 +661,14 @@ class PostService:
ml_scores_json, ml_scores_json,
) = await self._get_scores(raw_caption) ) = 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 = "" post_caption = ""
if message.caption: if message.caption:
post_caption = get_text_message( post_caption = get_text_message(
@@ -723,6 +754,14 @@ class PostService:
ml_scores_json, ml_scores_json,
) = await self._get_scores(raw_caption) ) = 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 = "" post_caption = ""
if message.caption: if message.caption:
post_caption = get_text_message( post_caption = get_text_message(
@@ -816,6 +855,14 @@ class PostService:
ml_scores_json, ml_scores_json,
) = await self._get_scores(raw_caption) ) = 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( post_caption = get_text_message(
album[0].caption.lower(), album[0].caption.lower(),
first_name, first_name,

View File

@@ -159,13 +159,28 @@ class RagApiClient:
if data.get("rag_confidence") is not None if data.get("rag_confidence") is not None
else 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 для логирования
confidence_str = f"{confidence:.4f}" if confidence is not None else "None" 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( logger.info(
f"RagApiClient: Скор успешно получен " f"RagApiClient: Скор успешно получен из API - "
f"(score={score:.4f}, confidence={confidence_str})" 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( return ScoringResult(

View File

@@ -203,6 +203,13 @@ def get_text_message(
if deepseek_score is not None: if deepseek_score is not None:
scores_lines.append(f"DeepSeek: {deepseek_score:.2f}") scores_lines.append(f"DeepSeek: {deepseek_score:.2f}")
if rag_score is not None: 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}" rag_line = f"RAG neg/pos: {rag_score:.2f}"
if rag_confidence is not None: if rag_confidence is not None:
rag_line += f" (уверенность: {rag_confidence:.0%})" rag_line += f" (уверенность: {rag_confidence:.0%})"

View File

@@ -1,8 +1,15 @@
import asyncio import asyncio
import os import os
import sys import sys
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch 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 import pytest
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import Chat, Message, User 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 import pytest
from helper_bot.handlers.callback.callback_handlers import ( from helper_bot.handlers.callback.callback_handlers import (
change_page,
delete_voice_message, delete_voice_message,
process_ban_user,
process_unlock_user,
return_to_main_menu,
save_voice_message, save_voice_message,
) )
from helper_bot.handlers.voice.constants import CALLBACK_DELETE, CALLBACK_SAVE 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) 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__": if __name__ == "__main__":
pytest.main([__file__]) 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 # Assert
self.mock_db.delete_user_blacklist.assert_called_once_with(user_id) 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: class TestUser:
"""Тесты для модели User""" """Тесты для модели User"""

View File

@@ -1,6 +1,6 @@
"""Tests for refactored private handlers""" """Tests for refactored private handlers"""
from unittest.mock import AsyncMock, MagicMock, Mock from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest import pytest
from aiogram import types from aiogram import types
@@ -28,6 +28,7 @@ class TestPrivateHandlers:
db.add_post = AsyncMock() db.add_post = AsyncMock()
db.add_message = AsyncMock() db.add_message = AsyncMock()
db.update_helper_message = AsyncMock() db.update_helper_message = AsyncMock()
db.update_user_activity = AsyncMock()
return db return db
@pytest.fixture @pytest.fixture
@@ -58,6 +59,7 @@ class TestPrivateHandlers:
message.from_user = from_user message.from_user = from_user
message.text = "test message" message.text = "test message"
message.message_id = 1
# Создаем мок для chat # Создаем мок для chat
chat = Mock() chat = Mock()
@@ -122,6 +124,21 @@ class TestPrivateHandlers:
chat_id=mock_settings.group_for_logs 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 @pytest.mark.asyncio
async def test_handle_start_message( async def test_handle_start_message(
self, mock_db, mock_settings, mock_message, mock_state self, mock_db, mock_settings, mock_message, mock_state
@@ -155,6 +172,151 @@ class TestPrivateHandlers:
mock_db.add_user.assert_called_once() mock_db.add_user.assert_called_once()
mock_db.update_user_date.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: class TestBotSettings:
"""Test class for BotSettings dataclass""" """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 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__": if __name__ == "__main__":
pytest.main([__file__]) pytest.main([__file__])

View File

@@ -274,6 +274,124 @@ class TestVoiceBotService:
assert hasattr(voice_service, "get_remaining_audio_count") assert hasattr(voice_service, "get_remaining_audio_count")
assert hasattr(voice_service, "send_welcome_messages") 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__": if __name__ == "__main__":
pytest.main([__file__]) pytest.main([__file__])