16 Commits

Author SHA1 Message Date
118189da82 Merge branch 'dev-15' of ssh://192.168.1.103:2222/kerrad/telegram-helper-bot into dev-15
All checks were successful
CI pipeline / Test & Code Quality (push) Successful in 35s
2026-03-01 00:15:33 +03:00
d963ea83ad fix deploy 2026-03-01 00:14:40 +03:00
937c54ecfb Merge pull request 'Merge pull request 'Pull Request: dev-15' (#17) from dev-15 into master' (#18) from master into dev-15
All checks were successful
CI pipeline / Test & Code Quality (push) Successful in 34s
Reviewed-on: #18
2026-02-28 21:13:26 +00:00
c3b75a0eb7 fix deploy
All checks were successful
CI pipeline / Test & Code Quality (push) Successful in 43s
2026-03-01 00:03:31 +03:00
b8428a5bac Merge pull request 'Pull Request: dev-15' (#17) from dev-15 into master
All checks were successful
CI pipeline / Test & Code Quality (pull_request) Successful in 34s
Reviewed-on: #17
2026-02-28 21:02:00 +00:00
3d6b4353f9 Refactor imports across multiple files to improve code organization and readability.
All checks were successful
CI pipeline / Test & Code Quality (push) Successful in 34s
2026-02-28 23:24:25 +03:00
d0c8dab24a fix imports
Some checks failed
CI pipeline / Test & Code Quality (push) Failing after 19s
2026-02-28 23:01:21 +03:00
31314c9c9b Добавлены методы для работы с настройками авто-модерации, включая получение и установку значений, а также переключение состояний авто-публикации и авто-отклонения. Обновлены соответствующие репозитории и обработчики для интеграции новых функций в админ-панели.
Some checks are pending
CI pipeline / Test & Code Quality (push) Waiting to run
2026-02-28 22:21:29 +03:00
b3cdadfd8e 11
Some checks failed
CI pipeline / Test & Code Quality (push) Has been cancelled
2026-02-28 21:30:16 +03:00
694cf1c106 Добавлены новые методы для получения статистики постов пользователей, информации о последних постах и количестве банов. Обновлены запросы в репозиториях для сортировки пользователей по дате бана. Исправлены вызовы функций форматирования сообщений для администраторов. Обновлены тесты для проверки новых функциональностей. 2026-02-28 21:30:08 +03:00
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
48 changed files with 6985 additions and 150 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

@@ -16,6 +16,14 @@ on:
description: 'Commit hash to rollback to (optional, uses last successful if empty)' description: 'Commit hash to rollback to (optional, uses last successful if empty)'
required: false required: false
type: string type: string
dry_run:
description: 'Dry run (only for deploy — no SSH, only show planned steps)'
required: true
type: choice
default: no
options:
- no
- yes
jobs: jobs:
deploy: deploy:
@@ -24,6 +32,8 @@ jobs:
if: | if: |
github.event_name == 'push' || github.event_name == 'push' ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'deploy') (github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'deploy')
env:
DRY_RUN: ${{ github.event.inputs.dry_run == 'yes' }}
concurrency: concurrency:
group: production-deploy-telegram-helper-bot group: production-deploy-telegram-helper-bot
cancel-in-progress: false cancel-in-progress: false
@@ -35,8 +45,27 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
ref: main ref: main
- name: Dry run (simulate deploy steps)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'yes'
run: |
echo "🔍 DRY RUN — no SSH, no changes on server"
echo "Would run on server:"
echo " 1. cd /home/prod/bots/telegram-helper-bot"
echo " 2. CURRENT_COMMIT=\$(git rev-parse HEAD); write to .deploy_history_telegram_helper_bot.txt"
echo " 3. git fetch origin main && git reset --hard origin/main"
echo " 4. python3 scripts/apply_migrations.py --db ... (if DB exists)"
echo " 5. docker-compose -f /home/prod/docker-compose.yml config (validate)"
echo " 6. docker-compose stop telegram-bot; build --pull telegram-bot; up -d telegram-bot"
echo " 7. sleep 10; check container bots_telegram_bot"
echo ""
echo "Secrets/vars required: SERVER_HOST, SERVER_USER, SSH_PRIVATE_KEY, SSH_PORT, TELEGRAM_BOT_TOKEN, TELEGRAM_TEST_BOT_TOKEN"
if [ -f docker-compose.yml ]; then
echo "✅ docker-compose.yml present in repo (validation would run on server from /home/prod)"
fi
- name: Deploy to server - name: Deploy to server
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'yes'
uses: appleboy/ssh-action@v1.0.0 uses: appleboy/ssh-action@v1.0.0
with: with:
host: ${{ vars.SERVER_HOST || secrets.SERVER_HOST }} host: ${{ vars.SERVER_HOST || secrets.SERVER_HOST }}
@@ -50,9 +79,10 @@ jobs:
echo "🚀 Starting deployment to production..." echo "🚀 Starting deployment to production..."
cd /home/prod sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true
cd /home/prod/bots/telegram-helper-bot
# Сохраняем информацию о коммите # Сохраняем информацию о коммите (до pull) — из репо telegram-helper-bot
CURRENT_COMMIT=$(git rev-parse HEAD) CURRENT_COMMIT=$(git rev-parse HEAD)
COMMIT_MESSAGE=$(git log -1 --pretty=format:"%s" || echo "Unknown") COMMIT_MESSAGE=$(git log -1 --pretty=format:"%s" || echo "Unknown")
COMMIT_AUTHOR=$(git log -1 --pretty=format:"%an" || echo "Unknown") COMMIT_AUTHOR=$(git log -1 --pretty=format:"%an" || echo "Unknown")
@@ -70,8 +100,6 @@ jobs:
# Обновляем код # Обновляем код
echo "📥 Pulling latest changes from main..." echo "📥 Pulling latest changes from main..."
sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true
cd /home/prod/bots/telegram-helper-bot
git fetch origin main git fetch origin main
git reset --hard origin/main git reset --hard origin/main
sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true
@@ -134,7 +162,7 @@ jobs:
fi fi
- name: Update deploy history - name: Update deploy history
if: always() if: always() && env.DRY_RUN != 'true'
uses: appleboy/ssh-action@v1.0.0 uses: appleboy/ssh-action@v1.0.0
with: with:
host: ${{ vars.SERVER_HOST || secrets.SERVER_HOST }} host: ${{ vars.SERVER_HOST || secrets.SERVER_HOST }}
@@ -155,7 +183,7 @@ jobs:
fi fi
- name: Send deployment notification - name: Send deployment notification
if: always() if: always() && env.DRY_RUN != 'true'
uses: appleboy/telegram-action@v1.0.0 uses: appleboy/telegram-action@v1.0.0
with: with:
to: ${{ secrets.TELEGRAM_CHAT_ID }} to: ${{ secrets.TELEGRAM_CHAT_ID }}
@@ -174,7 +202,7 @@ jobs:
continue-on-error: true continue-on-error: true
- name: Get PR body from merged PR - name: Get PR body from merged PR
if: job.status == 'success' && github.event_name == 'push' if: job.status == 'success' && github.event_name == 'push' && env.DRY_RUN != 'true'
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
@@ -209,7 +237,7 @@ jobs:
continue-on-error: true continue-on-error: true
- name: Send PR body to important logs - name: Send PR body to important logs
if: job.status == 'success' && github.event_name == 'push' && env.PR_BODY != '' if: job.status == 'success' && github.event_name == 'push' && env.DRY_RUN != 'true' && env.PR_BODY != ''
uses: appleboy/telegram-action@v1.0.0 uses: appleboy/telegram-action@v1.0.0
with: with:
to: ${{ secrets.IMPORTANT_LOGS_CHAT }} to: ${{ secrets.IMPORTANT_LOGS_CHAT }}

View File

@@ -279,6 +279,34 @@ class AsyncBotDB:
"""Получает тексты отклоненных постов для обучения RAG.""" """Получает тексты отклоненных постов для обучения RAG."""
return await self.factory.posts.get_declined_posts_texts(limit) return await self.factory.posts.get_declined_posts_texts(limit)
async def get_user_posts_stats(self, user_id: int) -> Tuple[int, int, int]:
"""
Получает статистику постов пользователя.
Returns:
Tuple (approved_count, declined_count, suggest_count)
"""
return await self.factory.posts.get_user_posts_stats(user_id)
async def get_last_post_by_author(self, user_id: int) -> Optional[str]:
"""Получает текст последнего поста пользователя."""
return await self.factory.posts.get_last_post_by_author(user_id)
async def get_user_ban_count(self, user_id: int) -> int:
"""Получает количество банов пользователя за все время."""
return await self.factory.blacklist_history.get_ban_count(user_id)
async def get_last_ban_info(
self, user_id: int
) -> Optional[Tuple[int, str, Optional[int]]]:
"""
Получает информацию о последнем бане пользователя.
Returns:
Tuple (date_ban, reason, date_unban) или None
"""
return await self.factory.blacklist_history.get_last_ban_info(user_id)
# Методы для работы с черным списком # Методы для работы с черным списком
async def set_user_blacklist( async def set_user_blacklist(
self, self,
@@ -361,7 +389,8 @@ class AsyncBotDB:
"""Возвращает список пользователей в черном списке с учетом смещения и ограничения.""" """Возвращает список пользователей в черном списке с учетом смещения и ограничения."""
users = await self.factory.blacklist.get_all_users(offset, limit) users = await self.factory.blacklist.get_all_users(offset, limit)
return [ return [
(user.user_id, user.message_for_user, user.date_to_unban) for user in users (user.user_id, user.message_for_user, user.date_to_unban, user.created_at)
for user in users
] ]
async def get_blacklist_users_by_id(self, user_id: int) -> Optional[tuple]: async def get_blacklist_users_by_id(self, user_id: int) -> Optional[tuple]:
@@ -543,3 +572,32 @@ class AsyncBotDB:
except Exception as e: except Exception as e:
self.logger.error(f"Error executing query: {e}") self.logger.error(f"Error executing query: {e}")
return None return None
# Методы для работы с настройками бота
async def get_auto_moderation_settings(self) -> Dict[str, Any]:
"""Получает все настройки авто-модерации."""
return await self.factory.bot_settings.get_auto_moderation_settings()
async def get_bool_setting(self, key: str, default: bool = False) -> bool:
"""Получает булево значение настройки."""
return await self.factory.bot_settings.get_bool_setting(key, default)
async def get_float_setting(self, key: str, default: float = 0.0) -> float:
"""Получает числовое значение настройки."""
return await self.factory.bot_settings.get_float_setting(key, default)
async def set_setting(self, key: str, value: str) -> None:
"""Устанавливает значение настройки."""
await self.factory.bot_settings.set_setting(key, value)
async def set_float_setting(self, key: str, value: float) -> None:
"""Устанавливает числовое значение настройки."""
await self.factory.bot_settings.set_float_setting(key, value)
async def toggle_auto_publish(self) -> bool:
"""Переключает состояние авто-публикации."""
return await self.factory.bot_settings.toggle_auto_publish()
async def toggle_auto_decline(self) -> bool:
"""Переключает состояние авто-отклонения."""
return await self.factory.bot_settings.toggle_auto_decline()

View File

@@ -10,12 +10,14 @@
- admin_repository: работа с администраторами - admin_repository: работа с администраторами
- audio_repository: работа с аудио - audio_repository: работа с аудио
- migration_repository: работа с миграциями БД - migration_repository: работа с миграциями БД
- bot_settings_repository: работа с настройками бота
""" """
from .admin_repository import AdminRepository from .admin_repository import AdminRepository
from .audio_repository import AudioRepository from .audio_repository import AudioRepository
from .blacklist_history_repository import BlacklistHistoryRepository from .blacklist_history_repository import BlacklistHistoryRepository
from .blacklist_repository import BlacklistRepository from .blacklist_repository import BlacklistRepository
from .bot_settings_repository import BotSettingsRepository
from .message_repository import MessageRepository from .message_repository import MessageRepository
from .migration_repository import MigrationRepository from .migration_repository import MigrationRepository
from .post_repository import PostRepository from .post_repository import PostRepository
@@ -30,4 +32,5 @@ __all__ = [
"AdminRepository", "AdminRepository",
"AudioRepository", "AudioRepository",
"MigrationRepository", "MigrationRepository",
"BotSettingsRepository",
] ]

View File

@@ -1,4 +1,4 @@
from typing import Optional from typing import Optional, Tuple
from database.base import DatabaseConnection from database.base import DatabaseConnection
from database.models import BlacklistHistoryRecord from database.models import BlacklistHistoryRecord
@@ -120,3 +120,55 @@ class BlacklistHistoryRepository(DatabaseConnection):
f"Ошибка обновления даты разбана в истории для user_id={user_id}: {str(e)}" f"Ошибка обновления даты разбана в истории для user_id={user_id}: {str(e)}"
) )
return False return False
async def get_ban_count(self, user_id: int) -> int:
"""
Получает количество банов пользователя за все время.
Args:
user_id: ID пользователя
Returns:
Количество банов
"""
query = "SELECT COUNT(*) FROM blacklist_history WHERE user_id = ?"
rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None
count = row[0] if row else 0
self.logger.info(f"Количество банов для user_id={user_id}: {count}")
return count
async def get_last_ban_info(
self, user_id: int
) -> Optional[Tuple[int, str, Optional[int]]]:
"""
Получает информацию о последнем бане пользователя.
Args:
user_id: ID пользователя
Returns:
Tuple (date_ban, reason, date_unban) или None, если банов не было
"""
query = """
SELECT date_ban, reason, date_unban FROM blacklist_history
WHERE user_id = ?
ORDER BY date_ban DESC
LIMIT 1
"""
rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None
if row:
date_ban = row[0]
reason = row[1]
date_unban = row[2]
self.logger.info(
f"Последний бан для user_id={user_id}: "
f"date_ban={date_ban}, reason={reason}, date_unban={date_unban}"
)
return (date_ban, reason, date_unban)
self.logger.info(f"Банов для user_id={user_id} не найдено")
return None

