"""Tests for sonarqube_mcp.server."""
from __future__ import annotations
import inspect
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from sonarqube_mcp.errors import SonarQubeError
from sonarqube_mcp.server import (
VALID_ISSUE_TYPES,
VALID_SEVERITIES,
VALID_STATUSES,
_clamp,
_safe_tool,
_validate_csv_enum,
create_server,
)
from sonarqube_mcp.settings import SonarQubeSettings
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@pytest.fixture()
def settings() -> SonarQubeSettings:
return SonarQubeSettings(
sonarqube_url="http://sonar.test",
sonarqube_token="tok",
request_timeout_sec=5.0,
)
def _get_tool_func(mcp: Any, name: str) -> Any:
"""Extract a registered tool's callable from FastMCP."""
tool = mcp._tool_manager._tools[name]
return tool.fn
# ------------------------------------------------------------------
# _clamp
# ------------------------------------------------------------------
class TestClamp:
def test_within_range(self) -> None:
assert _clamp(50, 1, 100) == 50
def test_below_minimum(self) -> None:
assert _clamp(-5, 1, 100) == 1
def test_above_maximum(self) -> None:
assert _clamp(999, 1, 100) == 100
def test_at_boundary(self) -> None:
assert _clamp(1, 1, 100) == 1
assert _clamp(100, 1, 100) == 100
# ------------------------------------------------------------------
# _validate_csv_enum
# ------------------------------------------------------------------
class TestValidateCsvEnum:
def test_none_is_ok(self) -> None:
_validate_csv_enum(None, VALID_SEVERITIES, "severities")
def test_empty_is_ok(self) -> None:
_validate_csv_enum("", VALID_SEVERITIES, "severities")
def test_valid_single(self) -> None:
_validate_csv_enum("MAJOR", VALID_SEVERITIES, "severities")
def test_valid_multiple(self) -> None:
_validate_csv_enum("MAJOR,MINOR,BLOCKER", VALID_SEVERITIES, "severities")
def test_invalid_raises(self) -> None:
with pytest.raises(SonarQubeError, match="Invalid severities"):
_validate_csv_enum("MAJOR,WRONG", VALID_SEVERITIES, "severities")
def test_valid_types(self) -> None:
_validate_csv_enum("BUG,VULNERABILITY", VALID_ISSUE_TYPES, "types")
def test_valid_statuses(self) -> None:
_validate_csv_enum("OPEN,CLOSED", VALID_STATUSES, "statuses")
# ------------------------------------------------------------------
# _safe_tool
# ------------------------------------------------------------------
class TestSafeTool:
def test_preserves_signature(self) -> None:
def my_func(x: int, y: str = "hi") -> dict[str, Any]:
return {"ok": True}
wrapped = _safe_tool(my_func)
sig = inspect.signature(wrapped)
params = list(sig.parameters.keys())
assert params == ["x", "y"]
def test_catches_sonarqube_error(self) -> None:
def failing() -> dict[str, Any]:
raise SonarQubeError(code="test", message="boom")
wrapped = _safe_tool(failing)
result = wrapped()
assert result["ok"] is False
assert result["error_code"] == "test"
def test_catches_unexpected_error(self) -> None:
def failing() -> dict[str, Any]:
raise RuntimeError("kaboom")
wrapped = _safe_tool(failing)
result = wrapped()
assert result["ok"] is False
assert result["error_code"] == "internal_error"
def test_passes_through_on_success(self) -> None:
def ok_func() -> dict[str, Any]:
return {"ok": True, "data": 42}
wrapped = _safe_tool(ok_func)
assert wrapped() == {"ok": True, "data": 42}
# ------------------------------------------------------------------
# Tool integration tests (via mocked client)
# ------------------------------------------------------------------
class TestToolCheckStatus:
@patch("sonarqube_mcp.server.SonarQubeClient")
def test_returns_status(self, mock_cls: MagicMock, settings: SonarQubeSettings) -> None:
mock_instance = mock_cls.return_value
mock_instance.get_system_status.return_value = {
"status": "UP",
"version": "10.5",
}
mcp = create_server(settings)
func = _get_tool_func(mcp, "check_status")
result = func()
assert result["ok"] is True
assert result["version"] == "10.5"
class TestToolListProjects:
@patch("sonarqube_mcp.server.SonarQubeClient")
def test_returns_projects(self, mock_cls: MagicMock, settings: SonarQubeSettings) -> None:
mock_instance = mock_cls.return_value
mock_instance.search_projects.return_value = {
"paging": {"total": 1, "pageIndex": 1, "pageSize": 20},
"components": [
{"key": "proj1", "name": "Project 1", "qualifier": "TRK"}
],
}
mcp = create_server(settings)
func = _get_tool_func(mcp, "list_projects")
result = func()
assert result["ok"] is True
assert len(result["projects"]) == 1
assert result["projects"][0]["key"] == "proj1"
class TestToolSearchIssues:
@patch("sonarqube_mcp.server.SonarQubeClient")
def test_returns_issues(self, mock_cls: MagicMock, settings: SonarQubeSettings) -> None:
mock_instance = mock_cls.return_value
mock_instance.search_issues.return_value = {
"paging": {"total": 1, "pageIndex": 1, "pageSize": 20},
"issues": [
{
"key": "ISS-1",
"rule": "py:S1192",
"severity": "MAJOR",
"component": "proj:file.py",
"project": "proj",
"line": 10,
"status": "OPEN",
"message": "Dup string",
"type": "CODE_SMELL",
"tags": ["convention"],
"creationDate": "2024-01-01",
}
],
}
mcp = create_server(settings)
func = _get_tool_func(mcp, "search_issues")
result = func()
assert result["ok"] is True
assert result["issues"][0]["key"] == "ISS-1"
@patch("sonarqube_mcp.server.SonarQubeClient")
def test_rejects_bad_severity(self, mock_cls: MagicMock, settings: SonarQubeSettings) -> None:
mcp = create_server(settings)
func = _get_tool_func(mcp, "search_issues")
result = func(severities="INVALID")
assert result["ok"] is False
assert "Invalid severities" in result["message"]
@patch("sonarqube_mcp.server.SonarQubeClient")
def test_rejects_bad_type(self, mock_cls: MagicMock, settings: SonarQubeSettings) -> None:
mcp = create_server(settings)
func = _get_tool_func(mcp, "search_issues")
result = func(types="NOPE")
assert result["ok"] is False
assert "Invalid types" in result["message"]
@patch("sonarqube_mcp.server.SonarQubeClient")
def test_rejects_bad_status(self, mock_cls: MagicMock, settings: SonarQubeSettings) -> None:
mcp = create_server(settings)
func = _get_tool_func(mcp, "search_issues")
result = func(statuses="BADSTATUS")
assert result["ok"] is False
assert "Invalid statuses" in result["message"]
class TestToolGetIssue:
@patch("sonarqube_mcp.server.SonarQubeClient")
def test_returns_issue_detail(self, mock_cls: MagicMock, settings: SonarQubeSettings) -> None:
mock_instance = mock_cls.return_value
mock_instance.get_issue.return_value = {
"issues": [
{
"key": "ISS-1",
"rule": "py:S1192",
"severity": "MAJOR",
"component": "proj:file.py",
"project": "proj",
"status": "OPEN",
"message": "Dup",
"type": "CODE_SMELL",
}
],
"components": [{"key": "proj:file.py", "name": "file.py"}],
"rules": [{"key": "py:S1192", "name": "String dup"}],
}
mcp = create_server(settings)
func = _get_tool_func(mcp, "get_issue")
result = func(issue_key="ISS-1")
assert result["ok"] is True
assert result["issue"]["key"] == "ISS-1"
assert result["issue"]["rule_name"] == "String dup"
@patch("sonarqube_mcp.server.SonarQubeClient")
def test_empty_key_error(self, mock_cls: MagicMock, settings: SonarQubeSettings) -> None:
mcp = create_server(settings)
func = _get_tool_func(mcp, "get_issue")
result = func(issue_key="")
assert result["ok"] is False
class TestToolGetProjectMetrics:
@patch("sonarqube_mcp.server.SonarQubeClient")
def test_returns_metrics(self, mock_cls: MagicMock, settings: SonarQubeSettings) -> None:
mock_instance = mock_cls.return_value
mock_instance.get_measures.return_value = {
"component": {
"key": "proj",
"name": "Project",
"measures": [
{"metric": "bugs", "value": "3"},
{"metric": "coverage", "value": "78.5"},
],
}
}
mcp = create_server(settings)
func = _get_tool_func(mcp, "get_project_metrics")
result = func(project_key="proj")
assert result["ok"] is True
assert result["metrics"]["bugs"] == "3"
assert result["metrics"]["coverage"] == "78.5"
@patch("sonarqube_mcp.server.SonarQubeClient")
def test_empty_key_error(self, mock_cls: MagicMock, settings: SonarQubeSettings) -> None:
mcp = create_server(settings)
func = _get_tool_func(mcp, "get_project_metrics")
result = func(project_key="")
assert result["ok"] is False
class TestToolGetRule:
@patch("sonarqube_mcp.server.SonarQubeClient")
def test_returns_rule(self, mock_cls: MagicMock, settings: SonarQubeSettings) -> None:
mock_instance = mock_cls.return_value
mock_instance.get_rule.return_value = {
"rule": {
"key": "py:S1192",
"name": "String duplicates",
"severity": "MAJOR",
"type": "CODE_SMELL",
"lang": "py",
"langName": "Python",
"status": "READY",
"tags": [],
"sysTags": ["convention"],
}
}
mcp = create_server(settings)
func = _get_tool_func(mcp, "get_rule")
result = func(rule_key="py:S1192")
assert result["ok"] is True
assert result["rule"]["key"] == "py:S1192"
@patch("sonarqube_mcp.server.SonarQubeClient")
def test_empty_key_error(self, mock_cls: MagicMock, settings: SonarQubeSettings) -> None:
mcp = create_server(settings)
func = _get_tool_func(mcp, "get_rule")
result = func(rule_key="")
assert result["ok"] is False