blob: 4df4b7122c419f136c6b6b994f34ddd7554d7c7a [file]
import json
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from gerrit_mcp_server_task.extension import _find_actionable, register
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def ctx(tmp_path: Path):
mock_ctx = MagicMock()
mock_ctx.get_base_url = MagicMock(return_value="https://gerrit.example.com/a")
mock_ctx.normalize_url = lambda u: u
mock_ctx.run_curl = AsyncMock()
mock_ctx.load_config = MagicMock(return_value={})
mock_ctx.log_path = tmp_path / "server.log"
mock_ctx.plugin_registry = MagicMock()
# requires_plugin awaits host_has_plugin — must be an AsyncMock
mock_ctx.plugin_registry.host_has_plugin = AsyncMock(return_value=True)
mock_ctx.extension_config = MagicMock(return_value={})
return mock_ctx
def _gerrit_response(roots):
"""Wrap roots in the Gerrit /changes/ response envelope."""
return json.dumps(
[{"change_id": "123", "plugins": [{"name": "task", "roots": roots}]}]
)
# ---------------------------------------------------------------------------
# _find_actionable unit tests
# ---------------------------------------------------------------------------
class TestFindActionable:
def test_ready_node_returned(self):
node = {"name": "t", "status": "READY", "sub_tasks": []}
assert _find_actionable(node) == [node]
def test_fail_node_returned(self):
node = {"name": "t", "status": "FAIL", "sub_tasks": []}
assert _find_actionable(node) == [node]
def test_pass_node_not_returned(self):
assert _find_actionable({"name": "t", "status": "PASS"}) == []
def test_duplicate_node_not_returned(self):
assert _find_actionable({"name": "t", "status": "DUPLICATE"}) == []
def test_skipped_node_not_returned(self):
assert _find_actionable({"name": "t", "status": "SKIPPED"}) == []
def test_unknown_node_not_returned(self):
assert _find_actionable({"name": "t", "status": "UNKNOWN"}) == []
def test_waiting_descends_into_children(self):
leaf = {"name": "leaf", "status": "READY", "sub_tasks": []}
waiting = {"name": "parent", "status": "WAITING", "sub_tasks": [leaf]}
assert _find_actionable(waiting) == [leaf]
def test_waiting_not_returned_itself(self):
waiting = {"name": "w", "status": "WAITING", "sub_tasks": []}
assert _find_actionable(waiting) == []
def test_nested_waiting_reaches_leaf(self):
leaf = {"name": "leaf", "status": "FAIL", "sub_tasks": []}
mid = {"name": "mid", "status": "WAITING", "sub_tasks": [leaf]}
root = {"name": "root", "status": "WAITING", "sub_tasks": [mid]}
assert _find_actionable(root) == [leaf]
def test_mixed_children(self):
pass_node = {"name": "pass", "status": "PASS"}
ready_node = {"name": "ready", "status": "READY"}
waiting = {
"name": "w",
"status": "WAITING",
"sub_tasks": [pass_node, ready_node],
}
assert _find_actionable(waiting) == [ready_node]
# ---------------------------------------------------------------------------
# register() integration tests
# ---------------------------------------------------------------------------
class TestRegister:
def test_register_adds_two_tools(self, ctx):
register(ctx)
assert ctx.mcp.tool.call_count == 2
@pytest.mark.asyncio
async def test_get_task_tree_returns_roots(self, ctx):
roots = [{"name": "coral", "status": "WAITING", "sub_tasks": []}]
ctx.run_curl = AsyncMock(return_value=_gerrit_response(roots))
registered = {}
def capture_decorator():
def decorator(fn):
registered[fn.__name__] = fn
return fn
return decorator
ctx.mcp.tool = MagicMock(side_effect=lambda: capture_decorator())
register(ctx)
# get_task_tree is wrapped by requires_plugin; call __wrapped__ directly
fn = registered.get("get_task_tree")
assert fn is not None
data = await fn(change_id="123", gerrit_base_url="https://gerrit.example.com/a")
assert data["change_id"] == "123"
assert data["roots"] == roots
@pytest.mark.asyncio
async def test_get_actionable_tasks_filters_status(self, ctx):
roots = [
{
"name": "top",
"status": "WAITING",
"sub_tasks": [
{"name": "pass-task", "status": "PASS", "sub_tasks": []},
{"name": "ready-task", "status": "READY", "sub_tasks": []},
],
}
]
ctx.run_curl = AsyncMock(return_value=_gerrit_response(roots))
registered = {}
def capture_decorator():
def decorator(fn):
registered[fn.__name__] = fn
return fn
return decorator
ctx.mcp.tool = MagicMock(side_effect=lambda: capture_decorator())
register(ctx)
fn = registered.get("get_actionable_tasks")
assert fn is not None
data = await fn(change_id="123", gerrit_base_url="https://gerrit.example.com/a")
assert len(data["actionable_tasks"]) == 1
assert data["actionable_tasks"][0]["name"] == "ready-task"
@pytest.mark.asyncio
async def test_get_task_tree_no_plugin_data(self, ctx):
ctx.run_curl = AsyncMock(
return_value=json.dumps([{"change_id": "123", "plugins": []}])
)
registered = {}
def capture_decorator():
def decorator(fn):
registered[fn.__name__] = fn
return fn
return decorator
ctx.mcp.tool = MagicMock(side_effect=lambda: capture_decorator())
register(ctx)
fn = registered.get("get_task_tree")
assert fn is not None
data = await fn(change_id="123", gerrit_base_url="https://gerrit.example.com/a")
assert data["roots"] == []
assert "note" in data
@pytest.mark.asyncio
async def test_get_task_tree_no_change_found(self, ctx):
ctx.run_curl = AsyncMock(return_value="[]")
registered = {}
def capture_decorator():
def decorator(fn):
registered[fn.__name__] = fn
return fn
return decorator
ctx.mcp.tool = MagicMock(side_effect=lambda: capture_decorator())
register(ctx)
fn = registered.get("get_task_tree")
assert fn is not None
with pytest.raises(ValueError, match="No change found"):
await fn(change_id="999", gerrit_base_url="https://gerrit.example.com/a")