diff --git a/apps/telegram-bot/commands.py b/apps/telegram-bot/commands.py index 35b50df..f08a04b 100644 --- a/apps/telegram-bot/commands.py +++ b/apps/telegram-bot/commands.py @@ -1,8 +1,5 @@ -"""Telegram command handlers for JIGAIDO.""" +"""Telegram command handlers for JIGAIDO - Thin wrappers around core services.""" -import json -import os -import re import time from functools import wraps from typing import Optional @@ -11,7 +8,13 @@ import dateparser from telegram import Update from telegram.ext import ContextTypes -import storage +from adapters.storage.json_file import JsonFileRoomStorage, JsonFileTrackingStorage +from core.services import BountyService, TrackingService + +ROOM_STORAGE = JsonFileRoomStorage() +TRACKING_STORAGE = JsonFileTrackingStorage() +BOUNTY_SERVICE = BountyService(ROOM_STORAGE) +TRACKING_SERVICE = TrackingService(TRACKING_STORAGE, ROOM_STORAGE) TELEGRAM_BOT_USERNAME = "your_bot_username" @@ -45,25 +48,25 @@ def parse_args(args: list[str]) -> tuple[Optional[str], Optional[str], Optional[ return text, link, due_date_ts -def format_bounty(b: dict, show_id: bool = True) -> str: +def format_bounty(b, show_id: bool = True) -> str: parts = [] if show_id: - parts.append(f"[#{b['id']}]") - if b.get("text"): - parts.append(b["text"]) - if b.get("link"): - parts.append(f"🔗 {b['link']}") - if b.get("due_date_ts"): - due_str = time.strftime("%Y-%m-%d", time.localtime(b["due_date_ts"])) - days_left = (b["due_date_ts"] - int(time.time())) // 86400 + parts.append(f"[#{b.id}]") + if b.text: + parts.append(b.text) + if b.link: + parts.append(f"🔗 {b.link}") + if b.due_date_ts: + due_str = time.strftime("%Y-%m-%d", time.localtime(b.due_date_ts)) + days_left = (b.due_date_ts - int(time.time())) // 86400 if days_left < 0: parts.append(f"⏰ {due_str} (OVERDUE)") elif days_left == 0: parts.append(f"⏰ {due_str} (TODAY)") else: parts.append(f"⏰ {due_str} ({days_left}d)") - if b.get("created_by_user_id"): - parts.append(f"by {b['created_by_user_id']}") + if b.created_by_user_id: + parts.append(f"by {b.created_by_user_id}") return " | ".join(parts) @@ -79,19 +82,26 @@ def get_user_id(update: Update) -> int: return update.effective_user.id -async def cmd_bounty(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: +def get_room_id(update: Update) -> int: + """Get room_id for the current context. + + For groups: negative group_id + For DMs: positive user_id + """ if is_group(update): - data = storage.load_group_bounties(get_group_id(update)) - bounties = data.get("bounties", []) - else: - data = storage.load_user_personal(get_user_id(update)) - bounties = data.get("bounties", []) + return get_group_id(update) + return get_user_id(update) + + +async def cmd_bounty(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + room_id = get_room_id(update) + bounties = BOUNTY_SERVICE.list_bounties(room_id) if not bounties: await update.message.reply_text("No bounties yet.") return - lines = [format_bounty(dict(b), show_id=True) for b in bounties] + lines = [format_bounty(b, show_id=True) for b in bounties] await update.message.reply_text("\n".join(lines), disable_web_page_preview=True) @@ -100,38 +110,18 @@ async def cmd_my(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: if is_group(update): group_id = get_group_id(update) - tracking = storage.load_user_tracking(group_id, user_id) - tracked = tracking.get("tracked", []) + bounties = TRACKING_SERVICE.get_tracked_bounties(group_id, user_id) else: - data = storage.load_user_personal(user_id) - bounties = data.get("bounties", []) - lines = [format_bounty(dict(b), show_id=True) for b in bounties] - await update.message.reply_text( - "\n".join(lines) if lines else "No personal bounties.", - disable_web_page_preview=True, - ) + room_id = get_room_id(update) + bounties = BOUNTY_SERVICE.list_bounties(room_id) + + if not bounties: + msg = "You are not tracking any bounties." if is_group(update) else "No personal bounties." + await update.message.reply_text(msg) return - if not tracked: - await update.message.reply_text("You are not tracking any bounties.") - return - - group_data = storage.load_group_bounties(group_id) - bounty_map = {b["id"]: b for b in group_data.get("bounties", [])} - - bounty_lines = [] - for t in tracked: - bounty = bounty_map.get(t["bounty_id"]) - if bounty: - bounty_lines.append(format_bounty(bounty, show_id=True)) - - if not bounty_lines: - await update.message.reply_text("You are not tracking any bounties.") - return - - await update.message.reply_text( - "\n".join(bounty_lines), disable_web_page_preview=True - ) + lines = [format_bounty(b, show_id=True) for b in bounties] + await update.message.reply_text("\n".join(lines), disable_web_page_preview=True) async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: @@ -149,19 +139,22 @@ async def cmd_add(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: return user_id = get_user_id(update) + room_id = get_room_id(update) - if is_group(update): - group_id = get_group_id(update) - bounty = storage.add_group_bounty(group_id, user_id, text, link, due_date_ts) - else: - bounty = storage.add_personal_bounty(user_id, text, link, due_date_ts) + bounty = BOUNTY_SERVICE.add_bounty( + room_id=room_id, + user_id=user_id, + text=text, + link=link, + due_date_ts=due_date_ts, + ) due_str = "" if due_date_ts: due_str = f" | Due: {time.strftime('%Y-%m-%d', time.localtime(due_date_ts))}" await update.message.reply_text( - f"✅ Bounty added (#{bounty['id']}){due_str}", + f"✅ Bounty added (#{bounty.id}){due_str}", disable_web_page_preview=True, ) @@ -186,25 +179,25 @@ async def cmd_update(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: return user_id = get_user_id(update) + room_id = get_room_id(update) - if is_group(update): - group_id = get_group_id(update) - bounty = storage.get_group_bounty(group_id, bounty_id) - if not bounty: - await update.message.reply_text("Bounty not found.") - return - if bounty["created_by_user_id"] != user_id: - await update.message.reply_text("⛔ Only the creator can edit this bounty.") - return - storage.update_group_bounty(group_id, bounty_id, text, link, due_date_ts) + try: + success = BOUNTY_SERVICE.update_bounty( + room_id=room_id, + bounty_id=bounty_id, + user_id=user_id, + text=text, + link=link, + due_date_ts=due_date_ts, + ) + except PermissionError as e: + await update.message.reply_text(f"⛔ {e}") + return + + if success: + await update.message.reply_text(f"✅ Bounty #{bounty_id} updated.") else: - bounty = storage.get_personal_bounty(user_id, bounty_id) - if not bounty: - await update.message.reply_text("Bounty not found.") - return - storage.update_personal_bounty(user_id, bounty_id, text, link, due_date_ts) - - await update.message.reply_text(f"✅ Bounty #{bounty_id} updated.") + await update.message.reply_text("Bounty not found.") async def cmd_delete(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: @@ -220,30 +213,29 @@ async def cmd_delete(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: return user_id = get_user_id(update) + room_id = get_room_id(update) - if is_group(update): - group_id = get_group_id(update) - bounty = storage.get_group_bounty(group_id, bounty_id) - if not bounty: - await update.message.reply_text("Bounty not found.") - return - if bounty["created_by_user_id"] != user_id: - await update.message.reply_text( - "⛔ Only the creator can delete this bounty." - ) - return - storage.delete_group_bounty(group_id, bounty_id) + try: + success = BOUNTY_SERVICE.delete_bounty( + room_id=room_id, + bounty_id=bounty_id, + user_id=user_id, + ) + except PermissionError as e: + await update.message.reply_text(f"⛔ {e}") + return + + if success: + await update.message.reply_text(f"✅ Bounty #{bounty_id} deleted.") else: - bounty = storage.get_personal_bounty(user_id, bounty_id) - if not bounty: - await update.message.reply_text("Bounty not found.") - return - storage.delete_personal_bounty(user_id, bounty_id) - - await update.message.reply_text(f"✅ Bounty #{bounty_id} deleted.") + await update.message.reply_text("Bounty not found.") async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + if not is_group(update): + await update.message.reply_text("⛔ /track is only available in groups.") + return + args = extract_args(update.message.text) if not args: await update.message.reply_text("Usage: /track ") @@ -256,25 +248,22 @@ async def cmd_track(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: return user_id = get_user_id(update) + room_id = get_room_id(update) - if is_group(update): - group_id = get_group_id(update) - bounty = storage.get_group_bounty(group_id, bounty_id) - if not bounty: - await update.message.reply_text("Bounty not found.") - return - if storage.track_bounty(group_id, user_id, bounty_id): - await update.message.reply_text(f"✅ Now tracking bounty #{bounty_id}.") - else: - await update.message.reply_text(f"Already tracking bounty #{bounty_id}.") - else: - if storage.track_bounty(user_id, user_id, bounty_id): + try: + if TRACKING_SERVICE.track_bounty(room_id, user_id, bounty_id): await update.message.reply_text(f"✅ Now tracking bounty #{bounty_id}.") else: await update.message.reply_text(f"Already tracking bounty #{bounty_id}.") + except ValueError as e: + await update.message.reply_text(str(e)) async def cmd_untrack(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: + if not is_group(update): + await update.message.reply_text("⛔ /untrack is only available in groups.") + return + args = extract_args(update.message.text) if not args: await update.message.reply_text("Usage: /untrack ") @@ -287,18 +276,12 @@ async def cmd_untrack(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: return user_id = get_user_id(update) + room_id = get_room_id(update) - if is_group(update): - group_id = get_group_id(update) - if storage.untrack_bounty(group_id, user_id, bounty_id): - await update.message.reply_text(f"✅ Untracked bounty #{bounty_id}.") - else: - await update.message.reply_text(f"Not tracking bounty #{bounty_id}.") + if TRACKING_SERVICE.untrack_bounty(room_id, user_id, bounty_id): + await update.message.reply_text(f"✅ Untracked bounty #{bounty_id}.") else: - if storage.untrack_bounty(user_id, user_id, bounty_id): - await update.message.reply_text(f"✅ Untracked bounty #{bounty_id}.") - else: - await update.message.reply_text(f"Not tracking bounty #{bounty_id}.") + await update.message.reply_text("Not tracking bounty #{bounty_id}.") async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: @@ -327,10 +310,10 @@ async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> None: "/bounty — list all bounties\n" "/my — bounties you're tracking\n" "/add [link] [due] — add bounty\n" - "/update [text] [link] [due] — update bounty\n" + "/update [text> [link] [due] — update bounty\n" "/delete — delete bounty\n" - "/track — track a bounty\n" - "/untrack — stop tracking\n" + "/track — track a bounty (groups only)\n" + "/untrack — stop tracking (groups only)\n" "/start — re-initialize\n" "/help — this message", disable_web_page_preview=True, diff --git a/apps/telegram-bot/tests/conftest.py b/apps/telegram-bot/tests/conftest.py index 81485ed..818ea7d 100644 --- a/apps/telegram-bot/tests/conftest.py +++ b/apps/telegram-bot/tests/conftest.py @@ -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) diff --git a/apps/telegram-bot/tests/test_commands.py b/apps/telegram-bot/tests/test_commands.py index 70fbb31..74d2ae1 100644 --- a/apps/telegram-bot/tests/test_commands.py +++ b/apps/telegram-bot/tests/test_commands.py @@ -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