blob: 5a480148147e90317af7688b2d39063a4eec9bb9 [file]
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import json
import os
from unittest.mock import AsyncMock, patch
import pytest
from gerrit_mcp_server import main
# --- Fixtures ---
@pytest.fixture
def mock_load_config():
"""Provides a mocked load_gerrit_config."""
with patch("gerrit_mcp_server.main.load_gerrit_config") as m:
yield m
@pytest.fixture
def mock_run_curl():
"""Provides a mocked run_curl."""
with patch("gerrit_mcp_server.main.run_curl", new_callable=AsyncMock) as m:
yield m
@pytest.fixture
def mock_exec():
"""Provides a mocked asyncio.create_subprocess_exec."""
with patch("asyncio.create_subprocess_exec", new_callable=AsyncMock) as m:
yield m
# --- Tests ---
def test_get_gerrit_base_url_with_env_var():
"""Tests that the base URL is retrieved from the environment variable."""
with patch.dict(os.environ, {"GERRIT_BASE_URL": "https://another-gerrit.com"}):
assert main._get_gerrit_base_url() == "https://another-gerrit.com"
def test_get_gerrit_base_url_with_parameter(mock_load_config):
"""Tests that the provided parameter overrides the configuration."""
assert (
main._get_gerrit_base_url("https://parameter-gerrit.com")
== "https://parameter-gerrit.com"
)
mock_load_config.assert_not_called()
@pytest.mark.parametrize(
"input_url, expected",
[
(
"fuchsia-review.git.private.corporation.com",
"https://fuchsia-review.googlesource.com",
),
(
"https://fuchsia-review.git.private.corporation.com",
"https://fuchsia-review.googlesource.com",
),
("fuchsia-review.googlesource.com", "https://fuchsia-review.googlesource.com"),
("another-gerrit.com", "https://another-gerrit.com"),
("http://another-gerrit.com", "https://another-gerrit.com"),
("https://another-gerrit.com", "https://another-gerrit.com"),
],
)
def test_normalize_gerrit_url(mock_load_config, input_url, expected):
"""Tests that URLs are correctly normalized based on the configuration."""
mock_load_config.return_value = {
"gerrit_hosts": [
{
"name": "Fuchsia",
"internal_url": "https://fuchsia-review.git.private.corporation.com/",
"external_url": "https://fuchsia-review.googlesource.com/",
}
]
}
gerrit_hosts = mock_load_config.return_value["gerrit_hosts"]
assert main._normalize_gerrit_url(input_url, gerrit_hosts) == expected
def test_build_extension_context_get_base_url_applies_auth_prefix(mock_load_config):
"""The extension context's get_base_url must normalize and apply the /a auth
prefix, mirroring the core tools, so extensions hit the authenticated endpoint."""
mock_load_config.return_value = {
"gerrit_hosts": [
{
"name": "ReviewAndroid",
"external_url": "https://review-android.example.com/",
"authentication": {"type": "http_basic"},
}
]
}
ctx = main._build_extension_context()
assert (
ctx.get_base_url("https://review-android.example.com")
== "https://review-android.example.com/a"
)
def test_build_extension_context_get_base_url_no_prefix_for_anonymous(mock_load_config):
"""A host without http_basic/git_cookies auth gets no /a prefix."""
mock_load_config.return_value = {
"gerrit_hosts": [
{
"name": "Public",
"external_url": "https://public-review.example.com/",
}
]
}
ctx = main._build_extension_context()
assert (
ctx.get_base_url("https://public-review.example.com")
== "https://public-review.example.com"
)
@pytest.mark.parametrize(
"exc,expected_code",
[
(FileNotFoundError("no config"), 1),
(ValueError("bad default url"), 2),
(json.JSONDecodeError("bad json", "doc", 0), 2),
],
)
def test_cli_main_exits_cleanly_on_config_error(exc, expected_code):
"""A config error while building the extension context must produce a
clean message and exit code, not an unhandled traceback that aborts
startup with a stack trace."""
with (
patch("gerrit_mcp_server.main._build_extension_context", side_effect=exc),
patch("gerrit_mcp_server.main.ExtensionLoader") as mock_loader,
patch("gerrit_mcp_server.main.mcp") as mock_mcp,
):
with pytest.raises(SystemExit) as excinfo:
main.cli_main(["gerrit-mcp-server", "stdio"])
assert excinfo.value.code == expected_code
mock_loader.assert_not_called()
mock_mcp.run.assert_not_called()
def test_cli_main_starts_when_context_builds(mock_load_config):
"""When the context builds cleanly, extensions load and the transport runs."""
mock_load_config.return_value = {"gerrit_hosts": []}
with (
patch("gerrit_mcp_server.main.ExtensionLoader") as mock_loader,
patch("gerrit_mcp_server.main.mcp") as mock_mcp,
):
main.cli_main(["gerrit-mcp-server", "stdio"])
mock_loader.return_value.load.assert_called_once()
mock_mcp.run.assert_called_once_with(transport="stdio")
def test_load_gerrit_config_not_found():
"""Tests that FileNotFoundError is raised when the config file is missing."""
with patch("gerrit_mcp_server.main.Path.exists", return_value=False):
with pytest.raises(FileNotFoundError):
main.load_gerrit_config()
def test_load_gerrit_config_with_env_var():
"""Tests loading the configuration from a path specified
in an environment variable."""
with patch(
"builtins.open", new_callable=AsyncMock
): # using AsyncMock just for convenience of mocking, though it's sync open
# Actually, builtins.open is sync. Let's use mock_open properly.
from unittest.mock import mock_open as sync_mock_open
m = sync_mock_open(read_data='{"key": "value"}')
with (
patch("builtins.open", m),
patch("pathlib.Path.exists", return_value=True),
patch.dict(
os.environ, {"GERRIT_CONFIG_PATH": "/fake/path/gerrit_config.json"}
),
):
config = main.load_gerrit_config()
assert config == {"key": "value"}
def test_get_gerrit_base_url_with_no_env_var(mock_load_config):
"""Tests that the default base URL is used when no environment variable is set."""
with patch.dict(os.environ, {}, clear=True):
mock_load_config.return_value = {
"default_gerrit_base_url": "https://fuchsia-review.googlesource.com",
"gerrit_hosts": [
{"external_url": "https://fuchsia-review.googlesource.com"}
],
}
assert main._get_gerrit_base_url() == "https://fuchsia-review.googlesource.com"
@pytest.mark.asyncio
async def test_run_curl_timeout(mock_exec, mock_load_config):
"""Tests that a TimeoutError is raised when the curl command times out."""
mock_load_config.return_value = {
"gerrit_hosts": [
{
"external_url": "https://example.com",
"authentication": {"type": "gob_curl"},
}
]
}
mock_exec.return_value.communicate.side_effect = asyncio.TimeoutError
with pytest.raises(asyncio.TimeoutError):
await main.run_curl(["https://example.com"], "https://example.com")
@pytest.mark.asyncio
async def test_run_curl_large_output(mock_exec, mock_load_config):
"""Tests handling of large output from the curl command."""
mock_load_config.return_value = {
"gerrit_hosts": [
{
"external_url": "https://example.com",
"authentication": {"type": "gob_curl"},
}
]
}
large_output = "a" * 10000
mock_exec.return_value.communicate.return_value = (large_output.encode(), b"")
mock_exec.return_value.returncode = 0
result = await main.run_curl(["https://example.com"], "https://example.com")
assert result == large_output
@pytest.mark.asyncio
async def test_run_curl_non_zero_exit(mock_exec, mock_load_config):
"""Tests that an exception is raised when the curl command exits
with a non-zero code."""
mock_load_config.return_value = {
"gerrit_hosts": [
{
"external_url": "https://example.com",
"authentication": {"type": "gob_curl"},
}
]
}
mock_exec.return_value.communicate.return_value = (b"", b"error")
mock_exec.return_value.returncode = 1
with pytest.raises(Exception):
await main.run_curl(["https://example.com"], "https://example.com")
@pytest.mark.asyncio
async def test_run_curl_removes_gerrit_prefix(mock_exec, mock_load_config):
"""Tests that the Gerrit JSON prefix is removed from the response."""
mock_load_config.return_value = {
"gerrit_hosts": [
{
"external_url": "https://example.com",
"authentication": {"type": "gob_curl"},
}
]
}
mock_exec.return_value.communicate.return_value = (b')]}\'\n{"key": "value"}', b"")
mock_exec.return_value.returncode = 0
result = await main.run_curl(["https://example.com"], "https://example.com")
assert result == '{"key": "value"}'
@pytest.mark.asyncio
async def test_get_bugs_from_cl_with_one_bug(mock_run_curl):
"""Tests extracting a single bug ID from a CL message."""
mock_run_curl.return_value = '{"message": "Fixes: b/12345"}'
result = await main.get_bugs_from_cl("123")
assert "Found bug(s): 12345" in result[0]["text"]
@pytest.mark.asyncio
async def test_get_bugs_from_cl_with_multiple_bugs(mock_run_curl):
"""Tests extracting multiple bug IDs from a CL message."""
mock_run_curl.return_value = '{"message": "Fixes: b/12345, b/67890"}'
result = await main.get_bugs_from_cl("123")
assert "Found bug(s): 12345, 67890" in result[0]["text"]
@pytest.mark.asyncio
async def test_get_bugs_from_cl_no_bugs(mock_run_curl):
"""Tests that the correct message is returned when no bugs are found."""
mock_run_curl.return_value = '{"message": "No bugs here"}'
result = await main.get_bugs_from_cl("123")
assert "No bug IDs found" in result[0]["text"]
@pytest.mark.asyncio
async def test_get_bugs_from_cl_no_commit_message(mock_run_curl):
"""Tests handling of a missing commit message."""
mock_run_curl.return_value = "{}"
result = await main.get_bugs_from_cl("123")
assert "No commit message found" in result[0]["text"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"unresolved_arg, expected_unresolved",
[
(None, True), # Default
(False, False),
(True, True),
],
)
async def test_post_review_comment(mock_run_curl, unresolved_arg, expected_unresolved):
"""Tests posting a review comment with different 'unresolved' states."""
mock_run_curl.return_value = '{"comments": {}}'
kwargs = {}
if unresolved_arg is not None:
kwargs["unresolved"] = unresolved_arg
result = await main.post_review_comment(
"123", "file.py", 10, "test comment", **kwargs
)
assert "Successfully posted comment" in result[0]["text"]
# Verify the payload
args, _ = mock_run_curl.call_args
curl_args = args[0]
data_index = curl_args.index("--data")
request_body = json.loads(curl_args[data_index + 1])
assert request_body["comments"]["file.py"][0]["unresolved"] is expected_unresolved
@pytest.mark.asyncio
async def test_post_review_comment_failure(mock_run_curl):
"""Tests handling of a failure response when posting a comment."""
mock_run_curl.return_value = '{"error": "failed"}'
result = await main.post_review_comment("123", "file.py", 10, "test comment")
assert "Failed to post comment" in result[0]["text"]
# --- Edge Case Tests ---
@pytest.mark.asyncio
async def test_get_change_details_handles_missing_reviewers(mock_run_curl):
"""Tests that get_change_details handles missing 'reviewers' field gracefully."""
mock_run_curl.return_value = json.dumps(
{
"_number": 123,
"subject": "Test",
"owner": {"email": "a@b.com"},
"status": "NEW",
}
)
result = await main.get_change_details("123")
assert "Reviewers:" not in result[0]["text"]
@pytest.mark.asyncio
async def test_get_change_details_handles_empty_reviewers_list(mock_run_curl):
"""Tests that get_change_details handles an empty reviewers list gracefully."""
mock_run_curl.return_value = json.dumps(
{
"_number": 123,
"subject": "Test",
"owner": {"email": "a@b.com"},
"status": "NEW",
"reviewers": {"REVIEWER": []},
}
)
result = await main.get_change_details("123")
assert "Reviewers:" in result[0]["text"]
@pytest.mark.asyncio
async def test_run_curl_sends_static_ua_when_no_context(mock_exec, mock_load_config):
"""run_curl always includes a User-Agent even without MCP context."""
mock_load_config.return_value = {
"gerrit_hosts": [
{
"external_url": "https://example.com",
"authentication": {"type": "gob_curl"},
}
]
}
mock_exec.return_value.communicate.return_value = (b"ok", b"")
mock_exec.return_value.returncode = 0
await main.run_curl(["https://example.com"], "https://example.com")
cmd = mock_exec.call_args.args
assert "-A" in cmd
ua = cmd[cmd.index("-A") + 1]
assert ua.startswith("gerrit-mcp-server/")
assert "mcp-python-sdk/" in ua
@pytest.mark.asyncio
async def test_run_curl_sends_x_mcp_tool_when_context_set(mock_exec, mock_load_config):
"""run_curl includes X-MCP-Tool header when _request_obs contextvar is set."""
from gerrit_mcp_server.http_headers import RequestObservabilityContext, _request_obs
mock_load_config.return_value = {
"gerrit_hosts": [
{
"external_url": "https://example.com",
"authentication": {"type": "gob_curl"},
}
]
}
mock_exec.return_value.communicate.return_value = (b"ok", b"")
mock_exec.return_value.returncode = 0
obs = RequestObservabilityContext(
tool_name="query_changes",
client_name="test-client",
client_version="1.0",
protocol_version="2025-11-25",
)
token = _request_obs.set(obs)
try:
await main.run_curl(["https://example.com"], "https://example.com")
finally:
_request_obs.reset(token)
cmd = mock_exec.call_args.args
assert "-H" in cmd
headers = [cmd[i + 1] for i, v in enumerate(cmd) if v == "-H"]
assert any(h == "X-MCP-Tool: query_changes" for h in headers)
assert any(h == "X-MCP-Client: test-client" for h in headers)
assert any(h == "X-MCP-Protocol-Version: 2025-11-25" for h in headers)
@pytest.mark.asyncio
async def test_list_change_files_handles_empty_response(mock_run_curl):
"""Tests that list_change_files handles an empty response gracefully."""
mock_run_curl.side_effect = [
json.dumps({}),
json.dumps({"current_revision_number": 1}),
]
result = await main.list_change_files("123")
assert "Files in CL 123 (Patch Set 1)" in result[0]["text"]
assert "[" not in result[0]["text"]