"""Tests for cli/main.py — CLI commands with mocked dependencies.""" import pytest from unittest.mock import patch, MagicMock from io import StringIO 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") is 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") is 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"])