"""Unit tests for BdClient.""" import json from unittest.mock import AsyncMock, MagicMock, patch import pytest from beads_mcp.bd_client import BdClient, BdCommandError, BdNotFoundError from beads_mcp.models import ( AddDependencyParams, CloseIssueParams, CreateIssueParams, ListIssuesParams, ReadyWorkParams, ReopenIssueParams, ShowIssueParams, UpdateIssueParams, ) @pytest.fixture def bd_client(): """Create a BdClient instance for testing.""" return BdClient(bd_path="/usr/bin/bd", beads_db="/tmp/test.db") @pytest.fixture def mock_process(): """Create a mock subprocess process.""" process = MagicMock() process.returncode = 0 process.communicate = AsyncMock(return_value=(b"", b"")) return process @pytest.mark.asyncio async def test_bd_client_initialization(): """Test BdClient initialization.""" client = BdClient(bd_path="/usr/bin/bd", beads_db="/tmp/test.db") assert client.bd_path == "/usr/bin/bd" assert client.beads_db == "/tmp/test.db" @pytest.mark.asyncio async def test_bd_client_without_db(): """Test BdClient initialization without database.""" client = BdClient(bd_path="/usr/bin/bd") assert client.bd_path == "/usr/bin/bd" assert client.beads_db is None @pytest.mark.asyncio async def test_run_command_success(bd_client, mock_process): """Test successful command execution.""" result_data = {"id": "bd-1", "title": "Test issue"} mock_process.communicate = AsyncMock(return_value=(json.dumps(result_data).encode(), b"")) with patch("asyncio.create_subprocess_exec", return_value=mock_process): result = await bd_client._run_command("show", "bd-1") assert result == result_data @pytest.mark.asyncio async def test_run_command_not_found(bd_client): """Test command execution when bd executable not found.""" with ( patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError()), pytest.raises(BdNotFoundError, match="bd CLI not found"), ): await bd_client._run_command("show", "bd-1") @pytest.mark.asyncio async def test_run_command_failure(bd_client, mock_process): """Test command execution failure.""" mock_process.returncode = 1 mock_process.communicate = AsyncMock(return_value=(b"", b"Error: Issue not found")) with ( patch("asyncio.create_subprocess_exec", return_value=mock_process), pytest.raises(BdCommandError, match="bd command failed"), ): await bd_client._run_command("show", "bd-999") @pytest.mark.asyncio async def test_run_command_invalid_json(bd_client, mock_process): """Test command execution with invalid JSON output.""" mock_process.communicate = AsyncMock(return_value=(b"invalid json", b"")) with ( patch("asyncio.create_subprocess_exec", return_value=mock_process), pytest.raises(BdCommandError, match="Failed to parse bd JSON output"), ): await bd_client._run_command("show", "bd-1") @pytest.mark.asyncio async def test_run_command_empty_output(bd_client, mock_process): """Test command execution with empty output.""" mock_process.communicate = AsyncMock(return_value=(b"", b"")) with patch("asyncio.create_subprocess_exec", return_value=mock_process): result = await bd_client._run_command("show", "bd-1") assert result == {} @pytest.mark.asyncio async def test_ready(bd_client, mock_process): """Test ready method.""" issues_data = [ { "id": "bd-1", "title": "Issue 1", "status": "open", "priority": 1, "issue_type": "bug", "created_at": "2025-01-25T00:00:00Z", "updated_at": "2025-01-25T00:00:00Z", }, { "id": "bd-2", "title": "Issue 2", "status": "open", "priority": 2, "issue_type": "feature", "created_at": "2025-01-25T00:00:00Z", "updated_at": "2025-01-25T00:00:00Z", }, ] mock_process.communicate = AsyncMock(return_value=(json.dumps(issues_data).encode(), b"")) with patch("asyncio.create_subprocess_exec", return_value=mock_process): params = ReadyWorkParams(limit=10, priority=1) issues = await bd_client.ready(params) assert len(issues) == 2 assert issues[0].id == "bd-1" assert issues[1].id == "bd-2" @pytest.mark.asyncio async def test_ready_with_assignee(bd_client, mock_process): """Test ready method with assignee filter.""" issues_data = [ { "id": "bd-1", "title": "Issue 1", "status": "open", "priority": 1, "issue_type": "bug", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", }, ] mock_process.communicate = AsyncMock(return_value=(json.dumps(issues_data).encode(), b"")) with patch("asyncio.create_subprocess_exec", return_value=mock_process): params = ReadyWorkParams(limit=10, assignee="alice") issues = await bd_client.ready(params) assert len(issues) == 1 assert issues[0].id == "bd-1" @pytest.mark.asyncio async def test_ready_invalid_response(bd_client, mock_process): """Test ready method with invalid response type.""" mock_process.communicate = AsyncMock( return_value=(json.dumps({"error": "not a list"}).encode(), b"") ) with patch("asyncio.create_subprocess_exec", return_value=mock_process): params = ReadyWorkParams(limit=10) issues = await bd_client.ready(params) assert issues == [] @pytest.mark.asyncio async def test_list_issues(bd_client, mock_process): """Test list_issues method.""" issues_data = [ { "id": "bd-1", "title": "Issue 1", "status": "open", "priority": 1, "issue_type": "bug", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", }, ] mock_process.communicate = AsyncMock(return_value=(json.dumps(issues_data).encode(), b"")) with patch("asyncio.create_subprocess_exec", return_value=mock_process): params = ListIssuesParams(status="open", priority=1) issues = await bd_client.list_issues(params) assert len(issues) == 1 assert issues[0].id == "bd-1" @pytest.mark.asyncio async def test_list_issues_invalid_response(bd_client, mock_process): """Test list_issues method with invalid response type.""" mock_process.communicate = AsyncMock( return_value=(json.dumps({"error": "not a list"}).encode(), b"") ) with patch("asyncio.create_subprocess_exec", return_value=mock_process): params = ListIssuesParams(status="open") issues = await bd_client.list_issues(params) assert issues == [] @pytest.mark.asyncio async def test_show(bd_client, mock_process): """Test show method.""" issue_data = { "id": "bd-1", "title": "Test issue", "description": "Test description", "status": "open", "priority": 1, "issue_type": "bug", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z", } mock_process.communicate = AsyncMock(return_value=(json.dumps(issue_data).encode(), b"")) with patch("asyncio.create_subprocess_exec", return_value=mock_process): params = ShowIssueParams(issue_id="bd-1") issue = await bd_client.show(params) assert issue.id == "bd-1" assert issue.title == "Test issue" @pytest.mark.asyncio async def test_show_invalid_response(bd_client, mock_process): """Test show method with invalid response type.""" mock_process.communicate = AsyncMock(return_value=(json.dumps(["not a dict"]).encode(), b"")) with ( patch("asyncio.create_subprocess_exec", return_value=mock_process), pytest.raises(BdCommandError, match="Invalid response for show"), ): params = ShowIssueParams(issue_id="bd-1") await bd_client.show(params) @pytest.mark.asyncio async def test_create(bd_client, mock_process): """Test create method.""" issue_data = { "id": "bd-5", "title": "New issue", "description": "New description", "status": "open", "priority": 2, "issue_type": "feature", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2025-01-25T00:00:00Z", } mock_process.communicate = AsyncMock(return_value=(json.dumps(issue_data).encode(), b"")) with patch("asyncio.create_subprocess_exec", return_value=mock_process): params = CreateIssueParams( title="New issue", description="New description", priority=2, issue_type="feature", ) issue = await bd_client.create(params) assert issue.id == "bd-5" assert issue.title == "New issue" @pytest.mark.asyncio async def test_create_with_optional_fields(bd_client, mock_process): """Test create method with all optional fields.""" issue_data = { "id": "test-42", "title": "New issue", "description": "Full description", "design": "Design notes", "acceptance_criteria": "Acceptance criteria", "external_ref": "gh-123", "status": "open", "priority": 1, "issue_type": "feature", "created_at": "2025-01-25T00:00:00Z", "updated_at": "2025-01-25T00:00:00Z", } mock_process.communicate = AsyncMock(return_value=(json.dumps(issue_data).encode(), b"")) with patch("asyncio.create_subprocess_exec", return_value=mock_process): params = CreateIssueParams( title="New issue", description="Full description", design="Design notes", acceptance="Acceptance criteria", external_ref="gh-123", priority=1, issue_type="feature", id="test-42", deps=["bd-1", "bd-2"], ) issue = await bd_client.create(params) assert issue.id == "test-42" assert issue.title == "New issue" @pytest.mark.asyncio async def test_create_invalid_response(bd_client, mock_process): """Test create method with invalid response type.""" mock_process.communicate = AsyncMock(return_value=(json.dumps(["not a dict"]).encode(), b"")) with ( patch("asyncio.create_subprocess_exec", return_value=mock_process), pytest.raises(BdCommandError, match="Invalid response for create"), ): params = CreateIssueParams(title="Test", priority=1, issue_type="task") await bd_client.create(params) @pytest.mark.asyncio async def test_update(bd_client, mock_process): """Test update method.""" issue_data = { "id": "bd-1", "title": "Updated title", "status": "in_progress", "priority": 1, "issue_type": "bug", "created_at": "2025-01-25T00:00:00Z", "updated_at": "2025-01-25T00:00:00Z", } mock_process.communicate = AsyncMock(return_value=(json.dumps(issue_data).encode(), b"")) with patch("asyncio.create_subprocess_exec", return_value=mock_process): params = UpdateIssueParams(issue_id="bd-1", status="in_progress", title="Updated title") issue = await bd_client.update(params) assert issue.id == "bd-1" assert issue.status == "in_progress" @pytest.mark.asyncio async def test_update_with_optional_fields(bd_client, mock_process): """Test update method with all optional fields.""" issue_data = { "id": "bd-1", "title": "Updated title", "design": "Design notes", "acceptance_criteria": "Acceptance criteria", "notes": "Additional notes", "external_ref": "gh-456", "status": "in_progress", "priority": 0, "issue_type": "bug", "created_at": "2025-01-25T00:00:00Z", "updated_at": "2025-01-25T00:00:00Z", } mock_process.communicate = AsyncMock(return_value=(json.dumps(issue_data).encode(), b"")) with patch("asyncio.create_subprocess_exec", return_value=mock_process): params = UpdateIssueParams( issue_id="bd-1", assignee="alice", design="Design notes", acceptance_criteria="Acceptance criteria", notes="Additional notes", external_ref="gh-456", ) issue = await bd_client.update(params) assert issue.id == "bd-1" assert issue.title == "Updated title" @pytest.mark.asyncio async def test_update_invalid_response(bd_client, mock_process): """Test update method with invalid response type.""" mock_process.communicate = AsyncMock(return_value=(json.dumps(["not a dict"]).encode(), b"")) with ( patch("asyncio.create_subprocess_exec", return_value=mock_process), pytest.raises(BdCommandError, match="Invalid response for update"), ): params = UpdateIssueParams(issue_id="bd-1", status="in_progress") await bd_client.update(params) @pytest.mark.asyncio async def test_close(bd_client, mock_process): """Test close method.""" issues_data = [ { "id": "bd-1", "title": "Closed issue", "status": "closed", "priority": 1, "issue_type": "bug", "created_at": "2025-01-25T00:00:00Z", "updated_at": "2025-01-25T00:00:00Z", "closed_at": "2025-01-25T01:00:00Z", } ] mock_process.communicate = AsyncMock(return_value=(json.dumps(issues_data).encode(), b"")) with patch("asyncio.create_subprocess_exec", return_value=mock_process): params = CloseIssueParams(issue_id="bd-1", reason="Completed") issues = await bd_client.close(params) assert len(issues) == 1 assert issues[0].status == "closed" @pytest.mark.asyncio async def test_close_invalid_response(bd_client, mock_process): """Test close method with invalid response type.""" mock_process.communicate = AsyncMock( return_value=(json.dumps({"error": "not a list"}).encode(), b"") ) with ( patch("asyncio.create_subprocess_exec", return_value=mock_process), pytest.raises(BdCommandError, match="Invalid response for close"), ): params = CloseIssueParams(issue_id="bd-1", reason="Test") await bd_client.close(params) @pytest.mark.asyncio async def test_reopen(bd_client, mock_process): """Test reopen method.""" issues_data = [ { "id": "bd-1", "title": "Reopened issue", "status": "open", "priority": 1, "issue_type": "bug", "created_at": "2025-01-25T00:00:00Z", "updated_at": "2025-01-25T02:00:00Z", "closed_at": None, } ] mock_process.communicate = AsyncMock(return_value=(json.dumps(issues_data).encode(), b"")) with patch("asyncio.create_subprocess_exec", return_value=mock_process): params = ReopenIssueParams(issue_ids=["bd-1"]) issues = await bd_client.reopen(params) assert len(issues) == 1 assert issues[0].id == "bd-1" assert issues[0].status == "open" assert issues[0].closed_at is None @pytest.mark.asyncio async def test_reopen_multiple_issues(bd_client, mock_process): """Test reopen method with multiple issues.""" issues_data = [ { "id": "bd-1", "title": "Reopened issue 1", "status": "open", "priority": 1, "issue_type": "bug", "created_at": "2025-01-25T00:00:00Z", "updated_at": "2025-01-25T02:00:00Z", "closed_at": None, }, { "id": "bd-2", "title": "Reopened issue 2", "status": "open", "priority": 2, "issue_type": "feature", "created_at": "2025-01-25T00:00:00Z", "updated_at": "2025-01-25T02:00:00Z", "closed_at": None, }, ] mock_process.communicate = AsyncMock(return_value=(json.dumps(issues_data).encode(), b"")) with patch("asyncio.create_subprocess_exec", return_value=mock_process): params = ReopenIssueParams(issue_ids=["bd-1", "bd-2"]) issues = await bd_client.reopen(params) assert len(issues) == 2 assert issues[0].id == "bd-1" assert issues[1].id == "bd-2" @pytest.mark.asyncio async def test_reopen_with_reason(bd_client, mock_process): """Test reopen method with reason parameter.""" issues_data = [ { "id": "bd-1", "title": "Reopened with reason", "status": "open", "priority": 1, "issue_type": "bug", "created_at": "2025-01-25T00:00:00Z", "updated_at": "2025-01-25T02:00:00Z", "closed_at": None, } ] mock_process.communicate = AsyncMock(return_value=(json.dumps(issues_data).encode(), b"")) with patch("asyncio.create_subprocess_exec", return_value=mock_process): params = ReopenIssueParams(issue_ids=["bd-1"], reason="Found regression") issues = await bd_client.reopen(params) assert len(issues) == 1 assert issues[0].id == "bd-1" assert issues[0].status == "open" @pytest.mark.asyncio async def test_reopen_invalid_response(bd_client, mock_process): """Test reopen method with invalid response type.""" mock_process.communicate = AsyncMock( return_value=(json.dumps({"error": "not a list"}).encode(), b"") ) with ( patch("asyncio.create_subprocess_exec", return_value=mock_process), pytest.raises(BdCommandError, match="Invalid response for reopen"), ): params = ReopenIssueParams(issue_ids=["bd-1"]) await bd_client.reopen(params) @pytest.mark.asyncio async def test_add_dependency(bd_client, mock_process): """Test add_dependency method.""" mock_process.communicate = AsyncMock(return_value=(b"Dependency added\n", b"")) with patch("asyncio.create_subprocess_exec", return_value=mock_process): params = AddDependencyParams(issue_id="bd-2", depends_on_id="bd-1", dep_type="blocks") await bd_client.add_dependency(params) # Should complete without raising an exception @pytest.mark.asyncio async def test_add_dependency_failure(bd_client, mock_process): """Test add_dependency with failure.""" mock_process.returncode = 1 mock_process.communicate = AsyncMock(return_value=(b"", b"Dependency already exists")) with ( patch("asyncio.create_subprocess_exec", return_value=mock_process), pytest.raises(BdCommandError, match="bd dep add failed"), ): params = AddDependencyParams(issue_id="bd-2", depends_on_id="bd-1", dep_type="blocks") await bd_client.add_dependency(params) @pytest.mark.asyncio async def test_add_dependency_not_found(bd_client): """Test add_dependency when bd executable not found.""" with ( patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError()), pytest.raises(BdNotFoundError, match="bd CLI not found"), ): params = AddDependencyParams(issue_id="bd-2", depends_on_id="bd-1", dep_type="blocks") await bd_client.add_dependency(params) @pytest.mark.asyncio async def test_quickstart(bd_client, mock_process): """Test quickstart method.""" quickstart_text = "# Beads Quickstart\n\nWelcome to beads..." mock_process.communicate = AsyncMock(return_value=(quickstart_text.encode(), b"")) with patch("asyncio.create_subprocess_exec", return_value=mock_process): result = await bd_client.quickstart() assert result == quickstart_text @pytest.mark.asyncio async def test_quickstart_failure(bd_client, mock_process): """Test quickstart with failure.""" mock_process.returncode = 1 mock_process.communicate = AsyncMock(return_value=(b"", b"Command not found")) with ( patch("asyncio.create_subprocess_exec", return_value=mock_process), pytest.raises(BdCommandError, match="bd quickstart failed"), ): await bd_client.quickstart() @pytest.mark.asyncio async def test_quickstart_not_found(bd_client): """Test quickstart when bd executable not found.""" with ( patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError()), pytest.raises(BdNotFoundError, match="bd CLI not found"), ): await bd_client.quickstart() @pytest.mark.asyncio async def test_stats(bd_client, mock_process): """Test stats method.""" stats_data = { "total_issues": 10, "open_issues": 5, "in_progress_issues": 2, "closed_issues": 3, "blocked_issues": 1, "ready_issues": 4, "average_lead_time_hours": 24.5, } mock_process.communicate = AsyncMock(return_value=(json.dumps(stats_data).encode(), b"")) with patch("asyncio.create_subprocess_exec", return_value=mock_process): result = await bd_client.stats() assert result.total_issues == 10 assert result.open_issues == 5 @pytest.mark.asyncio async def test_stats_invalid_response(bd_client, mock_process): """Test stats method with invalid response type.""" mock_process.communicate = AsyncMock(return_value=(json.dumps(["not a dict"]).encode(), b"")) with ( patch("asyncio.create_subprocess_exec", return_value=mock_process), pytest.raises(BdCommandError, match="Invalid response for stats"), ): await bd_client.stats() @pytest.mark.asyncio async def test_blocked(bd_client, mock_process): """Test blocked method.""" blocked_data = [ { "id": "bd-1", "title": "Blocked issue", "status": "blocked", "priority": 1, "issue_type": "bug", "created_at": "2025-01-25T00:00:00Z", "updated_at": "2025-01-25T00:00:00Z", "blocked_by_count": 2, "blocked_by": ["bd-2", "bd-3"], } ] mock_process.communicate = AsyncMock(return_value=(json.dumps(blocked_data).encode(), b"")) with patch("asyncio.create_subprocess_exec", return_value=mock_process): result = await bd_client.blocked() assert len(result) == 1 assert result[0].id == "bd-1" assert result[0].blocked_by_count == 2 @pytest.mark.asyncio async def test_blocked_invalid_response(bd_client, mock_process): """Test blocked method with invalid response type.""" mock_process.communicate = AsyncMock( return_value=(json.dumps({"error": "not a list"}).encode(), b"") ) with patch("asyncio.create_subprocess_exec", return_value=mock_process): result = await bd_client.blocked() assert result == [] @pytest.mark.asyncio async def test_init(bd_client, mock_process): """Test init method.""" init_output = "bd initialized successfully!" mock_process.communicate = AsyncMock(return_value=(init_output.encode(), b"")) with patch("asyncio.create_subprocess_exec", return_value=mock_process): from beads_mcp.models import InitParams params = InitParams(prefix="test") result = await bd_client.init(params) assert "bd initialized successfully!" in result @pytest.mark.asyncio async def test_init_failure(bd_client, mock_process): """Test init method with command failure.""" mock_process.returncode = 1 mock_process.communicate = AsyncMock(return_value=(b"", b"Failed to initialize")) with ( patch("asyncio.create_subprocess_exec", return_value=mock_process), pytest.raises(BdCommandError, match="bd init failed"), ): await bd_client.init()