View File

@@ -87,13 +87,14 @@ class BlacklistRepository(DatabaseConnection):
async def get_all_users( async def get_all_users(
self, offset: int = 0, limit: int = 10 self, offset: int = 0, limit: int = 10
) -> List[BlacklistUser]: ) -> List[BlacklistUser]:
"""Возвращает список пользователей в черном списке.""" """Возвращает список пользователей в черном списке, отсортированных по дате бана (новые первые)."""
query = """ query = """
SELECT user_id, message_for_user, date_to_unban, created_at, ban_author SELECT user_id, message_for_user, date_to_unban, created_at, ban_author
FROM blacklist FROM blacklist
LIMIT ?, ? ORDER BY created_at DESC
LIMIT ? OFFSET ?
""" """
rows = await self._execute_query_with_result(query, (offset, limit)) rows = await self._execute_query_with_result(query, (limit, offset))
users = [] users = []
for row in rows: for row in rows:
@@ -113,10 +114,11 @@ class BlacklistRepository(DatabaseConnection):
return users return users
async def get_all_users_no_limit(self) -> List[BlacklistUser]: async def get_all_users_no_limit(self) -> List[BlacklistUser]:
"""Возвращает список всех пользователей в черном списке без лимитов.""" """Возвращает список всех пользователей в черном списке без лимитов, отсортированных по дате бана (новые первые)."""
query = """ query = """
SELECT user_id, message_for_user, date_to_unban, created_at, ban_author SELECT user_id, message_for_user, date_to_unban, created_at, ban_author
FROM blacklist FROM blacklist
ORDER BY created_at DESC
""" """
rows = await self._execute_query_with_result(query) rows = await self._execute_query_with_result(query)

View File

@@ -0,0 +1,160 @@
"""Репозиторий для работы с настройками бота."""
from typing import Dict, Optional
from database.base import DatabaseConnection
class BotSettingsRepository(DatabaseConnection):
"""Репозиторий для управления настройками бота в таблице bot_settings."""
async def create_table(self) -> None:
"""Создает таблицу bot_settings, если она не существует."""
query = """
CREATE TABLE IF NOT EXISTS bot_settings (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
)
"""
await self._execute_query(query)
self.logger.info("Таблица bot_settings создана или уже существует")
async def get_setting(self, key: str) -> Optional[str]:
"""
Получает значение настройки по ключу.
Args:
key: Ключ настройки
Returns:
Значение настройки или None, если не найдено
"""
query = "SELECT value FROM bot_settings WHERE key = ?"
rows = await self._execute_query_with_result(query, (key,))
if rows and len(rows) > 0:
return rows[0][0]
return None
async def set_setting(self, key: str, value: str) -> None:
"""
Устанавливает значение настройки.
Args:
key: Ключ настройки
value: Значение настройки
"""
query = """
INSERT INTO bot_settings (key, value, updated_at)
VALUES (?, ?, strftime('%s', 'now'))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = strftime('%s', 'now')
"""
await self._execute_query(query, (key, value))
self.logger.debug(f"Настройка {key} установлена: {value}")
async def get_bool_setting(self, key: str, default: bool = False) -> bool:
"""
Получает булево значение настройки.
Args:
key: Ключ настройки
default: Значение по умолчанию
Returns:
True если значение 'true', иначе False
"""
value = await self.get_setting(key)
if value is None:
return default
return value.lower() == "true"
async def set_bool_setting(self, key: str, value: bool) -> None:
"""
Устанавливает булево значение настройки.
Args:
key: Ключ настройки
value: Булево значение
"""
await self.set_setting(key, "true" if value else "false")
async def get_float_setting(self, key: str, default: float = 0.0) -> float:
"""
Получает числовое значение настройки.
Args:
key: Ключ настройки
default: Значение по умолчанию
Returns:
Числовое значение или default
"""
value = await self.get_setting(key)
if value is None:
return default
try:
return float(value)
except ValueError:
self.logger.warning(
f"Невозможно преобразовать значение '{value}' в float для ключа '{key}'"
)
return default
async def set_float_setting(self, key: str, value: float) -> None:
"""
Устанавливает числовое значение настройки.
Args:
key: Ключ настройки
value: Числовое значение
"""
await self.set_setting(key, str(value))
async def get_auto_moderation_settings(self) -> Dict[str, any]:
"""
Получает все настройки авто-модерации.
Returns:
Словарь с настройками авто-модерации
"""
return {
"auto_publish_enabled": await self.get_bool_setting(
"auto_publish_enabled", False
),
"auto_decline_enabled": await self.get_bool_setting(
"auto_decline_enabled", False
),
"auto_publish_threshold": await self.get_float_setting(
"auto_publish_threshold", 0.8
),
"auto_decline_threshold": await self.get_float_setting(
"auto_decline_threshold", 0.4
),
}
async def toggle_auto_publish(self) -> bool:
"""
Переключает состояние авто-публикации.
Returns:
Новое состояние (True/False)
"""
current = await self.get_bool_setting("auto_publish_enabled", False)
new_value = not current
await self.set_bool_setting("auto_publish_enabled", new_value)
return new_value
async def toggle_auto_decline(self) -> bool:
"""
Переключает состояние авто-отклонения.
Returns:
Новое состояние (True/False)
"""
current = await self.get_bool_setting("auto_decline_enabled", False)
new_value = not current
await self.set_bool_setting("auto_decline_enabled", new_value)
return new_value

View File

@@ -545,3 +545,67 @@ class PostRepository(DatabaseConnection):
texts = [row[0] for row in rows if row[0]] texts = [row[0] for row in rows if row[0]]
self.logger.info(f"Получено {len(texts)} отклоненных постов для обучения") self.logger.info(f"Получено {len(texts)} отклоненных постов для обучения")
return texts return texts
async def get_user_posts_stats(self, user_id: int) -> Tuple[int, int, int]:
"""
Получает статистику постов пользователя.
Args:
user_id: ID пользователя
Returns:
Tuple (approved_count, declined_count, suggest_count)
"""
query = """
SELECT
SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved,
SUM(CASE WHEN status = 'declined' THEN 1 ELSE 0 END) as declined,
SUM(CASE WHEN status = 'suggest' THEN 1 ELSE 0 END) as suggest
FROM post_from_telegram_suggest
WHERE author_id = ? AND text != '^'
"""
rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None
if row:
approved = row[0] or 0
declined = row[1] or 0
suggest = row[2] or 0
self.logger.info(
f"Статистика постов для user_id={user_id}: "
f"approved={approved}, declined={declined}, suggest={suggest}"
)
return (approved, declined, suggest)
return (0, 0, 0)
async def get_last_post_by_author(self, user_id: int) -> Optional[str]:
"""
Получает текст последнего поста пользователя.
Args:
user_id: ID пользователя
Returns:
Текст последнего поста или None, если постов нет
"""
query = """
SELECT text FROM post_from_telegram_suggest
WHERE author_id = ? AND text IS NOT NULL AND text != '' AND text != '^'
ORDER BY created_at DESC
LIMIT 1
"""
rows = await self._execute_query_with_result(query, (user_id,))
row = rows[0] if rows else None
if row:
text = row[0]
self.logger.info(
f"Последний пост для user_id={user_id}: '{text[:50]}...'"
if len(text) > 50
else f"Последний пост для user_id={user_id}: '{text}'"
)
return text
self.logger.info(f"Постов для user_id={user_id} не найдено")
return None

View File

@@ -6,6 +6,7 @@ from database.repositories.blacklist_history_repository import (
BlacklistHistoryRepository, BlacklistHistoryRepository,
) )
from database.repositories.blacklist_repository import BlacklistRepository from database.repositories.blacklist_repository import BlacklistRepository
from database.repositories.bot_settings_repository import BotSettingsRepository
from database.repositories.message_repository import MessageRepository from database.repositories.message_repository import MessageRepository
from database.repositories.migration_repository import MigrationRepository from database.repositories.migration_repository import MigrationRepository
from database.repositories.post_repository import PostRepository from database.repositories.post_repository import PostRepository
@@ -25,6 +26,7 @@ class RepositoryFactory:
self._admin_repo: Optional[AdminRepository] = None self._admin_repo: Optional[AdminRepository] = None
self._audio_repo: Optional[AudioRepository] = None self._audio_repo: Optional[AudioRepository] = None
self._migration_repo: Optional[MigrationRepository] = None self._migration_repo: Optional[MigrationRepository] = None
self._bot_settings_repo: Optional[BotSettingsRepository] = None
@property @property
def users(self) -> UserRepository: def users(self) -> UserRepository:
@@ -82,6 +84,13 @@ class RepositoryFactory:
self._migration_repo = MigrationRepository(self.db_path) self._migration_repo = MigrationRepository(self.db_path)
return self._migration_repo return self._migration_repo
@property
def bot_settings(self) -> BotSettingsRepository:
"""Возвращает репозиторий настроек бота."""
if self._bot_settings_repo is None:
self._bot_settings_repo = BotSettingsRepository(self.db_path)
return self._bot_settings_repo
async def create_all_tables(self): async def create_all_tables(self):
"""Создает все таблицы в базе данных.""" """Создает все таблицы в базе данных."""
await self.migrations.create_table() # Сначала создаем таблицу миграций await self.migrations.create_table() # Сначала создаем таблицу миграций
@@ -92,6 +101,7 @@ class RepositoryFactory:
await self.posts.create_tables() await self.posts.create_tables()
await self.admins.create_tables() await self.admins.create_tables()
await self.audio.create_tables() await self.audio.create_tables()
await self.bot_settings.create_table()
async def check_database_integrity(self): async def check_database_integrity(self):
"""Проверяет целостность базы данных.""" """Проверяет целостность базы данных."""

View File

