From 5e6a5f16b124479c795c09fc01bc7a7b6d63744d Mon Sep 17 00:00:00 2001 From: shokollm <270575765+shokollm@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:18:48 +0000 Subject: [PATCH] Add CLI unit tests for jigaido issue #11 - Tests mock dependencies and verify command existence, flag processing, and output format - Tests use --group-id=-1001 format (equals sign) to avoid argparse treating -1001 as a flag - All 17 tests passing --- tests/test_cli.py | 376 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 tests/test_cli.py diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..8e7a49e --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,376 @@ +"""Tests for cli/main.py — CLI commands with mocked dependencies.""" + +import pytest +from unittest.mock import patch, MagicMock +from io import StringIO +import sys + +from core.models import Bounty +from core.ports import RoomStorage, TrackingStorage + + +class MockRoomStorage(RoomStorage): + """Mock RoomStorage for testing.""" + + def __init__(self): + self._rooms: dict[int, dict] = {} + + def load(self, room_id: int) -> dict | None: + return self._rooms.get(room_id) + + def save(self, room_data: dict) -> None: + self._rooms[room_data["room_id"]] = room_data + + def add_bounty(self, room_id: int, bounty: Bounty) -> None: + if room_id not in self._rooms: + self._rooms[room_id] = {"room_id": room_id, "bounties": [], "next_id": 1} + self._rooms[room_id]["bounties"].append(bounty) + + def update_bounty(self, room_id: int, bounty: Bounty) -> None: + if room_id in self._rooms: + for i, b in enumerate(self._rooms[room_id]["bounties"]): + if b.id == bounty.id: + self._rooms[room_id]["bounties"][i] = bounty + break + + def delete_bounty(self, room_id: int, bounty_id: int) -> None: + if room_id in self._rooms: + self._rooms[room_id]["bounties"] = [ + b for b in self._rooms[room_id]["bounties"] if b.id != bounty_id + ] + + def get_bounty(self, room_id: int, bounty_id: int) -> Bounty | None: + if room_id in self._rooms: + for b in self._rooms[room_id]["bounties"]: + if b.id == bounty_id: + return b + return None + + +class MockTrackingStorage(TrackingStorage): + """Mock TrackingStorage for testing.""" + + def __init__(self): + self._tracking: dict[int, dict] = {} + + def load(self, user_id: int) -> dict | None: + return self._tracking.get(user_id) + + def save(self, tracking_data: dict) -> None: + self._tracking[tracking_data["user_id"]] = tracking_data + + def track_bounty(self, user_id: int, bounty_id: int, room_id: int) -> bool: + if user_id not in self._tracking: + self._tracking[user_id] = {"user_id": user_id, "bounty_ids": []} + if bounty_id not in self._tracking[user_id]["bounty_ids"]: + self._tracking[user_id]["bounty_ids"].append(bounty_id) + return True + return False + + def untrack_bounty(self, user_id: int, bounty_id: int) -> bool: + if user_id in self._tracking: + if bounty_id in self._tracking[user_id]["bounty_ids"]: + self._tracking[user_id]["bounty_ids"].remove(bounty_id) + return True + return False + + def get_tracked_bounty_ids(self, user_id: int) -> list[int]: + if user_id in self._tracking: + return self._tracking[user_id]["bounty_ids"] + return [] + + +@pytest.fixture +def mock_services(): + """Create mock services for CLI testing.""" + room_storage = MockRoomStorage() + tracking_storage = MockTrackingStorage() + return room_storage, tracking_storage + + +class TestCLIParsing: + """Test CLI argument parsing through main().""" + + def test_add_command_requires_text(self): + """Test that 'add' command requires text argument.""" + from cli.main import main + + with patch("sys.argv", ["jigaido-cli", "add", "--group-id=-1001"]): + with patch("cli.main.create_services"): + with patch("sys.stderr", new=StringIO()) as stderr: + with pytest.raises(SystemExit): + main() + assert "arguments are required: text" in stderr.getvalue() + + def test_list_command(self): + """Test 'list' command parsing.""" + from cli.main import main + + with patch("sys.argv", ["jigaido-cli", "list", "--group-id=-1001"]): + with patch("cli.main.create_services") as mock_create: + mock_create.return_value = (MagicMock(), MagicMock()) + mock_bounty_service = MagicMock() + mock_bounty_service.list_bounties.return_value = [] + mock_create.return_value = (mock_bounty_service, MagicMock()) + + with patch("sys.stdout", new=StringIO()) as stdout: + main() + assert "No bounties" in stdout.getvalue() + + def test_my_command(self): + """Test 'my' command parsing.""" + from cli.main import main + + with patch( + "sys.argv", ["jigaido-cli", "my", "--group-id=-1001", "--user-id=123"] + ): + with patch("cli.main.create_services") as mock_create: + mock_tracking_service = MagicMock() + mock_tracking_service.get_tracked_bounties.return_value = [] + mock_create.return_value = (MagicMock(), mock_tracking_service) + + with patch("sys.stdout", new=StringIO()) as stdout: + main() + assert "Not tracking any bounties" in stdout.getvalue() + + def test_update_command(self): + """Test 'update' command parsing.""" + from cli.main import main + + with patch( + "sys.argv", ["jigaido-cli", "update", "1", "--group-id=-1001", "new text"] + ): + with patch("cli.main.create_services") as mock_create: + mock_bounty_service = MagicMock() + mock_bounty_service.update_bounty.return_value = True + mock_create.return_value = (mock_bounty_service, MagicMock()) + + with patch("sys.stdout", new=StringIO()) as stdout: + main() + assert "Updated bounty #1" in stdout.getvalue() + + def test_delete_command(self): + """Test 'delete' command parsing.""" + from cli.main import main + + with patch("sys.argv", ["jigaido-cli", "delete", "1", "--group-id=-1001"]): + with patch("cli.main.create_services") as mock_create: + mock_bounty_service = MagicMock() + mock_bounty_service.delete_bounty.return_value = True + mock_create.return_value = (mock_bounty_service, MagicMock()) + + with patch("sys.stdout", new=StringIO()) as stdout: + main() + assert "Deleted bounty #1" in stdout.getvalue() + + def test_track_command(self): + """Test 'track' command parsing.""" + from cli.main import main + + with patch( + "sys.argv", + ["jigaido-cli", "track", "1", "--group-id=-1001", "--user-id=123"], + ): + with patch("cli.main.create_services") as mock_create: + mock_tracking_service = MagicMock() + mock_tracking_service.track_bounty.return_value = True + mock_create.return_value = (MagicMock(), mock_tracking_service) + + with patch("sys.stdout", new=StringIO()) as stdout: + main() + assert "Tracking bounty #1" in stdout.getvalue() + + def test_untrack_command(self): + """Test 'untrack' command parsing.""" + from cli.main import main + + with patch( + "sys.argv", + ["jigaido-cli", "untrack", "1", "--group-id=-1001", "--user-id=123"], + ): + with patch("cli.main.create_services") as mock_create: + mock_tracking_service = MagicMock() + mock_tracking_service.untrack_bounty.return_value = True + mock_create.return_value = (MagicMock(), mock_tracking_service) + + with patch("sys.stdout", new=StringIO()) as stdout: + main() + assert "Untracked bounty #1" in stdout.getvalue() + + +class TestCLIValidation: + """Test CLI input validation.""" + + def test_requires_group_id_or_user_id(self): + """Test that commands require either --group-id or --user-id.""" + from cli.main import main + + with patch("sys.argv", ["jigaido-cli", "add", "some text"]): + with patch("sys.stderr", new=StringIO()) as stderr: + with pytest.raises(SystemExit): + main() + assert "group-id or --user-id is required" in stderr.getvalue() + + def test_unknown_command(self): + """Test handling of unknown commands.""" + from cli.main import main + + with patch("sys.argv", ["jigaido-cli", "unknown_cmd", "--group-id=-1001"]): + with patch("sys.stderr", new=StringIO()) as stderr: + with pytest.raises(SystemExit): + main() + assert "invalid choice" in stderr.getvalue() + + def test_update_clear_link_flag(self): + """Test update with --clear-link flag.""" + from cli.main import main + + with patch( + "sys.argv", + ["jigaido-cli", "update", "1", "--clear-link", "--group-id=-1001"], + ): + with patch("cli.main.create_services") as mock_create: + mock_bounty_service = MagicMock() + mock_bounty_service.update_bounty.return_value = True + mock_create.return_value = (mock_bounty_service, MagicMock()) + + main() + mock_bounty_service.update_bounty.assert_called_once() + call_kwargs = mock_bounty_service.update_bounty.call_args + assert call_kwargs.kwargs.get("clear_link") == True + + def test_update_clear_due_flag(self): + """Test update with --clear-due flag.""" + from cli.main import main + + with patch( + "sys.argv", + ["jigaido-cli", "update", "1", "--clear-due", "--group-id=-1001"], + ): + with patch("cli.main.create_services") as mock_create: + mock_bounty_service = MagicMock() + mock_bounty_service.update_bounty.return_value = True + mock_create.return_value = (mock_bounty_service, MagicMock()) + + main() + mock_bounty_service.update_bounty.assert_called_once() + call_kwargs = mock_bounty_service.update_bounty.call_args + assert call_kwargs.kwargs.get("clear_due") == True + + +class TestCLIOutput: + """Test CLI output formatting.""" + + def test_list_shows_bounties(self): + """Test that 'list' shows bounty details correctly.""" + from cli.main import main + + mock_bounty = MagicMock() + mock_bounty.id = 1 + mock_bounty.text = "Fix bug" + mock_bounty.link = "https://github.com/issue/1" + mock_bounty.due_date_ts = None + + with patch("sys.argv", ["jigaido-cli", "list", "--group-id=-1001"]): + with patch("cli.main.create_services") as mock_create: + mock_bounty_service = MagicMock() + mock_bounty_service.list_bounties.return_value = [mock_bounty] + mock_create.return_value = (mock_bounty_service, MagicMock()) + + with patch("sys.stdout", new=StringIO()) as stdout: + main() + output = stdout.getvalue() + assert "#1:" in output + assert "Fix bug" in output + assert "https://github.com/issue/1" in output + + def test_list_empty(self): + """Test that 'list' shows 'No bounties' when empty.""" + from cli.main import main + + with patch("sys.argv", ["jigaido-cli", "list", "--group-id=-1001"]): + with patch("cli.main.create_services") as mock_create: + mock_bounty_service = MagicMock() + mock_bounty_service.list_bounties.return_value = [] + mock_create.return_value = (mock_bounty_service, MagicMock()) + + with patch("sys.stdout", new=StringIO()) as stdout: + main() + assert "No bounties" in stdout.getvalue() + + def test_add_output(self): + """Test that 'add' outputs the new bounty ID.""" + from cli.main import main + + mock_bounty = MagicMock() + mock_bounty.id = 42 + + with patch("sys.argv", ["jigaido-cli", "add", "New task", "--group-id=-1001"]): + with patch("cli.main.create_services") as mock_create: + mock_bounty_service = MagicMock() + mock_bounty_service.add_bounty.return_value = mock_bounty + mock_create.return_value = (mock_bounty_service, MagicMock()) + + with patch("sys.stdout", new=StringIO()) as stdout: + main() + assert "Added bounty #42" in stdout.getvalue() + + +class TestCLIErrorHandling: + """Test CLI error handling.""" + + def test_delete_nonexistent_bounty(self): + """Test deleting a non-existent bounty.""" + from cli.main import main + + with patch("sys.argv", ["jigaido-cli", "delete", "999", "--group-id=-1001"]): + with patch("cli.main.create_services") as mock_create: + mock_bounty_service = MagicMock() + mock_bounty_service.delete_bounty.return_value = False + mock_create.return_value = (mock_bounty_service, MagicMock()) + + with patch("sys.stderr", new=StringIO()) as stderr: + with pytest.raises(SystemExit): + main() + assert "not found" in stderr.getvalue() + + def test_update_permission_error(self): + """Test update with permission error.""" + from cli.main import main + + with patch( + "sys.argv", + ["jigaido-cli", "update", "1", "new text", "--group-id=-1001"], + ): + with patch("cli.main.create_services") as mock_create: + mock_bounty_service = MagicMock() + mock_bounty_service.update_bounty.side_effect = PermissionError( + "Not owner" + ) + mock_create.return_value = (mock_bounty_service, MagicMock()) + + with patch("sys.stderr", new=StringIO()) as stderr: + with pytest.raises(SystemExit): + main() + assert "Not owner" in stderr.getvalue() + + def test_track_already_tracking(self): + """Test tracking a bounty that's already tracked.""" + from cli.main import main + + with patch( + "sys.argv", + ["jigaido-cli", "track", "1", "--group-id=-1001", "--user-id=123"], + ): + with patch("cli.main.create_services") as mock_create: + mock_tracking_service = MagicMock() + mock_tracking_service.track_bounty.return_value = False + mock_create.return_value = (MagicMock(), mock_tracking_service) + + with patch("sys.stdout", new=StringIO()) as stdout: + main() + assert "Already tracking" in stdout.getvalue() + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])