Skip to main content
Glama
rwxproject
by rwxproject
test_tools.py26 kB
""" Unit Tests for Tools Module Tests individual tool components and functionality. """ import json import os from unittest.mock import patch, AsyncMock, Mock import httpx import pytest from src.mcp_server.tools.arithmetic import ArithmeticTools from src.mcp_server.tools.weather import WeatherTools from src.mcp_server.tools.registry import ToolRegistry, get_tool_registry from src.mcp_server.config.settings import reset_settings @pytest.mark.unit def test_arithmetic_tools_creation(): """Test that arithmetic tools can be created.""" tools = ArithmeticTools() assert tools.name == "arithmetic" assert tools.logger is not None @pytest.mark.unit def test_arithmetic_tools_direct_methods(): """Test direct method calls on arithmetic tools.""" tools = ArithmeticTools() # Test add_numbers method result = tools.add_numbers(5, 3) assert result == 8 # Test subtract_numbers method result = tools.subtract_numbers(10, 4) assert result == 6 @pytest.mark.unit def test_arithmetic_tools_with_negative_numbers(): """Test arithmetic tools with negative numbers.""" tools = ArithmeticTools() # Add with negative assert tools.add_numbers(-5, 3) == -2 assert tools.add_numbers(5, -3) == 2 assert tools.add_numbers(-5, -3) == -8 # Subtract with negative assert tools.subtract_numbers(-5, 3) == -8 assert tools.subtract_numbers(5, -3) == 8 assert tools.subtract_numbers(-5, -3) == -2 @pytest.mark.unit def test_arithmetic_tools_with_zero(): """Test arithmetic tools with zero.""" tools = ArithmeticTools() # Add with zero assert tools.add_numbers(0, 5) == 5 assert tools.add_numbers(5, 0) == 5 assert tools.add_numbers(0, 0) == 0 # Subtract with zero assert tools.subtract_numbers(0, 5) == -5 assert tools.subtract_numbers(5, 0) == 5 assert tools.subtract_numbers(0, 0) == 0 @pytest.mark.unit def test_tool_registry_creation(): """Test tool registry creation and basic functionality.""" registry = ToolRegistry() assert len(registry._tool_classes) == 0 assert len(registry._registered_tools) == 0 @pytest.mark.unit def test_tool_registry_registration(): """Test registering tool classes with registry.""" registry = ToolRegistry() registry.register(ArithmeticTools) assert len(registry._tool_classes) == 1 assert ArithmeticTools in registry._tool_classes @pytest.mark.unit def test_tool_registry_invalid_registration(): """Test that invalid tool classes are rejected.""" registry = ToolRegistry() # Try to register a non-BaseTool class class InvalidTool: pass with pytest.raises(TypeError): registry.register(InvalidTool) @pytest.mark.unit def test_global_tool_registry(): """Test that global tool registry is accessible.""" registry = get_tool_registry() assert registry is not None assert isinstance(registry, ToolRegistry) # Should have at least ArithmeticTools registered assert len(registry._tool_classes) >= 1 @pytest.mark.unit def test_tool_info(): """Test tool info generation.""" tools = ArithmeticTools() info = tools.get_info() assert info["name"] == "arithmetic" assert info["class"] == "ArithmeticTools" assert "tools.arithmetic" in info["module"] # Weather Tools Tests @pytest.mark.unit def test_weather_tools_creation(): """Test that weather tools can be created.""" with patch.dict(os.environ, {}, clear=True): reset_settings() tools = WeatherTools() assert tools.name == "weather" assert tools.logger is not None @pytest.mark.unit def test_weather_tools_mock_data_enabled(): """Test weather tools with mock data enabled (default).""" with patch.dict(os.environ, {}, clear=True): reset_settings() tools = WeatherTools() assert tools._use_mock_data() is True @pytest.mark.unit def test_weather_tools_mock_data_disabled(): """Test weather tools with mock data disabled.""" with patch.dict(os.environ, {"USE_MOCK_WEATHER_DATA": "false"}, clear=True): reset_settings() tools = WeatherTools() assert tools._use_mock_data() is False @pytest.mark.unit def test_weather_tools_api_key_configuration(): """Test weather API key configuration.""" # Test with no API key with patch.dict(os.environ, {}, clear=True): reset_settings() tools = WeatherTools() assert tools._get_api_key() is None # Test with MCP_WEATHER_API_KEY with patch.dict(os.environ, {"MCP_WEATHER_API_KEY": "test_key_1"}, clear=True): reset_settings() tools = WeatherTools() assert tools._get_api_key() == "test_key_1" # Test with WEATHER_API_KEY (alternative naming) with patch.dict(os.environ, {"WEATHER_API_KEY": "test_key_2"}, clear=True): reset_settings() tools = WeatherTools() assert tools._get_api_key() == "test_key_2" @pytest.mark.unit def test_weather_tools_mock_data_structure(): """Test the structure of mock weather data.""" with patch.dict(os.environ, {}, clear=True): reset_settings() tools = WeatherTools() location = "Test City" mock_data = tools._get_mock_weather_data(location) # Validate required fields required_fields = [ "location", "temperature", "temperature_unit", "humidity", "humidity_unit", "pressure", "pressure_unit", "wind_speed", "wind_speed_unit", "wind_direction", "wind_direction_unit", "weather", "description", "visibility", "visibility_unit", "source", "timestamp" ] for field in required_fields: assert field in mock_data # Validate data types and values assert mock_data["location"] == location assert isinstance(mock_data["temperature"], (int, float)) assert mock_data["temperature_unit"] == "°C" assert isinstance(mock_data["humidity"], int) assert mock_data["humidity_unit"] == "%" assert isinstance(mock_data["pressure"], (int, float)) assert mock_data["pressure_unit"] == "hPa" assert isinstance(mock_data["wind_speed"], (int, float)) assert mock_data["wind_speed_unit"] == "m/s" assert isinstance(mock_data["wind_direction"], int) assert mock_data["wind_direction_unit"] == "degrees" assert isinstance(mock_data["weather"], str) assert isinstance(mock_data["description"], str) assert isinstance(mock_data["visibility"], int) assert mock_data["visibility_unit"] == "meters" assert mock_data["source"] == "mock_data" assert isinstance(mock_data["timestamp"], str) @pytest.mark.unit @pytest.mark.asyncio async def test_weather_tools_mock_mode_success(): """Test getting weather data in mock mode.""" with patch.dict(os.environ, {"USE_MOCK_WEATHER_DATA": "true"}, clear=True): reset_settings() tools = WeatherTools() # Create a mock FastMCP instance mock_mcp = Mock() # Register the tools tools.register_with_mcp(mock_mcp) # Get the registered function assert mock_mcp.tool.called get_weather_func = mock_mcp.tool.call_args[0][0] # First positional arg # Test the function result = await get_weather_func("New York") assert result["location"] == "New York" assert result["source"] == "mock_data" assert "temperature" in result assert "humidity" in result @pytest.mark.unit @pytest.mark.asyncio async def test_weather_tools_empty_location_validation(): """Test validation of empty location inputs.""" with patch.dict(os.environ, {}, clear=True): reset_settings() tools = WeatherTools() mock_mcp = Mock() tools.register_with_mcp(mock_mcp) get_weather_func = mock_mcp.tool.call_args[0][0] # Test empty string with pytest.raises(ValueError, match="Location cannot be empty"): await get_weather_func("") # Test whitespace only with pytest.raises(ValueError, match="Location cannot be empty"): await get_weather_func(" ") # Test None (this would be caught by function signature, but test anyway) with pytest.raises(ValueError, match="Location cannot be empty"): await get_weather_func(None) @pytest.mark.unit @pytest.mark.asyncio async def test_weather_tools_api_key_missing_error(): """Test error when API key is missing in non-mock mode.""" with patch.dict(os.environ, {"USE_MOCK_WEATHER_DATA": "false"}, clear=True): reset_settings() tools = WeatherTools() mock_mcp = Mock() tools.register_with_mcp(mock_mcp) get_weather_func = mock_mcp.tool.call_args[0][0] with pytest.raises(ValueError, match="WEATHER_API_KEY environment variable is required"): await get_weather_func("New York") @pytest.mark.unit @pytest.mark.asyncio async def test_weather_tools_api_success(): """Test successful API call.""" mock_response_data = { "name": "New York", "sys": {"country": "US"}, "main": { "temp": 25.5, "humidity": 70, "pressure": 1015 }, "wind": { "speed": 5.2, "deg": 180 }, "weather": [{ "main": "Clear", "description": "clear sky" }], "visibility": 10000, "dt": 1642249200 } with patch.dict(os.environ, { "USE_MOCK_WEATHER_DATA": "false", "WEATHER_API_KEY": "test_api_key" }, clear=True): reset_settings() tools = WeatherTools() mock_mcp = Mock() tools.register_with_mcp(mock_mcp) get_weather_func = mock_mcp.tool.call_args[0][0] with patch('httpx.AsyncClient') as mock_client: mock_response = Mock() mock_response.json.return_value = mock_response_data mock_response.raise_for_status.return_value = None mock_client.return_value.__aenter__.return_value.get = AsyncMock(return_value=mock_response) result = await get_weather_func("New York") assert result["location"] == "New York, US" assert result["temperature"] == 25.5 assert result["temperature_unit"] == "°C" assert result["humidity"] == 70 assert result["humidity_unit"] == "%" assert result["pressure"] == 1015 assert result["pressure_unit"] == "hPa" assert result["wind_speed"] == 5.2 assert result["wind_speed_unit"] == "m/s" assert result["wind_direction"] == 180 assert result["wind_direction_unit"] == "degrees" assert result["weather"] == "Clear" assert result["description"] == "clear sky" assert result["visibility"] == 10000 assert result["visibility_unit"] == "meters" assert result["source"] == "openweathermap" assert result["timestamp"] == 1642249200 @pytest.mark.unit @pytest.mark.asyncio async def test_weather_tools_api_404_error(): """Test API 404 error handling.""" with patch.dict(os.environ, { "USE_MOCK_WEATHER_DATA": "false", "WEATHER_API_KEY": "test_api_key" }, clear=True): reset_settings() tools = WeatherTools() mock_mcp = Mock() tools.register_with_mcp(mock_mcp) get_weather_func = mock_mcp.tool.call_args[0][0] with patch('httpx.AsyncClient') as mock_client: mock_response = Mock() mock_response.status_code = 404 mock_client.return_value.__aenter__.return_value.get = AsyncMock( side_effect=httpx.HTTPStatusError( "Not Found", request=Mock(), response=mock_response ) ) with pytest.raises(ValueError, match="Location 'InvalidCity' not found"): await get_weather_func("InvalidCity") @pytest.mark.unit @pytest.mark.asyncio async def test_weather_tools_api_401_error(): """Test API 401 (unauthorized) error handling.""" with patch.dict(os.environ, { "USE_MOCK_WEATHER_DATA": "false", "WEATHER_API_KEY": "invalid_key" }, clear=True): reset_settings() tools = WeatherTools() mock_mcp = Mock() tools.register_with_mcp(mock_mcp) get_weather_func = mock_mcp.tool.call_args[0][0] with patch('httpx.AsyncClient') as mock_client: mock_response = Mock() mock_response.status_code = 401 mock_client.return_value.__aenter__.return_value.get = AsyncMock( side_effect=httpx.HTTPStatusError( "Unauthorized", request=Mock(), response=mock_response ) ) with pytest.raises(ValueError, match="Invalid API key provided for weather service"): await get_weather_func("New York") @pytest.mark.unit @pytest.mark.asyncio async def test_weather_tools_api_timeout_error(): """Test API timeout error handling.""" with patch.dict(os.environ, { "USE_MOCK_WEATHER_DATA": "false", "WEATHER_API_KEY": "test_api_key" }, clear=True): reset_settings() tools = WeatherTools() mock_mcp = Mock() tools.register_with_mcp(mock_mcp) get_weather_func = mock_mcp.tool.call_args[0][0] with patch('httpx.AsyncClient') as mock_client: mock_client.return_value.__aenter__.return_value.get = AsyncMock( side_effect=httpx.TimeoutException("Request timed out") ) with pytest.raises(ValueError, match="Weather API request timed out"): await get_weather_func("New York") @pytest.mark.unit @pytest.mark.asyncio async def test_weather_tools_api_connection_error(): """Test API connection error handling.""" with patch.dict(os.environ, { "USE_MOCK_WEATHER_DATA": "false", "WEATHER_API_KEY": "test_api_key" }, clear=True): reset_settings() tools = WeatherTools() mock_mcp = Mock() tools.register_with_mcp(mock_mcp) get_weather_func = mock_mcp.tool.call_args[0][0] with patch('httpx.AsyncClient') as mock_client: mock_client.return_value.__aenter__.return_value.get = AsyncMock( side_effect=httpx.RequestError("Connection failed") ) with pytest.raises(ValueError, match="Weather API request failed: Connection failed"): await get_weather_func("New York") @pytest.mark.unit @pytest.mark.asyncio async def test_weather_tools_api_invalid_response(): """Test invalid API response handling.""" with patch.dict(os.environ, { "USE_MOCK_WEATHER_DATA": "false", "WEATHER_API_KEY": "test_api_key" }, clear=True): reset_settings() tools = WeatherTools() mock_mcp = Mock() tools.register_with_mcp(mock_mcp) get_weather_func = mock_mcp.tool.call_args[0][0] with patch('httpx.AsyncClient') as mock_client: mock_response = Mock() mock_response.json.side_effect = json.JSONDecodeError("Invalid JSON", "", 0) mock_response.raise_for_status.return_value = None mock_client.return_value.__aenter__.return_value.get = AsyncMock(return_value=mock_response) with pytest.raises(ValueError, match="Invalid weather API response format"): await get_weather_func("New York") @pytest.mark.unit @pytest.mark.asyncio async def test_weather_tools_api_missing_fields(): """Test API response with missing required fields.""" incomplete_response = { "name": "New York" # Missing required fields like main, weather, etc. } with patch.dict(os.environ, { "USE_MOCK_WEATHER_DATA": "false", "WEATHER_API_KEY": "test_api_key" }, clear=True): reset_settings() tools = WeatherTools() mock_mcp = Mock() tools.register_with_mcp(mock_mcp) get_weather_func = mock_mcp.tool.call_args[0][0] with patch('httpx.AsyncClient') as mock_client: mock_response = Mock() mock_response.json.return_value = incomplete_response mock_response.raise_for_status.return_value = None mock_client.return_value.__aenter__.return_value.get = AsyncMock(return_value=mock_response) with pytest.raises(ValueError, match="Invalid weather API response format"): await get_weather_func("New York") @pytest.mark.unit @pytest.mark.asyncio async def test_weather_tools_api_partial_wind_data(): """Test API response with partial wind data.""" mock_response_data = { "name": "New York", "sys": {"country": "US"}, "main": { "temp": 25.5, "humidity": 70, "pressure": 1015 }, # wind data is optional, test default values "weather": [{ "main": "Clear", "description": "clear sky" }], "visibility": 10000, "dt": 1642249200 } with patch.dict(os.environ, { "USE_MOCK_WEATHER_DATA": "false", "WEATHER_API_KEY": "test_api_key" }, clear=True): reset_settings() tools = WeatherTools() mock_mcp = Mock() tools.register_with_mcp(mock_mcp) get_weather_func = mock_mcp.tool.call_args[0][0] with patch('httpx.AsyncClient') as mock_client: mock_response = Mock() mock_response.json.return_value = mock_response_data mock_response.raise_for_status.return_value = None mock_client.return_value.__aenter__.return_value.get = AsyncMock(return_value=mock_response) result = await get_weather_func("New York") # Should have default wind values assert result["wind_speed"] == 0 assert result["wind_direction"] == 0 @pytest.mark.unit def test_weather_tools_registry_registration(): """Test that weather tools are properly registered in the global registry.""" registry = get_tool_registry() # WeatherTools should be in the registered tool classes assert WeatherTools in registry._tool_classes @pytest.mark.unit def test_weather_tools_info(): """Test weather tools info generation.""" with patch.dict(os.environ, {}, clear=True): reset_settings() tools = WeatherTools() info = tools.get_info() assert info["name"] == "weather" assert info["class"] == "WeatherTools" assert "tools.weather" in info["module"] @pytest.mark.unit @pytest.mark.asyncio async def test_weather_tools_whitespace_trimming(): """Test that location input is properly trimmed of whitespace.""" with patch.dict(os.environ, {"USE_MOCK_WEATHER_DATA": "true"}, clear=True): reset_settings() tools = WeatherTools() mock_mcp = Mock() tools.register_with_mcp(mock_mcp) get_weather_func = mock_mcp.tool.call_args[0][0] # Test with leading/trailing whitespace result = await get_weather_func(" New York ") assert result["location"] == "New York" # Trimmed @pytest.mark.unit @pytest.mark.asyncio async def test_weather_tools_http_status_error_generic(): """Test generic HTTP status error handling.""" with patch.dict(os.environ, { "USE_MOCK_WEATHER_DATA": "false", "WEATHER_API_KEY": "test_api_key" }, clear=True): reset_settings() tools = WeatherTools() mock_mcp = Mock() tools.register_with_mcp(mock_mcp) get_weather_func = mock_mcp.tool.call_args[0][0] with patch('httpx.AsyncClient') as mock_client: mock_response = Mock() mock_response.status_code = 500 mock_client.return_value.__aenter__.return_value.get = AsyncMock( side_effect=httpx.HTTPStatusError( "Internal Server Error", request=Mock(), response=mock_response ) ) with pytest.raises(ValueError, match="Weather API error: HTTP 500"): await get_weather_func("New York") @pytest.mark.unit def test_weather_tools_environment_variables(): """Test various environment variable configurations.""" # Test MCP_USE_MOCK_WEATHER_DATA with different values test_cases = [ ("true", True), ("True", True), ("TRUE", True), ("1", True), ("yes", True), ("on", True), ("false", False), ("False", False), ("FALSE", False), ("0", False), ("no", False), ("off", False), ("invalid", False), # Invalid values default to False ] for env_value, expected in test_cases: with patch.dict(os.environ, {"USE_MOCK_WEATHER_DATA": env_value}, clear=True): reset_settings() tools = WeatherTools() assert tools._use_mock_data() == expected, f"Failed for env_value='{env_value}'" @pytest.mark.unit def test_weather_tools_real_environment_variable(): """Test that weather tools can read from actual environment variables.""" import os # Test reading from actual environment if WEATHER_API_KEY is set if "WEATHER_API_KEY" in os.environ: # Don't reset settings to allow reading from real environment tools = WeatherTools() api_key = tools._get_api_key() # Should be able to read the actual environment variable assert api_key is not None assert api_key == os.environ["WEATHER_API_KEY"] # If USE_MOCK_WEATHER_DATA is not set, should default to True if "USE_MOCK_WEATHER_DATA" not in os.environ: assert tools._use_mock_data() is True else: expected = os.environ["USE_MOCK_WEATHER_DATA"].lower() in ("true", "1", "yes", "on") assert tools._use_mock_data() == expected else: # If no WEATHER_API_KEY in environment, test should pass but note it pytest.skip("WEATHER_API_KEY not found in environment - test requires local environment variable") @pytest.mark.unit @pytest.mark.asyncio async def test_weather_tools_with_real_environment(): """Test weather tools functionality with real environment variables.""" import os # Only run if WEATHER_API_KEY is available and USE_MOCK_WEATHER_DATA is false if "WEATHER_API_KEY" in os.environ and os.environ.get("USE_MOCK_WEATHER_DATA", "true").lower() in ("false", "0", "no", "off"): tools = WeatherTools() # Should not use mock data assert tools._use_mock_data() is False # Should have API key assert tools._get_api_key() is not None mock_mcp = Mock() tools.register_with_mcp(mock_mcp) get_weather_func = mock_mcp.tool.call_args[0][0] # Note: We won't actually call the API in tests to avoid rate limits # Just verify that the configuration is set up correctly print(f"Real environment test - API key configured: {tools._get_api_key() is not None}") print(f"Real environment test - Mock mode: {tools._use_mock_data()}") else: pytest.skip("Real API test requires WEATHER_API_KEY and USE_MOCK_WEATHER_DATA=false") @pytest.mark.unit @pytest.mark.asyncio async def test_weather_tools_api_key_validation(): """Test API key format validation.""" with patch.dict(os.environ, {"USE_MOCK_WEATHER_DATA": "false"}, clear=True): reset_settings() # Test invalid API key length with patch.dict(os.environ, {"WEATHER_API_KEY": "short_key"}, clear=True): reset_settings() tools = WeatherTools() mock_mcp = Mock() tools.register_with_mcp(mock_mcp) get_weather_func = mock_mcp.tool.call_args[0][0] with pytest.raises(ValueError, match="Invalid API key format: expected 32 characters"): await get_weather_func("New York") # Test valid length API key (should pass validation, may fail at API level) with patch.dict(os.environ, {"WEATHER_API_KEY": "12345678901234567890123456789012"}, clear=True): reset_settings() tools = WeatherTools() mock_mcp = Mock() tools.register_with_mcp(mock_mcp) get_weather_func = mock_mcp.tool.call_args[0][0] # This should pass API key validation but will likely fail with network/API error # We'll catch any exception, but specifically check it's not a format validation error try: await get_weather_func("New York") except ValueError as e: # Should not be a format validation error assert "Invalid API key format" not in str(e) except Exception: # Other exceptions (network, API response) are expected pass

Latest Blog Posts

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/rwxproject/mcp-server-template'

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