"""Tests for weather tool implementation."""
import pytest
from unittest.mock import AsyncMock, patch, Mock
import aiohttp
from src.tools.weather import (
WeatherService,
get_weather_handler,
WEATHER_TOOL_SCHEMA,
)
from src.utils.errors import APIError, ErrorCode
class TestWeatherService:
"""Test cases for WeatherService class."""
def test_weather_service_initialization(self, mock_settings):
"""Test WeatherService initialization."""
with patch("src.tools.weather.get_settings", return_value=mock_settings):
service = WeatherService()
assert service.settings == mock_settings
assert service.base_url == "https://api.openweathermap.org/data/2.5"
@pytest.mark.asyncio
async def test_get_current_weather_success(self, mock_settings, sample_weather_api_response):
"""Test successful weather data retrieval."""
with patch("src.tools.weather.get_settings", return_value=mock_settings):
service = WeatherService()
# Mock aiohttp session and response
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value=sample_weather_api_response)
# Create a mock session that properly handles async context
with patch("aiohttp.ClientSession") as mock_session_class:
mock_session = AsyncMock()
mock_session_class.return_value.__aenter__ = AsyncMock(return_value=mock_session)
mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None)
# Mock the get method to return our mock response
mock_session.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
mock_session.get.return_value.__aexit__ = AsyncMock(return_value=None)
result = await service.get_current_weather("San Francisco", "US", "metric")
# Verify the request was made correctly
mock_session.get.assert_called_once()
call_args = mock_session.get.call_args
assert call_args[0][0] == "https://api.openweathermap.org/data/2.5/weather"
params = call_args[1]["params"]
assert params["q"] == "San Francisco,US"
assert params["appid"] == "test_weather_key"
assert params["units"] == "metric"
# Verify the normalized response
assert result["location"]["city"] == "San Francisco"
assert result["location"]["country"] == "US"
assert result["temperature"]["current"] == 18.5
assert result["temperature"]["unit"] == "°C"
assert result["weather"]["condition"] == "Clouds"
assert result["source"] == "OpenWeatherMap"
@pytest.mark.asyncio
async def test_get_current_weather_no_api_key(self):
"""Test weather request without API key."""
mock_settings = Mock()
mock_settings.openweather_api_key = None
with patch("src.tools.weather.get_settings", return_value=mock_settings):
service = WeatherService()
with pytest.raises(APIError) as exc_info:
await service.get_current_weather("San Francisco")
error = exc_info.value
assert error.code == ErrorCode.API_KEY_MISSING
assert "OpenWeatherMap" in error.message
@pytest.mark.asyncio
async def test_get_current_weather_empty_city(self, mock_settings):
"""Test weather request with empty city name."""
with patch("src.tools.weather.get_settings", return_value=mock_settings):
service = WeatherService()
with pytest.raises(APIError) as exc_info:
await service.get_current_weather("")
error = exc_info.value
assert error.code == ErrorCode.INVALID_PARAMS
assert "City name cannot be empty" in error.message
@pytest.mark.asyncio
async def test_get_current_weather_invalid_units(self, mock_settings):
"""Test weather request with invalid units."""
with patch("src.tools.weather.get_settings", return_value=mock_settings):
service = WeatherService()
with pytest.raises(APIError) as exc_info:
await service.get_current_weather("San Francisco", units="invalid")
error = exc_info.value
assert error.code == ErrorCode.INVALID_PARAMS
assert "Units must be one of: metric, imperial, kelvin" in error.message
@pytest.mark.asyncio
async def test_get_current_weather_api_error_401(self, mock_settings):
"""Test weather request with invalid API key (401 error)."""
with patch("src.tools.weather.get_settings", return_value=mock_settings):
service = WeatherService()
mock_response = AsyncMock()
mock_response.status = 401
mock_response.text = AsyncMock(return_value="Unauthorized")
with patch("aiohttp.ClientSession") as mock_session_class:
mock_session = AsyncMock()
mock_session_class.return_value.__aenter__ = AsyncMock(return_value=mock_session)
mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None)
mock_session.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
mock_session.get.return_value.__aexit__ = AsyncMock(return_value=None)
with pytest.raises(APIError) as exc_info:
await service.get_current_weather("San Francisco")
error = exc_info.value
assert error.code == ErrorCode.API_KEY_INVALID
assert "Invalid OpenWeatherMap API key" in error.message
@pytest.mark.asyncio
async def test_get_current_weather_city_not_found(self, mock_settings):
"""Test weather request for non-existent city (404 error)."""
with patch("src.tools.weather.get_settings", return_value=mock_settings):
service = WeatherService()
mock_response = AsyncMock()
mock_response.status = 404
mock_response.text = AsyncMock(return_value="City not found")
with patch("aiohttp.ClientSession") as mock_session_class:
mock_session = AsyncMock()
mock_session_class.return_value.__aenter__ = AsyncMock(return_value=mock_session)
mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None)
mock_session.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
mock_session.get.return_value.__aexit__ = AsyncMock(return_value=None)
with pytest.raises(APIError) as exc_info:
await service.get_current_weather("NonExistentCity")
error = exc_info.value
assert error.code == ErrorCode.INVALID_PARAMS
assert "City 'NonExistentCity' not found" in error.message
@pytest.mark.asyncio
async def test_get_current_weather_network_error(self, mock_settings):
"""Test weather request with network error."""
with patch("src.tools.weather.get_settings", return_value=mock_settings):
service = WeatherService()
mock_session = AsyncMock()
mock_session.get.side_effect = aiohttp.ClientError("Network error")
with patch("aiohttp.ClientSession", return_value=mock_session):
with pytest.raises(APIError) as exc_info:
await service.get_current_weather("San Francisco")
error = exc_info.value
assert error.code == ErrorCode.EXTERNAL_API_ERROR
def test_normalize_weather_data_metric(self, sample_weather_api_response, mock_settings):
"""Test weather data normalization with metric units."""
with patch("src.tools.weather.get_settings", return_value=mock_settings):
service = WeatherService()
result = service._normalize_weather_data(sample_weather_api_response, "metric")
assert result["location"]["city"] == "San Francisco"
assert result["location"]["country"] == "US"
assert result["location"]["coordinates"]["latitude"] == 37.77
assert result["location"]["coordinates"]["longitude"] == -122.42
assert result["weather"]["condition"] == "Clouds"
assert result["weather"]["description"] == "few clouds"
assert result["temperature"]["current"] == 18.5
assert result["temperature"]["unit"] == "°C"
assert result["temperature"]["feels_like"] == 17.2
assert result["humidity"] == "72%"
assert result["pressure"] == "1013 hPa"
assert result["wind"]["speed"] == "3.6 m/s"
assert result["clouds"] == "20%"
assert result["source"] == "OpenWeatherMap"
def test_normalize_weather_data_imperial(self, sample_weather_api_response, mock_settings):
"""Test weather data normalization with imperial units."""
with patch("src.tools.weather.get_settings", return_value=mock_settings):
service = WeatherService()
result = service._normalize_weather_data(sample_weather_api_response, "imperial")
assert result["temperature"]["unit"] == "°F"
assert result["wind"]["speed"] == "3.6 mph"
def test_normalize_weather_data_kelvin(self, sample_weather_api_response, mock_settings):
"""Test weather data normalization with kelvin units."""
with patch("src.tools.weather.get_settings", return_value=mock_settings):
service = WeatherService()
result = service._normalize_weather_data(sample_weather_api_response, "kelvin")
assert result["temperature"]["unit"] == "K"
assert result["wind"]["speed"] == "3.6 m/s"
@pytest.mark.asyncio
async def test_get_current_weather_whitespace_handling(self, mock_settings, sample_weather_api_response):
"""Test weather request with whitespace in city/country names."""
with patch("src.tools.weather.get_settings", return_value=mock_settings):
service = WeatherService()
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value=sample_weather_api_response)
with patch("aiohttp.ClientSession") as mock_session_class:
mock_session = AsyncMock()
mock_session_class.return_value.__aenter__ = AsyncMock(return_value=mock_session)
mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None)
mock_session.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
mock_session.get.return_value.__aexit__ = AsyncMock(return_value=None)
await service.get_current_weather(" San Francisco ", " US ")
# Verify whitespace was stripped
call_args = mock_session.get.call_args
params = call_args[1]["params"]
assert params["q"] == "San Francisco,US"
class TestWeatherHandler:
"""Test cases for get_weather_handler function."""
@pytest.mark.asyncio
async def test_get_weather_handler_success(self, sample_weather_api_response):
"""Test successful weather handler execution."""
parameters = {
"city": "San Francisco",
"country": "US",
"units": "metric"
}
# Mock the weather service
with patch("src.tools.weather.weather_service") as mock_service:
mock_service.get_current_weather = AsyncMock(return_value={"test": "data"})
result = await get_weather_handler(parameters)
mock_service.get_current_weather.assert_called_once_with(
city="San Francisco",
country="US",
units="metric"
)
assert result == {"test": "data"}
@pytest.mark.asyncio
async def test_get_weather_handler_missing_city(self):
"""Test weather handler with missing city parameter."""
parameters = {"country": "US"}
with pytest.raises(APIError) as exc_info:
await get_weather_handler(parameters)
error = exc_info.value
assert error.code == ErrorCode.INVALID_PARAMS
assert "City parameter is required" in error.message
@pytest.mark.asyncio
async def test_get_weather_handler_default_units(self):
"""Test weather handler with default units."""
parameters = {"city": "San Francisco"}
with patch("src.tools.weather.weather_service") as mock_service:
mock_service.get_current_weather = AsyncMock(return_value={"test": "data"})
await get_weather_handler(parameters)
mock_service.get_current_weather.assert_called_once_with(
city="San Francisco",
country=None,
units="metric" # Default value
)
@pytest.mark.asyncio
async def test_get_weather_handler_propagates_service_errors(self):
"""Test that handler propagates errors from weather service."""
parameters = {"city": "San Francisco"}
with patch("src.tools.weather.weather_service") as mock_service:
service_error = APIError("Service error", ErrorCode.EXTERNAL_API_ERROR)
mock_service.get_current_weather = AsyncMock(side_effect=service_error)
with pytest.raises(APIError) as exc_info:
await get_weather_handler(parameters)
assert exc_info.value == service_error
class TestWeatherToolSchema:
"""Test cases for weather tool schema."""
def test_weather_tool_schema_structure(self):
"""Test that the weather tool schema has the correct structure."""
schema = WEATHER_TOOL_SCHEMA
assert schema["type"] == "object"
assert "properties" in schema
properties = schema["properties"]
# Check required city property
assert "city" in properties
city_prop = properties["city"]
assert city_prop["type"] == "string"
assert "description" in city_prop
# Check optional country property
assert "country" in properties
country_prop = properties["country"]
assert country_prop["type"] == "string"
assert "description" in country_prop
# Check optional units property
assert "units" in properties
units_prop = properties["units"]
assert units_prop["type"] == "string"
assert units_prop["default"] == "metric"
assert set(units_prop["enum"]) == {"metric", "imperial", "kelvin"}
def test_weather_tool_schema_required_fields(self):
"""Test that the weather tool schema specifies required fields correctly."""
schema = WEATHER_TOOL_SCHEMA
assert "required" in schema
assert schema["required"] == ["city"]