Добавлен функционал для отслеживания истории банов пользователей.

- Введена новая модель `BlacklistHistoryRecord` для хранения информации о банах и разблокировках.
- Обновлены методы `set_user_blacklist` и `delete_user_blacklist` в `AsyncBotDB` для логирования событий в историю.
- Обновлена схема базы данных для создания таблицы `blacklist_history` и соответствующих индексов.
- Обновлены тесты для проверки нового функционала и обработки ошибок при записи в историю.
This commit is contained in:
2026-01-23 16:23:27 +03:00
parent 7269130777
commit 3b841fcbfa
10 changed files with 904 additions and 20 deletions

View File

@@ -3,7 +3,7 @@ from datetime import datetime
from typing import Optional, List, Dict, Any, Tuple
from database.repository_factory import RepositoryFactory
from database.models import (
User, BlacklistUser, UserMessage, TelegramPost, PostContent,
User, BlacklistUser, BlacklistHistoryRecord, UserMessage, TelegramPost, PostContent,
Admin, AudioMessage
)
@@ -196,7 +196,10 @@ class AsyncBotDB:
date_to_unban: int = None,
ban_author: Optional[int] = None,
):
"""Добавляет пользователя в черный список."""
"""
Добавляет пользователя в черный список.
Также создает запись в истории банов для отслеживания.
"""
blacklist_user = BlacklistUser(
user_id=user_id,
message_for_user=message_for_user,
@@ -205,8 +208,39 @@ class AsyncBotDB:
)
await self.factory.blacklist.add_user(blacklist_user)
# Логируем в историю банов
try:
date_ban = int(datetime.now().timestamp())
history_record = BlacklistHistoryRecord(
user_id=user_id,
message_for_user=message_for_user,
date_ban=date_ban,
date_unban=None, # Будет установлено при разбане
ban_author=ban_author,
)
await self.factory.blacklist_history.add_record_on_ban(history_record)
except Exception as e:
# Ошибка записи в историю не должна ломать процесс бана
self.logger.error(
f"Ошибка записи в историю банов для user_id={user_id}: {e}"
)
async def delete_user_blacklist(self, user_id: int) -> bool:
"""Удаляет пользователя из черного списка."""
"""
Удаляет пользователя из черного списка.
Также обновляет запись в истории банов, устанавливая date_unban.
"""
# Сначала обновляем историю (если есть открытая запись)
try:
date_unban = int(datetime.now().timestamp())
await self.factory.blacklist_history.set_unban_date(user_id, date_unban)
except Exception as e:
# Ошибка записи в историю не должна ломать критический путь разбана
self.logger.error(
f"Ошибка обновления истории при разбане для user_id={user_id}: {e}"
)
# Удаляем из черного списка (критический путь)
return await self.factory.blacklist.remove_user(user_id)
async def check_user_in_blacklist(self, user_id: int) -> bool:

View File

@@ -29,6 +29,19 @@ class BlacklistUser:
ban_author: Optional[int] = None
@dataclass
class BlacklistHistoryRecord:
"""Модель записи истории банов/разбанов."""
user_id: int
message_for_user: Optional[str] = None
date_ban: int = 0
date_unban: Optional[int] = None
ban_author: Optional[int] = None
id: Optional[int] = None
created_at: Optional[int] = None
updated_at: Optional[int] = None
@dataclass
class UserMessage:
"""Модель сообщения пользователя."""

View File

@@ -4,6 +4,7 @@
Содержит репозитории для разных сущностей:
- user_repository: работа с пользователями
- blacklist_repository: работа с черным списком
- blacklist_history_repository: работа с историей банов/разбанов
- message_repository: работа с сообщениями
- post_repository: работа с постами
- admin_repository: работа с администраторами
@@ -12,12 +13,13 @@
from .user_repository import UserRepository
from .blacklist_repository import BlacklistRepository
from .blacklist_history_repository import BlacklistHistoryRepository
from .message_repository import MessageRepository
from .post_repository import PostRepository
from .admin_repository import AdminRepository
from .audio_repository import AudioRepository
__all__ = [
'UserRepository', 'BlacklistRepository', 'MessageRepository', 'PostRepository',
'AdminRepository', 'AudioRepository'
'UserRepository', 'BlacklistRepository', 'BlacklistHistoryRepository',
'MessageRepository', 'PostRepository', 'AdminRepository', 'AudioRepository'
]

View File

