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
This commit is contained in:
shokollm
2026-04-03 13:18:48 +00:00
parent 7202eeb1d2
commit 5e6a5f16b1

376
tests/test_cli.py Normal file
View File

@@ -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"])