Implements the `bd` reopen command across the entire MCP stack, enabling agents to reopen closed issues with optional reason tracking for audit trails. This addresses the need to handle regressions and incorrectly closed issues without manual `bd` CLI intervention. The reopen command is more explicit than `bd update --status open` and emits a dedicated Reopened event in the audit log, making it easier to track why issues were reopened during analysis. Changes: - `models.py`: Add ReopenIssueParams with issue_ids list and optional reason - `bd_client.py`: Implement reopen() method with JSON response parsing - `tools.py`: Add beads_reopen_issue() wrapper with Annotated types for MCP - `server.py`: Register 'reopen' MCP tool with description and parameters Testing (10 new): - `test_bd_client.py`: 4 unit tests (mocked subprocess) - `test_bd_client_integration.py`: 3 integration tests (real `bd` CLI) - `test_mcp_server_integration.py`: 3 MCP integration tests (FastMCP Client) - `test_tools.py`: 3 tools wrapper tests (mocked BdClient) Also updated `README.md`.
715 lines
23 KiB
Python
715 lines
23 KiB
Python
"""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(from_id="bd-2", to_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(from_id="bd-2", to_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(from_id="bd-2", to_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()
|