@@ -0,0 +1,119 @@
from typing import Optional
from database.base import DatabaseConnection
from database.models import BlacklistHistoryRecord
class BlacklistHistoryRepository(DatabaseConnection):
"""Репозиторий для работы с историей банов/разбанов."""
async def create_tables(self):
"""Создание таблицы истории банов/разбанов."""
query = '''
CREATE TABLE IF NOT EXISTS blacklist_history (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
message_for_user TEXT,
date_ban INTEGER NOT NULL,
date_unban INTEGER,
ban_author INTEGER,
created_at INTEGER DEFAULT (strftime('%s', 'now')),
updated_at INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE,
FOREIGN KEY (ban_author) REFERENCES our_users(user_id) ON DELETE SET NULL
)
'''
await self._execute_query(query)
# Создаем индексы
await self._execute_query(
"CREATE INDEX IF NOT EXISTS idx_blacklist_history_user_id ON blacklist_history(user_id)"
)
await self._execute_query(
"CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_ban ON blacklist_history(date_ban)"
)
await self._execute_query(
"CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_unban ON blacklist_history(date_unban)"
)
self.logger.info("Таблица истории банов/разбанов создана")
async def add_record_on_ban(self, record: BlacklistHistoryRecord) -> None:
"""Добавляет запись о бане в историю."""
query = """
INSERT INTO blacklist_history (
user_id, message_for_user, date_ban, date_unban, ban_author, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?)
"""
# Используем текущее время, если не указано
from datetime import datetime
current_timestamp = int(datetime.now().timestamp())
params = (
record.user_id,
record.message_for_user,
record.date_ban,
record.date_unban,
record.ban_author,
record.created_at if record.created_at is not None else current_timestamp,
record.updated_at if record.updated_at is not None else current_timestamp,
)
await self._execute_query(query, params)
self.logger.info(
f"Запись о бане добавлена в историю: user_id={record.user_id}, "
f"date_ban={record.date_ban}"
)
async def set_unban_date(self, user_id: int, date_unban: int) -> bool:
"""
Обновляет date_unban и updated_at в последней записи (date_unban IS NULL) для пользователя.
Args:
user_id: ID пользователя
date_unban: Timestamp даты разбана
Returns:
True если запись обновлена, False если не найдена открытая запись
"""
try:
from datetime import datetime
current_timestamp = int(datetime.now().timestamp())
# SQLite не поддерживает ORDER BY в UPDATE, поэтому используем подзапрос
# Сначала проверяем, есть ли открытая запись
check_query = """
SELECT id FROM blacklist_history
WHERE user_id = ? AND date_unban IS NULL
ORDER BY id DESC
LIMIT 1
"""
rows = await self._execute_query_with_result(check_query, (user_id,))
if not rows:
self.logger.warning(
f"Не найдена открытая запись в истории для обновления: user_id={user_id}"
)
return False
# Обновляем найденную запись
update_query = """
UPDATE blacklist_history
SET date_unban = ?,
updated_at = ?
WHERE id = ?
"""
record_id = rows[0][0]
params = (date_unban, current_timestamp, record_id)
await self._execute_query(update_query, params)
self.logger.info(
f"Дата разбана обновлена в истории: user_id={user_id}, date_unban={date_unban}"
)
return True
except Exception as e:
self.logger.error(
f"Ошибка обновления даты разбана в истории для user_id={user_id}: {str(e)}"
)
return False

View File

@@ -1,6 +1,7 @@
from typing import Optional
from database.repositories.user_repository import UserRepository
from database.repositories.blacklist_repository import BlacklistRepository
from database.repositories.blacklist_history_repository import BlacklistHistoryRepository
from database.repositories.message_repository import MessageRepository
from database.repositories.post_repository import PostRepository
from database.repositories.admin_repository import AdminRepository
@@ -14,6 +15,7 @@ class RepositoryFactory:
self.db_path = db_path
self._user_repo: Optional[UserRepository] = None
self._blacklist_repo: Optional[BlacklistRepository] = None
self._blacklist_history_repo: Optional[BlacklistHistoryRepository] = None
self._message_repo: Optional[MessageRepository] = None
self._post_repo: Optional[PostRepository] = None
self._admin_repo: Optional[AdminRepository] = None
@@ -33,6 +35,13 @@ class RepositoryFactory:
self._blacklist_repo = BlacklistRepository(self.db_path)
return self._blacklist_repo
@property
def blacklist_history(self) -> BlacklistHistoryRepository:
"""Возвращает репозиторий истории банов/разбанов."""
if self._blacklist_history_repo is None:
self._blacklist_history_repo = BlacklistHistoryRepository(self.db_path)
return self._blacklist_history_repo
@property
def messages(self) -> MessageRepository:
"""Возвращает репозиторий сообщений."""
@@ -65,6 +74,7 @@ class RepositoryFactory:
"""Создает все таблицы в базе данных."""
await self.users.create_tables()
await self.blacklist.create_tables()
await self.blacklist_history.create_tables()
await self.messages.create_tables()
await self.posts.create_tables()
await self.admins.create_tables()

View File

