Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59fd789a91 | |||
| cab85ecbf5 | |||
| f398274655 | |||
| a5d221ecad | |||
| 2ee6ea2b38 | |||
| 118189da82 | |||
| d963ea83ad | |||
| 937c54ecfb | |||
| c3b75a0eb7 | |||
| b8428a5bac | |||
| 3d6b4353f9 | |||
| d0c8dab24a | |||
| 31314c9c9b | |||
| b3cdadfd8e | |||
| 694cf1c106 | |||
|
|
e2a6944ed8 | ||
| 73c36061c7 | |||
| d87d4e492e | |||
| 68041037bd | |||
|
|
3933259674 | ||
| a5faa4bdc6 |
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
@@ -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-*', 'master' ]
|
||||||
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
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ jobs:
|
|||||||
|
|
||||||
✅ All tests passed! Code quality checks completed successfully.
|
✅ All tests passed! Code quality checks completed successfully.
|
||||||
|
|
||||||
🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
🔗 View details: ${{ vars.GITEA_PUBLIC_URL || github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Send test failure notification
|
- name: Send test failure notification
|
||||||
@@ -90,5 +91,5 @@ jobs:
|
|||||||
|
|
||||||
❌ Tests failed! Deployment blocked. Please fix the issues and try again.
|
❌ Tests failed! Deployment blocked. Please fix the issues and try again.
|
||||||
|
|
||||||
🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
🔗 View details: ${{ vars.GITEA_PUBLIC_URL || github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
101
.github/workflows/deploy.yml
vendored
101
.github/workflows/deploy.yml
vendored
@@ -2,7 +2,7 @@ name: Deploy to Production
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ master ]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
action:
|
action:
|
||||||
@@ -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
|
||||||
@@ -34,9 +44,28 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: master
|
||||||
|
|
||||||
|
- 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. Backup DB → database/tg-bot-database_YYYYMMDD-HHMMSS.db (удаляется при успехе)"
|
||||||
|
echo " 3. CURRENT_COMMIT + history; git fetch origin master && git reset --hard origin/master"
|
||||||
|
echo " 4. apply_migrations.py (бэкап БД делается в шаге 1, при успехе удаляется в конце)"
|
||||||
|
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,22 @@ jobs:
|
|||||||
|
|
||||||
echo "🚀 Starting deployment to production..."
|
echo "🚀 Starting deployment to production..."
|
||||||
|
|
||||||
cd /home/prod
|
DB_PATH="/home/prod/bots/telegram-helper-bot/database/tg-bot-database.db"
|
||||||
|
DB_DIR="/home/prod/bots/telegram-helper-bot/database"
|
||||||
|
BACKUP_FILE=""
|
||||||
|
|
||||||
# Сохраняем информацию о коммите
|
sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true
|
||||||
|
cd /home/prod/bots/telegram-helper-bot
|
||||||
|
|
||||||
|
# Бэкап БД в самом начале; при успешном деплое удалим в конце
|
||||||
|
if [ -f "$DB_PATH" ]; then
|
||||||
|
echo "💾 Creating database backup (before any changes)..."
|
||||||
|
BACKUP_NAME="tg-bot-database_$(date +%Y%m%d-%H%M%S).db"
|
||||||
|
BACKUP_FILE="${DB_DIR}/${BACKUP_NAME}"
|
||||||
|
cp "$DB_PATH" "$BACKUP_FILE" && echo "✅ Backup: $BACKUP_FILE" || { echo "❌ Backup failed!"; exit 1; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Сохраняем информацию о коммите (до 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")
|
||||||
@@ -69,23 +111,24 @@ jobs:
|
|||||||
tail -n "$HISTORY_SIZE" "$HISTORY_FILE" > "${HISTORY_FILE}.tmp" && mv "${HISTORY_FILE}.tmp" "$HISTORY_FILE"
|
tail -n "$HISTORY_SIZE" "$HISTORY_FILE" > "${HISTORY_FILE}.tmp" && mv "${HISTORY_FILE}.tmp" "$HISTORY_FILE"
|
||||||
|
|
||||||
# Обновляем код
|
# Обновляем код
|
||||||
echo "📥 Pulling latest changes from main..."
|
echo "📥 Pulling latest changes from master..."
|
||||||
sudo chown -R deploy:deploy /home/prod/bots/telegram-helper-bot || true
|
git fetch origin master
|
||||||
cd /home/prod/bots/telegram-helper-bot
|
git reset --hard origin/master
|
||||||
git fetch 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
|
||||||
|
|
||||||
NEW_COMMIT=$(git rev-parse HEAD)
|
NEW_COMMIT=$(git rev-parse HEAD)
|
||||||
echo "✅ Code updated: $CURRENT_COMMIT → $NEW_COMMIT"
|
echo "✅ Code updated: $CURRENT_COMMIT → $NEW_COMMIT"
|
||||||
|
|
||||||
# Применяем миграции БД перед перезапуском контейнера
|
# Применяем миграции БД (нужен venv с зависимостями: aiosqlite и др.)
|
||||||
echo "🔄 Applying database migrations..."
|
echo "🔄 Applying database migrations..."
|
||||||
DB_PATH="/home/prod/bots/telegram-helper-bot/database/tg-bot-database.db"
|
|
||||||
|
|
||||||
if [ -f "$DB_PATH" ]; then
|
if [ -f "$DB_PATH" ]; then
|
||||||
cd /home/prod/bots/telegram-helper-bot
|
cd /home/prod/bots/telegram-helper-bot
|
||||||
python3 scripts/apply_migrations.py --db "$DB_PATH" || {
|
if [ ! -d .venv ]; then
|
||||||
|
echo "📦 Creating .venv for migrations..."
|
||||||
|
python3 -m venv .venv
|
||||||
|
fi
|
||||||
|
.venv/bin/pip install -q -r requirements.txt
|
||||||
|
.venv/bin/python scripts/apply_migrations.py --db "$DB_PATH" || {
|
||||||
echo "❌ Ошибка при применении миграций!"
|
echo "❌ Ошибка при применении миграций!"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
@@ -127,6 +170,10 @@ jobs:
|
|||||||
|
|
||||||
if docker ps | grep -q bots_telegram_bot; then
|
if docker ps | grep -q bots_telegram_bot; then
|
||||||
echo "✅ Container is running"
|
echo "✅ Container is running"
|
||||||
|
# Успешный деплой — удаляем бэкап (при ошибке на любом шаге бэкап остаётся для rollback)
|
||||||
|
if [ -n "${BACKUP_FILE:-}" ] && [ -f "$BACKUP_FILE" ]; then
|
||||||
|
rm -f "$BACKUP_FILE" && echo "✅ Backup removed (deploy success)"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "❌ Container failed to start!"
|
echo "❌ Container failed to start!"
|
||||||
docker logs bots_telegram_bot --tail 50 || true
|
docker logs bots_telegram_bot --tail 50 || true
|
||||||
@@ -134,7 +181,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 +202,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 }}
|
||||||
@@ -164,30 +211,30 @@ jobs:
|
|||||||
${{ job.status == 'success' && '✅' || '❌' }} Deployment: ${{ job.status }}
|
${{ job.status == 'success' && '✅' || '❌' }} Deployment: ${{ job.status }}
|
||||||
|
|
||||||
📦 Repository: telegram-helper-bot
|
📦 Repository: telegram-helper-bot
|
||||||
🌿 Branch: main
|
🌿 Branch: master
|
||||||
📝 Commit: ${{ github.sha }}
|
📝 Commit: ${{ github.sha }}
|
||||||
👤 Author: ${{ github.actor }}
|
👤 Author: ${{ github.actor }}
|
||||||
|
|
||||||
${{ job.status == 'success' && '✅ Deployment successful! Container restarted with migrations applied.' || '❌ Deployment failed! Check logs for details.' }}
|
${{ job.status == 'success' && '✅ Deployment successful! Container restarted with migrations applied.' || '❌ Deployment failed! Check logs for details.' }}
|
||||||
|
|
||||||
🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
🔗 View details: ${{ vars.GITEA_PUBLIC_URL || github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
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: |
|
||||||
echo "🔍 Searching for merged PR associated with commit ${{ github.sha }}..."
|
echo "🔍 Searching for merged PR associated with commit ${{ github.sha }}..."
|
||||||
|
|
||||||
# Находим последний мерженный PR для main ветки по merge commit SHA
|
# Находим последний мерженный PR для master по merge commit SHA
|
||||||
COMMIT_SHA="${{ github.sha }}"
|
COMMIT_SHA="${{ github.sha }}"
|
||||||
PR_NUMBER=$(gh pr list --state merged --base main --limit 10 --json number,mergeCommit --jq ".[] | select(.mergeCommit.oid == \"$COMMIT_SHA\") | .number" | head -1)
|
PR_NUMBER=$(gh pr list --state merged --base master --limit 10 --json number,mergeCommit --jq ".[] | select(.mergeCommit.oid == \"$COMMIT_SHA\") | .number" | head -1)
|
||||||
|
|
||||||
# Если не нашли по merge commit, ищем последний мерженный PR
|
# Если не нашли по merge commit, ищем последний мерженный PR
|
||||||
if [ -z "$PR_NUMBER" ]; then
|
if [ -z "$PR_NUMBER" ]; then
|
||||||
echo "⚠️ PR not found by merge commit, trying to get latest merged PR..."
|
echo "⚠️ PR not found by merge commit, trying to get latest merged PR..."
|
||||||
PR_NUMBER=$(gh pr list --state merged --base main --limit 1 --json number --jq '.[0].number')
|
PR_NUMBER=$(gh pr list --state merged --base master --limit 1 --json number --jq '.[0].number')
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "null" ]; then
|
if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "null" ]; then
|
||||||
@@ -209,7 +256,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 }}
|
||||||
@@ -219,7 +266,7 @@ jobs:
|
|||||||
|
|
||||||
${{ env.PR_BODY }}
|
${{ env.PR_BODY }}
|
||||||
|
|
||||||
🔗 PR: ${{ github.server_url }}/${{ github.repository }}/pull/${{ env.PR_NUMBER }}
|
🔗 PR: ${{ vars.GITEA_PUBLIC_URL || github.server_url }}/${{ github.repository }}/pull/${{ env.PR_NUMBER }}
|
||||||
📝 Commit: ${{ github.sha }}
|
📝 Commit: ${{ github.sha }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
@@ -236,7 +283,7 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: master
|
||||||
|
|
||||||
- name: Rollback on server
|
- name: Rollback on server
|
||||||
uses: appleboy/ssh-action@v1.0.0
|
uses: appleboy/ssh-action@v1.0.0
|
||||||
@@ -294,7 +341,7 @@ jobs:
|
|||||||
|
|
||||||
# Откатываем код
|
# Откатываем код
|
||||||
echo "🔄 Rolling back code..."
|
echo "🔄 Rolling back code..."
|
||||||
git fetch origin main
|
git fetch origin master
|
||||||
git reset --hard "$ROLLBACK_COMMIT"
|
git reset --hard "$ROLLBACK_COMMIT"
|
||||||
|
|
||||||
# Исправляем права после отката
|
# Исправляем права после отката
|
||||||
@@ -346,12 +393,12 @@ jobs:
|
|||||||
${{ job.status == 'success' && '🔄' || '❌' }} Rollback: ${{ job.status }}
|
${{ job.status == 'success' && '🔄' || '❌' }} Rollback: ${{ job.status }}
|
||||||
|
|
||||||
📦 Repository: telegram-helper-bot
|
📦 Repository: telegram-helper-bot
|
||||||
🌿 Branch: main
|
🌿 Branch: master
|
||||||
📝 Rolled back to: ${{ github.event.inputs.rollback_commit || 'Last successful commit' }}
|
📝 Rolled back to: ${{ github.event.inputs.rollback_commit || 'Last successful commit' }}
|
||||||
👤 Triggered by: ${{ github.actor }}
|
👤 Triggered by: ${{ github.actor }}
|
||||||
|
|
||||||
${{ job.status == 'success' && '✅ Rollback completed successfully! Services restored to previous version.' || '❌ Rollback failed! Check logs for details.' }}
|
${{ job.status == 'success' && '✅ Rollback completed successfully! Services restored to previous version.' || '❌ Rollback failed! Check logs for details.' }}
|
||||||
|
|
||||||
🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
🔗 View details: ${{ vars.GITEA_PUBLIC_URL || github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
160
database/repositories/bot_settings_repository.py
Normal file
160
database/repositories/bot_settings_repository.py
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
"""Проверяет целостность базы данных."""
|
"""Проверяет целостность базы данных."""
|
||||||
|
|||||||
@@ -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)}")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# ХЕНДЛЕРЫ ПРОЦЕССА БАНА
|
# ХЕНДЛЕРЫ ПРОЦЕССА БАНА
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
115
scripts/create_bot_settings_table.py
Normal file
115
scripts/create_bot_settings_table.py
Normal 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))
|
||||||
@@ -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
|
||||||
|
|||||||
196
tests/test_admin_dependencies.py
Normal file
196
tests/test_admin_dependencies.py
Normal 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"}}
|
||||||
287
tests/test_admin_handlers.py
Normal file
287
tests/test_admin_handlers.py
Normal 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
198
tests/test_admin_utils.py
Normal 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>") == "<script>"
|
||||||
|
|
||||||
|
def test_escaping_ampersand(self):
|
||||||
|
"""Экранирование &."""
|
||||||
|
assert escape_html("a & b") == "a & b"
|
||||||
|
|
||||||
|
def test_escaping_quotes(self):
|
||||||
|
"""Экранирование кавычек."""
|
||||||
|
assert escape_html('"test"') == ""test""
|
||||||
|
|
||||||
|
|
||||||
|
@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 "<script>" in result
|
||||||
|
assert "<b>Bold</b>" 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 "<html>" 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")
|
||||||
130
tests/test_album_middleware.py
Normal file
130
tests/test_album_middleware.py
Normal 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
|
||||||
219
tests/test_auto_moderation_service.py
Normal file
219
tests/test_auto_moderation_service.py
Normal 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"]
|
||||||
112
tests/test_blacklist_middleware.py
Normal file
112
tests/test_blacklist_middleware.py
Normal 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"
|
||||||
@@ -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 запрос, без параметров
|
||||||
|
|||||||
171
tests/test_bot_settings_repository.py
Normal file
171
tests/test_bot_settings_repository.py
Normal 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)
|
||||||
109
tests/test_callback_dependency_factory.py
Normal file
109
tests/test_callback_dependency_factory.py
Normal 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()
|
||||||
@@ -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__])
|
||||||
|
|||||||
810
tests/test_callback_services.py
Normal file
810
tests/test_callback_services.py
Normal 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
172
tests/test_decorators.py
Normal 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
|
||||||
125
tests/test_deepseek_service.py
Normal file
125
tests/test_deepseek_service.py
Normal 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.0–1.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
|
||||||
63
tests/test_dependencies_middleware.py
Normal file
63
tests/test_dependencies_middleware.py
Normal 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"
|
||||||
@@ -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
184
tests/test_main.py
Normal 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)
|
||||||
375
tests/test_metrics_middleware.py
Normal file
375
tests/test_metrics_middleware.py
Normal 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
206
tests/test_rag_client.py
Normal 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
|
||||||
108
tests/test_rate_limit_middleware.py
Normal file
108
tests/test_rate_limit_middleware.py
Normal 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)
|
||||||
263
tests/test_rate_limit_monitor.py
Normal file
263
tests/test_rate_limit_monitor.py
Normal 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
|
||||||
@@ -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"""
|
||||||
|
|||||||
@@ -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
222
tests/test_s3_storage.py
Normal 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
|
||||||
249
tests/test_server_prometheus.py
Normal file
249
tests/test_server_prometheus.py
Normal 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
|
||||||
91
tests/test_text_middleware.py
Normal file
91
tests/test_text_middleware.py
Normal 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"
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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__])
|
||||||
|
|||||||
@@ -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__])
|
||||||
|
|||||||
Reference in New Issue
Block a user