Files
jigaido/apps/telegram-bot/tests/test_commands.py
shokollm 2158f71fd0 feat: normalize URLs without scheme to https://
- Add normalize_url() helper function in commands.py
- Automatically prefix URLs without scheme (e.g. github.com → https://github.com)
- Applies to both -link flag and auto-detected URLs
- Add 5 new tests for URL normalization
- Fix existing tests to handle 5-value return from parse_args()

Examples:
  /add Fix bug github.com/user/repo
  → stored as: https://github.com/user/repo
2026-04-09 10:10:06 +00:00

587 lines
19 KiB
Python

"""Tests for commands.py — parsing, formatting, and command handlers."""
import time
from unittest.mock import MagicMock, patch, AsyncMock
import pytest
from telegram import Update, Message, User, Chat
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:
def test_basic_split(self):
assert extract_args("/add hello world") == ["hello", "world"]
assert extract_args("/track 42") == ["42"]
def test_no_args(self):
assert extract_args("/bounty") == []
assert extract_args("/add") == []
def test_preserves_urls(self):
result = extract_args("/add check https://example.com stuff")
assert result == ["check", "https://example.com", "stuff"]
def test_none_input(self):
assert extract_args(None) == []
def test_strips_whitespace(self):
assert extract_args(" /add hello world ") == ["hello", "world"]
class TestParseArgs:
def test_text_only(self):
text, link, due, _, _ = parse_args(["hello", "world"])
assert text == "hello world"
assert link is None
assert due is None
def test_link_extracted(self):
text, link, due, _, _ = parse_args(["hello", "https://example.com"])
# "hello" is non-link non-date → becomes text; only the URL becomes link
assert text == "hello"
assert link == "https://example.com"
assert due is None
def test_text_and_link(self):
text, link, due, _, _ = parse_args(["hello", "world", "https://example.com"])
assert text == "hello world"
assert link == "https://example.com"
def test_due_date_parsed(self):
text, link, due, _, _ = parse_args(["hello", "tomorrow"])
assert text == "hello"
assert due is not None
# Should be some time in the future
assert due > int(time.time())
def test_all_three(self):
text, link, due, _, _ = parse_args(["hello", "https://example.com", "tomorrow"])
assert text == "hello"
assert link == "https://example.com"
assert due is not None
def test_http_and_https_both_detected(self):
_, link1, _, _, _ = parse_args(["http://example.com"])
_, link2, _, _, _ = parse_args(["https://example.com"])
assert link1 == "http://example.com"
assert link2 == "https://example.com"
def test_non_url_non_date_becomes_text(self):
text, link, due, _, _ = parse_args(["fix", "the", "bug"])
assert text == "fix the bug"
assert link is None
assert due is None
def test_multiple_links_first_only(self):
_, link, _, _, _ = parse_args(["text", "https://first.com", "https://second.com"])
assert link == "https://first.com"
def test_due_date_after_link(self):
text, link, due, _, _ = parse_args(["task", "https://example.com", "in 5 days"])
assert text == "task"
assert link == "https://example.com"
assert due is not None
def test_empty_args(self):
text, link, due, _, _ = parse_args([])
assert text is None
assert link is None
assert due is None
def test_date_parser_failure_returns_none(self):
# "asdfjkl" is not parseable → goes to text
text, link, due, _, _ = parse_args(["hello", "asdfjkl"])
assert text == "hello asdfjkl"
assert due is None
def test_link_takes_first_match(self):
# Even if it's not a valid URL, starts with https://
_, link, _, _, _ = parse_args(["skip", "https://not-real.but-still-a-link"])
assert link == "https://not-real.but-still-a-link"
def test_url_without_scheme_normalized_to_https(self):
"""URLs without scheme should get https:// prefix."""
_, link, _, _, _ = parse_args(["github.com/user/repo"])
assert link == "https://github.com/user/repo"
def test_url_without_scheme_github_normalized(self):
text, link, _, _, _ = parse_args(["Fix bug", "github.com/owner/repo"])
assert text == "Fix bug"
assert link == "https://github.com/owner/repo"
def test_url_with_explicit_https_unchanged(self):
_, link, _, _, _ = parse_args(["task", "https://example.com/page"])
assert link == "https://example.com/page"
def test_url_with_http_unchanged(self):
_, link, _, _, _ = parse_args(["task", "http://example.com/page"])
assert link == "http://example.com/page"
def test_url_link_flag_without_scheme_normalized(self):
_, link, _, _, _ = parse_args(["-link", "example.com/path"])
assert link == "https://example.com/path"
class TestFormatBounty:
def _row(
self,
id=1,
text="Test bounty",
link="https://example.com",
due_date_ts=None,
created_by_user_id=123456,
):
row = MagicMock()
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):
b = self._row(id=42)
out = format_bounty(b, show_id=True)
assert "[#42]" in out
def test_hides_id_when_requested(self):
b = self._row(id=42)
out = format_bounty(b, show_id=False)
assert "[#42]" not in out
def test_text_included(self):
b = self._row(text="Fix the login bug")
out = format_bounty(b)
assert "Fix the login bug" in out
def test_link_shown(self):
b = self._row(link="https://github.com/bob/repo")
out = format_bounty(b)
assert "https://github.com/bob/repo" in out
assert "🔗" in out
def test_no_link(self):
b = self._row(link=None)
out = format_bounty(b)
assert "🔗" not in out
def test_due_date_future(self):
future = int(time.time()) + 3 * 86400
b = self._row(due_date_ts=future)
out = format_bounty(b)
assert "" in out
assert "3d" in out
def test_due_date_today(self):
today = int(time.time()) + 3600 # within today
b = self._row(due_date_ts=today)
out = format_bounty(b)
assert "TODAY" in out
def test_due_date_overdue(self):
past = int(time.time()) - 86400 # yesterday
b = self._row(due_date_ts=past)
out = format_bounty(b)
assert "OVERDUE" in out
def test_created_by_shown(self):
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")
_, 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