Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/workflows/test-bot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ jobs:
run: |
alembic upgrade head

- name: Run bot test
- name: Run bot
env:
DATABASE_URL: postgresql://ctf:ctf@localhost:5432/ctf
run: |
Expand All @@ -71,6 +71,12 @@ jobs:
sleep 10
ps -p $BOT_PID && echo "Бот успешно запущен"
shell: bash
- name: Run Tests
env:
DATABASE_URL: postgresql://ctf:ctf@localhost:5432/ctf
BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
run: |
pytest tests/ -v --cov=bot --cov-report=xml

- name: Cleanup
if: always()
Expand Down
17 changes: 13 additions & 4 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,38 @@
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from types import SimpleNamespace
from aiogram.fsm.context import FSMContext
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import Message


@pytest.fixture
def mocked_db():
with patch("bot.database.db.get_db") as mocked_db:
# Patch the get_db import used by handlers (they import from `database.db`)
with patch("database.db.get_db") as mocked_db:
db_session = MagicMock()
mocked_db.return_value = db_session
yield db_session
# Make the returned object act as a context manager
mocked_db.return_value.__enter__.return_value = db_session
mocked_db.return_value.__exit__.return_value = None
yield mocked_db


@pytest.fixture
def mocked_scribe_root_me():
with patch("bot.utils.root_me.scribe_root_me", return_value="mocked_nickname"):
with patch("utils.root_me.scribe_root_me", return_value="mocked_nickname"):
yield


@pytest.fixture
def mock_message():
message = AsyncMock(spec=Message)
message.text = ""
# Ensure answer/reply are awaitable
message.answer = AsyncMock()
message.reply = AsyncMock()
# Provide a simple from_user object commonly accessed in handlers
message.from_user = SimpleNamespace(id=123, username="test_user")
return message


Expand Down
1 change: 1 addition & 0 deletions tests/test_competition.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
@pytest.mark.asyncio
async def test_competition_cmd_start(mock_message, mock_fsm: FSMContext):
mock_message.text = "/add_competition"
# pass a dummy user (not used in handler)
await competition_cmd_start(mock_message, mock_fsm)

# Verify bot response
Expand Down
63 changes: 63 additions & 0 deletions tests/test_leaderboard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import pytest
from types import SimpleNamespace

from bot.handlers.leaderboard import (
format_top_rating,
format_user_status,
leaderboard_handler,
)
from unittest.mock import patch


def make_user(id, username, points):
return SimpleNamespace(id=id, username=username, points=points)


def test_format_top_rating_empty():
msgs = format_top_rating([])
assert msgs[0].startswith("🏆")
assert "Пока что никто" in msgs[1]


def test_format_top_rating_nonempty():
users = [make_user(1, "u1", 10), make_user(2, "u2", 8)]
msgs = format_top_rating(users)
assert "1. @u1 — 10 баллов" in msgs[1]
assert "2. @u2 — 8 баллов" in msgs[2]


def test_format_user_status_in_top():
top = [make_user(1, "u1", 10), make_user(2, "u2", 5)]
user = make_user(2, "u2", 5)
msgs = format_user_status(user, top)
assert "Ваш статус" in msgs[0]
assert any("Вы на" in m for m in msgs)


def test_format_user_status_not_in_top_zero_points():
top = [make_user(1, "u1", 10)]
user = make_user(2, "u2", 0)
msgs = format_user_status(user, top)
assert "У вас пока нет баллов" in msgs[1]


def test_format_user_status_not_in_top_needs_points():
top = [make_user(i, f"u{i}", pts) for i, pts in enumerate(range(20, 0, -1), start=1)]
# last top score is 1; user has some points but not enough
user = make_user(999, "new", 1)
msgs = format_user_status(user, top)
assert "Чтобы войти в топ-20" in msgs[1]


@pytest.mark.asyncio
async def test_leaderboard_handler(mock_message, mocked_db):
user1 = SimpleNamespace(id=1, username="u1", points=10)
user2 = SimpleNamespace(id=2, username="u2", points=5)
# Patch UserDAO class used inside the handler module
with patch("bot.handlers.leaderboard.UserDAO") as MockUserDAO:
MockUserDAO.return_value.leaderboard.return_value = [user1, user2]
user = SimpleNamespace(id=3, username="me", points=0)
await leaderboard_handler(mock_message, user)

# Ensure answer called at least once
mock_message.answer.assert_called()
28 changes: 28 additions & 0 deletions tests/test_profiles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import pytest
from types import SimpleNamespace

from bot.handlers.profiles import my_profile_handler


@pytest.mark.asyncio
async def test_my_profile_complete(mock_message):
user = SimpleNamespace(full_name="John Doe", root_me_nickname="jdoe", lives=3, points=7)
await my_profile_handler(mock_message, user)

