Skip to main content
Glama

mcp-nixos

by utensils
test_edge_cases.py14.5 kB
#!/usr/bin/env python3 """Comprehensive edge case tests for MCP-NixOS server.""" from unittest.mock import Mock, patch import mcp_nixos.server as server import pytest import requests from mcp_nixos.server import ( error, es_query, parse_html_options, ) def get_tool_function(tool_name: str): """Get the underlying function from a FastMCP tool.""" tool = getattr(server, tool_name) if hasattr(tool, "fn"): return tool.fn return tool # Extract FastMCP tool functions nixos_search = get_tool_function("nixos_search") nixos_info = get_tool_function("nixos_info") nixos_stats = get_tool_function("nixos_stats") home_manager_search = get_tool_function("home_manager_search") home_manager_info = get_tool_function("home_manager_info") home_manager_list_options = get_tool_function("home_manager_list_options") darwin_search = get_tool_function("darwin_search") darwin_info = get_tool_function("darwin_info") darwin_list_options = get_tool_function("darwin_list_options") darwin_options_by_prefix = get_tool_function("darwin_options_by_prefix") class TestEdgeCases: """Test edge cases and error conditions.""" def test_error_with_none_message(self): """Test error function with None message.""" result = error(None) # type: ignore assert result == "Error (ERROR): " def test_error_with_empty_string(self): """Test error function with empty string.""" result = error("") assert result == "Error (ERROR): " def test_error_with_unicode(self): """Test error function with unicode characters.""" result = error("Failed to parse: 你好世界 🌍") assert result == "Error (ERROR): Failed to parse: 你好世界 🌍" @patch("requests.post") def test_es_query_malformed_response(self, mock_post): """Test es_query with malformed JSON response.""" mock_resp = Mock() mock_resp.raise_for_status = Mock() mock_resp.json = Mock(return_value={"unexpected": "structure"}) mock_post.return_value = mock_resp result = es_query("test-index", {"query": {}}) assert result == [] @patch("requests.post") def test_es_query_network_timeout(self, mock_post): """Test es_query with network timeout.""" mock_post.side_effect = requests.Timeout("Connection timed out") with pytest.raises(Exception, match="API error: Connection timed out"): es_query("test-index", {"query": {}}) @patch("requests.post") def test_es_query_http_error(self, mock_post): """Test es_query with HTTP error status.""" mock_resp = Mock() mock_resp.raise_for_status.side_effect = requests.HTTPError("503 Service Unavailable") mock_post.return_value = mock_resp with pytest.raises(Exception, match="API error: 503 Service Unavailable"): es_query("test-index", {"query": {}}) @patch("requests.get") def test_parse_html_options_large_document(self, mock_get): """Test parsing very large HTML documents.""" # Create a large HTML document with many options large_html = ( """ <html><body> """ + "\n".join( [ f""" <dt><a id="opt-test.option{i}">test.option{i}</a></dt> <dd> <p>Description for option {i}</p> <span class="term">Type: string</span> </dd> """ for i in range(1000) ] ) + """ </body></html> """ ) mock_resp = Mock() mock_resp.raise_for_status = Mock() mock_resp.text = large_html mock_get.return_value = mock_resp # Should respect limit options = parse_html_options("http://test.com", limit=50) assert len(options) == 50 assert options[0]["name"] == "test.option0" assert options[49]["name"] == "test.option49" @patch("requests.get") def test_parse_html_options_malformed_html(self, mock_get): """Test parsing malformed HTML with missing tags.""" malformed_html = """ <html><body> <dt><a id="opt-test.option1">test.option1</a> <!-- Missing closing dt tag --> <dd>Description without proper structure <!-- Missing closing dd tag --> <dt><a id="opt-test.option2">test.option2</a></dt> <!-- Missing dd for this dt --> <dt><a id="opt-test.option3">test.option3</a></dt> <dd><p>Proper description</p></dd> </body></html> """ mock_resp = Mock() mock_resp.raise_for_status = Mock() mock_resp.text = malformed_html mock_get.return_value = mock_resp options = parse_html_options("http://test.com") # Should handle malformed HTML gracefully assert len(options) >= 1 assert any(opt["name"] == "test.option3" for opt in options) @patch("requests.get") def test_parse_html_options_special_characters(self, mock_get): """Test parsing options with special characters and HTML entities.""" html_with_entities = """ <html><body> <dt><a id="opt-test.option&lt;name&gt;">test.option&lt;name&gt;</a></dt> <dd> <p>Description with &amp; entities &quot;quoted&quot; and &apos;apostrophes&apos;</p> <span class="term">Type: list of (attribute set)</span> </dd> <dt><a id="opt-programs.firefox.profiles._name_.search">programs.firefox.profiles.<name>.search</a></dt> <dd><p>Firefox search configuration</p></dd> </body></html> """ mock_resp = Mock() mock_resp.raise_for_status = Mock() mock_resp.text = html_with_entities mock_get.return_value = mock_resp options = parse_html_options("http://test.com") assert len(options) == 2 # BeautifulSoup should decode HTML entities assert options[0]["description"] == "Description with & entities \"quoted\" and 'apostrophes'" # The underscore replacement might change the name assert "programs.firefox.profiles" in options[1]["name"] assert "search" in options[1]["name"] @pytest.mark.asyncio async def test_nixos_search_invalid_parameters(self): """Test nixos_search with various invalid parameters.""" # Invalid type result = await nixos_search("test", search_type="invalid") assert "Error (ERROR): Invalid type 'invalid'" in result # Invalid channel result = await nixos_search("test", channel="nonexistent") assert "Error (ERROR): Invalid channel 'nonexistent'" in result # Invalid limit (too low) result = await nixos_search("test", limit=0) assert "Error (ERROR): Limit must be 1-100" in result # Invalid limit (too high) result = await nixos_search("test", limit=101) assert "Error (ERROR): Limit must be 1-100" in result @patch("mcp_nixos.server.es_query") @pytest.mark.asyncio async def test_nixos_search_with_empty_query(self, mock_es_query): """Test searching with empty query string.""" mock_es_query.return_value = [] result = await nixos_search("") assert "No packages found matching ''" in result @patch("mcp_nixos.server.es_query") @pytest.mark.asyncio async def test_nixos_search_programs_edge_case(self, mock_es_query): """Test programs search when program name doesn't match query.""" mock_es_query.return_value = [ {"_source": {"package_pname": "coreutils", "package_programs": ["ls", "cp", "mv", "rm"]}} ] # Search for 'ls' should find it in programs result = await nixos_search("ls", search_type="programs") assert "ls (provided by coreutils)" in result # Search for 'grep' should not show coreutils result = await nixos_search("grep", search_type="programs") assert "coreutils" not in result @patch("mcp_nixos.server.es_query") @pytest.mark.asyncio async def test_nixos_info_with_missing_fields(self, mock_es_query): """Test nixos_info when response has missing fields.""" # Package with minimal fields mock_es_query.return_value = [ { "_source": { "package_pname": "minimal-pkg" # Missing version, description, homepage, license } } ] result = await nixos_info("minimal-pkg", type="package") assert "Package: minimal-pkg" in result assert "Version: " in result # Empty version # Should not crash on missing fields @patch("mcp_nixos.server.es_query") @pytest.mark.asyncio async def test_nixos_info_option_html_stripping(self, mock_es_query): """Test HTML stripping in option descriptions.""" mock_es_query.return_value = [ { "_source": { "option_name": "test.option", "option_type": "boolean", "option_description": ( "<rendered-html><p>This is a <strong>test</strong> " "option with <a href='#'>links</a></p></rendered-html>" ), "option_default": "false", } } ] result = await nixos_info("test.option", type="option") assert "Description: This is a test option with links" in result assert "<" not in result # No HTML tags assert ">" not in result @patch("requests.post") @pytest.mark.asyncio async def test_nixos_stats_partial_failure(self, mock_post): """Test nixos_stats when one count request fails.""" # First call succeeds mock_resp1 = Mock() mock_resp1.json = Mock(return_value={"count": 100000}) # Second call fails mock_resp2 = Mock() mock_resp2.json = Mock(side_effect=ValueError("Invalid JSON")) mock_post.side_effect = [mock_resp1, mock_resp2] result = await nixos_stats() # With improved error handling, it should show 0 for failed count assert "Options: 0" in result or "Error (ERROR):" in result @pytest.mark.asyncio async def test_home_manager_search_edge_cases(self): """Test home_manager_search with edge cases.""" # Invalid limit result = await home_manager_search("test", limit=0) assert "Error (ERROR): Limit must be 1-100" in result result = await home_manager_search("test", limit=101) assert "Error (ERROR): Limit must be 1-100" in result @patch("mcp_nixos.server.parse_html_options") @pytest.mark.asyncio async def test_home_manager_info_exact_match(self, mock_parse): """Test home_manager_info requires exact name match.""" mock_parse.return_value = [ {"name": "programs.git", "description": "Git program", "type": ""}, {"name": "programs.git.enable", "description": "Enable git", "type": "boolean"}, ] # Should find exact match result = await home_manager_info("programs.git.enable") assert "Option: programs.git.enable" in result assert "Enable git" in result # Should not find partial match result = await home_manager_info("programs.git.en") assert "Error (NOT_FOUND):" in result @patch("mcp_nixos.server.parse_html_options") @pytest.mark.asyncio async def test_home_manager_list_options_category_extraction(self, mock_parse): """Test category extraction from option names.""" mock_parse.return_value = [ {"name": "programs.git.enable", "description": "", "type": ""}, {"name": "programs.firefox.enable", "description": "", "type": ""}, {"name": "services.gpg-agent.enable", "description": "", "type": ""}, {"name": "xdg.configHome", "description": "", "type": ""}, {"name": "single", "description": "No category", "type": ""}, # Edge case: no dot ] result = await home_manager_list_options() assert "programs (2 options)" in result assert "services (1 options)" in result assert "xdg (1 options)" in result assert "single (1 options)" in result @patch("mcp_nixos.server.parse_html_options") @pytest.mark.asyncio async def test_darwin_options_by_prefix_sorting(self, mock_parse): """Test darwin_options_by_prefix sorts results.""" mock_parse.return_value = [ {"name": "system.defaults.c", "description": "Option C", "type": ""}, {"name": "system.defaults.a", "description": "Option A", "type": ""}, {"name": "system.defaults.b", "description": "Option B", "type": ""}, ] result = await darwin_options_by_prefix("system.defaults") lines = result.split("\n") # Find option lines (those starting with •) option_lines = [line for line in lines if line.startswith("•")] assert option_lines[0] == "• system.defaults.a" assert option_lines[1] == "• system.defaults.b" assert option_lines[2] == "• system.defaults.c" @pytest.mark.asyncio async def test_all_tools_handle_exceptions_gracefully(self): """Test that all tools handle exceptions and return error messages.""" with patch("requests.post", side_effect=Exception("Network error")): result = await nixos_search("test") assert "Error (ERROR):" in result result = await nixos_info("test") assert "Error (ERROR):" in result result = await nixos_stats() assert "Error (ERROR):" in result with patch("requests.get", side_effect=Exception("Network error")): result = await home_manager_search("test") assert "Error (ERROR):" in result result = await home_manager_info("test") assert "Error (ERROR):" in result result = await home_manager_list_options() assert "Error (ERROR):" in result result = await darwin_search("test") assert "Error (ERROR):" in result result = await darwin_info("test") assert "Error (ERROR):" in result result = await darwin_list_options() assert "Error (ERROR):" in result

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/utensils/mcp-nixos'

If you have feedback or need assistance with the MCP directory API, please join our Discord server