@@ -21,6 +21,7 @@ from helper_bot.keyboards.keyboards import (
create_keyboard_for_ban_days, create_keyboard_for_ban_days,
create_keyboard_for_ban_reason, create_keyboard_for_ban_reason,
create_keyboard_with_pagination, create_keyboard_with_pagination,
get_auto_moderation_keyboard,
get_reply_keyboard_admin, get_reply_keyboard_admin,
) )
from helper_bot.utils.base_dependency_factory import get_global_instance from helper_bot.utils.base_dependency_factory import get_global_instance
@@ -138,7 +139,9 @@ async def get_banned_users(
keyboard = create_keyboard_with_pagination( keyboard = create_keyboard_with_pagination(
1, len(buttons_list), buttons_list, "unlock" 1, len(buttons_list), buttons_list, "unlock"
) )
await message.answer(text=message_text, reply_markup=keyboard) await message.answer(
text=message_text, reply_markup=keyboard, parse_mode="HTML"
)
else: else:
await message.answer( await message.answer(
text="В списке заблокированных пользователей никого нет" text="В списке заблокированных пользователей никого нет"
@@ -216,9 +219,11 @@ async def get_ml_stats(message: types.Message, state: FSMContext, **kwargs):
# Fallback на синхронные данные (если API недоступен) # Fallback на синхронные данные (если API недоступен)
lines.append(f" • API URL: {rag.get('api_url', 'N/A')}") lines.append(f" • API URL: {rag.get('api_url', 'N/A')}")
if "enabled" in rag: if "enabled" in rag:
lines.append( if rag.get("enabled"):
f" • Статус: {'✅ Включен' if rag.get('enabled') else '❌ Отключен'}" lines.append(f" • Статус: ⚠️ Включен, но API не отвечает")
) lines.append(f" • Проверьте доступность сервиса и API ключ")
else:
lines.append(f" • Статус: ❌ Отключен")
lines.append("") lines.append("")
@@ -244,6 +249,266 @@ async def get_ml_stats(message: types.Message, state: FSMContext, **kwargs):
await message.answer(f"❌ Ошибка получения статистики: {str(e)}") await message.answer(f"❌ Ошибка получения статистики: {str(e)}")
# ============================================================================
# ХЕНДЛЕРЫ АВТО-МОДЕРАЦИИ
# ============================================================================
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("ADMIN"),
F.text == "⚙️ Авто-модерация",
)
@track_time("auto_moderation_menu", "admin_handlers")
@track_errors("admin_handlers", "auto_moderation_menu")
async def auto_moderation_menu(
message: types.Message, state: FSMContext, bot_db: MagicData("bot_db")
):
"""Меню управления авто-модерацией"""
try:
logger.info(
f"Открытие меню авто-модерации пользователем: {message.from_user.full_name}"
)
settings = await bot_db.get_auto_moderation_settings()
text = _format_auto_moderation_status(settings)
keyboard = get_auto_moderation_keyboard(settings)
await message.answer(text, reply_markup=keyboard, parse_mode="HTML")
except Exception as e:
logger.error(f"Ошибка открытия меню авто-модерации: {e}")
await message.answer(f"❌ Ошибка: {str(e)}")
def _format_auto_moderation_status(settings: dict) -> str:
"""Форматирует текст статуса авто-модерации."""
auto_publish = settings.get("auto_publish_enabled", False)
auto_decline = settings.get("auto_decline_enabled", False)
publish_threshold = settings.get("auto_publish_threshold", 0.8)
decline_threshold = settings.get("auto_decline_threshold", 0.4)
publish_status = "✅ Включена" if auto_publish else "❌ Выключена"
decline_status = "✅ Включено" if auto_decline else "❌ Выключено"
return (
"⚙️ <b>Авто-модерация постов</b>\n\n"
f"🤖 <b>Авто-публикация:</b> {publish_status}\n"
f" Порог: RAG score ≥ <b>{publish_threshold}</b>\n\n"
f"🚫 <b>Авто-отклонение:</b> {decline_status}\n"
f" Порог: RAG score ≤ <b>{decline_threshold}</b>"
)
@admin_router.callback_query(F.data == "auto_mod_toggle_publish")
@track_time("toggle_auto_publish", "admin_handlers")
@track_errors("admin_handlers", "toggle_auto_publish")
async def toggle_auto_publish(call: types.CallbackQuery, bot_db: MagicData("bot_db")):
"""Переключение авто-публикации"""
try:
new_state = await bot_db.toggle_auto_publish()
logger.info(
f"Авто-публикация {'включена' if new_state else 'выключена'} "
f"пользователем {call.from_user.full_name}"
)
settings = await bot_db.get_auto_moderation_settings()
text = _format_auto_moderation_status(settings)
keyboard = get_auto_moderation_keyboard(settings)
await call.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
await call.answer(
f"Авто-публикация {'включена ✅' if new_state else 'выключена ❌'}"
)
except Exception as e:
logger.error(f"Ошибка переключения авто-публикации: {e}")
await call.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@admin_router.callback_query(F.data == "auto_mod_toggle_decline")
@track_time("toggle_auto_decline", "admin_handlers")
@track_errors("admin_handlers", "toggle_auto_decline")
async def toggle_auto_decline(call: types.CallbackQuery, bot_db: MagicData("bot_db")):
"""Переключение авто-отклонения"""
try:
new_state = await bot_db.toggle_auto_decline()
logger.info(
f"Авто-отклонение {'включено' if new_state else 'выключено'} "
f"пользователем {call.from_user.full_name}"
)
settings = await bot_db.get_auto_moderation_settings()
text = _format_auto_moderation_status(settings)
keyboard = get_auto_moderation_keyboard(settings)
await call.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
await call.answer(
f"Авто-отклонение {'включено ✅' if new_state else 'выключено ❌'}"
)
except Exception as e:
logger.error(f"Ошибка переключения авто-отклонения: {e}")
await call.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@admin_router.callback_query(F.data == "auto_mod_refresh")
@track_time("refresh_auto_moderation", "admin_handlers")
@track_errors("admin_handlers", "refresh_auto_moderation")
async def refresh_auto_moderation(
call: types.CallbackQuery, bot_db: MagicData("bot_db")
):
"""Обновление статуса авто-модерации"""
try:
settings = await bot_db.get_auto_moderation_settings()
text = _format_auto_moderation_status(settings)
keyboard = get_auto_moderation_keyboard(settings)
try:
await call.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
except Exception as edit_error:
if "message is not modified" in str(edit_error):
pass # Сообщение не изменилось - это нормально
else:
raise
await call.answer("🔄 Обновлено")
except Exception as e:
logger.error(f"Ошибка обновления статуса авто-модерации: {e}")
await call.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@admin_router.callback_query(F.data == "auto_mod_threshold_publish")
@track_time("change_publish_threshold", "admin_handlers")
@track_errors("admin_handlers", "change_publish_threshold")
async def change_publish_threshold(
call: types.CallbackQuery, state: FSMContext, bot_db: MagicData("bot_db")
):
"""Начало изменения порога авто-публикации"""
try:
await state.set_state("AWAIT_PUBLISH_THRESHOLD")
await call.message.answer(
"📈 <b>Изменение порога авто-публикации</b>\n\n"
"Введите новое значение порога (от 0.0 до 1.0).\n"
"Посты с RAG score ≥ этого значения будут автоматически публиковаться.\n\n"
"Текущее рекомендуемое значение: <b>0.8</b>",
parse_mode="HTML",
)
await call.answer()
except Exception as e:
logger.error(f"Ошибка начала изменения порога публикации: {e}")
await call.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@admin_router.callback_query(F.data == "auto_mod_threshold_decline")
@track_time("change_decline_threshold", "admin_handlers")
@track_errors("admin_handlers", "change_decline_threshold")
async def change_decline_threshold(
call: types.CallbackQuery, state: FSMContext, bot_db: MagicData("bot_db")
):
"""Начало изменения порога авто-отклонения"""
try:
await state.set_state("AWAIT_DECLINE_THRESHOLD")
await call.message.answer(
"📉 <b>Изменение порога авто-отклонения</b>\n\n"
"Введите новое значение порога (от 0.0 до 1.0).\n"
"Посты с RAG score ≤ этого значения будут автоматически отклоняться.\n\n"
"Текущее рекомендуемое значение: <b>0.4</b>",
parse_mode="HTML",
)
await call.answer()
except Exception as e:
logger.error(f"Ошибка начала изменения порога отклонения: {e}")
await call.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("AWAIT_PUBLISH_THRESHOLD"),
)
@track_time("process_publish_threshold", "admin_handlers")
@track_errors("admin_handlers", "process_publish_threshold")
async def process_publish_threshold(
message: types.Message, state: FSMContext, bot_db: MagicData("bot_db")
):
"""Обработка нового порога авто-публикации"""
try:
value = float(message.text.strip().replace(",", "."))
if not 0.0 <= value <= 1.0:
raise ValueError("Значение должно быть от 0.0 до 1.0")
await bot_db.set_float_setting("auto_publish_threshold", value)
logger.info(
f"Порог авто-публикации изменен на {value} "
f"пользователем {message.from_user.full_name}"
)
await state.set_state("ADMIN")
await message.answer(
f"✅ Порог авто-публикации изменен на <b>{value}</b>",
parse_mode="HTML",
)
settings = await bot_db.get_auto_moderation_settings()
text = _format_auto_moderation_status(settings)
keyboard = get_auto_moderation_keyboard(settings)
await message.answer(text, reply_markup=keyboard, parse_mode="HTML")
except ValueError as e:
await message.answer(
f"❌ Неверное значение: {e}\n" "Введите число от 0.0 до 1.0 (например: 0.8)"
)
except Exception as e:
logger.error(f"Ошибка изменения порога публикации: {e}")
await state.set_state("ADMIN")
await message.answer(f"❌ Ошибка: {str(e)}")
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("AWAIT_DECLINE_THRESHOLD"),
)
@track_time("process_decline_threshold", "admin_handlers")
@track_errors("admin_handlers", "process_decline_threshold")
async def process_decline_threshold(
message: types.Message, state: FSMContext, bot_db: MagicData("bot_db")
):
"""Обработка нового порога авто-отклонения"""
try:
value = float(message.text.strip().replace(",", "."))
if not 0.0 <= value <= 1.0:
raise ValueError("Значение должно быть от 0.0 до 1.0")
await bot_db.set_float_setting("auto_decline_threshold", value)
logger.info(
f"Порог авто-отклонения изменен на {value} "
f"пользователем {message.from_user.full_name}"
)
await state.set_state("ADMIN")
await message.answer(
f"✅ Порог авто-отклонения изменен на <b>{value}</b>",
parse_mode="HTML",
)
settings = await bot_db.get_auto_moderation_settings()
text = _format_auto_moderation_status(settings)
keyboard = get_auto_moderation_keyboard(settings)
await message.answer(text, reply_markup=keyboard, parse_mode="HTML")
except ValueError as e:
await message.answer(
f"❌ Неверное значение: {e}\n" "Введите число от 0.0 до 1.0 (например: 0.4)"
)
except Exception as e:
logger.error(f"Ошибка изменения порога отклонения: {e}")
await state.set_state("ADMIN")
await message.answer(f"❌ Ошибка: {str(e)}")
# ============================================================================ # ============================================================================
# ХЕНДЛЕРЫ ПРОЦЕССА БАНА # ХЕНДЛЕРЫ ПРОЦЕССА БАНА
# ============================================================================ # ============================================================================

View File

@@ -255,6 +255,8 @@ async def change_page(call: CallbackQuery, bot_db: MagicData("bot_db"), **kwargs
logger.info(f"Переход на страницу {page_number}") logger.info(f"Переход на страницу {page_number}")
items_per_page = 9
if call.message.text == "Список пользователей которые последними обращались к боту": if call.message.text == "Список пользователей которые последними обращались к боту":
list_users = await bot_db.get_last_users(30) list_users = await bot_db.get_last_users(30)
keyboard = create_keyboard_with_pagination( keyboard = create_keyboard_with_pagination(
@@ -266,11 +268,13 @@ async def change_page(call: CallbackQuery, bot_db: MagicData("bot_db"), **kwargs
reply_markup=keyboard, reply_markup=keyboard,
) )
else: else:
message_user = await get_banned_users_list(int(page_number) * 7 - 7, bot_db) offset = (page_number - 1) * items_per_page
message_user = await get_banned_users_list(offset, bot_db)
await call.bot.edit_message_text( await call.bot.edit_message_text(
chat_id=call.message.chat.id, chat_id=call.message.chat.id,
message_id=call.message.message_id, message_id=call.message.message_id,
text=message_user, text=message_user,
parse_mode="HTML",
) )
buttons = await get_banned_users_buttons(bot_db) buttons = await get_banned_users_buttons(bot_db)

View File

@@ -8,7 +8,7 @@ from aiogram.types import CallbackQuery
from helper_bot.keyboards.keyboards import create_keyboard_for_ban_reason from helper_bot.keyboards.keyboards import create_keyboard_for_ban_reason
from helper_bot.utils.helper_func import ( from helper_bot.utils.helper_func import (
delete_user_blacklist, delete_user_blacklist,
get_text_message, get_publish_text,
send_audio_message, send_audio_message,
send_media_group_to_channel, send_media_group_to_channel,
send_photo_message, send_photo_message,
@@ -137,7 +137,7 @@ class PostPublishService:
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных") raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
# Формируем финальный текст с учетом is_anonymous # Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message( formatted_text = get_publish_text(
raw_text, user.first_name, user.username, is_anonymous raw_text, user.first_name, user.username, is_anonymous
) )
@@ -188,7 +188,7 @@ class PostPublishService:
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных") raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
# Формируем финальный текст с учетом is_anonymous # Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message( formatted_text = get_publish_text(
raw_text, user.first_name, user.username, is_anonymous raw_text, user.first_name, user.username, is_anonymous
) )
@@ -247,7 +247,7 @@ class PostPublishService:
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных") raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
# Формируем финальный текст с учетом is_anonymous # Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message( formatted_text = get_publish_text(
raw_text, user.first_name, user.username, is_anonymous raw_text, user.first_name, user.username, is_anonymous
) )
@@ -340,7 +340,7 @@ class PostPublishService:
raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных") raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных")
# Формируем финальный текст с учетом is_anonymous # Формируем финальный текст с учетом is_anonymous
formatted_text = get_text_message( formatted_text = get_publish_text(
raw_text, user.first_name, user.username, is_anonymous raw_text, user.first_name, user.username, is_anonymous
) )
@@ -452,7 +452,7 @@ class PostPublishService:
f"Пользователь {author_id} не найден в базе данных" f"Пользователь {author_id} не найден в базе данных"
) )
formatted_text = get_text_message( formatted_text = get_publish_text(
raw_text, user.first_name, user.username, is_anonymous raw_text, user.first_name, user.username, is_anonymous
) )
@@ -838,7 +838,7 @@ class BanService:
await self.db.set_user_blacklist( await self.db.set_user_blacklist(
user_id=author_id, user_id=author_id,
user_name=None, user_name=None,
message_for_user="Спам", message_for_user="Последний пост",
date_to_unban=date_to_unban, date_to_unban=date_to_unban,
ban_author=ban_author_id, ban_author=ban_author_id,
) )

View File

@@ -31,7 +31,13 @@ from helper_bot.utils.metrics import db_query_time, track_errors, track_time
# Local imports - modular components # Local imports - modular components
from .constants import BUTTON_TEXTS, ERROR_MESSAGES, FSM_STATES from .constants import BUTTON_TEXTS, ERROR_MESSAGES, FSM_STATES
from .decorators import error_handler from .decorators import error_handler
from .services import BotSettings, PostService, StickerService, UserService from .services import (
AutoModerationService,
BotSettings,
PostService,
StickerService,
UserService,
)
# Expose sleep for tests (tests patch helper_bot.handlers.private.private_handlers.sleep) # Expose sleep for tests (tests patch helper_bot.handlers.private.private_handlers.sleep)
sleep = asyncio.sleep sleep = asyncio.sleep
@@ -50,7 +56,12 @@ class PrivateHandlers:
self.db = db self.db = db
self.settings = settings self.settings = settings
self.user_service = UserService(db, settings) self.user_service = UserService(db, settings)
self.post_service = PostService(db, settings, s3_storage, scoring_manager) self.auto_moderation_service = AutoModerationService(
db, settings, scoring_manager, s3_storage
)
self.post_service = PostService(
db, settings, s3_storage, scoring_manager, self.auto_moderation_service
)
self.sticker_service = StickerService(settings) self.sticker_service = StickerService(settings)
self.router = Router() self.router = Router()
@@ -291,12 +302,33 @@ class PrivateHandlers:
"""Handle messages in admin chat states""" """Handle messages in admin chat states"""
# User service operations with metrics # User service operations with metrics
await self.user_service.update_user_activity(message.from_user.id) await self.user_service.update_user_activity(message.from_user.id)
await message.forward(chat_id=self.settings.group_for_message)
# Формируем обогащённое сообщение для админов
user_id = message.from_user.id
full_name = message.from_user.full_name
username = message.from_user.username
message_text = message.text or ""
enriched_message = await self.user_service.format_user_message_for_admins(
user_id=user_id,
full_name=full_name,
username=username,
message_text=message_text,
)
# Отправляем обогащённое сообщение вместо forward
sent_message = await message.bot.send_message(
chat_id=self.settings.group_for_message,
text=enriched_message,
parse_mode="HTML",
)
current_date = datetime.now() current_date = datetime.now()
date = int(current_date.timestamp()) date = int(current_date.timestamp())
# Сохраняем message_id из результата send_message
await self.db.add_message( await self.db.add_message(
message.text, message.from_user.id, message.message_id + 1, date message.text, message.from_user.id, sent_message.message_id, date
) )
question = messages.get_message(get_first_name(message), "QUESTION") question = messages.get_message(get_first_name(message), "QUESTION")