# Ensure reply called with formatted profile containing fields
mock_message.reply.assert_called_once()
called_arg = mock_message.reply.call_args.args[0]
assert "Полное имя: John Doe" in called_arg
assert "RootMe ник: jdoe" in called_arg
assert "Очки: 7" in called_arg


@pytest.mark.asyncio
async def test_my_profile_missing_fields(mock_message):
user = SimpleNamespace(full_name=None, root_me_nickname=None, lives=3, points=0)
await my_profile_handler(mock_message, user)

mock_message.reply.assert_called_once()
called_arg = mock_message.reply.call_args.args[0]
assert "Полное имя: Не указано" in called_arg
assert "RootMe ник: Не указано" in called_arg
32 changes: 15 additions & 17 deletions tests/test_start.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import pytest
from aiogram.fsm.context import FSMContext
from unittest.mock import patch
from sqlalchemy.exc import IntegrityError

from bot.handlers.start import cmd_start, get_fullname, save_user
Expand All @@ -8,7 +9,8 @@
@pytest.mark.asyncio
async def test_cmd_start(mock_message, mock_fsm: FSMContext):
mock_message.text = "/start"
await cmd_start(mock_message, mock_fsm)
# pass user=None to simulate unregistered user
await cmd_start(mock_message, mock_fsm, None)

# Verify bot response
mock_message.answer.assert_called_once_with("Привет! Отправь мне свое ФИО.")
Expand Down Expand Up @@ -40,35 +42,31 @@ async def test_save_user(
mock_message, mock_fsm: FSMContext, mocked_db, mocked_scribe_root_me
):
mock_message.text = "https://www.root-me.org/user"
mock_message.from_user.username = "test_user"
# from_user provided by fixture

await mock_fsm.set_data({"full_name": "John Doe"})
user_dao_mock = mocked_db.return_value.__enter__.return_value
user_dao_mock.create_user.return_value = None
# Patch UserDAO used in handler to use our mocked DB session
with patch("bot.handlers.start.UserDAO") as MockUserDAO:
MockUserDAO.return_value.create_user.return_value = None
await save_user(mock_message, mock_fsm)

await save_user(mock_message, mock_fsm)

# Verify user creation in DB
user_dao_mock.create_user.assert_called_once_with(
"test_user", "John Doe", "mocked_nickname"
)
# Verify user creation in DB
MockUserDAO.return_value.create_user.assert_called_once()

# Verify bot response
mock_message.reply.assert_called_once_with("Запись пользователя в БД сохранена!")
mock_message.answer.assert_called()


@pytest.mark.asyncio
async def test_save_user_integrity_error(
mock_message, mock_fsm: FSMContext, mocked_db, mocked_scribe_root_me
):
mock_message.text = "https://www.root-me.org/user"
mock_message.from_user.username = "test_user"

await mock_fsm.set_data({"full_name": "John Doe"})
user_dao_mock = mocked_db.return_value.__enter__.return_value
user_dao_mock.create_user.side_effect = IntegrityError(None, None, None)

await save_user(mock_message, mock_fsm)
with patch("bot.handlers.start.UserDAO") as MockUserDAO:
MockUserDAO.return_value.create_user.side_effect = IntegrityError(None, None, None)
await save_user(mock_message, mock_fsm)

# Verify bot response
mock_message.reply.assert_called_once_with("Ошибка сохранения пользователя в БД")
mock_message.reply.assert_called_once()
35 changes: 35 additions & 0 deletions tests/test_tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import pytest
from types import SimpleNamespace

from bot.handlers.tasks import my_tasks_handler
from unittest.mock import patch


@pytest.mark.asyncio
async def test_my_tasks_no_tasks(mock_message, mocked_db):
# Setup: TaskDao.user_tasks returns empty list
# Patch the TaskDao used in the handler module to return empty list
with patch("bot.handlers.tasks.TaskDao") as MockTaskDao:
MockTaskDao.return_value.user_tasks.return_value = []
user = SimpleNamespace(id=1, username="test_user", points=0)
await my_tasks_handler(mock_message, user)

# Expect a reply indicating no tasks
mock_message.reply.assert_called_once_with("Пока нет никаких задач.")


@pytest.mark.asyncio
async def test_my_tasks_with_tasks(mock_message, mocked_db):
# Create a fake task object similar to database.models.Task
fake_task = SimpleNamespace(name="Task1", description="Desc", url="http://u")

# Mock the DAO to return one task
with patch("bot.handlers.tasks.TaskDao") as MockTaskDao:
MockTaskDao.return_value.user_tasks.return_value = [fake_task]
user = SimpleNamespace(id=1, username="test_user", points=0)
await my_tasks_handler(mock_message, user)

expected_text = (
"Задача: Task1\nОписание: Desc\nСсылка: http://u\n\n"
)
mock_message.reply.assert_called_once_with(expected_text)