"""Smoke tests for the MCP server layer. Uses FastMCP's public API: - mcp.list_tools() to enumerate registered tools - mcp.call_tool(name, args) to invoke one This avoids reaching into private attributes and reflects exactly what an MCP client sees over the wire. """ from __future__ import annotations import base64 import httpx import pytest import respx from homelab_mcp_gitea import server BASE = "https://gitea.test" @pytest.fixture(autouse=True) def _env(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("GITEA_URL", BASE) monkeypatch.setenv("GITEA_TOKEN", "test-token") def _text(call_result: object) -> str: """Extract the first text block from a FastMCP call_tool result.""" content_blocks, _meta = call_result # type: ignore[misc] return content_blocks[0].text # type: ignore[union-attr] @pytest.mark.asyncio async def test_all_six_tools_registered() -> None: tools = await server.mcp.list_tools() names = {t.name for t in tools} expected = { "list_repos", "get_repo", "get_file", "create_issue", "list_open_prs", "merge_pr", } assert expected <= names, f"missing tools: {expected - names}" @pytest.mark.asyncio @respx.mock async def test_list_repos_tool_routes_through_client() -> None: respx.get(f"{BASE}/api/v1/user/repos").mock( return_value=httpx.Response(200, json=[{"full_name": "root/alpha", "private": False}]) ) result = await server.mcp.call_tool("list_repos", {"limit": 10}) assert "root/alpha" in _text(result) @pytest.mark.asyncio @respx.mock async def test_get_file_tool_returns_decoded_text() -> None: raw = "from fastapi import FastAPI\n" encoded = base64.b64encode(raw.encode()).decode() respx.get(f"{BASE}/api/v1/repos/root/alpha/contents/main.py").mock( return_value=httpx.Response(200, json={"content": encoded}) ) result = await server.mcp.call_tool( "get_file", {"owner": "root", "repo": "alpha", "path": "main.py", "ref": "main"} ) assert _text(result) == raw @pytest.mark.asyncio @respx.mock async def test_create_issue_tool_returns_number_and_url() -> None: respx.post(f"{BASE}/api/v1/repos/root/alpha/issues").mock( return_value=httpx.Response( 201, json={ "number": 7, "title": "x", "html_url": "https://gitea.test/root/alpha/issues/7", }, ) ) result = await server.mcp.call_tool( "create_issue", {"owner": "root", "repo": "alpha", "title": "x", "body": ""} ) text = _text(result) assert "#7" in text assert "issues/7" in text @pytest.mark.asyncio @respx.mock async def test_merge_pr_tool_success_message() -> None: respx.post(f"{BASE}/api/v1/repos/root/alpha/pulls/5/merge").mock( return_value=httpx.Response(200, content=b"") ) result = await server.mcp.call_tool("merge_pr", {"owner": "root", "repo": "alpha", "index": 5}) assert "merged successfully" in _text(result) @pytest.mark.asyncio @respx.mock async def test_error_from_gitea_surfaces_as_human_string() -> None: respx.get(f"{BASE}/api/v1/user/repos").mock( return_value=httpx.Response(401, json={"message": "unauthorized"}) ) result = await server.mcp.call_tool("list_repos", {}) text = _text(result) assert "Gitea API error" in text assert "401" in text