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