Switched Weather Provider to OpenMeteo #15

This commit is contained in:
Tim Zöller 2026-04-27 21:54:46 +02:00
parent 5f85417c80
commit 5df4da86a5
3 changed files with 228 additions and 353 deletions

View file

@ -18,13 +18,14 @@ import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestTemplate;
import java.math.BigDecimal;
import java.net.URI;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
@ -49,47 +50,26 @@ class WeatherServiceTest {
private Activity testActivity;
private UUID activityId;
// Sample OpenWeatherMap API response
// Sample Open-Meteo archive API response (clear sky, WMO code 0)
private static final String SAMPLE_WEATHER_RESPONSE = """
{
"coord": {"lon": 8.2552, "lat": 49.9894},
"weather": [
{
"id": 800,
"main": "Clear",
"description": "clear sky",
"icon": "01d"
}
],
"base": "stations",
"main": {
"temp": 15.5,
"feels_like": 14.2,
"temp_min": 13.0,
"temp_max": 17.0,
"pressure": 1013,
"humidity": 65
},
"visibility": 10000,
"wind": {
"speed": 3.5,
"deg": 180
},
"clouds": {
"all": 20
},
"dt": 1700758089,
"sys": {
"type": 2,
"id": 2012516,
"country": "DE",
"sunrise": 1700721600,
"sunset": 1700757600
},
"timezone": 3600,
"id": 2873891,
"name": "Mannheim",
"cod": 200
"latitude": 49.98,
"longitude": 8.26,
"hourly": {
"time": ["2025-11-23T17:00", "2025-11-23T18:00", "2025-11-23T19:00"],
"temperature_2m": [14.0, 15.5, 13.8],
"apparent_temperature": [12.5, 14.2, 12.0],
"relative_humidity_2m": [60, 65, 70],
"surface_pressure": [1012, 1013, 1012],
"wind_speed_10m": [10.0, 12.6, 11.0],
"wind_direction_10m": [170, 180, 190],
"cloud_cover": [15, 20, 25],
"rain": [0.0, 0.0, 0.0],
"snowfall": [0.0, 0.0, 0.0],
"precipitation": [0.0, 0.0, 0.0],
"visibility": [10000, 10000, 10000],
"weather_code": [0, 0, 1]
}
}
""";
@ -98,12 +78,11 @@ class WeatherServiceTest {
activityId = UUID.randomUUID();
testActivity = new Activity();
testActivity.setId(activityId);
testActivity.setStartedAt(LocalDateTime.now().minusDays(1)); // Recent activity
testActivity.setStartedAt(LocalDateTime.of(2025, 11, 23, 18, 8, 9));
// Inject the real RestTemplate mock and set config values
ReflectionTestUtils.setField(weatherService, "restTemplate", restTemplate);
ReflectionTestUtils.setField(weatherService, "weatherEnabled", true);
ReflectionTestUtils.setField(weatherService, "apiKey", "test-api-key-12345678901234567890");
}
@Test
@ -123,7 +102,7 @@ class WeatherServiceTest {
""";
testActivity.setTrackPointsJson(trackPointsJson);
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
.thenReturn(SAMPLE_WEATHER_RESPONSE);
when(weatherDataRepository.save(any(WeatherData.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
@ -136,7 +115,7 @@ class WeatherServiceTest {
assertEquals(new BigDecimal("15.5"), weatherData.getTemperatureCelsius());
assertEquals("Clear", weatherData.getWeatherCondition());
verify(restTemplate, times(1)).getForObject(any(URI.class), eq(String.class));
verify(restTemplate, times(1)).getForObject(anyString(), eq(String.class), any(Map.class));
verify(weatherDataRepository, times(1)).save(any(WeatherData.class));
}
@ -162,7 +141,7 @@ class WeatherServiceTest {
""";
testActivity.setTrackPointsJson(trackPointsJson);
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
.thenReturn(SAMPLE_WEATHER_RESPONSE);
when(weatherDataRepository.save(any(WeatherData.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
@ -176,39 +155,13 @@ class WeatherServiceTest {
assertEquals(new BigDecimal("14.2"), weatherData.getFeelsLikeCelsius());
assertEquals(65, weatherData.getHumidity());
assertEquals(1013, weatherData.getPressure());
assertEquals(new BigDecimal("3.5"), weatherData.getWindSpeedMps());
assertEquals(new BigDecimal("3.50"), weatherData.getWindSpeedMps());
assertEquals("clear sky", weatherData.getWeatherDescription());
verify(restTemplate, times(1)).getForObject(any(URI.class), eq(String.class));
verify(restTemplate, times(1)).getForObject(anyString(), eq(String.class), any(Map.class));
verify(weatherDataRepository, times(1)).save(any(WeatherData.class));
}
@Test
@DisplayName("Should return empty when weather is disabled in config")
void testFetchWeather_Disabled() {
ReflectionTestUtils.setField(weatherService, "weatherEnabled", false);
testActivity.setTrackPointsJson("[{\"lat\":50.0,\"lon\":8.0}]");
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
assertTrue(result.isEmpty());
verify(weatherDataRepository, never()).save(any(WeatherData.class));
verify(restTemplate, never()).getForObject(any(URI.class), eq(String.class));
}
@Test
@DisplayName("Should return empty when API key is not configured")
void testFetchWeather_NoApiKey() {
ReflectionTestUtils.setField(weatherService, "apiKey", "");
testActivity.setTrackPointsJson("[{\"lat\":50.0,\"lon\":8.0}]");
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
assertTrue(result.isEmpty());
verify(weatherDataRepository, never()).save(any(WeatherData.class));
verify(restTemplate, never()).getForObject(any(URI.class), eq(String.class));
}
@Test
@DisplayName("Should return empty when track points JSON is null")
void testFetchWeather_NoTrackPoints() {
@ -260,20 +213,7 @@ class WeatherServiceTest {
assertTrue(result.isEmpty());
verify(weatherDataRepository, never()).save(any(WeatherData.class));
verify(restTemplate, never()).getForObject(any(URI.class), eq(String.class));
}
@Test
@DisplayName("Should return empty for old activities (>5 days)")
void testFetchWeather_OldActivity() {
testActivity.setStartedAt(LocalDateTime.now().minusDays(10)); // Old activity
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
assertTrue(result.isEmpty());
verify(restTemplate, never()).getForObject(any(URI.class), eq(String.class));
verify(weatherDataRepository, never()).save(any(WeatherData.class));
verify(restTemplate, never()).getForObject(anyString(), eq(String.class), any(Map.class));
}
@Test
@ -281,11 +221,11 @@ class WeatherServiceTest {
void testFetchWeather_AuthenticationError() {
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
.thenThrow(new HttpClientErrorException(
org.springframework.http.HttpStatus.UNAUTHORIZED,
"Unauthorized",
"{\"cod\":401,\"message\":\"Invalid API key\"}".getBytes(),
"{\"error\":true,\"reason\":\"Unauthorized\"}".getBytes(),
null
));
@ -300,7 +240,7 @@ class WeatherServiceTest {
void testFetchWeather_NetworkError() {
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
.thenThrow(new ResourceAccessException("Connection timeout"));
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
@ -314,7 +254,7 @@ class WeatherServiceTest {
void testFetchWeather_MalformedResponse() {
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
.thenReturn("this is not valid JSON");
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
@ -328,24 +268,29 @@ class WeatherServiceTest {
void testParseWeatherResponse_AllFields() {
String responseWithRain = """
{
"main": {
"temp": 10.0,
"feels_like": 8.5,
"pressure": 1015,
"humidity": 80
},
"weather": [{"main": "Rain", "description": "light rain", "icon": "10d"}],
"wind": {"speed": 5.5, "deg": 270},
"clouds": {"all": 75},
"visibility": 8000,
"rain": {"1h": 2.5},
"sys": {"sunrise": 1700721600, "sunset": 1700757600}
"latitude": 50.0,
"longitude": 8.0,
"hourly": {
"time": ["2025-11-23T17:00", "2025-11-23T18:00", "2025-11-23T19:00"],
"temperature_2m": [11.0, 10.0, 9.5],
"apparent_temperature": [9.0, 8.5, 8.0],
"relative_humidity_2m": [75, 80, 85],
"surface_pressure": [1014, 1015, 1015],
"wind_speed_10m": [18.0, 19.8, 20.0],
"wind_direction_10m": [260, 270, 275],
"cloud_cover": [70, 75, 80],
"rain": [1.5, 2.5, 3.0],
"snowfall": [0.0, 0.0, 0.0],
"precipitation": [1.5, 2.5, 3.0],
"visibility": [9000, 8000, 7000],
"weather_code": [61, 61, 63]
}
}
""";
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
.thenReturn(responseWithRain);
when(weatherDataRepository.save(any(WeatherData.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
@ -357,15 +302,13 @@ class WeatherServiceTest {
assertEquals(new BigDecimal("10.0"), weatherData.getTemperatureCelsius());
assertEquals(new BigDecimal("8.5"), weatherData.getFeelsLikeCelsius());
assertEquals("Rain", weatherData.getWeatherCondition());
assertEquals("light rain", weatherData.getWeatherDescription());
assertEquals(new BigDecimal("5.5"), weatherData.getWindSpeedMps());
assertEquals("slight rain", weatherData.getWeatherDescription());
assertEquals(new BigDecimal("5.50"), weatherData.getWindSpeedMps());
assertEquals(270, weatherData.getWindDirection());
assertEquals(75, weatherData.getCloudiness());
assertEquals(8000, weatherData.getVisibilityMeters());
assertEquals(new BigDecimal("2.5"), weatherData.getPrecipitationMm());
assertNotNull(weatherData.getSunrise());
assertNotNull(weatherData.getSunset());
assertEquals("openweathermap", weatherData.getDataSource());
assertEquals("open-meteo", weatherData.getDataSource());
assertNotNull(weatherData.getFetchedAt());
}
@ -374,19 +317,29 @@ class WeatherServiceTest {
void testParseWeatherResponse_MinimalFields() {
String minimalResponse = """
{
"main": {
"temp": 15.0,
"feels_like": 14.0,
"pressure": 1010,
"humidity": 60
},
"weather": [{"main": "Clouds", "description": "few clouds", "icon": "02d"}]
"latitude": 50.0,
"longitude": 8.0,
"hourly": {
"time": ["2025-11-23T18:00"],
"temperature_2m": [15.0],
"apparent_temperature": [14.0],
"relative_humidity_2m": [60],
"surface_pressure": [1010],
"wind_speed_10m": [null],
"wind_direction_10m": [null],
"cloud_cover": [null],
"rain": [null],
"snowfall": [null],
"precipitation": [null],
"visibility": [null],
"weather_code": [2]
}
}
""";
testActivity.setTrackPointsJson("[{\"latitude\":50.0,\"longitude\":8.0}]");
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
.thenReturn(minimalResponse);
when(weatherDataRepository.save(any(WeatherData.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
@ -441,7 +394,7 @@ class WeatherServiceTest {
""";
testActivity.setTrackPointsJson(trackPointsJson);
when(restTemplate.getForObject(any(URI.class), eq(String.class)))
when(restTemplate.getForObject(anyString(), eq(String.class), any(Map.class)))
.thenReturn(SAMPLE_WEATHER_RESPONSE);
when(weatherDataRepository.save(any(WeatherData.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
@ -449,6 +402,6 @@ class WeatherServiceTest {
Optional<WeatherData> result = weatherService.fetchWeatherForActivity(testActivity);
assertTrue(result.isPresent());
verify(restTemplate, times(1)).getForObject(any(URI.class), eq(String.class));
verify(restTemplate, times(1)).getForObject(anyString(), eq(String.class), any(Map.class));
}
}