View File

@@ -22,6 +22,7 @@ from helper_bot.utils.helper_func import (
check_username_and_full_name, check_username_and_full_name,
determine_anonymity, determine_anonymity,
get_first_name, get_first_name,
get_publish_text,
get_text_message, get_text_message,
prepare_media_group_from_middlewares, prepare_media_group_from_middlewares,
send_audio_message, send_audio_message,
@@ -156,6 +157,96 @@ class UserService:
username = message.from_user.username or "Без никнейма" username = message.from_user.username or "Без никнейма"
return html.escape(full_name), html.escape(username) return html.escape(full_name), html.escape(username)
async def format_user_message_for_admins(
self, user_id: int, full_name: str, username: str, message_text: str
) -> str:
"""
Форматирует сообщение пользователя для отправки админам с обогащёнными данными.
Args:
user_id: ID пользователя
full_name: Полное имя пользователя
username: Username пользователя (может быть None)
message_text: Текст сообщения пользователя
Returns:
Отформатированное сообщение для админов
"""
safe_full_name = (
html.escape(full_name) if full_name else "Неизвестный пользователь"
)
safe_username = html.escape(username) if username else None
safe_message_text = html.escape(message_text) if message_text else ""
# Формируем строку с информацией об авторе
if safe_username:
author_info = f"{safe_full_name} (@{safe_username})"
else:
author_info = f"{safe_full_name} (Ник не указан)"
# Получаем статистику постов
approved, declined, suggest = await self.db.get_user_posts_stats(user_id)
total_posts = approved + declined + suggest
# Получаем последний пост
last_post = await self.db.get_last_post_by_author(user_id)
if last_post:
if len(last_post) > 80:
last_post_display = f'"{html.escape(last_post[:80])}..."'
else:
last_post_display = f'"{html.escape(last_post)}"'
else:
last_post_display = "Нет постов"
# Получаем дату регистрации
user_info = await self.db.get_user_by_id(user_id)
if user_info and user_info.date_added:
date_added = datetime.fromtimestamp(user_info.date_added).strftime(
"%d.%m.%Y"
)
else:
date_added = "Неизвестно"
# Получаем информацию о банах
ban_count = await self.db.get_user_ban_count(user_id)
ban_section = ""
if ban_count > 0:
last_ban = await self.db.get_last_ban_info(user_id)
if last_ban:
date_ban, reason, date_unban = last_ban
ban_date_str = datetime.fromtimestamp(date_ban).strftime("%d.%m.%Y")
reason_display = html.escape(reason) if reason else "Не указана"
if date_unban:
unban_date_str = datetime.fromtimestamp(date_unban).strftime(
"%d.%m.%Y %H:%M"
)
last_ban_info = (
f" Последний: {ban_date_str}, причина «{reason_display}», "
f"истёк {unban_date_str}"
)
else:
last_ban_info = (
f" Последний: {ban_date_str}, причина «{reason_display}», "
f"активен"
)
ban_section = f"\n\n🚫 Банов: {ban_count}\n{last_ban_info}"
# Формируем итоговое сообщение
formatted_message = (
f"👤 От: {author_info} | ID: {user_id}\n\n"
f"📊 Постов в базе: {total_posts}\n"
f"📝 Последний пост: {last_post_display}\n"
f"📅 В боте с: {date_added}"
f"{ban_section}\n\n"
f"---\n"
f"<b>Сообщение пользователя:</b>\n\n"
f"<b>{safe_message_text}</b>"
)
return formatted_message
class PostService: class PostService:
"""Service for post-related operations""" """Service for post-related operations"""
@@ -166,11 +257,13 @@ class PostService:
settings: BotSettings, settings: BotSettings,
s3_storage=None, s3_storage=None,
scoring_manager=None, scoring_manager=None,
auto_moderation_service: "AutoModerationService" = None,
) -> None: ) -> None:
self.db = db self.db = db
self.settings = settings self.settings = settings
self.s3_storage = s3_storage self.s3_storage = s3_storage
self.scoring_manager = scoring_manager self.scoring_manager = scoring_manager
self.auto_moderation = auto_moderation_service
async def _save_media_background( async def _save_media_background(
self, sent_message: types.Message, bot_db: Any, s3_storage self, sent_message: types.Message, bot_db: Any, s3_storage
@@ -236,6 +329,16 @@ class PostService:
f"PostService: Ошибка сохранения скоров для {message_id}: {e}" f"PostService: Ошибка сохранения скоров для {message_id}: {e}"
) )
async def _add_submitted_post_background(
self, text: str, post_id: int, rag_score: float = None
) -> None:
"""Индексирует пост в RAG submitted collection в фоне."""
try:
if self.scoring_manager:
await self.scoring_manager.add_submitted_post(text, post_id, rag_score)
except Exception as e:
logger.warning(f"PostService: Ошибка добавления поста в submitted: {e}")
async def _get_scores_with_error_handling(self, text: str) -> tuple: async def _get_scores_with_error_handling(self, text: str) -> tuple:
""" """
Получает скоры для текста поста с обработкой ошибок. Получает скоры для текста поста с обработкой ошибок.
@@ -281,6 +384,206 @@ class PostService:
error_message = "Не удалось рассчитать скоры" error_message = "Не удалось рассчитать скоры"
return None, None, None, None, None, error_message return None, None, None, None, None, error_message
@track_time("_handle_auto_action", "post_service")
@track_errors("post_service", "_handle_auto_action")
async def _handle_auto_action(
self,
auto_action: str,
message: types.Message,
content_type: str,
original_raw_text: str,
first_name: str,
is_anonymous: bool,
rag_score: float,
ml_scores_json: str = None,
album: Union[list, None] = None,
) -> None:
"""
Обрабатывает автоматическое действие (публикация или отклонение).
Args:
auto_action: 'publish' или 'decline'
message: Сообщение пользователя
content_type: Тип контента
original_raw_text: Оригинальный текст поста
first_name: Имя автора
is_anonymous: Флаг анонимности
rag_score: Скор RAG модели
ml_scores_json: JSON со скорами для БД
album: Медиагруппа (если есть)
"""
author_id = message.from_user.id
author_name = message.from_user.full_name or first_name
author_username = message.from_user.username or ""
try:
if auto_action == "publish":
await self._auto_publish(
message=message,
content_type=content_type,
original_raw_text=original_raw_text,
first_name=first_name,
is_anonymous=is_anonymous,
rag_score=rag_score,
ml_scores_json=ml_scores_json,
album=album,
)
else: # decline
await self._auto_decline(message=message, author_id=author_id)
# Логируем действие
if self.auto_moderation:
await self.auto_moderation.log_auto_action(
bot=message.bot,
action=auto_action,
author_id=author_id,
author_name=author_name,
author_username=author_username,
rag_score=rag_score,
post_text=original_raw_text,
)
except Exception as e:
logger.error(
f"PostService: Ошибка авто-{auto_action} для message_id={message.message_id}: {e}"
)
raise
@track_time("_auto_publish", "post_service")
@track_errors("post_service", "_auto_publish")
async def _auto_publish(
self,
message: types.Message,
content_type: str,
original_raw_text: str,
first_name: str,
is_anonymous: bool,
rag_score: float,
ml_scores_json: str = None,
album: Union[list, None] = None,
) -> None:
"""Автоматически публикует пост в канал."""
author_id = message.from_user.id
username = message.from_user.username
# Формируем текст для публикации (без скоров и разметки)
formatted_text = get_publish_text(
original_raw_text, first_name, username, is_anonymous
)
sent_message = None
# Публикуем в зависимости от типа контента
if content_type == "text":
sent_message = await message.bot.send_message(
chat_id=self.settings.main_public,
text=formatted_text,
)
elif content_type == "photo":
sent_message = await message.bot.send_photo(
chat_id=self.settings.main_public,
photo=message.photo[-1].file_id,
caption=formatted_text,
)
elif content_type == "video":
sent_message = await message.bot.send_video(
chat_id=self.settings.main_public,
video=message.video.file_id,
caption=formatted_text,
)
elif content_type == "audio":
sent_message = await message.bot.send_audio(
chat_id=self.settings.main_public,
audio=message.audio.file_id,
caption=formatted_text,
)
elif content_type == "voice":
sent_message = await message.bot.send_voice(
chat_id=self.settings.main_public,
voice=message.voice.file_id,
)
elif content_type == "video_note":
sent_message = await message.bot.send_video_note(
chat_id=self.settings.main_public,
video_note=message.video_note.file_id,
)
elif content_type == "media_group" and album:
# TODO: Реализовать авто-публикацию медиагрупп при необходимости
logger.warning(
"PostService: Авто-публикация медиагрупп пока не поддерживается"
)
return
if sent_message:
# Сохраняем пост в БД со статусом approved
post = TelegramPost(
message_id=sent_message.message_id,
text=original_raw_text,
author_id=author_id,
created_at=int(datetime.now().timestamp()),
is_anonymous=is_anonymous,
status="approved",
)
await self.db.add_post(post)
# Сохраняем скоры если есть
if ml_scores_json:
asyncio.create_task(
self._save_scores_background(
sent_message.message_id, ml_scores_json
)
)
# Индексируем пост в RAG
if self.scoring_manager and original_raw_text and original_raw_text.strip():
asyncio.create_task(
self._add_submitted_post_background(
original_raw_text, sent_message.message_id, rag_score
)
)
# Уведомляем автора
try:
await message.bot.send_message(
chat_id=author_id,
text="Твой пост был выложен🥰",
)
except Exception as e:
logger.warning(
f"PostService: Не удалось уведомить автора {author_id}: {e}"
)
logger.info(
f"PostService: Пост авто-опубликован в {self.settings.main_public}, "
f"author_id={author_id}, rag_score={rag_score:.2f}"
)
@track_time("_auto_decline", "post_service")
@track_errors("post_service", "_auto_decline")
async def _auto_decline(self, message: types.Message, author_id: int) -> None:
"""Автоматически отклоняет пост."""
# Обучаем RAG на отклоненном посте
if self.scoring_manager:
original_text = message.text or message.caption or ""
if original_text and original_text.strip():
try:
await self.scoring_manager.on_post_declined(original_text)
except Exception as e:
logger.warning(
f"PostService: Ошибка обучения RAG на отклоненном посте: {e}"
)
# Уведомляем автора
try:
await message.bot.send_message(
chat_id=author_id,
text="Твой пост был отклонен😔",
)
except Exception as e:
logger.warning(f"PostService: Не удалось уведомить автора {author_id}: {e}")
logger.info(f"PostService: Пост авто-отклонен, author_id={author_id}")
@track_time("_process_post_background", "post_service") @track_time("_process_post_background", "post_service")
@track_errors("post_service", "_process_post_background") @track_errors("post_service", "_process_post_background")
async def _process_post_background( async def _process_post_background(
@@ -321,6 +624,37 @@ class PostService:
error_message, error_message,
) = await self._get_scores_with_error_handling(original_raw_text) ) = await self._get_scores_with_error_handling(original_raw_text)
# Проверяем похожие посты (до добавления текущего в submitted)
similar_warning = ""
if self.scoring_manager and original_raw_text and original_raw_text.strip():
try:
similar_result = await self.scoring_manager.find_similar_posts(
original_raw_text, threshold=0.9, hours=24
)
if similar_result and similar_result.similar_count > 0:
# Формируем предупреждение с текстом похожего поста
similar_text = ""
if similar_result.similar_posts:
first_similar = similar_result.similar_posts[0]
if first_similar.text:
truncated_text = first_similar.text[:150]
if len(first_similar.text) > 150:
truncated_text += "..."
similar_text = f'\n<b>Текст поста:</b>\n"{html.escape(truncated_text)}"'
similar_warning = (
f"\n\n⚠️ <b>Похожий пост за последние 24ч</b> "
f"(совпадение {similar_result.max_similarity:.0%})"
f"{similar_text}"
)
logger.info(
f"PostService: Найден похожий пост для message_id={message.message_id}, "
f"similar_count={similar_result.similar_count}, "
f"max_similarity={similar_result.max_similarity:.2%}"
)
except Exception as e:
logger.warning(f"PostService: Ошибка поиска похожих постов: {e}")
# Формируем текст для поста (с сообщением об ошибке если есть) # Формируем текст для поста (с сообщением об ошибке если есть)
text_for_post = original_raw_text text_for_post = original_raw_text
if error_message: if error_message:
@@ -334,19 +668,55 @@ 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,
message.from_user.username, message.from_user.username,
deepseek_score=deepseek_score, deepseek_score=deepseek_score,
rag_score=rag_score, rag_score=rag_score,
rag_confidence=rag_confidence, user_id=message.from_user.id,
rag_score_pos_only=rag_score_pos_only,
) )
# Добавляем предупреждение о похожем посте
if similar_warning:
post_text += similar_warning
# Определяем анонимность по исходному тексту (без сообщения об ошибке) # Определяем анонимность по исходному тексту (без сообщения об ошибке)
is_anonymous = determine_anonymity(original_raw_text) is_anonymous = determine_anonymity(original_raw_text)
# Проверяем авто-модерацию
logger.debug(
f"PostService: Проверка авто-модерации - "
f"auto_moderation={self.auto_moderation is not None}, "
f"rag_score={rag_score}"
)
if self.auto_moderation and rag_score is not None:
auto_action = await self.auto_moderation.check_auto_action(rag_score)
logger.info(
f"PostService: Авто-модерация решение - "
f"rag_score={rag_score:.2f}, action={auto_action}"
)
if auto_action in ("publish", "decline"):
await self._handle_auto_action(
auto_action=auto_action,
message=message,
content_type=content_type,
original_raw_text=original_raw_text,
first_name=first_name,
is_anonymous=is_anonymous,
rag_score=rag_score,
ml_scores_json=ml_scores_json,
album=album,
)
return
markup = get_reply_keyboard_for_post() markup = get_reply_keyboard_for_post()
sent_message = None sent_message = None
@@ -394,8 +764,11 @@ class PostService:
markup, markup,
) )
elif content_type == "media_group": elif content_type == "media_group":
# Добавляем предупреждение о похожем посте в caption медиагруппы
if similar_warning:
post_text += similar_warning
# Для медиагруппы используем специальную обработку # Для медиагруппы используем специальную обработку
# Передаем ml_scores_json для сохранения в БД # Передаем ml_scores_json и rag_score для сохранения в БД
await self._process_media_group_background( await self._process_media_group_background(
message, message,
album, album,
@@ -404,6 +777,7 @@ class PostService:
is_anonymous, is_anonymous,
original_raw_text, original_raw_text,
ml_scores_json, ml_scores_json,
rag_score,
) )
return return
else: else:
@@ -441,6 +815,14 @@ class PostService:
) )
) )
# Индексируем пост в RAG submitted collection (после успешной отправки)
if self.scoring_manager and original_raw_text and original_raw_text.strip():
asyncio.create_task(
self._add_submitted_post_background(
original_raw_text, sent_message.message_id, rag_score
)
)
except Exception as e: except Exception as e:
logger.error( logger.error(
f"PostService: Критическая ошибка в _process_post_background для {content_type}: {e}" f"PostService: Критическая ошибка в _process_post_background для {content_type}: {e}"
@@ -455,6 +837,7 @@ class PostService:
is_anonymous: bool, is_anonymous: bool,
original_raw_text: str, original_raw_text: str,
ml_scores_json: str = None, ml_scores_json: str = None,
rag_score: float = None,
) -> None: ) -> None:
"""Обрабатывает медиагруппу в фоне""" """Обрабатывает медиагруппу в фоне"""
try: try:
@@ -488,6 +871,14 @@ class PostService:
self._save_scores_background(main_post_id, ml_scores_json) self._save_scores_background(main_post_id, ml_scores_json)
) )
# Индексируем пост в RAG submitted collection
if self.scoring_manager and original_raw_text and original_raw_text.strip():
asyncio.create_task(
self._add_submitted_post_background(
original_raw_text, main_post_id, rag_score
)
)
for msg_id in media_group_message_ids: for msg_id in media_group_message_ids:
await self.db.add_message_link(main_post_id, msg_id) await self.db.add_message_link(main_post_id, msg_id)
@@ -530,6 +921,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(),
@@ -537,8 +936,7 @@ class PostService:
message.from_user.username, message.from_user.username,
deepseek_score=deepseek_score, deepseek_score=deepseek_score,
rag_score=rag_score, rag_score=rag_score,
rag_confidence=rag_confidence, user_id=message.from_user.id,
rag_score_pos_only=rag_score_pos_only,
) )
markup = get_reply_keyboard_for_post() markup = get_reply_keyboard_for_post()
@@ -580,6 +978,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(
@@ -588,8 +994,7 @@ class PostService:
message.from_user.username, message.from_user.username,
deepseek_score=deepseek_score, deepseek_score=deepseek_score,
rag_score=rag_score, rag_score=rag_score,
rag_confidence=rag_confidence, user_id=message.from_user.id,
rag_score_pos_only=rag_score_pos_only,
) )
markup = get_reply_keyboard_for_post() markup = get_reply_keyboard_for_post()
@@ -638,6 +1043,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(
@@ -646,8 +1059,7 @@ class PostService:
message.from_user.username, message.from_user.username,
deepseek_score=deepseek_score, deepseek_score=deepseek_score,
rag_score=rag_score, rag_score=rag_score,
rag_confidence=rag_confidence, user_id=message.from_user.id,
rag_score_pos_only=rag_score_pos_only,
) )
markup = get_reply_keyboard_for_post() markup = get_reply_keyboard_for_post()
@@ -723,6 +1135,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(
@@ -731,8 +1151,7 @@ class PostService:
message.from_user.username, message.from_user.username,
deepseek_score=deepseek_score, deepseek_score=deepseek_score,
rag_score=rag_score, rag_score=rag_score,
rag_confidence=rag_confidence, user_id=message.from_user.id,
rag_score_pos_only=rag_score_pos_only,
) )
markup = get_reply_keyboard_for_post() markup = get_reply_keyboard_for_post()
@@ -816,14 +1235,21 @@ 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,
message.from_user.username, message.from_user.username,
deepseek_score=deepseek_score, deepseek_score=deepseek_score,
rag_score=rag_score, rag_score=rag_score,
rag_confidence=rag_confidence, user_id=message.from_user.id,
rag_score_pos_only=rag_score_pos_only,
) )
is_anonymous = determine_anonymity(raw_caption) is_anonymous = determine_anonymity(raw_caption)
@@ -934,3 +1360,126 @@ class StickerService:
random_stick_bye = random.choice(name_stick_bye) random_stick_bye = random.choice(name_stick_bye)
random_stick_bye = FSInputFile(path=random_stick_bye) random_stick_bye = FSInputFile(path=random_stick_bye)
await message.answer_sticker(random_stick_bye) await message.answer_sticker(random_stick_bye)
class AutoModerationService:
"""
Сервис автоматической модерации постов на основе RAG score.
Автоматически публикует посты с высоким скором и отклоняет с низким.
"""
def __init__(
self,
db: DatabaseProtocol,
settings: BotSettings,
scoring_manager=None,
s3_storage=None,
) -> None:
self.db = db
self.settings = settings
self.scoring_manager = scoring_manager
self.s3_storage = s3_storage
@track_time("check_auto_action", "auto_moderation_service")
async def check_auto_action(self, rag_score: float) -> str:
"""
Проверяет, требуется ли автоматическое действие.
Args:
rag_score: Скор от RAG модели (0.0 - 1.0)
Returns:
'publish' - автопубликация
'decline' - автоотклонение
'manual' - ручная модерация
"""
if rag_score is None:
return "manual"
settings = await self.db.get_auto_moderation_settings()
auto_publish_enabled = settings.get("auto_publish_enabled", False)
auto_decline_enabled = settings.get("auto_decline_enabled", False)
auto_publish_threshold = settings.get("auto_publish_threshold", 0.8)
auto_decline_threshold = settings.get("auto_decline_threshold", 0.4)
logger.info(
f"AutoModeration: Настройки из БД - "
f"publish_enabled={auto_publish_enabled}, decline_enabled={auto_decline_enabled}, "
f"publish_threshold={auto_publish_threshold}, decline_threshold={auto_decline_threshold}, "
f"rag_score={rag_score:.2f}"
)
if auto_publish_enabled and rag_score >= auto_publish_threshold:
logger.info(
f"AutoModeration: score {rag_score:.2f} >= {auto_publish_threshold} → auto_publish"
)
return "publish"
if auto_decline_enabled and rag_score <= auto_decline_threshold:
logger.info(
f"AutoModeration: score {rag_score:.2f} <= {auto_decline_threshold} → auto_decline"
)
return "decline"
return "manual"
@track_time("log_auto_action", "auto_moderation_service")
async def log_auto_action(
self,
bot,
action: str,
author_id: int,
author_name: str,
author_username: str,
rag_score: float,
post_text: str,
) -> None:
"""
Отправляет лог автоматического действия в IMPORTANT_LOGS.
Args:
bot: Экземпляр бота для отправки сообщений
action: Тип действия ('publish' или 'decline')
author_id: ID автора поста
author_name: Имя автора
author_username: Username автора
rag_score: Скор модели
post_text: Текст поста
"""
try:
safe_name = html.escape(author_name or "Без имени")
safe_username = html.escape(author_username or "нет")
truncated_text = post_text[:200] if post_text else ""
if len(post_text or "") > 200:
truncated_text += "..."
safe_text = html.escape(truncated_text)
if action == "publish":
emoji = "🤖"
action_title = "АВТО-ПУБЛИКАЦИЯ"
action_result = "✅ Пост автоматически опубликован"
else:
emoji = "🚫"
action_title = "АВТО-ОТКЛОНЕНИЕ"
action_result = "❌ Пост автоматически отклонён"
message_text = (
f"{emoji} <b>{action_title}</b>\n\n"
f"👤 <b>Автор:</b> {safe_name} (@{safe_username}) | ID: {author_id}\n"
f"📊 <b>RAG Score:</b> {rag_score:.2f}\n\n"
f"📝 <b>Текст поста:</b>\n"
f'"{safe_text}"\n\n'
f"{action_result}"
)
await bot.send_message(
chat_id=self.settings.important_logs,
text=message_text,
parse_mode="HTML",
)
logger.info(f"AutoModeration: Лог отправлен в IMPORTANT_LOGS ({action})")
except Exception as e:
logger.error(f"AutoModeration: Ошибка отправки лога: {e}")

