"""Tests for nearby search MCP tool.
Requirements covered:
- DA-009: Support point + radius queries
- 4.2: Tool parameters specification for find_nearby
"""
import json
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from jana_mcp.client import APIError, AuthenticationError
from jana_mcp.tools.nearby import NEARBY_TOOL
class TestNearbyToolDefinition:
"""Test nearby tool definition and schema."""
def test_tool_name(self):
"""Test tool has correct name."""
assert NEARBY_TOOL.name == "find_nearby"
def test_tool_has_description(self):
"""Test tool has description."""
assert NEARBY_TOOL.description is not None
assert len(NEARBY_TOOL.description) > 0
def test_input_schema_has_location_point(self):
"""Test schema includes required location_point parameter (DA-009)."""
props = NEARBY_TOOL.inputSchema["properties"]
assert "location_point" in props
assert props["location_point"]["type"] == "array"
assert props["location_point"]["minItems"] == 2
assert props["location_point"]["maxItems"] == 2
def test_input_schema_has_radius_km(self):
"""Test schema includes required radius_km parameter (DA-009)."""
props = NEARBY_TOOL.inputSchema["properties"]
assert "radius_km" in props
assert props["radius_km"]["type"] == "number"
assert props["radius_km"]["minimum"] == 0.1
assert props["radius_km"]["maximum"] == 500
def test_input_schema_has_sources(self):
"""Test schema includes sources parameter."""
props = NEARBY_TOOL.inputSchema["properties"]
assert "sources" in props
assert props["sources"]["type"] == "array"
# Check source enum values
source_items = props["sources"]["items"]
assert "enum" in source_items
sources = source_items["enum"]
assert "openaq" in sources
assert "climatetrace" in sources
assert "edgar" in sources
def test_input_schema_has_limit(self):
"""Test schema includes limit parameter."""
props = NEARBY_TOOL.inputSchema["properties"]
assert "limit" in props
def test_required_parameters(self):
"""Test location_point and radius_km are required."""
required = NEARBY_TOOL.inputSchema.get("required", [])
assert "location_point" in required
assert "radius_km" in required
class TestNearbyToolExecution:
"""Test nearby tool execution."""
@pytest.mark.asyncio
async def test_execute_with_required_params(self, mock_client):
"""Test execution with required parameters (DA-009)."""
from jana_mcp.tools.nearby import execute_nearby
mock_client.find_nearby.return_value = {
"results": [
{
"type": "station",
"name": "Test Station",
"distance_km": 5.2,
"source": "openaq",
}
]
}
result = await execute_nearby(
mock_client,
{"location_point": [-122.4, 37.7], "radius_km": 25},
)
mock_client.find_nearby.assert_called_once()
call_kwargs = mock_client.find_nearby.call_args[1]
assert call_kwargs["point"] == [-122.4, 37.7]
assert call_kwargs["radius_km"] == 25
@pytest.mark.asyncio
async def test_execute_with_sources(self, mock_client):
"""Test execution with source filter."""
from jana_mcp.tools.nearby import execute_nearby
mock_client.find_nearby.return_value = {"results": []}
result = await execute_nearby(
mock_client,
{
"location_point": [-122.4, 37.7],
"radius_km": 25,
"sources": ["openaq", "climatetrace"],
},
)
call_kwargs = mock_client.find_nearby.call_args[1]
assert call_kwargs["sources"] == ["openaq", "climatetrace"]
@pytest.mark.asyncio
async def test_execute_with_limit(self, mock_client):
"""Test execution with custom limit."""
from jana_mcp.tools.nearby import execute_nearby
mock_client.find_nearby.return_value = {"results": []}
result = await execute_nearby(
mock_client,
{"location_point": [-122.4, 37.7], "radius_km": 25, "limit": 50},
)
call_kwargs = mock_client.find_nearby.call_args[1]
assert call_kwargs["limit"] == 50
@pytest.mark.asyncio
async def test_execute_requires_location_point(self, mock_client):
"""Test execution fails without location_point."""
from jana_mcp.tools.nearby import execute_nearby
result = await execute_nearby(
mock_client,
{"radius_km": 25},
)
assert len(result) == 1
import json
error_data = json.loads(result[0].text)
assert error_data.get("success") is False
assert "error" in error_data
mock_client.find_nearby.assert_not_called()
@pytest.mark.asyncio
async def test_execute_requires_radius_km(self, mock_client):
"""Test execution fails without radius_km."""
from jana_mcp.tools.nearby import execute_nearby
result = await execute_nearby(
mock_client,
{"location_point": [-122.4, 37.7]},
)
assert len(result) == 1
import json
error_data = json.loads(result[0].text)
assert error_data.get("success") is False
assert "error" in error_data
mock_client.find_nearby.assert_not_called()
@pytest.mark.asyncio
async def test_execute_handles_api_error(self, mock_client):
"""Test execution handles API errors gracefully."""
from jana_mcp.tools.nearby import execute_nearby
import httpx
mock_client.find_nearby.side_effect = httpx.RequestError("API Error")
result = await execute_nearby(
mock_client,
{"location_point": [-122.4, 37.7], "radius_km": 25},
)
assert len(result) == 1
import json
error_data = json.loads(result[0].text)
assert error_data.get("success") is False
assert "error" in error_data
@pytest.mark.asyncio
async def test_result_format_json(self, mock_client):
"""Test result is properly formatted JSON."""
from jana_mcp.tools.nearby import execute_nearby
mock_client.find_nearby.return_value = {
"results": [
{
"type": "station",
"name": "San Francisco Station",
"distance_km": 2.5,
"source": "openaq",
"coordinates": {"longitude": -122.41, "latitude": 37.78},
}
]
}
result = await execute_nearby(
mock_client,
{"location_point": [-122.4, 37.7], "radius_km": 10},
)
data = json.loads(result[0].text)
assert "results" in data
assert len(data["results"]) == 1
@pytest.mark.asyncio
async def test_execute_default_limit(self, mock_client):
"""Test execution uses default limit."""
from jana_mcp.tools.nearby import execute_nearby
mock_client.find_nearby.return_value = {"results": []}
result = await execute_nearby(
mock_client,
{"location_point": [-122.4, 37.7], "radius_km": 25},
)
call_kwargs = mock_client.find_nearby.call_args[1]
assert call_kwargs["limit"] == 100
class TestNearbyToolValidation:
"""Test input validation for nearby tool."""
@pytest.mark.asyncio
async def test_accepts_valid_coordinates(self, mock_client):
"""Test accepts valid coordinate values."""
from jana_mcp.tools.nearby import execute_nearby
# Test various valid coordinates
test_cases = [
[-180, -90], # Min values
[180, 90], # Max values
[0, 0], # Origin
[-122.4, 37.7], # San Francisco
]
for coords in test_cases:
mock_client.find_nearby.reset_mock()
result = await execute_nearby(
mock_client,
{"location_point": coords, "radius_km": 10},
)
mock_client.find_nearby.assert_called_once()
@pytest.mark.asyncio
async def test_accepts_various_radius_values(self, mock_client):
"""Test accepts various valid radius values."""
from jana_mcp.tools.nearby import execute_nearby
test_radii = [0.1, 1, 10, 50, 100, 500]
for radius in test_radii:
mock_client.find_nearby.reset_mock()
result = await execute_nearby(
mock_client,
{"location_point": [0, 0], "radius_km": radius},
)
mock_client.find_nearby.assert_called_once()
class TestNearbyToolErrorPaths:
"""Test error handling paths for nearby tool."""
@pytest.mark.asyncio
async def test_handles_api_error(self, mock_client):
"""Test handles APIError gracefully."""
from jana_mcp.tools.nearby import execute_nearby
mock_client.find_nearby.side_effect = APIError("API Error", status_code=500)
result = await execute_nearby(
mock_client,
{"location_point": [-122.4, 37.7], "radius_km": 10},
)
error_data = json.loads(result[0].text)
assert error_data.get("success") is False
assert error_data.get("error_code") == "API_ERROR"
@pytest.mark.asyncio
async def test_handles_authentication_error(self, mock_client):
"""Test handles AuthenticationError gracefully."""
from jana_mcp.tools.nearby import execute_nearby
mock_client.find_nearby.side_effect = AuthenticationError("Auth failed")
result = await execute_nearby(
mock_client,
{"location_point": [-122.4, 37.7], "radius_km": 10},
)
error_data = json.loads(result[0].text)
assert error_data.get("success") is False
assert error_data.get("error_code") == "API_ERROR"
@pytest.mark.asyncio
async def test_handles_network_error(self, mock_client):
"""Test handles network errors gracefully."""
from jana_mcp.tools.nearby import execute_nearby
mock_client.find_nearby.side_effect = httpx.RequestError("Network error")
result = await execute_nearby(
mock_client,
{"location_point": [-122.4, 37.7], "radius_km": 10},
)
error_data = json.loads(result[0].text)
assert error_data.get("success") is False
assert error_data.get("error_code") == "NETWORK_ERROR"
@pytest.mark.asyncio
async def test_handles_data_error(self, mock_client):
"""Test handles data parsing errors."""
from jana_mcp.tools.nearby import execute_nearby
mock_client.find_nearby.side_effect = KeyError("missing_key")
result = await execute_nearby(
mock_client,
{"location_point": [-122.4, 37.7], "radius_km": 10},
)
error_data = json.loads(result[0].text)
assert error_data.get("success") is False
assert error_data.get("error_code") == "DATA_ERROR"
@pytest.mark.asyncio
async def test_validates_invalid_coordinates(self, mock_client):
"""Test rejects invalid coordinates."""
from jana_mcp.tools.nearby import execute_nearby
result = await execute_nearby(
mock_client,
{"location_point": [200.0, 40.0], "radius_km": 10}, # lon > 180
)
error_data = json.loads(result[0].text)
assert error_data.get("success") is False
assert error_data.get("error_code") == "VALIDATION_ERROR"
mock_client.find_nearby.assert_not_called()