@@ -40,6 +40,20 @@ CREATE TABLE IF NOT EXISTS blacklist (
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE
);
-- Blacklist history for tracking all ban/unban events
CREATE TABLE IF NOT EXISTS blacklist_history (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
message_for_user TEXT,
date_ban INTEGER NOT NULL,
date_unban INTEGER,
ban_author INTEGER,
created_at INTEGER DEFAULT (strftime('%s', 'now')),
updated_at INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE,
FOREIGN KEY (ban_author) REFERENCES our_users(user_id) ON DELETE SET NULL
);
-- User message history
CREATE TABLE IF NOT EXISTS user_messages (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
@@ -109,6 +123,9 @@ CREATE INDEX IF NOT EXISTS idx_audio_message_reference_author_id ON audio_messag
CREATE INDEX IF NOT EXISTS idx_user_messages_user_id ON user_messages(user_id);
CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_author_id ON post_from_telegram_suggest(author_id);
CREATE INDEX IF NOT EXISTS idx_blacklist_date_to_unban ON blacklist(date_to_unban);
CREATE INDEX IF NOT EXISTS idx_blacklist_history_user_id ON blacklist_history(user_id);
CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_ban ON blacklist_history(date_ban);
CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_unban ON blacklist_history(date_unban);
CREATE INDEX IF NOT EXISTS idx_user_messages_date ON user_messages(date);
CREATE INDEX IF NOT EXISTS idx_audio_message_reference_date ON audio_message_reference(date_added);
CREATE INDEX IF NOT EXISTS idx_post_from_telegram_suggest_date ON post_from_telegram_suggest(created_at);

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""
Скрипт миграции для создания таблицы blacklist_history.
Таблица хранит историю всех операций бана/разбана пользователей.
"""
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
from logs.custom_logger import logger
DEFAULT_DB_PATH = "database/tg-bot-database.db"
def _table_exists(rows: list, table_name: str) -> bool:
"""Проверяет существование таблицы по результатам PRAGMA table_list."""
for row in rows:
if row[1] == table_name: # name column
return True
return False
async def main(db_path: str) -> None:
db_path = os.path.abspath(db_path)
if not os.path.exists(db_path):
logger.error("База данных не найдена: %s", db_path)
print(f"Ошибка: база данных не найдена: {db_path}")
return
async with aiosqlite.connect(db_path) as conn:
await conn.execute("PRAGMA foreign_keys = ON")
# Проверяем наличие таблицы blacklist_history
cursor = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='blacklist_history'"
)
rows = await cursor.fetchall()
await cursor.close()
if not rows:
logger.info("Создание таблицы blacklist_history")
# Создаем таблицу
await conn.execute("""
CREATE TABLE IF NOT EXISTS blacklist_history (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
message_for_user TEXT,
date_ban INTEGER NOT NULL,
date_unban INTEGER,
ban_author INTEGER,
created_at INTEGER DEFAULT (strftime('%s', 'now')),
updated_at INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE,
FOREIGN KEY (ban_author) REFERENCES our_users(user_id) ON DELETE SET NULL
)
""")
# Создаем индексы
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_blacklist_history_user_id ON blacklist_history(user_id)"
)
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_ban ON blacklist_history(date_ban)"
)
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_unban ON blacklist_history(date_unban)"
)
await conn.commit()
logger.info("Таблица blacklist_history и индексы успешно созданы")
print("Таблица blacklist_history и индексы успешно созданы.")
else:
print("Таблица blacklist_history уже существует.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Создание таблицы blacklist_history для истории банов/разбанов"
)
parser.add_argument(
"--db",
default=os.environ.get("DB_PATH", DEFAULT_DB_PATH),
help="Путь к БД (или DB_PATH)",
)
args = parser.parse_args()
asyncio.run(main(args.db))

View File

@@ -14,6 +14,13 @@ class TestAsyncBotDB:
mock_factory.audio.delete_audio_moderate_record = AsyncMock()
mock_factory.users = Mock()
mock_factory.users.logger = Mock()
# Моки для blacklist и blacklist_history
mock_factory.blacklist = Mock()
mock_factory.blacklist.add_user = AsyncMock()
mock_factory.blacklist.remove_user = AsyncMock(return_value=True)
mock_factory.blacklist_history = Mock()
mock_factory.blacklist_history.add_record_on_ban = AsyncMock()
mock_factory.blacklist_history.set_unban_date = AsyncMock(return_value=True)
return mock_factory
@pytest.fixture
@@ -102,3 +109,107 @@ class TestAsyncBotDB:
await async_bot_db.delete_audio_moderate_record(message_id)
mock_factory.audio.delete_audio_moderate_record.assert_called_once_with(message_id)
@pytest.mark.asyncio
async def test_set_user_blacklist_calls_history(self, async_bot_db, mock_factory):
"""Тест что set_user_blacklist вызывает добавление в историю"""
user_id = 12345
message_for_user = "Нарушение правил"
date_to_unban = 1234567890
ban_author = 999
await async_bot_db.set_user_blacklist(
user_id=user_id,
user_name=None,
message_for_user=message_for_user,
date_to_unban=date_to_unban,
ban_author=ban_author
)
# Проверяем, что сначала добавлен в blacklist
mock_factory.blacklist.add_user.assert_called_once()
# Проверяем, что затем добавлена запись в историю
mock_factory.blacklist_history.add_record_on_ban.assert_called_once()
# Проверяем параметры записи в историю
history_call = mock_factory.blacklist_history.add_record_on_ban.call_args[0][0]
assert history_call.user_id == user_id
assert history_call.message_for_user == message_for_user
assert history_call.date_ban is not None
assert history_call.date_unban is None
assert history_call.ban_author == ban_author
@pytest.mark.asyncio
async def test_set_user_blacklist_history_error_does_not_fail(self, async_bot_db, mock_factory):
"""Тест что ошибка записи в историю не ломает процесс бана"""
user_id = 12345
mock_factory.blacklist_history.add_record_on_ban.side_effect = Exception("History error")
# Бан должен пройти успешно, несмотря на ошибку в истории
await async_bot_db.set_user_blacklist(
user_id=user_id,
message_for_user="Тест",
date_to_unban=None,
ban_author=None
)
# Проверяем, что пользователь все равно добавлен в blacklist
mock_factory.blacklist.add_user.assert_called_once()
# Проверяем, что попытка записи в историю была
mock_factory.blacklist_history.add_record_on_ban.assert_called_once()
@pytest.mark.asyncio
async def test_delete_user_blacklist_calls_history(self, async_bot_db, mock_factory):
"""Тест что delete_user_blacklist вызывает обновление истории"""
user_id = 12345
result = await async_bot_db.delete_user_blacklist(user_id)
# Проверяем, что сначала обновлена история
mock_factory.blacklist_history.set_unban_date.assert_called_once()
history_call = mock_factory.blacklist_history.set_unban_date.call_args
assert history_call[0][0] == user_id
assert history_call[0][1] is not None # date_unban timestamp
# Проверяем, что затем удален из blacklist
mock_factory.blacklist.remove_user.assert_called_once_with(user_id)
# Проверяем результат
assert result is True
@pytest.mark.asyncio
async def test_delete_user_blacklist_history_error_does_not_fail(self, async_bot_db, mock_factory):
"""Тест что ошибка обновления истории не ломает процесс разбана"""
user_id = 12345
mock_factory.blacklist_history.set_unban_date.side_effect = Exception("History error")
# Разбан должен пройти успешно, несмотря на ошибку в истории
result = await async_bot_db.delete_user_blacklist(user_id)
# Проверяем, что попытка обновления истории была
mock_factory.blacklist_history.set_unban_date.assert_called_once()
# Проверяем, что пользователь все равно удален из blacklist
mock_factory.blacklist.remove_user.assert_called_once_with(user_id)
# Проверяем результат
assert result is True
@pytest.mark.asyncio
async def test_delete_user_blacklist_returns_false_on_blacklist_error(self, async_bot_db, mock_factory):
"""Тест что delete_user_blacklist возвращает False при ошибке удаления из blacklist"""
user_id = 12345
mock_factory.blacklist.remove_user.return_value = False
result = await async_bot_db.delete_user_blacklist(user_id)
# Проверяем, что история обновлена
mock_factory.blacklist_history.set_unban_date.assert_called_once()
# Проверяем, что удаление из blacklist было попытка
mock_factory.blacklist.remove_user.assert_called_once_with(user_id)
# Проверяем результат
assert result is False

View File

@@ -17,7 +17,7 @@ class TestAutoUnbanIntegration:
@pytest.fixture
def setup_test_db(self, test_db_path):
"""Создает тестовую базу данных с таблицей blacklist"""
"""Создает тестовую базу данных с таблицами blacklist, our_users и blacklist_history"""
# Удаляем старую тестовую базу если она существует
if os.path.exists(test_db_path):
os.remove(test_db_path)
@@ -26,30 +26,112 @@ class TestAutoUnbanIntegration:
conn = sqlite3.connect(test_db_path)
cursor = conn.cursor()
# Создаем таблицу blacklist
# Включаем поддержку внешних ключей
cursor.execute("PRAGMA foreign_keys = ON")
# Создаем таблицу our_users (нужна для внешних ключей)
cursor.execute('''
CREATE TABLE IF NOT EXISTS blacklist (
user_id INTEGER PRIMARY KEY,
user_name TEXT,
message_for_user TEXT,
date_to_unban INTEGER
CREATE TABLE IF NOT EXISTS our_users (
user_id INTEGER NOT NULL PRIMARY KEY,
first_name TEXT,
full_name TEXT,
username TEXT,
is_bot BOOLEAN DEFAULT 0,
language_code TEXT,
has_stickers BOOLEAN DEFAULT 0 NOT NULL,
emoji TEXT,
date_added INTEGER NOT NULL,
date_changed INTEGER NOT NULL,
voice_bot_welcome_received BOOLEAN DEFAULT 0
)
''')
# Добавляем тестовые данные
# Создаем таблицу blacklist
cursor.execute('''
CREATE TABLE IF NOT EXISTS blacklist (
user_id INTEGER NOT NULL PRIMARY KEY,
message_for_user TEXT,
date_to_unban INTEGER,
created_at INTEGER DEFAULT (strftime('%s', 'now')),
ban_author INTEGER,
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE
)
''')
# Создаем таблицу blacklist_history
cursor.execute('''
CREATE TABLE IF NOT EXISTS blacklist_history (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
message_for_user TEXT,
date_ban INTEGER NOT NULL,
date_unban INTEGER,
ban_author INTEGER,
created_at INTEGER DEFAULT (strftime('%s', 'now')),
updated_at INTEGER DEFAULT (strftime('%s', 'now')),
FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE,
FOREIGN KEY (ban_author) REFERENCES our_users(user_id) ON DELETE SET NULL
)
''')
# Создаем индексы для blacklist_history
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_blacklist_history_user_id ON blacklist_history(user_id)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_ban ON blacklist_history(date_ban)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_blacklist_history_date_unban ON blacklist_history(date_unban)
''')
# Добавляем тестовых пользователей в our_users
current_time = int(datetime.now(timezone(timedelta(hours=3))).timestamp())
users_data = [
(123, "Test", "Test User 1", "test_user1", 0, "ru", 0, "😊", current_time, current_time, 0),
(456, "Test", "Test User 2", "test_user2", 0, "ru", 0, "😊", current_time, current_time, 0),
(789, "Test", "Test User 3", "test_user3", 0, "ru", 0, "😊", current_time, current_time, 0),
(999, "Test", "Test User 4", "test_user4", 0, "ru", 0, "😊", current_time, current_time, 0),
]
cursor.executemany(
"""INSERT INTO our_users (user_id, first_name, full_name, username, is_bot,
language_code, has_stickers, emoji, date_added, date_changed, voice_bot_welcome_received)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
users_data
)
# Добавляем тестовые данные в blacklist
today_timestamp = int(datetime.now(timezone(timedelta(hours=3))).timestamp())
tomorrow_timestamp = int((datetime.now(timezone(timedelta(hours=3))) + timedelta(days=1)).timestamp())
test_data = [
(123, "test_user1", "Test ban 1", today_timestamp), # Разблокируется сегодня
(456, "test_user2", "Test ban 2", today_timestamp), # Разблокируется сегодня
(789, "test_user3", "Test ban 3", tomorrow_timestamp), # Разблокируется завтра
(999, "test_user4", "Test ban 4", None), # Навсегда заблокирован
blacklist_data = [
(123, "Test ban 1", today_timestamp, current_time, None), # Разблокируется сегодня
(456, "Test ban 2", today_timestamp, current_time, None), # Разблокируется сегодня
(789, "Test ban 3", tomorrow_timestamp, current_time, None), # Разблокируется завтра
(999, "Test ban 4", None, current_time, None), # Навсегда заблокирован
]
cursor.executemany(
"INSERT INTO blacklist (user_id, user_name, message_for_user, date_to_unban) VALUES (?, ?, ?, ?)",
test_data
"INSERT INTO blacklist (user_id, message_for_user, date_to_unban, created_at, ban_author) VALUES (?, ?, ?, ?, ?)",
blacklist_data
)
# Добавляем тестовые данные в blacklist_history
# Для пользователей 123 и 456 (которые будут разблокированы) создаем записи с date_unban = NULL
yesterday_timestamp = int((datetime.now(timezone(timedelta(hours=3))) - timedelta(days=1)).timestamp())
history_data = [
(123, "Test ban 1", yesterday_timestamp, None, None, yesterday_timestamp, yesterday_timestamp), # Будет разблокирован
(456, "Test ban 2", yesterday_timestamp, None, None, yesterday_timestamp, yesterday_timestamp), # Будет разблокирован
(789, "Test ban 3", yesterday_timestamp, None, None, yesterday_timestamp, yesterday_timestamp), # Не будет разблокирован сегодня
(999, "Test ban 4", yesterday_timestamp, None, None, yesterday_timestamp, yesterday_timestamp), # Навсегда заблокирован
]
cursor.executemany(
"""INSERT INTO blacklist_history
(user_id, message_for_user, date_ban, date_unban, ban_author, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
history_data
)
conn.commit()
@@ -105,9 +187,20 @@ class TestAutoUnbanIntegration:
initial_count = cursor.fetchone()[0]
assert initial_count == 4
# Проверяем начальное состояние истории: должно быть 2 записи с date_unban IS NULL для user_id 123 и 456
cursor.execute("SELECT COUNT(*) FROM blacklist_history WHERE user_id IN (123, 456) AND date_unban IS NULL")
initial_open_history = cursor.fetchone()[0]
assert initial_open_history == 2
# Запоминаем время до разбана для проверки updated_at
before_unban_timestamp = int(datetime.now(timezone(timedelta(hours=3))).timestamp())
# Выполняем автоматический разбан
await scheduler.auto_unban_users()
# Запоминаем время после разбана для проверки updated_at
after_unban_timestamp = int(datetime.now(timezone(timedelta(hours=3))).timestamp())
# Проверяем, что пользователи с сегодняшней датой разблокированы
current_timestamp = int(datetime.now(timezone(timedelta(hours=3))).timestamp())
cursor.execute("SELECT COUNT(*) FROM blacklist WHERE date_to_unban IS NOT NULL AND date_to_unban <= ?",
@@ -131,6 +224,32 @@ class TestAutoUnbanIntegration:
final_count = cursor.fetchone()[0]
assert final_count == 2 # Остались только завтрашние и навсегда заблокированные
# Проверяем историю банов: для user_id 123 и 456 должны быть установлены date_unban
cursor.execute("SELECT user_id, date_unban, updated_at FROM blacklist_history WHERE user_id IN (123, 456) ORDER BY user_id")
history_records = cursor.fetchall()
assert len(history_records) == 2
for user_id, date_unban, updated_at in history_records:
# Проверяем, что date_unban установлен (не NULL)
assert date_unban is not None, f"date_unban должен быть установлен для user_id={user_id}"
assert isinstance(date_unban, int), f"date_unban должен быть integer для user_id={user_id}"
# Проверяем, что date_unban находится в разумных пределах (между before и after)
assert before_unban_timestamp <= date_unban <= after_unban_timestamp, \
f"date_unban для user_id={user_id} должен быть между {before_unban_timestamp} и {after_unban_timestamp}, получен {date_unban}"
# Проверяем, что updated_at обновлен
assert updated_at is not None, f"updated_at должен быть установлен для user_id={user_id}"
assert isinstance(updated_at, int), f"updated_at должен быть integer для user_id={user_id}"
assert before_unban_timestamp <= updated_at <= after_unban_timestamp, \
f"updated_at для user_id={user_id} должен быть между {before_unban_timestamp} и {after_unban_timestamp}, получен {updated_at}"
# Проверяем, что для user_id 789 и 999 записи в истории остались без изменений (date_unban все еще NULL)
cursor.execute("SELECT COUNT(*) FROM blacklist_history WHERE user_id IN (789, 999) AND date_unban IS NULL")
unchanged_history = cursor.fetchone()[0]
assert unchanged_history == 2, "Записи для user_id 789 и 999 должны остаться с date_unban = NULL"
conn.close()
# Проверяем, что отчет был отправлен
@@ -148,6 +267,12 @@ class TestAutoUnbanIntegration:
cursor = conn.cursor()
current_timestamp = int(datetime.now(timezone(timedelta(hours=3))).timestamp())
cursor.execute("DELETE FROM blacklist WHERE date_to_unban IS NOT NULL AND date_to_unban <= ?", (current_timestamp,))
# Проверяем начальное состояние истории: все записи должны иметь date_unban = NULL
cursor.execute("SELECT COUNT(*) FROM blacklist_history WHERE date_unban IS NULL")
initial_open_history = cursor.fetchone()[0]
assert initial_open_history == 4 # Все 4 записи должны быть открытыми
conn.commit()
conn.close()
@@ -159,6 +284,14 @@ class TestAutoUnbanIntegration:
# Выполняем автоматический разбан
await scheduler.auto_unban_users()
# Проверяем, что история не изменилась (все записи все еще с date_unban = NULL)
conn = sqlite3.connect(setup_test_db)
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM blacklist_history WHERE date_unban IS NULL")
final_open_history = cursor.fetchone()[0]
assert final_open_history == 4, "История не должна изменяться, если нет пользователей для разблокировки"
conn.close()
# Проверяем, что отчет не был отправлен (нет пользователей для разблокировки)
mock_bot.send_message.assert_not_called()
@@ -190,6 +323,100 @@ class TestAutoUnbanIntegration:
assert call_args[1]['chat_id'] == '-1001234567891' # important_logs
assert "Ошибка автоматического разбана" in call_args[1]['text']
@pytest.mark.asyncio
@patch('helper_bot.utils.auto_unban_scheduler.get_global_instance')
async def test_auto_unban_updates_history(self, mock_get_instance, setup_test_db, mock_bdf, mock_bot):
"""Тест что автоматический разбан обновляет историю банов"""
# Настройка моков
mock_get_instance.return_value = mock_bdf
# Создаем планировщик
scheduler = AutoUnbanScheduler()
scheduler.bot_db = mock_bdf.database
scheduler.set_bot(mock_bot)
conn = sqlite3.connect(setup_test_db)
cursor = conn.cursor()
# Проверяем начальное состояние: для user_id 123 и 456 должны быть записи с date_unban = NULL
cursor.execute("""
SELECT id, user_id, date_ban, date_unban, updated_at
FROM blacklist_history
WHERE user_id IN (123, 456) AND date_unban IS NULL
ORDER BY user_id
""")
initial_records = cursor.fetchall()
assert len(initial_records) == 2, "Должно быть 2 открытые записи для user_id 123 и 456"
# Запоминаем ID записей и их начальные значения updated_at
record_ids = {row[0]: (row[1], row[4]) for row in initial_records}
# Запоминаем время до разбана
before_unban_timestamp = int(datetime.now(timezone(timedelta(hours=3))).timestamp())
conn.close()
# Выполняем автоматический разбан
await scheduler.auto_unban_users()
# Запоминаем время после разбана
after_unban_timestamp = int(datetime.now(timezone(timedelta(hours=3))).timestamp())
# Проверяем, что записи обновлены
conn = sqlite3.connect(setup_test_db)
cursor = conn.cursor()
cursor.execute("""
SELECT id, user_id, date_ban, date_unban, updated_at
FROM blacklist_history
WHERE user_id IN (123, 456)
ORDER BY user_id
""")
updated_records = cursor.fetchall()
assert len(updated_records) == 2, "Должно быть 2 записи для user_id 123 и 456"
for record_id, user_id, date_ban, date_unban, updated_at in updated_records:
# Проверяем, что это одна из наших записей
assert record_id in record_ids, f"Запись с id={record_id} должна быть в исходных записях"
# Проверяем, что date_unban установлен
assert date_unban is not None, f"date_unban должен быть установлен для user_id={user_id}"
assert isinstance(date_unban, int), f"date_unban должен быть integer для user_id={user_id}"
# Проверяем, что date_unban находится в разумных пределах
assert before_unban_timestamp <= date_unban <= after_unban_timestamp, \
f"date_unban для user_id={user_id} должен быть между {before_unban_timestamp} и {after_unban_timestamp}"
# Проверяем, что updated_at обновлен (должен быть больше начального значения)
assert updated_at is not None, f"updated_at должен быть установлен для user_id={user_id}"
assert isinstance(updated_at, int), f"updated_at должен быть integer для user_id={user_id}"
assert before_unban_timestamp <= updated_at <= after_unban_timestamp, \
f"updated_at для user_id={user_id} должен быть между {before_unban_timestamp} и {after_unban_timestamp}"
# Проверяем, что updated_at действительно обновлен (больше начального значения)
initial_updated_at = record_ids[record_id][1]
assert updated_at >= initial_updated_at, \
f"updated_at для user_id={user_id} должен быть больше или равен начальному значению"
# Проверяем, что обновлена только последняя запись для каждого пользователя
# (если бы было несколько записей, обновилась бы только последняя)
cursor.execute("""
SELECT COUNT(*) FROM blacklist_history
WHERE user_id IN (123, 456) AND date_unban IS NOT NULL
""")
closed_records = cursor.fetchone()[0]
assert closed_records == 2, "Должно быть закрыто 2 записи (по одной для каждого пользователя)"
cursor.execute("""
SELECT COUNT(*) FROM blacklist_history
WHERE user_id IN (123, 456) AND date_unban IS NULL
""")
open_records = cursor.fetchone()[0]
assert open_records == 0, "Не должно быть открытых записей для user_id 123 и 456"
conn.close()
def test_date_format_consistency(self, setup_test_db, mock_bdf):
"""Тест консистентности формата дат"""
scheduler = AutoUnbanScheduler()

View File

@@ -0,0 +1,257 @@
import pytest
from unittest.mock import Mock, AsyncMock, patch
from datetime import datetime
import time
from database.repositories.blacklist_history_repository import BlacklistHistoryRepository
from database.models import BlacklistHistoryRecord
class TestBlacklistHistoryRepository:
"""Тесты для BlacklistHistoryRepository"""
@pytest.fixture
def mock_db_connection(self):
"""Мок для DatabaseConnection"""
mock_connection = Mock()
mock_connection._execute_query = AsyncMock()
mock_connection._execute_query_with_result = AsyncMock()
mock_connection.logger = Mock()
return mock_connection
@pytest.fixture
def blacklist_history_repository(self, mock_db_connection):
"""Экземпляр BlacklistHistoryRepository для тестов"""
# Патчим наследование от DatabaseConnection
with patch.object(BlacklistHistoryRepository, '__init__', return_value=None):
repo = BlacklistHistoryRepository()
repo._execute_query = mock_db_connection._execute_query
repo._execute_query_with_result = mock_db_connection._execute_query_with_result
repo.logger = mock_db_connection.logger
return repo
@pytest.fixture
def sample_history_record(self):
"""Тестовая запись истории бана"""
current_time = int(time.time())
return BlacklistHistoryRecord(
user_id=12345,
message_for_user="Нарушение правил",
date_ban=current_time,
date_unban=None,
ban_author=999,
created_at=current_time,
updated_at=current_time,
)
@pytest.fixture
def sample_history_record_with_unban(self):
"""Тестовая запись истории бана с датой разбана"""
current_time = int(time.time())
return BlacklistHistoryRecord(
user_id=12345,
message_for_user="Нарушение правил",
date_ban=current_time - 86400, # Бан был вчера
date_unban=current_time, # Разбан сегодня
ban_author=999,
created_at=current_time - 86400,
updated_at=current_time,
)
@pytest.mark.asyncio
async def test_create_tables(self, blacklist_history_repository):
"""Тест создания таблицы истории банов/разбанов"""
await blacklist_history_repository.create_tables()
# Проверяем, что метод вызван (4 раза: таблица + 3 индекса)
assert blacklist_history_repository._execute_query.call_count == 4
calls = blacklist_history_repository._execute_query.call_args_list
# Проверяем, что создается таблица с правильной структурой
create_table_call = calls[0]
assert "CREATE TABLE IF NOT EXISTS blacklist_history" in create_table_call[0][0]
assert "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT" in create_table_call[0][0]
assert "user_id INTEGER NOT NULL" in create_table_call[0][0]
assert "message_for_user TEXT" in create_table_call[0][0]
assert "date_ban INTEGER NOT NULL" in create_table_call[0][0]
assert "date_unban INTEGER" in create_table_call[0][0]
assert "ban_author INTEGER" in create_table_call[0][0]
assert "created_at INTEGER DEFAULT (strftime('%s', 'now'))" in create_table_call[0][0]
assert "updated_at INTEGER DEFAULT (strftime('%s', 'now'))" in create_table_call[0][0]
assert "FOREIGN KEY (user_id) REFERENCES our_users(user_id) ON DELETE CASCADE" in create_table_call[0][0]
assert "FOREIGN KEY (ban_author) REFERENCES our_users(user_id) ON DELETE SET NULL" in create_table_call[0][0]
# Проверяем создание индексов
index_calls = calls[1:4]
index_names = [call[0][0] for call in index_calls]
assert any("idx_blacklist_history_user_id" in idx for idx in index_names)
assert any("idx_blacklist_history_date_ban" in idx for idx in index_names)
assert any("idx_blacklist_history_date_unban" in idx for idx in index_names)
# Проверяем логирование
blacklist_history_repository.logger.info.assert_called_once_with(
"Таблица истории банов/разбанов создана"
)
@pytest.mark.asyncio
async def test_add_record_on_ban(self, blacklist_history_repository, sample_history_record):
"""Тест добавления записи о бане в историю"""
await blacklist_history_repository.add_record_on_ban(sample_history_record)
# Проверяем, что метод вызван с правильными параметрами
blacklist_history_repository._execute_query.assert_called_once()
call_args = blacklist_history_repository._execute_query.call_args
# Проверяем SQL запрос
sql_query = call_args[0][0].replace('\n', ' ').replace(' ', ' ').strip()
assert "INSERT INTO blacklist_history" in sql_query
assert "user_id, message_for_user, date_ban, date_unban, ban_author, created_at, updated_at" in sql_query
# Проверяем параметры
params = call_args[0][1]
assert params[0] == 12345 # user_id
assert params[1] == "Нарушение правил" # message_for_user
assert params[2] == sample_history_record.date_ban # date_ban
assert params[3] is None # date_unban
assert params[4] == 999 # ban_author
assert params[5] == sample_history_record.created_at # created_at
assert params[6] == sample_history_record.updated_at # updated_at
# Проверяем логирование
blacklist_history_repository.logger.info.assert_called_once()
log_call = blacklist_history_repository.logger.info.call_args[0][0]
assert "Запись о бане добавлена в историю" in log_call
assert "user_id=12345" in log_call
@pytest.mark.asyncio
async def test_add_record_on_ban_with_defaults(self, blacklist_history_repository):
"""Тест добавления записи о бане с дефолтными значениями created_at и updated_at"""
record = BlacklistHistoryRecord(
user_id=12345,
message_for_user="Тест",
date_ban=int(time.time()),
date_unban=None,
ban_author=None,
created_at=None, # Будет установлено автоматически
updated_at=None, # Будет установлено автоматически
)
await blacklist_history_repository.add_record_on_ban(record)
# Проверяем, что метод вызван
blacklist_history_repository._execute_query.assert_called_once()
call_args = blacklist_history_repository._execute_query.call_args
# Проверяем, что created_at и updated_at установлены (не None)
params = call_args[0][1]
assert params[5] is not None # created_at
assert params[6] is not None # updated_at
assert isinstance(params[5], int)
assert isinstance(params[6], int)
@pytest.mark.asyncio
async def test_set_unban_date_success(self, blacklist_history_repository):
"""Тест успешного обновления даты разбана"""
user_id = 12345
date_unban = int(time.time())
# Мокируем результат проверки - находим открытую запись
blacklist_history_repository._execute_query_with_result.return_value = [(100,)] # id записи
result = await blacklist_history_repository.set_unban_date(user_id, date_unban)
# Проверяем, что сначала проверяется наличие записи
assert blacklist_history_repository._execute_query_with_result.call_count == 1
check_call = blacklist_history_repository._execute_query_with_result.call_args
assert "SELECT id FROM blacklist_history" in check_call[0][0]
assert check_call[0][1] == (user_id,)
# Проверяем, что затем обновляется запись
assert blacklist_history_repository._execute_query.call_count == 1
update_call = blacklist_history_repository._execute_query.call_args
update_query = update_call[0][0].replace('\n', ' ').replace(' ', ' ').strip()
assert "UPDATE blacklist_history" in update_query
assert "SET date_unban = ?" in update_query
assert "updated_at = ?" in update_query
# Проверяем параметры обновления
update_params = update_call[0][1]
assert update_params[0] == date_unban
assert update_params[1] is not None # updated_at (текущее время)
assert isinstance(update_params[1], int)
assert update_params[2] == 100 # id записи
# Проверяем результат
assert result is True
# Проверяем логирование
blacklist_history_repository.logger.info.assert_called_once()
log_call = blacklist_history_repository.logger.info.call_args[0][0]
assert "Дата разбана обновлена в истории" in log_call
assert f"user_id={user_id}" in log_call
@pytest.mark.asyncio
async def test_set_unban_date_no_open_record(self, blacklist_history_repository):
"""Тест обновления даты разбана когда нет открытой записи"""
user_id = 12345
date_unban = int(time.time())
# Мокируем результат проверки - нет открытых записей
blacklist_history_repository._execute_query_with_result.return_value = []
result = await blacklist_history_repository.set_unban_date(user_id, date_unban)
# Проверяем, что проверка была выполнена
assert blacklist_history_repository._execute_query_with_result.call_count == 1
# Проверяем, что UPDATE не был вызван (нет записей для обновления)
blacklist_history_repository._execute_query.assert_not_called()
# Проверяем результат
assert result is False
# Проверяем логирование предупреждения
blacklist_history_repository.logger.warning.assert_called_once()
log_call = blacklist_history_repository.logger.warning.call_args[0][0]
assert "Не найдена открытая запись в истории для обновления" in log_call
assert f"user_id={user_id}" in log_call
@pytest.mark.asyncio
async def test_set_unban_date_exception(self, blacklist_history_repository):
"""Тест обработки исключения при обновлении даты разбана"""
user_id = 12345
date_unban = int(time.time())
# Мокируем исключение при проверке
blacklist_history_repository._execute_query_with_result.side_effect = Exception("Database error")
result = await blacklist_history_repository.set_unban_date(user_id, date_unban)
# Проверяем, что метод вернул False при ошибке
assert result is False
# Проверяем логирование ошибки
blacklist_history_repository.logger.error.assert_called_once()
log_call = blacklist_history_repository.logger.error.call_args[0][0]
assert "Ошибка обновления даты разбана в истории" in log_call
assert f"user_id={user_id}" in log_call
@pytest.mark.asyncio
async def test_set_unban_date_update_exception(self, blacklist_history_repository):
"""Тест обработки исключения при обновлении записи"""
user_id = 12345
date_unban = int(time.time())
# Мокируем успешную проверку, но ошибку при обновлении
blacklist_history_repository._execute_query_with_result.return_value = [(100,)]
blacklist_history_repository._execute_query.side_effect = Exception("Update error")
result = await blacklist_history_repository.set_unban_date(user_id, date_unban)
# Проверяем, что метод вернул False при ошибке
assert result is False
# Проверяем логирование ошибки
blacklist_history_repository.logger.error.assert_called_once()
log_call = blacklist_history_repository.logger.error.call_args[0][0]
assert "Ошибка обновления даты разбана в истории" in log_call