View File

@@ -46,11 +46,64 @@ def get_reply_keyboard_admin():
types.KeyboardButton(text="Разбан (список)"), types.KeyboardButton(text="Разбан (список)"),
types.KeyboardButton(text="📊 ML Статистика"), types.KeyboardButton(text="📊 ML Статистика"),
) )
builder.row(types.KeyboardButton(text="⚙️ Авто-модерация"))
builder.row(types.KeyboardButton(text="Вернуться в бота")) builder.row(types.KeyboardButton(text="Вернуться в бота"))
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True) markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
return markup return markup
def get_auto_moderation_keyboard(settings: dict) -> types.InlineKeyboardMarkup:
"""
Создает inline клавиатуру для управления авто-модерацией.
Args:
settings: Словарь с текущими настройками авто-модерации
Returns:
InlineKeyboardMarkup с кнопками управления
"""
builder = InlineKeyboardBuilder()
auto_publish = settings.get("auto_publish_enabled", False)
auto_decline = settings.get("auto_decline_enabled", False)
publish_threshold = settings.get("auto_publish_threshold", 0.8)
decline_threshold = settings.get("auto_decline_threshold", 0.4)
publish_status = "" if auto_publish else ""
decline_status = "" if auto_decline else ""
builder.row(
types.InlineKeyboardButton(
text=f"{publish_status} Авто-публикация (≥{publish_threshold})",
callback_data="auto_mod_toggle_publish",
)
)
builder.row(
types.InlineKeyboardButton(
text=f"{decline_status} Авто-отклонение (≤{decline_threshold})",
callback_data="auto_mod_toggle_decline",
)
)
builder.row(
types.InlineKeyboardButton(
text="📈 Изменить порог публикации",
callback_data="auto_mod_threshold_publish",
),
types.InlineKeyboardButton(
text="📉 Изменить порог отклонения",
callback_data="auto_mod_threshold_decline",
),
)
builder.row(
types.InlineKeyboardButton(
text="🔄 Обновить",
callback_data="auto_mod_refresh",
)
)
return builder.as_markup()
@track_time("create_keyboard_with_pagination", "keyboard_service") @track_time("create_keyboard_with_pagination", "keyboard_service")
@track_errors("keyboard_service", "create_keyboard_with_pagination") @track_errors("keyboard_service", "create_keyboard_with_pagination")
def create_keyboard_with_pagination( def create_keyboard_with_pagination(

View File

@@ -4,7 +4,8 @@ HTTP клиент для взаимодействия с внешним RAG се
Использует REST API для получения скоров и отправки примеров. Использует REST API для получения скоров и отправки примеров.
""" """
from typing import Any, Dict, Optional from dataclasses import dataclass
from typing import Any, Dict, List, Optional
import httpx import httpx
@@ -15,6 +16,30 @@ from .base import ScoringResult
from .exceptions import InsufficientExamplesError, ScoringError, TextTooShortError from .exceptions import InsufficientExamplesError, ScoringError, TextTooShortError
@dataclass
class SimilarPost:
"""Данные о похожем посте."""
similarity: float
created_at: int
post_id: Optional[int]
text: str
rag_score: Optional[float]
@dataclass
class SimilarPostsResult:
"""Результат поиска похожих постов."""
similar_count: int
similar_posts: List[SimilarPost]
max_similarity: float = 0.0
def __post_init__(self):
if self.similar_posts:
self.max_similarity = max(p.similarity for p in self.similar_posts)
class RagApiClient: class RagApiClient:
""" """
HTTP клиент для взаимодействия с внешним RAG сервисом. HTTP клиент для взаимодействия с внешним RAG сервисом.
@@ -159,13 +184,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(
@@ -314,21 +354,39 @@ class RagApiClient:
Словарь со статистикой или пустой словарь при ошибке Словарь со статистикой или пустой словарь при ошибке
""" """
if not self._enabled: if not self._enabled:
logger.debug("RagApiClient: get_stats пропущен - клиент отключен")
return {} return {}
try: try:
logger.debug(f"RagApiClient: Запрос статистики от {self.api_url}/stats")
response = await self._client.get(f"{self.api_url}/stats") response = await self._client.get(f"{self.api_url}/stats")
if response.status_code == 200: if response.status_code == 200:
return response.json() data = response.json()
logger.info(
f"RagApiClient: Статистика получена успешно: "
f"model_loaded={data.get('model_loaded')}, "
f"model_name={data.get('model_name')}, "
f"vector_store={data.get('vector_store', {}).get('total_count', 'N/A')} примеров"
)
return data
elif response.status_code == 401 or response.status_code == 403:
logger.warning(
f"RagApiClient: Ошибка авторизации при получении статистики: "
f"status={response.status_code}, body={response.text[:200]}"
)
return {}
else: else:
logger.warning( logger.warning(
f"RagApiClient: Неожиданный статус при получении статистики: {response.status_code}" f"RagApiClient: Неожиданный статус при получении статистики: "
f"status={response.status_code}, body={response.text[:200]}"
) )
return {} return {}
except httpx.TimeoutException: except httpx.TimeoutException:
logger.warning(f"RagApiClient: Таймаут при получении статистики") logger.warning(
f"RagApiClient: Таймаут при получении статистики (timeout={self.timeout}s)"
)
return {} return {}
except httpx.RequestError as e: except httpx.RequestError as e:
logger.warning( logger.warning(
@@ -350,3 +408,138 @@ class RagApiClient:
"api_url": self.api_url, "api_url": self.api_url,
"timeout": self.timeout, "timeout": self.timeout,
} }
@track_time("find_similar_posts", "rag_client")
async def find_similar_posts(
self, text: str, threshold: float = 0.9, hours: int = 24
) -> Optional[SimilarPostsResult]:
"""
Ищет похожие посты за последние N часов.
Args:
text: Текст поста для поиска похожих
threshold: Порог схожести (0.0-1.0), по умолчанию 0.9
hours: За сколько часов искать (1-168), по умолчанию 24
Returns:
SimilarPostsResult с информацией о похожих постах или None при ошибке
"""
if not self._enabled:
return None
if not text or not text.strip():
return None
try:
response = await self._client.post(
f"{self.api_url}/similar",
json={"text": text.strip(), "threshold": threshold, "hours": hours},
)
if response.status_code == 200:
data = response.json()
similar_posts = []
for post_data in data.get("similar_posts", []):
similar_posts.append(
SimilarPost(
similarity=float(post_data.get("similarity", 0.0)),
created_at=int(post_data.get("created_at", 0)),
post_id=post_data.get("post_id"),
text=post_data.get("text", ""),
rag_score=post_data.get("rag_score"),
)
)
result = SimilarPostsResult(
similar_count=data.get("similar_count", 0),
similar_posts=similar_posts,
)
if result.similar_count > 0:
logger.info(
f"RagApiClient: Найдено {result.similar_count} похожих постов "
f"(max_similarity={result.max_similarity:.2%})"
)
return result
else:
logger.warning(
f"RagApiClient: Неожиданный статус при поиске похожих постов: "
f"{response.status_code}, body: {response.text}"
)
return None
except httpx.TimeoutException:
logger.warning("RagApiClient: Таймаут при поиске похожих постов")
return None
except httpx.RequestError as e:
logger.warning(
f"RagApiClient: Ошибка подключения при поиске похожих постов: {e}"
)
return None
except Exception as e:
logger.error(f"RagApiClient: Ошибка поиска похожих постов: {e}")
return None
@track_time("add_submitted_post", "rag_client")
async def add_submitted_post(
self,
text: str,
post_id: Optional[int] = None,
rag_score: Optional[float] = None,
) -> bool:
"""
Добавляет пост в коллекцию submitted для поиска похожих.
Args:
text: Текст поста
post_id: ID поста (опционально)
rag_score: RAG скор на момент добавления (опционально)
Returns:
True если пост успешно добавлен
"""
if not self._enabled:
return False
if not text or not text.strip():
return False
try:
payload = {"text": text.strip()}
if post_id is not None:
payload["post_id"] = post_id
if rag_score is not None:
payload["rag_score"] = rag_score
response = await self._client.post(
f"{self.api_url}/submitted",
json=payload,
)
if response.status_code in (200, 201):
data = response.json()
logger.debug(
f"RagApiClient: Пост добавлен в submitted "
f"(post_id={post_id}, submitted_count={data.get('submitted_count', 'N/A')})"
)
return True
else:
logger.warning(
f"RagApiClient: Неожиданный статус при добавлении в submitted: "
f"{response.status_code}"
)
return False
except httpx.TimeoutException:
logger.warning("RagApiClient: Таймаут при добавлении в submitted")
return False
except httpx.RequestError as e:
logger.warning(
f"RagApiClient: Ошибка подключения при добавлении в submitted: {e}"
)
return False
except Exception as e:
logger.error(f"RagApiClient: Ошибка добавления в submitted: {e}")
return False

View File

@@ -221,3 +221,46 @@ class ScoringManager:
stats["deepseek"] = self.deepseek_service.get_stats() stats["deepseek"] = self.deepseek_service.get_stats()
return stats return stats
@track_time("find_similar_posts", "scoring_manager")
async def find_similar_posts(
self, text: str, threshold: float = 0.9, hours: int = 24
):
"""
Ищет похожие посты через RAG API.
Args:
text: Текст для поиска похожих
threshold: Порог схожести (0.0-1.0)
hours: За сколько часов искать
Returns:
SimilarPostsResult или None
"""
if not self.rag_client or not self.rag_client.is_enabled:
return None
return await self.rag_client.find_similar_posts(text, threshold, hours)
@track_time("add_submitted_post", "scoring_manager")
async def add_submitted_post(
self,
text: str,
post_id: Optional[int] = None,
rag_score: Optional[float] = None,
) -> bool:
"""
Добавляет пост в коллекцию submitted для поиска похожих.
Args:
text: Текст поста
post_id: ID поста (опционально)
rag_score: RAG скор на момент добавления (опционально)
Returns:
True если успешно добавлен
"""
if not self.rag_client or not self.rag_client.is_enabled:
return False
return await self.rag_client.add_submitted_post(text, post_id, rag_score)

View File

@@ -138,6 +138,52 @@ def determine_anonymity(post_text: str) -> bool:
return False return False
def get_publish_text(
post_text: str,
first_name: str,
username: str = None,
is_anonymous: Optional[bool] = None,
) -> str:
"""
Форматирует текст для финальной публикации в канал.
Только текст поста + подпись автора или анон.
Args:
post_text: Текст сообщения
first_name: Имя автора поста
username: Юзернейм автора поста (может быть None)
is_anonymous: Флаг анонимности (True - анонимно, False - не анонимно, None - legacy)
Returns:
str: Текст для публикации в канал
"""
safe_post_text = post_text or ""
safe_first_name = first_name or "Пользователь"
# Формируем строку с информацией об авторе
if username:
author_info = f"{safe_first_name} @{username}"
else:
author_info = f"{safe_first_name}"
# Определяем анонимность и формируем финальный текст
if is_anonymous is not None:
if is_anonymous:
final_text = f"{safe_post_text}\n\nПост опубликован анонимно"
else:
final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}"
else:
# Legacy: определяем по тексту
if "неанон" in post_text.lower() or "не анон" in post_text.lower():
final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}"
elif "анон" in post_text.lower():
final_text = f"{safe_post_text}\n\nПост опубликован анонимно"
else:
final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}"
return final_text
def get_text_message( def get_text_message(
post_text: str, post_text: str,
first_name: str, first_name: str,
@@ -147,10 +193,10 @@ def get_text_message(
rag_score: Optional[float] = None, rag_score: Optional[float] = None,
rag_confidence: Optional[float] = None, rag_confidence: Optional[float] = None,
rag_score_pos_only: Optional[float] = None, rag_score_pos_only: Optional[float] = None,
user_id: Optional[int] = None,
): ):
""" """
Форматирует текст сообщения для публикации в зависимости от наличия ключевых слов "анон" и "неанон" Форматирует текст сообщения для модерации (с полной информацией об авторе и скорами).
или переданного параметра is_anonymous.
Args: Args:
post_text: Текст сообщения post_text: Текст сообщения
@@ -161,57 +207,69 @@ def get_text_message(
rag_score: Скор от RAG/ruBERT neg/pos (0.0-1.0, опционально) rag_score: Скор от RAG/ruBERT neg/pos (0.0-1.0, опционально)
rag_confidence: Уверенность RAG модели (0.0-1.0, зависит от количества примеров) rag_confidence: Уверенность RAG модели (0.0-1.0, зависит от количества примеров)
rag_score_pos_only: Скор RAG только по положительным примерам (0.0-1.0, опционально) rag_score_pos_only: Скор RAG только по положительным примерам (0.0-1.0, опционально)
user_id: ID пользователя Telegram (опционально)
Returns: Returns:
str: - Сформированный текст сообщения. str: - Сформированный текст сообщения для модерации.
""" """
# Экранируем post_text для безопасного использования в HTML # Экранируем post_text для безопасного использования в HTML
safe_post_text = html.escape(str(post_text)) if post_text else "" safe_post_text = html.escape(str(post_text)) if post_text else ""
# Экранируем username для безопасного использования в HTML # Экранируем username для безопасного использования в HTML
safe_username = html.escape(username) if username else None safe_username = html.escape(username) if username else None
safe_first_name = html.escape(first_name) if first_name else "Пользователь"
# Формируем строку с информацией об авторе # Формируем шапку с информацией об авторе
if safe_username: if safe_username:
author_info = f"{first_name} @{safe_username}" header = f"👤 От: {safe_first_name} (@{safe_username})"
else: else:
author_info = f"{first_name} (Ник не указан)" header = f"👤 От: {safe_first_name} (Ник не указан)"
# Формируем базовый текст if user_id:
# Если передан is_anonymous, используем его, иначе определяем по тексту (legacy) header += f" | ID: {user_id}"
# Формируем строку с информацией об авторе для подвала
if safe_username:
author_info = f"{safe_first_name} @{safe_username}"
else:
author_info = f"{safe_first_name} (Ник не указан)"
# Формируем блок с текстом поста
separator = "=" * 32
post_block = f"{header}\n<b>Текст поста:</b>\n{separator}\n{safe_post_text}"
# Определяем анонимность и формируем подвал
if is_anonymous is not None: if is_anonymous is not None:
if is_anonymous: if is_anonymous:
final_text = f"{safe_post_text}\n\nПост опубликован анонимно" post_block += f"\n\nПост опубликован анонимно"
else: else:
final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" post_block += f"\n\n<b>Автор поста:</b> {author_info}"
else: else:
# Legacy: определяем по тексту # Legacy: определяем по тексту
if "неанон" in post_text or "не анон" in post_text: if "неанон" in post_text or "не анон" in post_text:
final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" post_block += f"\n\n<b>Автор поста:</b> {author_info}"
elif "анон" in post_text: elif "анон" in post_text:
final_text = f"{safe_post_text}\n\nПост опубликован анонимно" post_block += f"\n\nПост опубликован анонимно"
else: else:
final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" post_block += f"\n\n<b>Автор поста:</b> {author_info}"
# Добавляем блок со скорами если есть post_block += f"\n{separator}"
if (
deepseek_score is not None # Добавляем блок со скорами если есть (без RAG pos only и уверенности)
or rag_score is not None if deepseek_score is not None or rag_score is not None:
or rag_score_pos_only is not None scores_lines = ["📊 <b>Уверенность в одобрении:</b>"]
):
scores_lines = ["\n📊 Уверенность в одобрении:"]
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:
rag_line = f"RAG neg/pos: {rag_score:.2f}" logger.debug(
if rag_confidence is not None: f"get_text_message: Форматирование rag_score - "
rag_line += f" (уверенность: {rag_confidence:.0%})" f"rag_score={rag_score} (type: {type(rag_score).__name__}), "
scores_lines.append(rag_line) f"formatted_value={rag_score:.2f}"
if rag_score_pos_only is not None: )
scores_lines.append(f"RAG pos only: {rag_score_pos_only:.2f}") scores_lines.append(f"RAG neg/pos: {rag_score:.2f}")
final_text += "\n" + "\n".join(scores_lines) post_block += "\n" + "\n".join(scores_lines)
return final_text return post_block
@track_time("download_file", "helper_func") @track_time("download_file", "helper_func")
@@ -847,15 +905,14 @@ async def send_text_message(
): ):
from .rate_limiter import send_with_rate_limit from .rate_limiter import send_with_rate_limit
# Экранируем post_text для безопасного использования в HTML
safe_post_text = html.escape(str(post_text)) if post_text else ""
async def _send_message(): async def _send_message():
if markup is None: if markup is None:
return await message.bot.send_message(chat_id=chat_id, text=safe_post_text) return await message.bot.send_message(
chat_id=chat_id, text=post_text, parse_mode="HTML"
)
else: else:
return await message.bot.send_message( return await message.bot.send_message(
chat_id=chat_id, text=safe_post_text, reply_markup=markup chat_id=chat_id, text=post_text, reply_markup=markup, parse_mode="HTML"
) )
sent_message = await send_with_rate_limit(_send_message, chat_id) sent_message = await send_with_rate_limit(_send_message, chat_id)
@@ -871,16 +928,17 @@ async def send_photo_message(
post_text: str, post_text: str,
markup: types.ReplyKeyboardMarkup = None, markup: types.ReplyKeyboardMarkup = None,
): ):
# Экранируем post_text для безопасного использования в HTML
safe_post_text = html.escape(str(post_text)) if post_text else ""
if markup is None: if markup is None:
sent_message = await message.bot.send_photo( sent_message = await message.bot.send_photo(
chat_id=chat_id, caption=safe_post_text, photo=photo chat_id=chat_id, caption=post_text, photo=photo, parse_mode="HTML"
) )
else: else:
sent_message = await message.bot.send_photo( sent_message = await message.bot.send_photo(
chat_id=chat_id, caption=safe_post_text, photo=photo, reply_markup=markup chat_id=chat_id,
caption=post_text,
photo=photo,
reply_markup=markup,
parse_mode="HTML",
) )
return sent_message return sent_message
@@ -894,16 +952,17 @@ async def send_video_message(
post_text: str = "", post_text: str = "",
markup: types.ReplyKeyboardMarkup = None, markup: types.ReplyKeyboardMarkup = None,
): ):
# Экранируем post_text для безопасного использования в HTML
safe_post_text = html.escape(str(post_text)) if post_text else ""
if markup is None: if markup is None:
sent_message = await message.bot.send_video( sent_message = await message.bot.send_video(
chat_id=chat_id, caption=safe_post_text, video=video chat_id=chat_id, caption=post_text, video=video, parse_mode="HTML"
) )
else: else:
sent_message = await message.bot.send_video( sent_message = await message.bot.send_video(
chat_id=chat_id, caption=safe_post_text, video=video, reply_markup=markup chat_id=chat_id,
caption=post_text,
video=video,
reply_markup=markup,
parse_mode="HTML",
) )
return sent_message return sent_message
@@ -936,16 +995,17 @@ async def send_audio_message(
post_text: str, post_text: str,
markup: types.ReplyKeyboardMarkup = None, markup: types.ReplyKeyboardMarkup = None,
): ):
# Экранируем post_text для безопасного использования в HTML
safe_post_text = html.escape(str(post_text)) if post_text else ""
if markup is None: if markup is None:
sent_message = await message.bot.send_audio( sent_message = await message.bot.send_audio(
chat_id=chat_id, caption=safe_post_text, audio=audio chat_id=chat_id, caption=post_text, audio=audio, parse_mode="HTML"
) )
else: else:
sent_message = await message.bot.send_audio( sent_message = await message.bot.send_audio(
chat_id=chat_id, caption=safe_post_text, audio=audio, reply_markup=markup chat_id=chat_id,
caption=post_text,
audio=audio,
reply_markup=markup,
parse_mode="HTML",
) )
return sent_message return sent_message
@@ -1005,11 +1065,14 @@ async def get_banned_users_list(offset: int, bot_db):
message - текст сообщения message - текст сообщения
user_ids - лист кортежей [(user_name: user_id)] user_ids - лист кортежей [(user_name: user_id)]
""" """
users = await bot_db.get_banned_users_from_db_with_limits(limit=7, offset=offset) items_per_page = 9
users = await bot_db.get_banned_users_from_db_with_limits(
limit=items_per_page, offset=offset
)
message = "Список заблокированных пользователей:\n" message = "Список заблокированных пользователей:\n"
for user in users: for user in users:
user_id, ban_reason, unban_date = user user_id, ban_reason, unban_date, ban_date = user
# Получаем имя пользователя из таблицы users # Получаем имя пользователя из таблицы users
username = await bot_db.get_username(user_id) username = await bot_db.get_username(user_id)
full_name = await bot_db.get_full_name_by_id(user_id) full_name = await bot_db.get_full_name_by_id(user_id)
@@ -1021,41 +1084,42 @@ async def get_banned_users_list(offset: int, bot_db):
html.escape(str(ban_reason)) if ban_reason else "Причина не указана" html.escape(str(ban_reason)) if ban_reason else "Причина не указана"
) )
# Форматируем дату разбана в человекочитаемый формат # Форматируем дату бана в человекочитаемый формат
if unban_date: safe_ban_date = _format_timestamp_to_date(ban_date)
try:
# Предполагаем, что unban_date это UNIX timestamp
if isinstance(unban_date, (int, float)):
unban_datetime = datetime.fromtimestamp(unban_date)
safe_unban_date = unban_datetime.strftime("%d-%m-%Y %H:%M")
elif isinstance(unban_date, str):
# Если это строка, попытаемся её обработать
try:
# Попробуем преобразовать строку в timestamp
timestamp = int(unban_date)
unban_datetime = datetime.fromtimestamp(timestamp)
safe_unban_date = unban_datetime.strftime("%d-%m-%Y %H:%M")
except (ValueError, TypeError):
# Если не удалось, показываем как есть
safe_unban_date = html.escape(str(unban_date))
elif hasattr(unban_date, "strftime"):
# Если это datetime объект
safe_unban_date = unban_date.strftime("%d-%m-%Y %H:%M")
else:
# Для всех остальных случаев
safe_unban_date = html.escape(str(unban_date))
except (ValueError, TypeError, OSError):
# В случае ошибки показываем исходное значение
safe_unban_date = html.escape(str(unban_date))
else:
safe_unban_date = "Дата не указана"
message += f"**Пользователь:** {safe_user_name}\n" # Форматируем дату разбана в человекочитаемый формат
message += f"**Причина бана:** {safe_ban_reason}\n" safe_unban_date = _format_timestamp_to_date(unban_date, default="Навсегда")
message += f"**Дата разбана:** {safe_unban_date}\n\n"
message += f"<b>Пользователь:</b> {safe_user_name}\n"
message += f"<b>Причина бана:</b> {safe_ban_reason}\n"
message += f"<b>Дата бана:</b> {safe_ban_date}\n"
message += f"<b>Дата разбана:</b> {safe_unban_date}\n\n"
return message return message
def _format_timestamp_to_date(timestamp, default: str = "Дата не указана") -> str:
"""Форматирует timestamp в читаемую дату."""
if not timestamp:
return default
try:
if isinstance(timestamp, (int, float)):
dt = datetime.fromtimestamp(timestamp)
return dt.strftime("%d.%m.%Y %H:%M")
elif isinstance(timestamp, str):
try:
ts = int(timestamp)
dt = datetime.fromtimestamp(ts)
return dt.strftime("%d.%m.%Y %H:%M")
except (ValueError, TypeError):
return html.escape(str(timestamp))
elif hasattr(timestamp, "strftime"):
return timestamp.strftime("%d.%m.%Y %H:%M")
else:
return html.escape(str(timestamp))
except (ValueError, TypeError, OSError):
return html.escape(str(timestamp))
@track_time("get_banned_users_buttons", "helper_func") @track_time("get_banned_users_buttons", "helper_func")
@track_errors("helper_func", "get_banned_users_buttons") @track_errors("helper_func", "get_banned_users_buttons")
@db_query_time("get_banned_users_buttons", "users", "select") @db_query_time("get_banned_users_buttons", "users", "select")

