"""Contract tests for the CLI dispatcher (the second face of the same tools).""" import json import httpx import respx from server.gitea_mcp import TOOLS, dispatch, main BASE = "https://gitea.test/api/v1" # ── Registry parity ───────────────────────────────────────────────────────────── def test_tools_registry_matches_count(): # Same number the smoke test asserts for MCP registration. assert len(TOOLS) == 66 def test_every_tool_is_callable(): assert all(callable(fn) for fn in TOOLS.values()) # ── serve vs CLI routing ──────────────────────────────────────────────────────── def test_no_args_means_serve(): assert dispatch([]) is None def test_serve_subcommand_means_serve(): assert dispatch(["serve"]) is None # ── Discovery ─────────────────────────────────────────────────────────────────── def test_list_tools_payload(): env = dispatch(["list-tools"]) assert env["success"] is True assert env["data"]["count"] == 66 names = {t["name"] for t in env["data"]["tools"]} assert "get_repo" in names # parameter spec carries name/type/required get_repo = next(t for t in env["data"]["tools"] if t["name"] == "get_repo") owner = next(p for p in get_repo["params"] if p["name"] == "owner") assert owner["required"] is True assert owner["type"] == "str" # ── Happy path: flags map to a correct REST call ──────────────────────────────── @respx.mock def test_dispatch_happy_path(): route = respx.get(f"{BASE}/repos/andrey/infra").mock( return_value=httpx.Response(200, json={"name": "infra", "id": 7}) ) env = dispatch(["get_repo", "--owner", "andrey", "--repo", "infra"]) assert route.called assert env["success"] is True assert env["data"]["name"] == "infra" @respx.mock def test_int_flag_is_typed(): route = respx.get(f"{BASE}/user/repos").mock(return_value=httpx.Response(200, json=[])) dispatch(["list_my_repos", "--limit", "5", "--page", "2"]) params = dict(route.calls[0].request.url.params) assert params["limit"] == "5" assert params["page"] == "2" @respx.mock def test_bool_no_flag_sends_false(): route = respx.post(f"{BASE}/user/repos").mock( return_value=httpx.Response(201, json={"name": "x"}) ) dispatch(["create_repo", "--name", "x", "--no-private", "--no-auto-init"]) body = json.loads(route.calls[0].request.content) assert body["private"] is False assert body["auto_init"] is False @respx.mock def test_list_flag_takes_multiple_values(): route = respx.put(f"{BASE}/repos/o/r/topics").mock(return_value=httpx.Response(204)) dispatch(["set_repo_topics", "--owner", "o", "--repo", "r", "--topics", "ci", "backend"]) body = json.loads(route.calls[0].request.content) assert body["topics"] == ["ci", "backend"] # ── Error handling ────────────────────────────────────────────────────────────── def test_unknown_command(): env = dispatch(["no_such_tool"]) assert env["success"] is False assert env["type"] == "UnknownCommand" def test_missing_required_flag_is_usage_error(): env = dispatch(["get_repo", "--owner", "andrey"]) # no --repo assert env["success"] is False assert env["type"] == "UsageError" @respx.mock def test_api_error_is_propagated_as_envelope(): respx.get(f"{BASE}/repos/o/missing").mock( return_value=httpx.Response(404, json={"message": "not found"}) ) env = dispatch(["get_repo", "--owner", "o", "--repo", "missing"]) assert env["success"] is False assert "404" in env["error"] # ── main() prints exactly one JSON object and exits 0 ─────────────────────────── @respx.mock def test_main_prints_single_json_and_exits_zero(capsys, monkeypatch): respx.get(f"{BASE}/repos/o/r").mock(return_value=httpx.Response(200, json={"name": "r"})) monkeypatch.setattr("sys.argv", ["gitea_mcp.py", "get_repo", "--owner", "o", "--repo", "r"]) try: main() except SystemExit as e: assert e.code == 0 out = capsys.readouterr().out.strip() parsed = json.loads(out) # exactly one JSON object assert parsed["success"] is True