From 0ff45eb5f1d9a6fb05aef173e56076e2e847abcf Mon Sep 17 00:00:00 2001 From: DarkMK692 Date: Tue, 7 Oct 2025 13:11:04 +0300 Subject: [PATCH 1/3] Tests --- tests/conftest.py | 17 ++++++++--- tests/test_competition.py | 1 + tests/test_leaderboard.py | 64 +++++++++++++++++++++++++++++++++++++++ tests/test_profiles.py | 28 +++++++++++++++++ tests/test_start.py | 32 +++++++++----------- tests/test_tasks.py | 35 +++++++++++++++++++++ 6 files changed, 156 insertions(+), 21 deletions(-) create mode 100644 tests/test_leaderboard.py create mode 100644 tests/test_profiles.py create mode 100644 tests/test_tasks.py diff --git a/tests/conftest.py b/tests/conftest.py index 43ff7e6..1e6dc0e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ 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 @@ -8,15 +9,18 @@ @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 @@ -24,6 +28,11 @@ def mocked_scribe_root_me(): 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 diff --git a/tests/test_competition.py b/tests/test_competition.py index 2226ddb..5c5c53e 100644 --- a/tests/test_competition.py +++ b/tests/test_competition.py @@ -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 diff --git a/tests/test_leaderboard.py b/tests/test_leaderboard.py new file mode 100644 index 0000000..bc4724e --- /dev/null +++ b/tests/test_leaderboard.py @@ -0,0 +1,64 @@ +import pytest +from types import SimpleNamespace + +from bot.handlers.leaderboard import ( + format_top_rating, + format_user_status, + leaderboard_handler, +) +from bot.database.user_dao import UserDAO +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() diff --git a/tests/test_profiles.py b/tests/test_profiles.py new file mode 100644 index 0000000..f121899 --- /dev/null +++ b/tests/test_profiles.py @@ -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 diff --git a/tests/test_start.py b/tests/test_start.py index 85b5285..d38514e 100644 --- a/tests/test_start.py +++ b/tests/test_start.py @@ -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 @@ -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("Привет! Отправь мне свое ФИО.") @@ -40,21 +42,19 @@ 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 @@ -62,13 +62,11 @@ 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() diff --git a/tests/test_tasks.py b/tests/test_tasks.py new file mode 100644 index 0000000..4e08b64 --- /dev/null +++ b/tests/test_tasks.py @@ -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) From baeb19e1b5c314297108d13e0264dd1c1a28d370 Mon Sep 17 00:00:00 2001 From: DarkMK692 Date: Tue, 7 Oct 2025 13:13:59 +0300 Subject: [PATCH 2/3] flake --- tests/test_leaderboard.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_leaderboard.py b/tests/test_leaderboard.py index bc4724e..cb5a8ef 100644 --- a/tests/test_leaderboard.py +++ b/tests/test_leaderboard.py @@ -6,7 +6,6 @@ format_user_status, leaderboard_handler, ) -from bot.database.user_dao import UserDAO from unittest.mock import patch From 2801a23cf8ef75ca91e6ec12a0611c5bf86f83de Mon Sep 17 00:00:00 2001 From: DarkMK692 Date: Sun, 12 Oct 2025 17:20:30 +0300 Subject: [PATCH 3/3] add into cicd --- .github/workflows/test-bot.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-bot.yml b/.github/workflows/test-bot.yml index dfe4766..a005955 100644 --- a/.github/workflows/test-bot.yml +++ b/.github/workflows/test-bot.yml @@ -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: | @@ -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()