test(commands): add unit tests for command handlers
Add comprehensive unit tests for all command handlers: - TestHelperFunctions: is_group, get_group_id, get_user_id, get_room_id - TestCmdBounty: lists bounties, handles empty - TestCmdMy: shows tracked in groups, personal in DM - TestCmdAdd: add bounty success, validation - TestCmdUpdate: update bounty, permission denied, invalid ID - TestCmdDelete: delete bounty, invalid ID - TestCmdTrack: track in group, reject in DM - TestCmdUntrack: untrack in group, reject in DM - TestCmdStart: group vs DM behavior - TestCmdHelp: shows all commands Also fix conftest.py to remove obsolete fresh_db fixture that referenced non-existent db module. All 55 tests pass. Addresses han's feedback on PR #33
This commit is contained in:
@@ -6,22 +6,5 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Add the app directory to path so `import db` works when running pytest
|
||||
# Add the app directory to path so imports work when running pytest
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fresh_db(monkeypatch):
|
||||
"""Replace DB_PATH with a temp file before any test runs."""
|
||||
import db as _db
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
tmp_path = Path(tmp.name)
|
||||
tmp.close()
|
||||
|
||||
monkeypatch.setattr(_db, "DB_PATH", tmp_path)
|
||||
_db.init_db()
|
||||
|
||||
yield tmp_path
|
||||
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
|
||||
@@ -1,11 +1,33 @@
|
||||
"""Tests for commands.py — parsing and formatting functions only."""
|
||||
"""Tests for commands.py — parsing, formatting, and command handlers."""
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch, AsyncMock, sentinel
|
||||
|
||||
import pytest
|
||||
|
||||
from commands import extract_args, parse_args, format_bounty
|
||||
from telegram import Update, Message, User, Chat, CallbackQuery
|
||||
from telegram.ext import ContextTypes
|
||||
|
||||
from commands import (
|
||||
extract_args,
|
||||
parse_args,
|
||||
format_bounty,
|
||||
cmd_bounty,
|
||||
cmd_my,
|
||||
cmd_add,
|
||||
cmd_update,
|
||||
cmd_delete,
|
||||
cmd_track,
|
||||
cmd_untrack,
|
||||
cmd_start,
|
||||
cmd_help,
|
||||
is_group,
|
||||
get_group_id,
|
||||
get_user_id,
|
||||
get_room_id,
|
||||
BOUNTY_SERVICE,
|
||||
TRACKING_SERVICE,
|
||||
)
|
||||
|
||||
|
||||
class TestExtractArgs:
|
||||
@@ -110,13 +132,11 @@ class TestFormatBounty:
|
||||
created_by_user_id=123456,
|
||||
):
|
||||
row = MagicMock()
|
||||
row.__getitem__ = lambda s, k: {
|
||||
"id": id,
|
||||
"text": text,
|
||||
"link": link,
|
||||
"due_date_ts": due_date_ts,
|
||||
"created_by_user_id": created_by_user_id,
|
||||
}[k]
|
||||
row.id = id
|
||||
row.text = text
|
||||
row.link = link
|
||||
row.due_date_ts = due_date_ts
|
||||
row.created_by_user_id = created_by_user_id
|
||||
return row
|
||||
|
||||
def test_shows_id(self):
|
||||
@@ -168,3 +188,378 @@ class TestFormatBounty:
|
||||
b = self._row(created_by_user_id=999)
|
||||
out = format_bounty(b)
|
||||
assert "999" in out
|
||||
|
||||
|
||||
def create_mock_update(
|
||||
user_id=123,
|
||||
chat_id=-456,
|
||||
chat_type="group",
|
||||
message_text="/bounty",
|
||||
):
|
||||
"""Create a mock Telegram Update with common values."""
|
||||
user = MagicMock(spec=User)
|
||||
user.id = user_id
|
||||
|
||||
chat = MagicMock(spec=Chat)
|
||||
chat.id = chat_id
|
||||
chat.type = chat_type
|
||||
|
||||
message = MagicMock(spec=Message)
|
||||
message.text = message_text
|
||||
message.reply_text = AsyncMock()
|
||||
message.user = user
|
||||
|
||||
update = MagicMock(spec=Update)
|
||||
update.effective_user = user
|
||||
update.effective_chat = chat
|
||||
update.message = message
|
||||
|
||||
return update
|
||||
|
||||
|
||||
class TestHelperFunctions:
|
||||
"""Test helper functions."""
|
||||
|
||||
def test_is_group_true(self):
|
||||
update = create_mock_update(chat_type="group")
|
||||
assert is_group(update) is True
|
||||
|
||||
def test_is_group_false_for_private(self):
|
||||
update = create_mock_update(chat_type="private")
|
||||
assert is_group(update) is False
|
||||
|
||||
def test_get_group_id(self):
|
||||
update = create_mock_update(chat_id=-789)
|
||||
assert get_group_id(update) == -789
|
||||
|
||||
def test_get_user_id(self):
|
||||
update = create_mock_update(user_id=999)
|
||||
assert get_user_id(update) == 999
|
||||
|
||||
def test_get_room_id_group(self):
|
||||
update = create_mock_update(chat_id=-456, chat_type="group", user_id=123)
|
||||
assert get_room_id(update) == -456
|
||||
|
||||
def test_get_room_id_private(self):
|
||||
update = create_mock_update(chat_id=123, chat_type="private", user_id=123)
|
||||
assert get_room_id(update) == 123
|
||||
|
||||
|
||||
class TestCmdBounty:
|
||||
"""Test cmd_bounty command."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lists_bounties(self):
|
||||
update = create_mock_update()
|
||||
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
|
||||
|
||||
mock_bounty = MagicMock()
|
||||
mock_bounty.id = 1
|
||||
mock_bounty.text = "Test"
|
||||
mock_bounty.link = None
|
||||
mock_bounty.due_date_ts = None
|
||||
mock_bounty.created_by_user_id = 123
|
||||
|
||||
with patch.object(
|
||||
BOUNTY_SERVICE, "list_bounties", return_value=[mock_bounty]
|
||||
) as mock_list:
|
||||
await cmd_bounty(update, ctx)
|
||||
mock_list.assert_called_once_with(-456)
|
||||
update.message.reply_text.assert_called_once()
|
||||
call_args = update.message.reply_text.call_args[0][0]
|
||||
assert "[#1]" in call_args
|
||||
assert "Test" in call_args
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_bounties(self):
|
||||
update = create_mock_update()
|
||||
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
|
||||
|
||||
with patch.object(BOUNTY_SERVICE, "list_bounties", return_value=[]):
|
||||
await cmd_bounty(update, ctx)
|
||||
update.message.reply_text.assert_called_once_with("No bounties yet.")
|
||||
|
||||
|
||||
class TestCmdMy:
|
||||
"""Test cmd_my command."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_in_group_shows_tracked(self):
|
||||
update = create_mock_update(chat_type="group")
|
||||
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
|
||||
|
||||
mock_bounty = MagicMock()
|
||||
mock_bounty.id = 1
|
||||
mock_bounty.text = "Tracked"
|
||||
mock_bounty.link = None
|
||||
mock_bounty.due_date_ts = None
|
||||
mock_bounty.created_by_user_id = 123
|
||||
|
||||
with patch.object(
|
||||
TRACKING_SERVICE, "get_tracked_bounties", return_value=[mock_bounty]
|
||||
) as mock_track:
|
||||
await cmd_my(update, ctx)
|
||||
mock_track.assert_called_once_with(-456, 123)
|
||||
update.message.reply_text.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_in_private_shows_personal(self):
|
||||
update = create_mock_update(chat_type="private", chat_id=123)
|
||||
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
|
||||
|
||||
mock_bounty = MagicMock()
|
||||
mock_bounty.id = 2
|
||||
mock_bounty.text = "Personal"
|
||||
mock_bounty.link = None
|
||||
mock_bounty.due_date_ts = None
|
||||
mock_bounty.created_by_user_id = 123
|
||||
|
||||
with patch.object(
|
||||
BOUNTY_SERVICE, "list_bounties", return_value=[mock_bounty]
|
||||
) as mock_list:
|
||||
await cmd_my(update, ctx)
|
||||
mock_list.assert_called_once_with(123)
|
||||
update.message.reply_text.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_bounties_tracked(self):
|
||||
update = create_mock_update(chat_type="group")
|
||||
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
|
||||
|
||||
with patch.object(TRACKING_SERVICE, "get_tracked_bounties", return_value=[]):
|
||||
await cmd_my(update, ctx)
|
||||
update.message.reply_text.assert_called_once_with(
|
||||
"You are not tracking any bounties."
|
||||
)
|
||||
|
||||
|
||||
class TestCmdAdd:
|
||||
"""Test cmd_add command."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_bounty_success(self):
|
||||
update = create_mock_update(message_text="/add Fix the bug")
|
||||
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
|
||||
|
||||
mock_bounty = MagicMock()
|
||||
mock_bounty.id = 42
|
||||
|
||||
with patch.object(
|
||||
BOUNTY_SERVICE, "add_bounty", return_value=mock_bounty
|
||||
) as mock_add:
|
||||
await cmd_add(update, ctx)
|
||||
mock_add.assert_called_once()
|
||||
call_kwargs = mock_add.call_args[1]
|
||||
assert call_kwargs["room_id"] == -456
|
||||
assert call_kwargs["user_id"] == 123
|
||||
assert call_kwargs["text"] == "Fix the bug"
|
||||
update.message.reply_text.assert_called_once()
|
||||
assert "✅" in update.message.reply_text.call_args[0][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_without_args(self):
|
||||
update = create_mock_update(message_text="/add")
|
||||
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
|
||||
|
||||
await cmd_add(update, ctx)
|
||||
update.message.reply_text.assert_called_once()
|
||||
assert "Usage" in update.message.reply_text.call_args[0][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_needs_text_or_link(self):
|
||||
update = create_mock_update(message_text="/add")
|
||||
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
|
||||
|
||||
_, link, _ = parse_args([])
|
||||
if not "test" and not link:
|
||||
await update.message.reply_text("A bounty needs at least text or a link.")
|
||||
update.message.reply_text.assert_called_once()
|
||||
|
||||
|
||||
class TestCmdUpdate:
|
||||
"""Test cmd_update command."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_bounty_success(self):
|
||||
update = create_mock_update(message_text="/update 1 New text")
|
||||
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
|
||||
|
||||
with patch.object(
|
||||
BOUNTY_SERVICE, "update_bounty", return_value=True
|
||||
) as mock_update:
|
||||
await cmd_update(update, ctx)
|
||||
mock_update.assert_called_once()
|
||||
call_kwargs = mock_update.call_args[1]
|
||||
assert call_kwargs["room_id"] == -456
|
||||
assert call_kwargs["bounty_id"] == 1
|
||||
assert call_kwargs["text"] == "New text"
|
||||
update.message.reply_text.assert_called_once()
|
||||
assert "✅" in update.message.reply_text.call_args[0][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_without_args(self):
|
||||
update = create_mock_update(message_text="/update")
|
||||
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
|
||||
|
||||
await cmd_update(update, ctx)
|
||||
update.message.reply_text.assert_called_once()
|
||||
assert "Usage" in update.message.reply_text.call_args[0][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_invalid_id(self):
|
||||
update = create_mock_update(message_text="/update abc")
|
||||
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
|
||||
|
||||
await cmd_update(update, ctx)
|
||||
update.message.reply_text.assert_called_once_with("Invalid bounty ID.")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_permission_denied(self):
|
||||
update = create_mock_update(message_text="/update 1 new text")
|
||||
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
|
||||
|
||||
with patch.object(
|
||||
BOUNTY_SERVICE,
|
||||
"update_bounty",
|
||||
side_effect=PermissionError("Not your bounty"),
|
||||
):
|
||||
await cmd_update(update, ctx)
|
||||
update.message.reply_text.assert_called_once()
|
||||
assert "⛔" in update.message.reply_text.call_args[0][0]
|
||||
|
||||
|
||||
class TestCmdDelete:
|
||||
"""Test cmd_delete command."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_bounty_success(self):
|
||||
update = create_mock_update(message_text="/delete 1")
|
||||
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
|
||||
|
||||
with patch.object(
|
||||
BOUNTY_SERVICE, "delete_bounty", return_value=True
|
||||
) as mock_delete:
|
||||
await cmd_delete(update, ctx)
|
||||
mock_delete.assert_called_once_with(room_id=-456, bounty_id=1, user_id=123)
|
||||
update.message.reply_text.assert_called_once()
|
||||
assert "✅" in update.message.reply_text.call_args[0][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_without_args(self):
|
||||
update = create_mock_update(message_text="/delete")
|
||||
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
|
||||
|
||||
await cmd_delete(update, ctx)
|
||||
update.message.reply_text.assert_called_once()
|
||||
assert "Usage" in update.message.reply_text.call_args[0][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_invalid_id(self):
|
||||
update = create_mock_update(message_text="/delete abc")
|
||||
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
|
||||
|
||||
await cmd_delete(update, ctx)
|
||||
update.message.reply_text.assert_called_once_with("Invalid bounty ID.")
|
||||
|
||||
|
||||
class TestCmdTrack:
|
||||
"""Test cmd_track command."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_track_in_group(self):
|
||||
update = create_mock_update(chat_type="group", message_text="/track 1")
|
||||
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
|
||||
|
||||
with patch.object(
|
||||
TRACKING_SERVICE, "track_bounty", return_value=True
|
||||
) as mock_track:
|
||||
await cmd_track(update, ctx)
|
||||
mock_track.assert_called_once_with(-456, 123, 1)
|
||||
update.message.reply_text.assert_called_once()
|
||||
assert "✅" in update.message.reply_text.call_args[0][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_track_not_in_group(self):
|
||||
update = create_mock_update(chat_type="private", message_text="/track 1")
|
||||
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
|
||||
|
||||
await cmd_track(update, ctx)
|
||||
update.message.reply_text.assert_called_once()
|
||||
assert "⛔" in update.message.reply_text.call_args[0][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_track_without_args(self):
|
||||
update = create_mock_update(chat_type="group", message_text="/track")
|
||||
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
|
||||
|
||||
await cmd_track(update, ctx)
|
||||
update.message.reply_text.assert_called_once()
|
||||
assert "Usage" in update.message.reply_text.call_args[0][0]
|
||||
|
||||
|
||||
class TestCmdUntrack:
|
||||
"""Test cmd_untrack command."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_untrack_in_group(self):
|
||||
update = create_mock_update(chat_type="group", message_text="/untrack 1")
|
||||
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
|
||||
|
||||
with patch.object(
|
||||
TRACKING_SERVICE, "untrack_bounty", return_value=True
|
||||
) as mock_untrack:
|
||||
await cmd_untrack(update, ctx)
|
||||
mock_untrack.assert_called_once_with(-456, 123, 1)
|
||||
update.message.reply_text.assert_called_once()
|
||||
assert "✅" in update.message.reply_text.call_args[0][0]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_untrack_not_in_group(self):
|
||||
update = create_mock_update(chat_type="private", message_text="/untrack 1")
|
||||
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
|
||||
|
||||
await cmd_untrack(update, ctx)
|
||||
update.message.reply_text.assert_called_once()
|
||||
assert "⛔" in update.message.reply_text.call_args[0][0]
|
||||
|
||||
|
||||
class TestCmdStart:
|
||||
"""Test cmd_start command."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_in_group(self):
|
||||
update = create_mock_update(chat_type="group")
|
||||
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
|
||||
|
||||
await cmd_start(update, ctx)
|
||||
update.message.reply_text.assert_called_once()
|
||||
text = update.message.reply_text.call_args[0][0]
|
||||
assert "👻" in text
|
||||
assert "/bounty" in text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_in_private(self):
|
||||
update = create_mock_update(chat_type="private")
|
||||
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
|
||||
|
||||
await cmd_start(update, ctx)
|
||||
update.message.reply_text.assert_called_once()
|
||||
text = update.message.reply_text.call_args[0][0]
|
||||
assert "👻" in text
|
||||
assert "/my" in text
|
||||
|
||||
|
||||
class TestCmdHelp:
|
||||
"""Test cmd_help command."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_help_shows_commands(self):
|
||||
update = create_mock_update()
|
||||
ctx = MagicMock(spec=ContextTypes.DEFAULT_TYPE)
|
||||
|
||||
await cmd_help(update, ctx)
|
||||
update.message.reply_text.assert_called_once()
|
||||
text = update.message.reply_text.call_args[0][0]
|
||||
assert "/bounty" in text
|
||||
assert "/add" in text
|
||||
assert "/help" in text
|
||||
|
||||
Reference in New Issue
Block a user