Dev 6 #9

Merged
KerradKerridi merged 13 commits from dev-6 into master 2025-08-30 11:58:45 +00:00
8 changed files with 1026 additions and 386 deletions
Showing only changes of commit 9688cdd85f - Show all commits

3
__init__.py Normal file
View File

@@ -0,0 +1,3 @@
# This file makes the root directory a Python package

View File

@@ -41,10 +41,12 @@ async def admin_panel(message: types.Message, state: FSMContext):
reply_markup=markup) reply_markup=markup)
else: else:
await message.answer('Доступ запрещен, досвидания!') await message.answer('Доступ запрещен, досвидания!')
await state.set_state("START")
except Exception as e: except Exception as e:
logger.error(f"Ошибка при запуске админ панели: {e}") logger.error(f"Ошибка при запуске админ панели: {e}")
await message.bot.send_message(IMPORTANT_LOGS, await message.bot.send_message(IMPORTANT_LOGS,
f'Ошибка в функции admin_panel {e}. Traceback: {traceback.format_exc()}') f'Ошибка в функции admin_panel {e}. Traceback: {traceback.format_exc()}')
await state.set_state("START")
@admin_router.message( @admin_router.message(
@@ -52,7 +54,12 @@ async def admin_panel(message: types.Message, state: FSMContext):
StateFilter("ADMIN"), StateFilter("ADMIN"),
F.text == 'Бан (Список)' F.text == 'Бан (Список)'
) )
async def get_last_users(message: types.Message): async def get_last_users(message: types.Message, state: FSMContext):
# Дополнительная проверка на админские права
if not check_access(message.from_user.id, BotDB):
await message.answer('Доступ запрещен!')
await state.set_state("START")
return
logger.info( logger.info(
f"Попытка получения списка последних пользователей. Текст сообщения: {message.text} Имя автора сообщения: {message.from_user.full_name})") f"Попытка получения списка последних пользователей. Текст сообщения: {message.text} Имя автора сообщения: {message.from_user.full_name})")
list_users = BotDB.get_last_users_from_db() list_users = BotDB.get_last_users_from_db()
@@ -67,6 +74,11 @@ async def get_last_users(message: types.Message):
F.text == 'Бан по нику' F.text == 'Бан по нику'
) )
async def ban_by_nickname(message: types.Message, state: FSMContext): async def ban_by_nickname(message: types.Message, state: FSMContext):
# Дополнительная проверка на админские права
if not check_access(message.from_user.id, BotDB):
await message.answer('Доступ запрещен!')
await state.set_state("START")
return
await message.answer('Пришли мне username блокируемого пользователя') await message.answer('Пришли мне username блокируемого пользователя')
await state.set_state('PRE_BAN') await state.set_state('PRE_BAN')
@@ -77,25 +89,29 @@ async def ban_by_nickname(message: types.Message, state: FSMContext):
F.text == 'Бан по ID' F.text == 'Бан по ID'
) )
async def ban_by_id(message: types.Message, state: FSMContext): async def ban_by_id(message: types.Message, state: FSMContext):
# Дополнительная проверка на админские права
if not check_access(message.from_user.id, BotDB):
await message.answer('Доступ запрещен!')
await state.set_state("START")
return
await message.answer('Пришли мне ID блокируемого пользователя') await message.answer('Пришли мне ID блокируемого пользователя')
await state.set_state('PRE_BAN_ID') await state.set_state('PRE_BAN_ID')
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("ADMIN"),
F.text == 'Тестовый бан'
)
async def ban_by_forward(message: types.Message, state: FSMContext):
await message.answer('Перешлите мне сообщение от пользователя, которого хотите заблокировать')
await state.set_state('PRE_BAN_FORWARD')
@admin_router.message( @admin_router.message(
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
StateFilter("PRE_BAN", "PRE_BAN_ID", "BAN_2"),
F.text == 'Отменить' F.text == 'Отменить'
) )
async def decline_ban(message: types.Message, state: FSMContext): async def decline_ban(message: types.Message, state: FSMContext):
# Дополнительная проверка на админские права
if not check_access(message.from_user.id, BotDB):
await message.answer('Доступ запрещен!')
await state.set_state("START")
return
current_state = await state.get_state() current_state = await state.get_state()
await state.set_data({}) await state.set_data({})
await state.set_state("ADMIN") await state.set_state("ADMIN")
@@ -109,6 +125,11 @@ async def decline_ban(message: types.Message, state: FSMContext):
StateFilter("PRE_BAN") StateFilter("PRE_BAN")
) )
async def ban_by_nickname_step_2(message: types.Message, state: FSMContext): async def ban_by_nickname_step_2(message: types.Message, state: FSMContext):
# Дополнительная проверка на админские права
if not check_access(message.from_user.id, BotDB):
await message.answer('Доступ запрещен!')
await state.set_state("START")
return
logger.info( logger.info(
f"Функция ban_by_nickname_2. Получен никнейм пользователя: {message.text}") f"Функция ban_by_nickname_2. Получен никнейм пользователя: {message.text}")
user_name = message.text user_name = message.text
@@ -132,6 +153,11 @@ async def ban_by_nickname_step_2(message: types.Message, state: FSMContext):
StateFilter("PRE_BAN_ID") StateFilter("PRE_BAN_ID")
) )
async def ban_by_id_step_2(message: types.Message, state: FSMContext): async def ban_by_id_step_2(message: types.Message, state: FSMContext):
# Дополнительная проверка на админские права
if not check_access(message.from_user.id, BotDB):
await message.answer('Доступ запрещен!')
await state.set_state("START")
return
try: try:
user_id = int(message.text) user_id = int(message.text)
logger.info(f"Функция ban_by_id_step_2. Получен ID пользователя: {user_id}") logger.info(f"Функция ban_by_id_step_2. Получен ID пользователя: {user_id}")
@@ -168,73 +194,10 @@ async def ban_by_id_step_2(message: types.Message, state: FSMContext):
await message.answer('Вернулись в меню', reply_markup=markup) await message.answer('Вернулись в меню', reply_markup=markup)
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("PRE_BAN_FORWARD"),
F.forward_from
)
async def ban_by_forward_step_2(message: types.Message, state: FSMContext):
"""Обработчик пересланных сообщений для бана пользователя"""
try:
# Получаем информацию о пользователе из пересланного сообщения
forwarded_user = message.forward_from
if not forwarded_user:
await message.answer("Не удалось получить информацию о пользователе из пересланного сообщения. Возможно, пользователь скрыл возможность пересылки своих сообщений.")
await state.set_state('ADMIN')
markup = get_reply_keyboard_admin()
await message.answer('Вернулись в меню', reply_markup=markup)
return
user_id = forwarded_user.id
user_name = forwarded_user.username or "private_username"
full_name = forwarded_user.full_name or "Неизвестно"
logger.info(f"Функция ban_by_forward_step_2. Получен пользователь из пересланного сообщения: ID={user_id}, username={user_name}, full_name={full_name}")
# Проверяем, существует ли пользователь в базе
user_info = BotDB.get_user_info_by_id(user_id)
if not user_info:
# Если пользователя нет в базе, используем информацию из пересланного сообщения
logger.info(f"Пользователь с ID {user_id} не найден в базе данных, используем данные из пересланного сообщения")
user_name = user_name
full_name = full_name
else:
# Если пользователь есть в базе, используем данные из базы
user_name = user_info.get('username', user_name)
full_name = user_info.get('full_name', full_name)
await state.update_data(user_id=user_id, user_name=user_name, message_for_user=None,
date_to_unban=None)
markup = create_keyboard_for_ban_reason()
# Экранируем потенциально проблемные символы
user_name_escaped = html.escape(str(user_name))
full_name_escaped = html.escape(str(full_name))
await message.answer(
text=f"<b>Выбран пользователь из пересланного сообщения:\nid:</b> {user_id}\n<b>username:</b> {user_name_escaped}\n"
f"Имя:{full_name_escaped}\nВыбери причину бана из списка или напиши ее в чат",
reply_markup=markup)
await state.set_state('BAN_2')
except Exception as e:
logger.error(f"Ошибка при обработке пересланного сообщения: {e}")
await message.answer("Произошла ошибка при обработке пересланного сообщения.")
await state.set_state('ADMIN')
markup = get_reply_keyboard_admin()
await message.answer('Вернулись в меню', reply_markup=markup)
@admin_router.message(
ChatTypeFilter(chat_type=["private"]),
StateFilter("PRE_BAN_FORWARD")
)
async def ban_by_forward_invalid(message: types.Message, state: FSMContext):
"""Обработчик для случаев, когда сообщение не является пересланным или не содержит информацию о пользователе"""
if message.forward_from_chat:
await message.answer("Пересланное сообщение из канала или группы не содержит информацию о конкретном пользователе. Пожалуйста, перешлите сообщение из приватного чата.")
else:
await message.answer("Пожалуйста, перешлите сообщение от пользователя, которого хотите заблокировать. Обычное сообщение не подходит.")
@admin_router.message( @admin_router.message(

View File

@@ -1,5 +1,6 @@
import html import html
import traceback import traceback
from datetime import datetime, timedelta
from aiogram import Router, F from aiogram import Router, F
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
@@ -209,6 +210,43 @@ async def decline_post_for_group(call: CallbackQuery, state: FSMContext):
await call.answer(text='Что-то пошло не так!', show_alert=True, cache_time=3) await call.answer(text='Что-то пошло не так!', show_alert=True, cache_time=3)
@callback_router.callback_query(
F.data == "ban"
)
async def ban_user_from_post(call: CallbackQuery):
try:
# Получаем информацию о пользователе из сообщения
author_id = BotDB.get_author_id_by_message_id(call.message.message_id)
user_name = BotDB.get_username(user_id=author_id)
full_name = call.message.from_user.full_name if call.message.from_user else "Неизвестно"
# Устанавливаем причину бана и дату разблокировки (+7 дней)
current_date = datetime.now()
date_to_unban = current_date + timedelta(days=7)
# Записываем в базу данных
BotDB.set_user_blacklist(
user_id=author_id,
user_name=user_name,
message_for_user="Спам",
date_to_unban=date_to_unban
)
# Удаляем пост из предложки
await call.bot.delete_message(chat_id=GROUP_FOR_POST, message_id=call.message.message_id)
# Отправляем сообщение пользователю о блокировке
date_str = date_to_unban.strftime("%d.%m.%Y %H:%M")
await send_text_message(author_id, call.message, f"Ты заблокирован за спам. Дата разблокировки: {date_str}")
logger.info(f"Пользователь {author_id} заблокирован за спам до {date_str}")
await call.answer(text='Пользователь заблокирован!', cache_time=3)
except Exception as e:
logger.error(f'Ошибка при блокировке пользователя: {str(e)}')
await call.answer(text='Ошибка при блокировке!', show_alert=True, cache_time=3)
@callback_router.callback_query( @callback_router.callback_query(
F.data.contains('ban') F.data.contains('ban')
) )
@@ -230,7 +268,7 @@ async def process_ban_user(call: CallbackQuery, state: FSMContext):
await state.set_state('BAN_2') await state.set_state('BAN_2')
else: else:
markup = get_reply_keyboard_admin() markup = get_reply_keyboard_admin()
await call.message.answer(text='Пользователь с таким ID не найден в базе', markup=markup) await call.message.answer(text='Пользователь с таким ID не найден в базе', reply_markup=markup)
await state.set_state('ADMIN') await state.set_state('ADMIN')

View File

@@ -5,10 +5,12 @@ from aiogram.utils.keyboard import ReplyKeyboardBuilder, InlineKeyboardBuilder
def get_reply_keyboard_for_post(): def get_reply_keyboard_for_post():
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.row(types.InlineKeyboardButton( builder.row(types.InlineKeyboardButton(
text="Опубликовать", callback_data="publish") text="Опубликовать", callback_data="publish"),
types.InlineKeyboardButton(
text="Отклонить", callback_data="decline")
) )
builder.row(types.InlineKeyboardButton( builder.row(types.InlineKeyboardButton(
text="Отклонить", callback_data="decline") text="👮‍♂️ Забанить", callback_data="ban")
) )
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
@@ -37,7 +39,7 @@ def get_reply_keyboard_admin():
builder.add(types.KeyboardButton(text="Бан (Список)")) builder.add(types.KeyboardButton(text="Бан (Список)"))
builder.add(types.KeyboardButton(text="Бан по нику")) builder.add(types.KeyboardButton(text="Бан по нику"))
builder.add(types.KeyboardButton(text="Бан по ID")) builder.add(types.KeyboardButton(text="Бан по ID"))
builder.add(types.KeyboardButton(text="Тестовый бан")) builder.row()
builder.add(types.KeyboardButton(text="Разбан (список)")) builder.add(types.KeyboardButton(text="Разбан (список)"))
builder.add(types.KeyboardButton(text="Вернуться в бота")) builder.add(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)
@@ -49,7 +51,7 @@ def create_keyboard_with_pagination(page: int, total_items: int, array_items: li
Создает клавиатуру с пагинацией для заданного набора элементов и устанавливает необходимый callback Создает клавиатуру с пагинацией для заданного набора элементов и устанавливает необходимый callback
Args: Args:
page: Номер текущей страницы. page: Номер текущей страницы (начинается с 1).
total_items: Общее количество элементов. total_items: Общее количество элементов.
array_items: Лист кортежей. Содержит в себе user_name: user_id array_items: Лист кортежей. Содержит в себе user_name: user_id
callback: Действие в коллбеке. Вернет callback вида ({callback}_{user_id}) callback: Действие в коллбеке. Вернет callback вида ({callback}_{user_id})
@@ -58,33 +60,74 @@ def create_keyboard_with_pagination(page: int, total_items: int, array_items: li
InlineKeyboardMarkup: Клавиатура с кнопками пагинации. InlineKeyboardMarkup: Клавиатура с кнопками пагинации.
""" """
# Проверяем валидность входных данных
if page < 1:
page = 1
if not array_items:
# Если нет элементов, возвращаем только кнопку "Назад"
keyboard = InlineKeyboardBuilder()
home_button = types.InlineKeyboardButton(text="🏠 Назад", callback_data="return")
keyboard.row(home_button)
return keyboard.as_markup()
# Определяем общее количество страниц # Определяем общее количество страниц
total_pages = (total_items + 9 - 1) // 9 items_per_page = 9
total_pages = (total_items + items_per_page - 1) // items_per_page
# Ограничиваем страницу максимальным значением
if page > total_pages:
page = total_pages
# Создаем билдер для клавиатуры # Создаем билдер для клавиатуры
keyboard = InlineKeyboardBuilder() keyboard = InlineKeyboardBuilder()
# Вычисляем стартовый номер для текущей страницы
start_index = (page - 1) * 9
# Кнопки с номерами страниц # Вычисляем стартовый номер для текущей страницы
for i in range(start_index, min(start_index + 9, len(array_items))): start_index = (page - 1) * items_per_page
keyboard.add(types.InlineKeyboardButton(
# Кнопки с элементами текущей страницы
end_index = min(start_index + items_per_page, len(array_items))
current_row = []
for i in range(start_index, end_index):
current_row.append(types.InlineKeyboardButton(
text=f"{array_items[i][0]}", callback_data=f"{callback}_{array_items[i][1]}" text=f"{array_items[i][0]}", callback_data=f"{callback}_{array_items[i][1]}"
)) ))
keyboard.adjust(3)
next_button = types.InlineKeyboardButton( # Когда набирается 3 кнопки, добавляем ряд
text="➡️ Следующая", callback_data=f"page_{page + 1}" if len(current_row) == 3:
) keyboard.row(*current_row)
prev_button = types.InlineKeyboardButton( current_row = []
text="⬅️ Предыдущая", callback_data=f"page_{page - 1}"
) # Добавляем оставшиеся кнопки, если они есть
keyboard.row(prev_button, next_button) if current_row:
home_button = types.InlineKeyboardButton( keyboard.row(*current_row)
text="🏠 Назад", callback_data="return")
# Создаем кнопки навигации только если нужно
navigation_buttons = []
# Кнопка "Предыдущая" - показываем только если не первая страница
if page > 1:
prev_button = types.InlineKeyboardButton(
text="⬅️ Предыдущая", callback_data=f"page_{page - 1}"
)
navigation_buttons.append(prev_button)
# Кнопка "Следующая" - показываем только если не последняя страница
if page < total_pages:
next_button = types.InlineKeyboardButton(
text="➡️ Следующая", callback_data=f"page_{page + 1}"
)
navigation_buttons.append(next_button)
# Добавляем кнопки навигации, если они есть
if navigation_buttons:
keyboard.row(*navigation_buttons)
# Кнопка "Назад"
home_button = types.InlineKeyboardButton(text="🏠 Назад", callback_data="return")
keyboard.row(home_button) keyboard.row(home_button)
k = keyboard.as_markup()
return k return keyboard.as_markup()
def create_keyboard_for_ban_reason(): def create_keyboard_for_ban_reason():

View File

@@ -16,6 +16,6 @@ async def start_bot(bdf):
link_preview_is_disabled=bdf.settings['Telegram']['preview_link'] link_preview_is_disabled=bdf.settings['Telegram']['preview_link']
), timeout=30.0) # Добавляем таймаут для предотвращения зависаний ), timeout=30.0) # Добавляем таймаут для предотвращения зависаний
dp = Dispatcher(storage=MemoryStorage(), fsm_strategy=FSMStrategy.GLOBAL_USER) dp = Dispatcher(storage=MemoryStorage(), fsm_strategy=FSMStrategy.GLOBAL_USER)
dp.include_routers(private_router, callback_router, group_router, admin_router) dp.include_routers(admin_router, private_router, callback_router, group_router)
await bot.delete_webhook(drop_pending_updates=True) await bot.delete_webhook(drop_pending_updates=True)
await dp.start_polling(bot, skip_updates=True) await dp.start_polling(bot, skip_updates=True)

View File

@@ -9,7 +9,6 @@ class StateUser(StatesGroup):
PRE_CHAT = State() PRE_CHAT = State()
PRE_BAN = State() PRE_BAN = State()
PRE_BAN_ID = State() PRE_BAN_ID = State()
PRE_BAN_FORWARD = State()
BAN_2 = State() BAN_2 = State()
BAN_3 = State() BAN_3 = State()
BAN_4 = State() BAN_4 = State()

View File

@@ -5,7 +5,8 @@ from aiogram.types import ReplyKeyboardMarkup, KeyboardButton, InlineKeyboardMar
from helper_bot.keyboards.keyboards import ( from helper_bot.keyboards.keyboards import (
get_reply_keyboard, get_reply_keyboard,
get_reply_keyboard_for_post, get_reply_keyboard_for_post,
get_reply_keyboard_leave_chat get_reply_keyboard_leave_chat,
create_keyboard_with_pagination
) )
from helper_bot.filters.main import ChatTypeFilter from helper_bot.filters.main import ChatTypeFilter
from database.db import BotDB from database.db import BotDB
@@ -326,5 +327,125 @@ class TestKeyboardIntegration:
assert 'Выйти из чата' in leave_buttons assert 'Выйти из чата' in leave_buttons
class TestPagination:
"""Тесты для функции create_keyboard_with_pagination"""
def test_pagination_empty_list(self):
"""Тест с пустым списком элементов"""
keyboard = create_keyboard_with_pagination(1, 0, [], 'test')
assert keyboard is not None
# Проверяем, что есть только кнопка "Назад"
assert len(keyboard.inline_keyboard) == 1
assert keyboard.inline_keyboard[0][0].text == "🏠 Назад"
def test_pagination_single_page(self):
"""Тест с одной страницей"""
items = [("User1", 1), ("User2", 2), ("User3", 3)]
keyboard = create_keyboard_with_pagination(1, 3, items, 'test')
# Проверяем количество кнопок (3 пользователя + кнопка "Назад")
assert len(keyboard.inline_keyboard) == 2 # 1 ряд с пользователями + 1 ряд с "Назад"
assert len(keyboard.inline_keyboard[0]) == 3 # 3 пользователя в первом ряду
assert keyboard.inline_keyboard[1][0].text == "🏠 Назад"
# Проверяем, что нет кнопок навигации
assert len(keyboard.inline_keyboard[0]) == 3 # только пользователи
def test_pagination_multiple_pages(self):
"""Тест с несколькими страницами"""
items = [("User" + str(i), i) for i in range(1, 15)] # 14 пользователей
keyboard = create_keyboard_with_pagination(1, 14, items, 'test')
# На первой странице должно быть 9 пользователей (3 ряда по 3) + кнопка "Следующая" + "Назад"
assert len(keyboard.inline_keyboard) == 5 # 3 ряда пользователей + навигация + назад
assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя
assert len(keyboard.inline_keyboard[1]) == 3 # второй ряд: 3 пользователя
assert len(keyboard.inline_keyboard[2]) == 3 # третий ряд: 3 пользователя
assert keyboard.inline_keyboard[3][0].text == "➡️ Следующая" # кнопка навигации
assert keyboard.inline_keyboard[4][0].text == "🏠 Назад" # кнопка назад
def test_pagination_second_page(self):
"""Тест второй страницы"""
items = [("User" + str(i), i) for i in range(1, 15)] # 14 пользователей
keyboard = create_keyboard_with_pagination(2, 14, items, 'test')
# На второй странице должно быть 5 пользователей (2 ряда: 3+2) + кнопки "Предыдущая" и "Назад"
assert len(keyboard.inline_keyboard) == 4 # 2 ряда пользователей + навигация + назад
assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя
assert len(keyboard.inline_keyboard[1]) == 2 # второй ряд: 2 пользователя
assert keyboard.inline_keyboard[2][0].text == "⬅️ Предыдущая"
assert keyboard.inline_keyboard[3][0].text == "🏠 Назад"
def test_pagination_middle_page(self):
"""Тест средней страницы"""
items = [("User" + str(i), i) for i in range(1, 25)] # 24 пользователя
keyboard = create_keyboard_with_pagination(2, 24, items, 'test')
# На второй странице должно быть 9 пользователей (3 ряда по 3) + кнопки "Предыдущая" и "Следующая"
assert len(keyboard.inline_keyboard) == 5 # 3 ряда пользователей + навигация + назад
assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя
assert len(keyboard.inline_keyboard[1]) == 3 # второй ряд: 3 пользователя
assert len(keyboard.inline_keyboard[2]) == 3 # третий ряд: 3 пользователя
assert keyboard.inline_keyboard[3][0].text == "⬅️ Предыдущая"
assert keyboard.inline_keyboard[3][1].text == "➡️ Следующая"
def test_pagination_invalid_page_number(self):
"""Тест с некорректным номером страницы"""
items = [("User" + str(i), i) for i in range(1, 10)] # 9 пользователей
keyboard = create_keyboard_with_pagination(0, 9, items, 'test') # страница 0
# Должна вернуться первая страница
assert len(keyboard.inline_keyboard) == 4 # 3 ряда пользователей + назад
assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя
assert len(keyboard.inline_keyboard[1]) == 3 # второй ряд: 3 пользователя
assert len(keyboard.inline_keyboard[2]) == 3 # третий ряд: 3 пользователя
def test_pagination_page_out_of_range(self):
"""Тест с номером страницы больше максимального"""
items = [("User" + str(i), i) for i in range(1, 10)] # 9 пользователей
keyboard = create_keyboard_with_pagination(5, 9, items, 'test') # страница 5 при 1 странице
# Должна вернуться первая страница
assert len(keyboard.inline_keyboard) == 4 # 3 ряда пользователей + назад
assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя
assert len(keyboard.inline_keyboard[1]) == 3 # второй ряд: 3 пользователя
assert len(keyboard.inline_keyboard[2]) == 3 # третий ряд: 3 пользователя
def test_pagination_callback_data_format(self):
"""Тест формата callback_data"""
items = [("User1", 123), ("User2", 456)]
keyboard = create_keyboard_with_pagination(1, 2, items, 'ban')
# Проверяем формат callback_data для пользователей
assert keyboard.inline_keyboard[0][0].callback_data == "ban_123"
assert keyboard.inline_keyboard[0][1].callback_data == "ban_456"
# Проверяем формат callback_data для кнопки "Назад"
assert keyboard.inline_keyboard[1][0].callback_data == "return"
def test_pagination_navigation_callback_data(self):
"""Тест callback_data для кнопок навигации"""
items = [("User" + str(i), i) for i in range(1, 15)] # 14 пользователей
keyboard = create_keyboard_with_pagination(2, 14, items, 'test')
# Проверяем callback_data для кнопки "Предыдущая"
assert keyboard.inline_keyboard[2][0].callback_data == "page_1"
# Проверяем callback_data для кнопки "Назад"
assert keyboard.inline_keyboard[3][0].callback_data == "return"
def test_pagination_exactly_items_per_page(self):
"""Тест когда количество элементов точно равно items_per_page"""
items = [("User" + str(i), i) for i in range(1, 10)] # ровно 9 пользователей
keyboard = create_keyboard_with_pagination(1, 9, items, 'test')
# Должна быть только одна страница без кнопок навигации
assert len(keyboard.inline_keyboard) == 4 # 3 ряда пользователей + назад
assert len(keyboard.inline_keyboard[0]) == 3 # первый ряд: 3 пользователя
assert len(keyboard.inline_keyboard[1]) == 3 # второй ряд: 3 пользователя
assert len(keyboard.inline_keyboard[2]) == 3 # третий ряд: 3 пользователя
assert keyboard.inline_keyboard[3][0].text == "🏠 Назад"
if __name__ == '__main__': if __name__ == '__main__':
pytest.main([__file__, '-v']) pytest.main([__file__, '-v'])

View File

@@ -1,11 +1,33 @@
import pytest import pytest
from unittest.mock import Mock, patch from unittest.mock import Mock, patch, AsyncMock
from datetime import datetime from datetime import datetime
import os
from helper_bot.utils.helper_func import ( from helper_bot.utils.helper_func import (
get_first_name, get_first_name,
get_text_message, get_text_message,
check_username_and_full_name check_username_and_full_name,
safe_html_escape,
download_file,
prepare_media_group_from_middlewares,
add_in_db_media_mediagroup,
add_in_db_media,
send_media_group_message_to_private_chat,
send_media_group_to_channel,
send_text_message,
send_photo_message,
send_video_message,
send_video_note_message,
send_audio_message,
send_voice_message,
check_access,
add_days_to_date,
get_banned_users_list,
get_banned_users_buttons,
delete_user_blacklist,
update_user_info,
check_user_emoji,
get_random_emoji
) )
from helper_bot.utils.messages import get_message from helper_bot.utils.messages import get_message
from helper_bot.utils.base_dependency_factory import BaseDependencyFactory, get_global_instance from helper_bot.utils.base_dependency_factory import BaseDependencyFactory, get_global_instance
@@ -83,6 +105,40 @@ class TestHelperFunctions:
assert result is True assert result is True
class TestSafeHtmlEscape:
"""Тесты для функции безопасного экранирования HTML"""
def test_safe_html_escape_normal_text(self):
"""Тест экранирования обычного текста"""
result = safe_html_escape("Hello World")
assert result == "Hello World"
def test_safe_html_escape_html_tags(self):
"""Тест экранирования HTML тегов"""
result = safe_html_escape("<script>alert('xss')</script>")
assert result == "&lt;script&gt;alert(&#x27;xss&#x27;)&lt;/script&gt;"
def test_safe_html_escape_special_chars(self):
"""Тест экранирования специальных символов"""
result = safe_html_escape("& < > \" '")
assert result == "&amp; &lt; &gt; &quot; &#x27;"
def test_safe_html_escape_none_input(self):
"""Тест экранирования None значения"""
result = safe_html_escape(None)
assert result == ""
def test_safe_html_escape_empty_string(self):
"""Тест экранирования пустой строки"""
result = safe_html_escape("")
assert result == ""
def test_safe_html_escape_non_string_input(self):
"""Тест экранирования нестрокового ввода"""
result = safe_html_escape(123)
assert result == "123"
class TestMessages: class TestMessages:
"""Тесты для системы сообщений""" """Тесты для системы сообщений"""
@@ -204,5 +260,422 @@ class TestConfigurationHandling:
pass pass
class TestDownloadFile:
"""Тесты для функции скачивания файлов"""
@pytest.mark.asyncio
async def test_download_file_success(self):
"""Тест успешного скачивания файла"""
mock_message = Mock()
mock_message.bot = AsyncMock()
# Мокаем get_file
mock_file = Mock()
mock_file.file_path = "photos/file_123.jpg"
mock_message.bot.get_file.return_value = mock_file
# Мокаем download_file
mock_message.bot.download_file = AsyncMock()
# Мокаем os.makedirs
with patch('os.makedirs') as mock_makedirs:
with patch('os.path.join', return_value="files/photos/file_123.jpg"):
result = await download_file(mock_message, "file_id_123")
assert result == "files/photos/file_123.jpg"
mock_makedirs.assert_called()
mock_message.bot.get_file.assert_called_once_with("file_id_123")
mock_message.bot.download_file.assert_called_once()
@pytest.mark.asyncio
async def test_download_file_exception(self):
"""Тест обработки ошибки при скачивании"""
mock_message = Mock()
mock_message.bot = AsyncMock()
mock_message.bot.get_file.side_effect = Exception("Network error")
with patch('os.makedirs'):
with patch('helper_bot.utils.helper_func.logger') as mock_logger:
result = await download_file(mock_message, "file_id_123")
assert result is None
mock_logger.error.assert_called_once()
class TestPrepareMediaGroup:
"""Тесты для подготовки медиагрупп"""
@pytest.mark.asyncio
async def test_prepare_media_group_photos(self):
"""Тест подготовки медиагруппы с фотографиями"""
album = []
for i in range(3):
message = Mock()
message.photo = [Mock()]
message.photo[-1].file_id = f"photo_{i}"
album.append(message)
result = await prepare_media_group_from_middlewares(album, "Тестовая подпись")
assert len(result) == 3
assert result[0].media == "photo_0"
assert result[1].media == "photo_1"
assert result[2].media == "photo_2"
assert result[2].caption == "Тестовая подпись"
@pytest.mark.asyncio
async def test_prepare_media_group_mixed_types(self):
"""Тест подготовки медиагруппы с разными типами медиа"""
album = []
# Фото
photo_message = Mock()
photo_message.photo = [Mock()]
photo_message.photo[-1].file_id = "photo_1"
album.append(photo_message)
# Видео
video_message = Mock()
video_message.photo = None
video_message.video = Mock()
video_message.video.file_id = "video_1"
album.append(video_message)
# Аудио
audio_message = Mock()
audio_message.photo = None
audio_message.video = None
audio_message.audio = Mock()
audio_message.audio.file_id = "audio_1"
album.append(audio_message)
result = await prepare_media_group_from_middlewares(album, "Смешанная группа")
assert len(result) == 3
assert result[0].media == "photo_1"
assert result[1].media == "video_1"
assert result[2].media == "audio_1"
assert result[2].caption == "Смешанная группа"
@pytest.mark.asyncio
async def test_prepare_media_group_empty_album(self):
"""Тест подготовки пустой медиагруппы"""
album = []
result = await prepare_media_group_from_middlewares(album, "Пустая группа")
assert result == []
@pytest.mark.asyncio
async def test_prepare_media_group_unsupported_type(self):
"""Тест подготовки медиагруппы с неподдерживаемым типом"""
album = []
message = Mock()
message.photo = None
message.video = None
message.audio = None
album.append(message)
result = await prepare_media_group_from_middlewares(album, "Тест")
assert result == []
class TestMediaDatabaseOperations:
"""Тесты для операций с медиа в базе данных"""
@pytest.mark.asyncio
async def test_add_in_db_media_mediagroup(self):
"""Тест добавления медиагруппы в базу данных"""
sent_message = []
for i in range(2):
message = Mock()
message.message_id = i + 1
message.photo = [Mock()]
message.photo[-1].file_id = f"photo_{i}"
sent_message.append(message)
mock_db = Mock()
with patch('helper_bot.utils.helper_func.download_file', return_value=f"files/photo_{i}.jpg"):
await add_in_db_media_mediagroup(sent_message, mock_db)
assert mock_db.add_post_content_in_db.call_count == 2
@pytest.mark.asyncio
async def test_add_in_db_media_photo(self):
"""Тест добавления фото в базу данных"""
mock_message = Mock()
mock_message.message_id = 123
mock_message.photo = [Mock()]
mock_message.photo[-1].file_id = "photo_123"
mock_db = Mock()
with patch('helper_bot.utils.helper_func.download_file', return_value="files/photo_123.jpg"):
await add_in_db_media(mock_message, mock_db)
mock_db.add_post_content_in_db.assert_called_once_with(
123, 123, "files/photo_123.jpg", 'photo'
)
@pytest.mark.asyncio
async def test_add_in_db_media_video(self):
"""Тест добавления видео в базу данных"""
mock_message = Mock()
mock_message.message_id = 123
mock_message.photo = None # У видео нет фото
mock_message.video = Mock()
mock_message.video.file_id = "video_123"
mock_db = Mock()
with patch('helper_bot.utils.helper_func.download_file', return_value="files/video_123.mp4"):
await add_in_db_media(mock_message, mock_db)
mock_db.add_post_content_in_db.assert_called_once_with(
123, 123, "files/video_123.mp4", 'video'
)
@pytest.mark.asyncio
async def test_add_in_db_media_voice(self):
"""Тест добавления голосового сообщения в базу данных"""
mock_message = Mock()
mock_message.message_id = 123
mock_message.photo = None # У голосового сообщения нет фото
mock_message.video = None # У голосового сообщения нет видео
mock_message.voice = Mock()
mock_message.voice.file_id = "voice_123"
mock_db = Mock()
with patch('helper_bot.utils.helper_func.download_file', return_value="files/voice_123.ogg"):
await add_in_db_media(mock_message, mock_db)
mock_db.add_post_content_in_db.assert_called_once_with(
123, 123, "files/voice_123.ogg", 'voice'
)
class TestSendMessageFunctions:
"""Тесты для функций отправки сообщений"""
@pytest.mark.asyncio
async def test_send_text_message_without_markup(self):
"""Тест отправки текстового сообщения без разметки"""
mock_message = Mock()
mock_message.bot = AsyncMock()
mock_message.bot.send_message = AsyncMock()
mock_sent_message = Mock()
mock_sent_message.message_id = 456
mock_message.bot.send_message.return_value = mock_sent_message
result = await send_text_message(123, mock_message, "Тестовое сообщение")
assert result == 456
mock_message.bot.send_message.assert_called_once_with(
chat_id=123,
text="Тестовое сообщение"
)
@pytest.mark.asyncio
async def test_send_text_message_with_markup(self):
"""Тест отправки текстового сообщения с разметкой"""
mock_message = Mock()
mock_message.bot = AsyncMock()
mock_message.bot.send_message = AsyncMock()
mock_markup = Mock()
mock_sent_message = Mock()
mock_sent_message.message_id = 456
mock_message.bot.send_message.return_value = mock_sent_message
result = await send_text_message(123, mock_message, "Тестовое сообщение", mock_markup)
assert result == 456
mock_message.bot.send_message.assert_called_once_with(
chat_id=123,
text="Тестовое сообщение",
reply_markup=mock_markup
)
@pytest.mark.asyncio
async def test_send_photo_message(self):
"""Тест отправки фото"""
mock_message = Mock()
mock_message.bot = AsyncMock()
mock_message.bot.send_photo = AsyncMock()
mock_sent_message = Mock()
mock_message.bot.send_photo.return_value = mock_sent_message
result = await send_photo_message(123, mock_message, "photo.jpg", "Подпись к фото")
assert result == mock_sent_message
mock_message.bot.send_photo.assert_called_once_with(
chat_id=123,
caption="Подпись к фото",
photo="photo.jpg"
)
@pytest.mark.asyncio
async def test_send_video_message(self):
"""Тест отправки видео"""
mock_message = Mock()
mock_message.bot = AsyncMock()
mock_message.bot.send_video = AsyncMock()
mock_sent_message = Mock()
mock_message.bot.send_video.return_value = mock_sent_message
result = await send_video_message(123, mock_message, "video.mp4", "Подпись к видео")
assert result == mock_sent_message
mock_message.bot.send_video.assert_called_once_with(
chat_id=123,
caption="Подпись к видео",
video="video.mp4"
)
class TestUtilityFunctions:
"""Тесты для утилитарных функций"""
def test_check_access(self):
"""Тест проверки доступа"""
mock_db = Mock()
mock_db.is_admin.return_value = True
result = check_access(123, mock_db)
assert result is True
mock_db.is_admin.return_value = False
result = check_access(123, mock_db)
assert result is False
def test_add_days_to_date(self):
"""Тест добавления дней к дате"""
with patch('helper_bot.utils.helper_func.datetime') as mock_datetime:
from datetime import timedelta
mock_now = datetime(2024, 1, 1)
mock_datetime.now.return_value = mock_now
mock_datetime.timedelta = timedelta
result = add_days_to_date(5)
expected_date = (mock_now + timedelta(days=5)).strftime("%d-%m-%Y")
assert result == expected_date
def test_get_banned_users_list(self):
"""Тест получения списка заблокированных пользователей"""
mock_db = Mock()
mock_db.get_banned_users_from_db_with_limits.return_value = [
("User1", 123, "Spam", "01-01-2025"),
("User2", 456, "Violation", "02-01-2025")
]
result = get_banned_users_list(0, mock_db)
assert "Список заблокированных пользователей:" in result
assert "User1" in result
assert "User2" in result
assert "Spam" in result
assert "Violation" in result
def test_get_banned_users_buttons(self):
"""Тест получения кнопок заблокированных пользователей"""
mock_db = Mock()
mock_db.get_banned_users_from_db.return_value = [
("User1", 123),
("User2", 456)
]
result = get_banned_users_buttons(mock_db)
assert len(result) == 2
assert result[0] == ("User1", 123)
assert result[1] == ("User2", 456)
def test_delete_user_blacklist(self):
"""Тест удаления пользователя из черного списка"""
mock_db = Mock()
mock_db.delete_user_blacklist.return_value = True
result = delete_user_blacklist(123, mock_db)
assert result is True
mock_db.delete_user_blacklist.assert_called_once_with(user_id=123)
class TestUserManagement:
"""Тесты для управления пользователями"""
@pytest.mark.asyncio
async def test_update_user_info_new_user(self):
"""Тест обновления информации о новом пользователе"""
mock_message = Mock()
mock_message.from_user.id = 123
mock_message.from_user.full_name = "Test User"
mock_message.from_user.username = "testuser"
mock_message.from_user.is_bot = False
mock_message.from_user.language_code = "ru"
mock_message.answer = AsyncMock()
mock_message.bot.send_message = AsyncMock()
with patch('helper_bot.utils.helper_func.get_first_name', return_value="Test"):
with patch('helper_bot.utils.helper_func.get_random_emoji', return_value="😀"):
with patch('helper_bot.utils.helper_func.BotDB') as mock_bot_db:
mock_bot_db.user_exists.return_value = False
mock_bot_db.add_new_user_in_db = Mock()
mock_bot_db.update_date_for_user = Mock()
await update_user_info("test", mock_message)
mock_bot_db.add_new_user_in_db.assert_called_once()
mock_bot_db.update_date_for_user.assert_called_once()
def test_check_user_emoji_existing(self):
"""Тест проверки эмодзи пользователя (существующий)"""
mock_message = Mock()
mock_message.from_user.id = 123
with patch('helper_bot.utils.helper_func.BotDB') as mock_bot_db:
mock_bot_db.check_emoji_for_user.return_value = "😀"
result = check_user_emoji(mock_message)
assert result == "😀"
def test_check_user_emoji_new(self):
"""Тест проверки эмодзи пользователя (новый)"""
mock_message = Mock()
mock_message.from_user.id = 123
with patch('helper_bot.utils.helper_func.BotDB') as mock_bot_db:
mock_bot_db.check_emoji_for_user.return_value = None
mock_bot_db.update_emoji_for_user = Mock()
with patch('helper_bot.utils.helper_func.get_random_emoji', return_value="😀"):
result = check_user_emoji(mock_message)
assert result == "😀"
mock_bot_db.update_emoji_for_user.assert_called_once_with(user_id=123, emoji="😀")
def test_get_random_emoji_success(self):
"""Тест получения случайного эмодзи (успех)"""
with patch('helper_bot.utils.helper_func.BotDB') as mock_bot_db:
mock_bot_db.check_emoji.return_value = False
with patch('helper_bot.utils.helper_func.random.choice', return_value="😀"):
result = get_random_emoji()
assert result == "😀"
def test_get_random_emoji_fallback(self):
"""Тест получения случайного эмодзи (fallback)"""
with patch('helper_bot.utils.helper_func.BotDB') as mock_bot_db:
mock_bot_db.check_emoji.return_value = True # Все эмодзи заняты
with patch('helper_bot.utils.helper_func.random.choice', return_value="😀"):
with patch('helper_bot.utils.helper_func.logger') as mock_logger:
result = get_random_emoji()
assert result == "Эмоджи не определен"
mock_logger.error.assert_called_once()
if __name__ == '__main__': if __name__ == '__main__':
pytest.main([__file__, '-v']) pytest.main([__file__, '-v'])