mcilspy/tests/test_server_tools.py
Ryan Malloy 70c4a4a39a test: comprehensive test suite for mcilspy MCP server
Add complete test coverage for the mcilspy package:

- T7: Create TestAssembly.dll fixture with known types/members
- T1: Integration tests using real assembly (metadata reader + ILSpy wrapper)
- T2: MCP tool tests with mocked wrapper for each @mcp.tool()
- T3: Error path tests for regex, file not found, invalid assemblies
- T4: Concurrency tests with asyncio.gather() for parallel operations
- T5: Docstring coverage tests using AST introspection
- T6: Timeout behavior tests for 5-minute subprocess timeout

Test summary:
- 147 tests passing
- 14 skipped (ilspycmd-dependent integration tests)
- 73% code coverage
- All ruff linting checks pass
2026-02-08 11:40:57 -07:00

581 lines
20 KiB
Python

"""Tests for MCP server tool functions.
These tests exercise the @mcp.tool() decorated functions in server.py.
We mock the ILSpyWrapper to test the tool logic independently of ilspycmd.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from mcilspy import server
from mcilspy.models import (
AssemblyInfo,
DecompileResponse,
ListTypesResponse,
TypeInfo,
)
class TestCheckIlspyInstallation:
"""Tests for check_ilspy_installation tool."""
@pytest.mark.asyncio
async def test_both_installed(self):
"""Test when both dotnet and ilspycmd are installed."""
mock_status = {
"dotnet_available": True,
"dotnet_version": "8.0.100",
"ilspycmd_available": True,
"ilspycmd_version": "8.2.0",
"ilspycmd_path": "/home/user/.dotnet/tools/ilspycmd",
}
with patch.object(server, "_check_dotnet_tools", return_value=mock_status):
result = await server.check_ilspy_installation()
assert "dotnet CLI" in result
assert "8.0.100" in result
assert "ilspycmd" in result
assert "8.2.0" in result
assert "ready to use" in result
@pytest.mark.asyncio
async def test_dotnet_not_installed(self):
"""Test when dotnet is not installed."""
mock_status = {
"dotnet_available": False,
"dotnet_version": None,
"ilspycmd_available": False,
"ilspycmd_version": None,
"ilspycmd_path": None,
}
with patch.object(server, "_check_dotnet_tools", return_value=mock_status):
result = await server.check_ilspy_installation()
assert "Not found" in result
assert "dotnet.microsoft.com" in result
@pytest.mark.asyncio
async def test_ilspycmd_not_installed(self):
"""Test when dotnet is installed but ilspycmd is not."""
mock_status = {
"dotnet_available": True,
"dotnet_version": "8.0.100",
"ilspycmd_available": False,
"ilspycmd_version": None,
"ilspycmd_path": None,
}
with patch.object(server, "_check_dotnet_tools", return_value=mock_status):
result = await server.check_ilspy_installation()
assert "ilspycmd" in result
assert "Not installed" in result
assert "install_ilspy" in result.lower() or "dotnet tool install" in result
class TestDecompileAssembly:
"""Tests for decompile_assembly tool."""
@pytest.mark.asyncio
async def test_successful_decompile(self):
"""Test successful decompilation returns formatted output."""
mock_response = DecompileResponse(
success=True,
assembly_name="TestAssembly",
type_name="MyClass",
source_code="public class MyClass { }",
)
mock_wrapper = MagicMock()
mock_wrapper.decompile = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.decompile_assembly("/path/to/test.dll")
assert "Decompilation result" in result
assert "TestAssembly" in result
assert "public class MyClass" in result
assert "```csharp" in result
@pytest.mark.asyncio
async def test_decompile_with_output_dir(self):
"""Test decompilation to output directory."""
mock_response = DecompileResponse(
success=True,
assembly_name="TestAssembly",
output_path="/tmp/output",
)
mock_wrapper = MagicMock()
mock_wrapper.decompile = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.decompile_assembly(
"/path/to/test.dll", output_dir="/tmp/output"
)
assert "successful" in result.lower()
assert "/tmp/output" in result
@pytest.mark.asyncio
async def test_decompile_failure(self):
"""Test failed decompilation returns error message."""
mock_response = DecompileResponse(
success=False,
assembly_name="TestAssembly",
error_message="Assembly not found",
)
mock_wrapper = MagicMock()
mock_wrapper.decompile = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.decompile_assembly("/path/to/nonexistent.dll")
assert "failed" in result.lower()
assert "Assembly not found" in result
@pytest.mark.asyncio
async def test_decompile_with_type_name(self):
"""Test decompiling a specific type."""
mock_response = DecompileResponse(
success=True,
assembly_name="TestAssembly",
type_name="MyNamespace.MyClass",
source_code="namespace MyNamespace { public class MyClass { } }",
)
mock_wrapper = MagicMock()
mock_wrapper.decompile = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.decompile_assembly(
"/path/to/test.dll", type_name="MyNamespace.MyClass"
)
assert "MyNamespace.MyClass" in result
@pytest.mark.asyncio
async def test_decompile_exception_handling(self):
"""Test that exceptions are handled gracefully."""
mock_wrapper = MagicMock()
mock_wrapper.decompile = AsyncMock(side_effect=RuntimeError("Test error"))
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.decompile_assembly("/path/to/test.dll")
assert "Error" in result
assert "Test error" in result
class TestListTypes:
"""Tests for list_types tool."""
@pytest.mark.asyncio
async def test_list_types_success(self):
"""Test successful type listing."""
mock_types = [
TypeInfo(name="ClassA", full_name="NS.ClassA", kind="Class", namespace="NS"),
TypeInfo(name="ClassB", full_name="NS.ClassB", kind="Class", namespace="NS"),
TypeInfo(name="IService", full_name="NS.IService", kind="Interface", namespace="NS"),
]
mock_response = ListTypesResponse(
success=True,
types=mock_types,
total_count=3,
)
mock_wrapper = MagicMock()
mock_wrapper.list_types = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.list_types("/path/to/test.dll")
assert "Types in" in result
assert "Found 3 types" in result
assert "ClassA" in result
assert "ClassB" in result
assert "IService" in result
@pytest.mark.asyncio
async def test_list_types_grouped_by_namespace(self):
"""Test that types are grouped by namespace."""
mock_types = [
TypeInfo(name="ClassA", full_name="NS1.ClassA", kind="Class", namespace="NS1"),
TypeInfo(name="ClassB", full_name="NS2.ClassB", kind="Class", namespace="NS2"),
]
mock_response = ListTypesResponse(
success=True,
types=mock_types,
total_count=2,
)
mock_wrapper = MagicMock()
mock_wrapper.list_types = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.list_types("/path/to/test.dll")
# Should have namespace headers
assert "## NS1" in result
assert "## NS2" in result
@pytest.mark.asyncio
async def test_list_types_with_entity_types(self):
"""Test listing specific entity types."""
mock_response = ListTypesResponse(
success=True,
types=[TypeInfo(name="IService", full_name="NS.IService", kind="Interface", namespace="NS")],
total_count=1,
)
mock_wrapper = MagicMock()
mock_wrapper.list_types = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.list_types(
"/path/to/test.dll", entity_types=["interface"]
)
assert "IService" in result
mock_wrapper.list_types.assert_called_once()
@pytest.mark.asyncio
async def test_list_types_no_types_found(self):
"""Test when no types are found."""
mock_response = ListTypesResponse(
success=True,
types=[],
total_count=0,
error_message="No types found in assembly",
)
mock_wrapper = MagicMock()
mock_wrapper.list_types = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.list_types("/path/to/test.dll")
assert "No types found" in result
class TestSearchTypes:
"""Tests for search_types tool."""
@pytest.mark.asyncio
async def test_search_types_finds_matches(self):
"""Test searching types by pattern."""
mock_types = [
TypeInfo(name="UserService", full_name="NS.UserService", kind="Class", namespace="NS"),
TypeInfo(name="OrderService", full_name="NS.OrderService", kind="Class", namespace="NS"),
TypeInfo(name="Helper", full_name="NS.Helper", kind="Class", namespace="NS"),
]
mock_response = ListTypesResponse(
success=True,
types=mock_types,
total_count=3,
)
mock_wrapper = MagicMock()
mock_wrapper.list_types = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.search_types("/path/to/test.dll", pattern="Service")
assert "Search Results" in result
assert "UserService" in result
assert "OrderService" in result
assert "Helper" not in result
@pytest.mark.asyncio
async def test_search_types_case_insensitive(self):
"""Test case-insensitive search (default)."""
mock_types = [
TypeInfo(name="SERVICE", full_name="NS.SERVICE", kind="Class", namespace="NS"),
]
mock_response = ListTypesResponse(success=True, types=mock_types, total_count=1)
mock_wrapper = MagicMock()
mock_wrapper.list_types = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.search_types(
"/path/to/test.dll", pattern="service", case_sensitive=False
)
assert "SERVICE" in result
@pytest.mark.asyncio
async def test_search_types_with_namespace_filter(self):
"""Test searching with namespace filter."""
mock_types = [
TypeInfo(name="ClassA", full_name="App.Services.ClassA", kind="Class", namespace="App.Services"),
TypeInfo(name="ClassB", full_name="App.Models.ClassB", kind="Class", namespace="App.Models"),
]
mock_response = ListTypesResponse(success=True, types=mock_types, total_count=2)
mock_wrapper = MagicMock()
mock_wrapper.list_types = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.search_types(
"/path/to/test.dll", pattern="Class", namespace_filter="Services"
)
assert "ClassA" in result
assert "ClassB" not in result
@pytest.mark.asyncio
async def test_search_types_no_matches(self):
"""Test when no types match the pattern."""
mock_types = [
TypeInfo(name="Helper", full_name="NS.Helper", kind="Class", namespace="NS"),
]
mock_response = ListTypesResponse(success=True, types=mock_types, total_count=1)
mock_wrapper = MagicMock()
mock_wrapper.list_types = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.search_types("/path/to/test.dll", pattern="Service")
assert "No types found" in result
class TestSearchMethods:
"""Tests for search_methods tool (using metadata reader)."""
@pytest.mark.asyncio
async def test_search_methods_uses_metadata_reader(self, test_assembly_path):
"""Test that search_methods uses MetadataReader directly."""
result = await server.search_methods(test_assembly_path, pattern="Do")
assert "DoSomething" in result
@pytest.mark.asyncio
async def test_search_methods_filters_by_pattern(self, test_assembly_path):
"""Test method name pattern filtering."""
result = await server.search_methods(test_assembly_path, pattern="Get")
assert "GetGreeting" in result
assert "DoSomething" not in result
@pytest.mark.asyncio
async def test_search_methods_public_only(self, test_assembly_path):
"""Test filtering for public methods."""
result = await server.search_methods(
test_assembly_path, pattern="Method", public_only=True
)
# Should find public methods
assert "Method" in result or "No methods found" in result
class TestSearchFields:
"""Tests for search_fields tool."""
@pytest.mark.asyncio
async def test_search_fields_finds_constants(self, test_assembly_path):
"""Test finding constant fields."""
result = await server.search_fields(test_assembly_path, pattern="API")
assert "API_KEY" in result
@pytest.mark.asyncio
async def test_search_fields_constants_only(self, test_assembly_path):
"""Test filtering for constants only."""
result = await server.search_fields(
test_assembly_path, pattern="", constants_only=True
)
assert "const" in result
class TestSearchProperties:
"""Tests for search_properties tool."""
@pytest.mark.asyncio
async def test_search_properties(self, test_assembly_path):
"""Test searching for properties."""
result = await server.search_properties(test_assembly_path, pattern="Name")
assert "Name" in result
class TestListEvents:
"""Tests for list_events tool."""
@pytest.mark.asyncio
async def test_list_events(self, test_assembly_path):
"""Test listing events from assembly."""
result = await server.list_events(test_assembly_path)
assert "OnChange" in result or "No events" in result
class TestListResources:
"""Tests for list_resources tool."""
@pytest.mark.asyncio
async def test_list_resources_empty(self, test_assembly_path):
"""Test listing resources (test assembly has none)."""
result = await server.list_resources(test_assembly_path)
assert "No embedded resources" in result or "Embedded Resources" in result
class TestGetMetadataSummary:
"""Tests for get_metadata_summary tool."""
@pytest.mark.asyncio
async def test_get_metadata_summary(self, test_assembly_path):
"""Test getting metadata summary."""
result = await server.get_metadata_summary(test_assembly_path)
assert "Assembly Metadata Summary" in result
assert "Name" in result
assert "Version" in result
assert "Statistics" in result
assert "Types" in result
assert "Methods" in result
class TestGetAssemblyInfo:
"""Tests for get_assembly_info tool."""
@pytest.mark.asyncio
async def test_get_assembly_info_success(self):
"""Test getting assembly info successfully."""
mock_info = AssemblyInfo(
name="TestAssembly",
full_name="TestAssembly, Version=1.0.0.0",
location="/path/to/test.dll",
version="1.0.0.0",
target_framework=".NETStandard,Version=v2.0",
is_signed=False,
has_debug_info=False,
)
mock_wrapper = MagicMock()
mock_wrapper.get_assembly_info = AsyncMock(return_value=mock_info)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.get_assembly_info("/path/to/test.dll")
assert "Assembly Information" in result
assert "TestAssembly" in result
assert "1.0.0.0" in result
@pytest.mark.asyncio
async def test_get_assembly_info_exception(self):
"""Test handling of exceptions in get_assembly_info."""
mock_wrapper = MagicMock()
mock_wrapper.get_assembly_info = AsyncMock(
side_effect=FileNotFoundError("File not found")
)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.get_assembly_info("/nonexistent/file.dll")
assert "Error" in result
class TestGenerateDiagrammer:
"""Tests for generate_diagrammer tool."""
@pytest.mark.asyncio
async def test_generate_diagrammer_success(self):
"""Test successful diagram generation."""
mock_response = {
"success": True,
"output_directory": "/tmp/diagrammer",
}
mock_wrapper = MagicMock()
mock_wrapper.generate_diagrammer = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.generate_diagrammer("/path/to/test.dll")
assert "successfully" in result.lower()
assert "/tmp/diagrammer" in result
@pytest.mark.asyncio
async def test_generate_diagrammer_failure(self):
"""Test failed diagram generation."""
mock_response = {
"success": False,
"error_message": "Failed to generate diagram",
}
mock_wrapper = MagicMock()
mock_wrapper.generate_diagrammer = AsyncMock(return_value=mock_response)
with patch.object(server, "get_wrapper", return_value=mock_wrapper):
result = await server.generate_diagrammer("/path/to/test.dll")
assert "Failed" in result
class TestHelperFunctions:
"""Tests for helper functions in server.py."""
def test_format_error_with_context(self):
"""Test _format_error with context."""
error = ValueError("test error")
result = server._format_error(error, "testing")
assert "Error" in result
assert "testing" in result
assert "test error" in result
def test_format_error_without_context(self):
"""Test _format_error without context."""
error = RuntimeError("something went wrong")
result = server._format_error(error)
assert "Error" in result
assert "something went wrong" in result
def test_find_ilspycmd_path_not_installed(self):
"""Test _find_ilspycmd_path when not installed."""
with (
patch("shutil.which", return_value=None),
patch("os.path.isfile", return_value=False),
):
result = server._find_ilspycmd_path()
assert result is None
def test_find_ilspycmd_path_in_path(self):
"""Test _find_ilspycmd_path when in PATH."""
with patch("shutil.which", return_value="/usr/local/bin/ilspycmd"):
result = server._find_ilspycmd_path()
assert result == "/usr/local/bin/ilspycmd"
def test_detect_platform_linux(self):
"""Test platform detection on Linux."""
with (
patch("platform.system", return_value="Linux"),
patch("builtins.open", MagicMock()),
patch("shutil.which", return_value="/usr/bin/pacman"),
):
result = server._detect_platform()
assert result["system"] == "linux"
assert result["package_manager"] is not None
def test_detect_platform_windows(self):
"""Test platform detection on Windows."""
with (
patch("platform.system", return_value="Windows"),
patch("shutil.which", return_value=None),
):
result = server._detect_platform()
assert result["system"] == "windows"