diff --git a/database/async_db.py b/database/async_db.py index 859a49f..07973eb 100644 --- a/database/async_db.py +++ b/database/async_db.py @@ -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, @@ -204,9 +207,40 @@ class AsyncBotDB: ban_author=ban_author, ) 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: diff --git a/database/models.py b/database/models.py index 12fe524..4f719d9 100644 --- a/database/models.py +++ b/database/models.py @@ -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: """Модель сообщения пользователя.""" diff --git a/database/repositories/__init__.py b/database/repositories/__init__.py index 4f8f70b..867b4bf 100644 --- a/database/repositories/__init__.py +++ b/database/repositories/__init__.py @@ -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' ] diff --git a/database/repositories/blacklist_history_repository.py b/database/repositories/blacklist_history_repository.py new file mode 100644 index 0000000..e0914a0 --- /dev/null +++ b/database/repositories/blacklist_history_repository.py @@ -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 diff --git a/database/repository_factory.py b/database/repository_factory.py index d5e5d34..154e611 100644 --- a/database/repository_factory.py +++ b/database/repository_factory.py @@ -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() diff --git a/database/schema.sql b/database/schema.sql index 5c0a132..da9e9aa 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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); diff --git a/scripts/create_blacklist_history_table.py b/scripts/create_blacklist_history_table.py new file mode 100644 index 0000000..5c1c517 --- /dev/null +++ b/scripts/create_blacklist_history_table.py @@ -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)) diff --git a/tests/test_async_db.py b/tests/test_async_db.py index d87b686..81b3f8a 100644 --- a/tests/test_async_db.py +++ b/tests/test_async_db.py @@ -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 diff --git a/tests/test_auto_unban_integration.py b/tests/test_auto_unban_integration.py index 4ddc64a..5113d92 100644 --- a/tests/test_auto_unban_integration.py +++ b/tests/test_auto_unban_integration.py @@ -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() diff --git a/tests/test_blacklist_history_repository.py b/tests/test_blacklist_history_repository.py new file mode 100644 index 0000000..b0ceca6 --- /dev/null +++ b/tests/test_blacklist_history_repository.py @@ -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