View File

@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""
Миграция: Создание таблицы bot_settings для хранения настроек бота.
Создает таблицу с ключ-значение для хранения:
- auto_publish_enabled: Включена ли авто-публикация (default: false)
- auto_decline_enabled: Включено ли авто-отклонение (default: false)
- auto_publish_threshold: Порог для авто-публикации (default: 0.8)
- auto_decline_threshold: Порог для авто-отклонения (default: 0.4)
"""
import argparse
import asyncio
import os
import sys
from pathlib import Path
project_root = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(project_root))
import aiosqlite
try:
from logs.custom_logger import logger
except ImportError:
import logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
DEFAULT_DB_PATH = "database/tg-bot-database.db"
DEFAULT_SETTINGS = [
("auto_publish_enabled", "false"),
("auto_decline_enabled", "false"),
("auto_publish_threshold", "0.8"),
("auto_decline_threshold", "0.4"),
]
async def table_exists(conn: aiosqlite.Connection, table_name: str) -> bool:
"""Проверяет существование таблицы."""
cursor = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
(table_name,),
)
result = await cursor.fetchone()
return result is not None
async def main(db_path: str) -> None:
"""
Основная функция миграции.
Создает таблицу bot_settings и добавляет дефолтные настройки.
Миграция идемпотентна - можно запускать повторно без ошибок.
"""
db_path = os.path.abspath(db_path)
if not os.path.exists(db_path):
logger.error(f"База данных не найдена: {db_path}")
return
async with aiosqlite.connect(db_path) as conn:
await conn.execute("PRAGMA foreign_keys = ON")
if not await table_exists(conn, "bot_settings"):
await conn.execute("""
CREATE TABLE bot_settings (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
)
""")
logger.info("Таблица bot_settings создана")
for key, value in DEFAULT_SETTINGS:
await conn.execute(
"INSERT INTO bot_settings (key, value) VALUES (?, ?)",
(key, value),
)
logger.info(f"Добавлена настройка: {key} = {value}")
else:
logger.info("Таблица bot_settings уже существует")
for key, value in DEFAULT_SETTINGS:
cursor = await conn.execute(
"SELECT COUNT(*) FROM bot_settings WHERE key = ?", (key,)
)
row = await cursor.fetchone()
if row[0] == 0:
await conn.execute(
"INSERT INTO bot_settings (key, value) VALUES (?, ?)",
(key, value),
)
logger.info(f"Добавлена отсутствующая настройка: {key} = {value}")
await conn.commit()
logger.info("Миграция create_bot_settings_table завершена успешно")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Создание таблицы bot_settings для настроек авто-модерации"
)
parser.add_argument(
"--db",
default=os.environ.get("DATABASE_PATH", DEFAULT_DB_PATH),
help="Путь к БД",
)
args = parser.parse_args()
asyncio.run(main(args.db))

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,219 @@
"""Тесты для AutoModerationService."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from helper_bot.handlers.private.services import AutoModerationService, BotSettings
class TestAutoModerationService:
"""Тесты для сервиса авто-модерации."""
@pytest.fixture
def mock_db(self):
"""Создает мок базы данных."""
db = MagicMock()
db.get_auto_moderation_settings = AsyncMock()
return db
@pytest.fixture
def settings(self):
"""Создает настройки бота."""
return BotSettings(
group_for_posts="-123",
group_for_message="-456",
main_public="@test_channel",
group_for_logs="-789",
important_logs="-999",
preview_link="false",
logs="false",
test="false",
)
@pytest.fixture
def service(self, mock_db, settings):
"""Создает экземпляр сервиса."""
return AutoModerationService(mock_db, settings)
@pytest.mark.asyncio
async def test_check_auto_action_returns_manual_when_score_is_none(
self, service, mock_db
):
"""Тест: возвращает manual когда score равен None."""
result = await service.check_auto_action(None)
assert result == "manual"
@pytest.mark.asyncio
async def test_check_auto_action_returns_publish_when_score_above_threshold(
self, service, mock_db
):
"""Тест: возвращает publish когда score выше порога."""
mock_db.get_auto_moderation_settings.return_value = {
"auto_publish_enabled": True,
"auto_decline_enabled": False,
"auto_publish_threshold": 0.8,
"auto_decline_threshold": 0.4,
}
result = await service.check_auto_action(0.9)
assert result == "publish"
@pytest.mark.asyncio
async def test_check_auto_action_returns_decline_when_score_below_threshold(
self, service, mock_db
):
"""Тест: возвращает decline когда score ниже порога."""
mock_db.get_auto_moderation_settings.return_value = {
"auto_publish_enabled": False,
"auto_decline_enabled": True,
"auto_publish_threshold": 0.8,
"auto_decline_threshold": 0.4,
}
result = await service.check_auto_action(0.3)
assert result == "decline"
@pytest.mark.asyncio
async def test_check_auto_action_returns_manual_when_disabled(
self, service, mock_db
):
"""Тест: возвращает manual когда авто-действия отключены."""
mock_db.get_auto_moderation_settings.return_value = {
"auto_publish_enabled": False,
"auto_decline_enabled": False,
"auto_publish_threshold": 0.8,
"auto_decline_threshold": 0.4,
}
result = await service.check_auto_action(0.9)
assert result == "manual"
@pytest.mark.asyncio
async def test_check_auto_action_returns_manual_when_score_in_middle(
self, service, mock_db
):
"""Тест: возвращает manual когда score между порогами."""
mock_db.get_auto_moderation_settings.return_value = {
"auto_publish_enabled": True,
"auto_decline_enabled": True,
"auto_publish_threshold": 0.8,
"auto_decline_threshold": 0.4,
}
result = await service.check_auto_action(0.6)
assert result == "manual"
@pytest.mark.asyncio
async def test_check_auto_action_publish_at_exact_threshold(self, service, mock_db):
"""Тест: возвращает publish когда score равен порогу."""
mock_db.get_auto_moderation_settings.return_value = {
"auto_publish_enabled": True,
"auto_decline_enabled": False,
"auto_publish_threshold": 0.8,
"auto_decline_threshold": 0.4,
}
result = await service.check_auto_action(0.8)
assert result == "publish"
@pytest.mark.asyncio
async def test_check_auto_action_decline_at_exact_threshold(self, service, mock_db):
"""Тест: возвращает decline когда score равен порогу."""
mock_db.get_auto_moderation_settings.return_value = {
"auto_publish_enabled": False,
"auto_decline_enabled": True,
"auto_publish_threshold": 0.8,
"auto_decline_threshold": 0.4,
}
result = await service.check_auto_action(0.4)
assert result == "decline"
@pytest.mark.asyncio
async def test_log_auto_action_publish(self, service, settings):
"""Тест отправки лога для авто-публикации."""
mock_bot = MagicMock()
mock_bot.send_message = AsyncMock()
await service.log_auto_action(
bot=mock_bot,
action="publish",
author_id=12345,
author_name="Test User",
author_username="testuser",
rag_score=0.85,
post_text="Test post text",
)
mock_bot.send_message.assert_called_once()
call_kwargs = mock_bot.send_message.call_args[1]
assert call_kwargs["chat_id"] == settings.important_logs
assert "АВТО-ПУБЛИКАЦИЯ" in call_kwargs["text"]
assert "Test User" in call_kwargs["text"]
assert "0.85" in call_kwargs["text"]
@pytest.mark.asyncio
async def test_log_auto_action_decline(self, service, settings):
"""Тест отправки лога для авто-отклонения."""
mock_bot = MagicMock()
mock_bot.send_message = AsyncMock()
await service.log_auto_action(
bot=mock_bot,
action="decline",
author_id=12345,
author_name="Test User",
author_username="testuser",
rag_score=0.25,
post_text="Test post text",
)
mock_bot.send_message.assert_called_once()
call_kwargs = mock_bot.send_message.call_args[1]
assert "АВТО-ОТКЛОНЕНИЕ" in call_kwargs["text"]
@pytest.mark.asyncio
async def test_log_auto_action_handles_exception(self, service):
"""Тест обработки исключения при отправке лога."""
mock_bot = MagicMock()
mock_bot.send_message = AsyncMock(side_effect=Exception("Network error"))
# Не должно выбрасывать исключение
await service.log_auto_action(
bot=mock_bot,
action="publish",
author_id=12345,
author_name="Test User",
author_username="testuser",
rag_score=0.85,
post_text="Test post text",
)
@pytest.mark.asyncio
async def test_log_auto_action_truncates_long_text(self, service):
"""Тест обрезки длинного текста в логе."""
mock_bot = MagicMock()
mock_bot.send_message = AsyncMock()
long_text = "a" * 300
await service.log_auto_action(
bot=mock_bot,
action="publish",
author_id=12345,
author_name="Test User",
author_username="testuser",
rag_score=0.85,
post_text=long_text,
)
call_kwargs = mock_bot.send_message.call_args[1]
# Текст должен быть обрезан до 200 символов + "..."
assert "..." in call_kwargs["text"]

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

