"""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"