- Add bypass_path_validation fixture for tests using mock paths - Update find_ilspycmd_path references to use mcilspy.utils - Fix wrapper fixture patches in timeout tests - Update assertions for new output formats (pagination, etc.) - Mark all taskmaster domains as merged in status.json All 165 tests passing.
598 lines
21 KiB
Python
598 lines
21 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,
|
|
)
|
|
from mcilspy.utils import find_ilspycmd_path
|
|
|
|
|
|
# Fixture to bypass path validation for tests using mock paths
|
|
@pytest.fixture
|
|
def bypass_path_validation():
|
|
"""Bypass _validate_assembly_path for tests using mock wrapper."""
|
|
def passthrough(path):
|
|
return path
|
|
with patch.object(server, "_validate_assembly_path", side_effect=passthrough):
|
|
yield
|
|
|
|
|
|
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
|
|
|
|
|
|
@pytest.mark.usefixtures("bypass_path_validation")
|
|
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
|
|
|
|
|
|
@pytest.mark.usefixtures("bypass_path_validation")
|
|
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
|
|
# New pagination format: "Showing X of Y types"
|
|
assert "Showing 3 of 3 types" in result or "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
|
|
|
|
|
|
@pytest.mark.usefixtures("bypass_path_validation")
|
|
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
|
|
|
|
|
|
@pytest.mark.usefixtures("bypass_path_validation")
|
|
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
|
|
|
|
|
|
@pytest.mark.usefixtures("bypass_path_validation")
|
|
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("mcilspy.utils.shutil.which", return_value=None),
|
|
patch("mcilspy.utils.os.path.isfile", return_value=False),
|
|
):
|
|
result = find_ilspycmd_path()
|
|
|
|
assert result is None
|
|
|
|
def test_find_ilspycmd_path_in_path(self):
|
|
"""Test find_ilspycmd_path when in PATH."""
|
|
with patch("mcilspy.utils.shutil.which", return_value="/usr/local/bin/ilspycmd"):
|
|
result = 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"
|