@@ -274,9 +274,9 @@ class TestBlacklistRepository:
# Нормализуем SQL запрос (убираем лишние пробелы и переносы строк) # Нормализуем SQL запрос (убираем лишние пробелы и переносы строк)
actual_query = " ".join(call_args[0][0].split()) actual_query = " ".join(call_args[0][0].split())
expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist LIMIT ?, ?" expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist ORDER BY created_at DESC LIMIT ? OFFSET ?"
assert actual_query == expected_query assert actual_query == expected_query
assert call_args[0][1] == (0, 10) assert call_args[0][1] == (10, 0)
# Проверяем логирование # Проверяем логирование
blacklist_repository.logger.info.assert_called_once_with( blacklist_repository.logger.info.assert_called_once_with(
@@ -310,7 +310,7 @@ class TestBlacklistRepository:
# Нормализуем SQL запрос (убираем лишние пробелы и переносы строк) # Нормализуем SQL запрос (убираем лишние пробелы и переносы строк)
actual_query = " ".join(call_args[0][0].split()) actual_query = " ".join(call_args[0][0].split())
expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist" expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist ORDER BY created_at DESC"
assert actual_query == expected_query assert actual_query == expected_query
# Проверяем, что параметры пустые (без лимитов) # Проверяем, что параметры пустые (без лимитов)
assert len(call_args[0]) == 1 # Только SQL запрос, без параметров assert len(call_args[0]) == 1 # Только SQL запрос, без параметров

View File

@@ -0,0 +1,171 @@
"""Тесты для BotSettingsRepository."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from database.repositories.bot_settings_repository import BotSettingsRepository
class TestBotSettingsRepository:
"""Тесты для репозитория настроек бота."""
@pytest.fixture
def repository(self):
"""Создает экземпляр репозитория с замоканным путем к БД."""
return BotSettingsRepository("test.db")
@pytest.mark.asyncio
async def test_get_setting_returns_value(self, repository):
"""Тест получения настройки по ключу."""
with patch.object(
repository, "_execute_query_with_result", new_callable=AsyncMock
) as mock_query:
mock_query.return_value = [("true",)]
result = await repository.get_setting("auto_publish_enabled")
assert result == "true"
mock_query.assert_called_once()
@pytest.mark.asyncio
async def test_get_setting_returns_none_when_not_found(self, repository):
"""Тест получения несуществующей настройки."""
with patch.object(
repository, "_execute_query_with_result", new_callable=AsyncMock
) as mock_query:
mock_query.return_value = []
result = await repository.get_setting("nonexistent_key")
assert result is None
@pytest.mark.asyncio
async def test_set_setting(self, repository):
"""Тест установки настройки."""
with patch.object(
repository, "_execute_query", new_callable=AsyncMock
) as mock_query:
await repository.set_setting("auto_publish_enabled", "true")
mock_query.assert_called_once()
call_args = mock_query.call_args[0]
assert "auto_publish_enabled" in str(call_args)
assert "true" in str(call_args)
@pytest.mark.asyncio
async def test_get_bool_setting_true(self, repository):
"""Тест получения булевой настройки со значением true."""
with patch.object(
repository, "get_setting", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = "true"
result = await repository.get_bool_setting("auto_publish_enabled")
assert result is True
@pytest.mark.asyncio
async def test_get_bool_setting_false(self, repository):
"""Тест получения булевой настройки со значением false."""
with patch.object(
repository, "get_setting", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = "false"
result = await repository.get_bool_setting("auto_publish_enabled")
assert result is False
@pytest.mark.asyncio
async def test_get_bool_setting_default(self, repository):
"""Тест получения булевой настройки с дефолтным значением."""
with patch.object(
repository, "get_setting", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = None
result = await repository.get_bool_setting("auto_publish_enabled", True)
assert result is True
@pytest.mark.asyncio
async def test_get_float_setting(self, repository):
"""Тест получения числовой настройки."""
with patch.object(
repository, "get_setting", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = "0.8"
result = await repository.get_float_setting("auto_publish_threshold")
assert result == 0.8
@pytest.mark.asyncio
async def test_get_float_setting_invalid_value(self, repository):
"""Тест получения числовой настройки с некорректным значением."""
with patch.object(
repository, "get_setting", new_callable=AsyncMock
) as mock_get:
mock_get.return_value = "invalid"
result = await repository.get_float_setting("auto_publish_threshold", 0.5)
assert result == 0.5
@pytest.mark.asyncio
async def test_get_auto_moderation_settings(self, repository):
"""Тест получения всех настроек авто-модерации."""
with (
patch.object(
repository, "get_bool_setting", new_callable=AsyncMock
) as mock_bool,
patch.object(
repository, "get_float_setting", new_callable=AsyncMock
) as mock_float,
):
mock_bool.side_effect = [True, False]
mock_float.side_effect = [0.8, 0.4]
result = await repository.get_auto_moderation_settings()
assert result["auto_publish_enabled"] is True
assert result["auto_decline_enabled"] is False
assert result["auto_publish_threshold"] == 0.8
assert result["auto_decline_threshold"] == 0.4
@pytest.mark.asyncio
async def test_toggle_auto_publish(self, repository):
"""Тест переключения авто-публикации."""
with (
patch.object(
repository, "get_bool_setting", new_callable=AsyncMock
) as mock_get,
patch.object(
repository, "set_bool_setting", new_callable=AsyncMock
) as mock_set,
):
mock_get.return_value = False
result = await repository.toggle_auto_publish()
assert result is True
mock_set.assert_called_once_with("auto_publish_enabled", True)
@pytest.mark.asyncio
async def test_toggle_auto_decline(self, repository):
"""Тест переключения авто-отклонения."""
with (
patch.object(
repository, "get_bool_setting", new_callable=AsyncMock
) as mock_get,
patch.object(
repository, "set_bool_setting", new_callable=AsyncMock
) as mock_set,
):
mock_get.return_value = True
result = await repository.toggle_auto_decline()
assert result is False
mock_set.assert_called_once_with("auto_decline_enabled", False)

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

View File

@@ -115,7 +115,7 @@ class TestKeyboards:
assert isinstance(keyboard, ReplyKeyboardMarkup) assert isinstance(keyboard, ReplyKeyboardMarkup)
assert keyboard.keyboard is not None assert keyboard.keyboard is not None
assert len(keyboard.keyboard) == 3 # Три строки assert len(keyboard.keyboard) == 4 # Четыре строки
# Проверяем первую строку (3 кнопки) # Проверяем первую строку (3 кнопки)
first_row = keyboard.keyboard[0] first_row = keyboard.keyboard[0]
@@ -130,10 +130,15 @@ class TestKeyboards:
assert second_row[0].text == "Разбан (список)" assert second_row[0].text == "Разбан (список)"
assert second_row[1].text == "📊 ML Статистика" assert second_row[1].text == "📊 ML Статистика"
# Проверяем третью строку (1 кнопка) # Проверяем третью строку (1 кнопка - авто-модерация)
third_row = keyboard.keyboard[2] third_row = keyboard.keyboard[2]
assert len(third_row) == 1 assert len(third_row) == 1
assert third_row[0].text == "Вернуться в бота" assert third_row[0].text == "⚙️ Авто-модерация"
# Проверяем четвертую строку (1 кнопка)
fourth_row = keyboard.keyboard[3]
assert len(fourth_row) == 1
assert fourth_row[0].text == "Вернуться в бота"
def test_get_reply_keyboard_for_post(self): def test_get_reply_keyboard_for_post(self):
"""Тест клавиатуры для постов""" """Тест клавиатуры для постов"""

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,12 @@ 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()
db.get_user_posts_stats = AsyncMock(return_value=(5, 2, 3))
db.get_last_post_by_author = AsyncMock(return_value="Last post text")
db.get_user_by_id = AsyncMock(return_value=Mock(date_added=1704067200))
db.get_user_ban_count = AsyncMock(return_value=0)
db.get_last_ban_info = AsyncMock(return_value=None)
return db return db
@pytest.fixture @pytest.fixture
@@ -58,6 +64,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 +129,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 +177,149 @@ 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"])
mock_message.bot.send_message = AsyncMock(return_value=Mock(message_id=100))
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.bot.send_message.assert_called_once()
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"])
mock_message.bot.send_message = AsyncMock(return_value=Mock(message_id=100))
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.bot.send_message.assert_called_once()
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

@@ -665,7 +665,7 @@ class TestSendMessageFunctions:
assert result == mock_sent_message assert result == mock_sent_message
mock_message.bot.send_photo.assert_called_once_with( mock_message.bot.send_photo.assert_called_once_with(
chat_id=123, caption="Подпись к фото", photo="photo.jpg" chat_id=123, caption="Подпись к фото", photo="photo.jpg", parse_mode="HTML"
) )
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -684,7 +684,7 @@ class TestSendMessageFunctions:
assert result == mock_sent_message assert result == mock_sent_message
mock_message.bot.send_video.assert_called_once_with( mock_message.bot.send_video.assert_called_once_with(
chat_id=123, caption="Подпись к видео", video="video.mp4" chat_id=123, caption="Подпись к видео", video="video.mp4", parse_mode="HTML"
) )
@@ -722,8 +722,9 @@ class TestUtilityFunctions:
"""Тест получения списка заблокированных пользователей""" """Тест получения списка заблокированных пользователей"""
mock_db = AsyncMock() mock_db = AsyncMock()
mock_db.get_banned_users_from_db_with_limits.return_value = [ mock_db.get_banned_users_from_db_with_limits.return_value = [
(123, "Spam", 1704067200), # user_id, ban_reason, unban_date (timestamp) # user_id, ban_reason, unban_date (timestamp), ban_date (timestamp)
(456, "Violation", 1704153600), (123, "Spam", 1704067200, 1703980800),
(456, "Violation", 1704153600, 1704067200),
] ]
mock_db.get_username.return_value = None mock_db.get_username.return_value = None
mock_db.get_full_name_by_id.return_value = "Test User" mock_db.get_full_name_by_id.return_value = "Test User"
@@ -734,18 +735,16 @@ class TestUtilityFunctions:
assert "Test User" in result assert "Test User" in result
assert "Spam" in result assert "Spam" in result
assert "Violation" in result assert "Violation" in result
assert "<b>Дата бана:</b>" in result
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_banned_users_list_with_string_timestamp(self): async def test_get_banned_users_list_with_string_timestamp(self):
"""Тест получения списка заблокированных пользователей со строковым timestamp""" """Тест получения списка заблокированных пользователей со строковым timestamp"""
mock_db = AsyncMock() mock_db = AsyncMock()
mock_db.get_banned_users_from_db_with_limits.return_value = [ mock_db.get_banned_users_from_db_with_limits.return_value = [
( # user_id, ban_reason, unban_date (string timestamp), ban_date (string timestamp)
123, (123, "Spam", "1704067200", "1703980800"),
"Spam", (456, "Violation", "1704153600", "1704067200"),
"1704067200",
), # user_id, ban_reason, unban_date (string timestamp)
(456, "Violation", "1704153600"),
] ]
mock_db.get_username.return_value = None mock_db.get_username.return_value = None
mock_db.get_full_name_by_id.return_value = "Test User" mock_db.get_full_name_by_id.return_value = "Test User"
@@ -756,6 +755,7 @@ class TestUtilityFunctions:
assert "Test User" in result assert "Test User" in result
assert "Spam" in result assert "Spam" in result
assert "Violation" in result assert "Violation" in result
assert "<b>Дата бана:</b>" in result
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_banned_users_buttons(self): async def test_get_banned_users_buttons(self):

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__])