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

@ -1,6 +1,5 @@
package net.javahippie.fitpub.service; package net.javahippie.fitpub.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -9,23 +8,22 @@ import net.javahippie.fitpub.model.entity.Activity;
import net.javahippie.fitpub.model.entity.TrackPoint; import net.javahippie.fitpub.model.entity.TrackPoint;
import net.javahippie.fitpub.model.entity.WeatherData; import net.javahippie.fitpub.model.entity.WeatherData;
import net.javahippie.fitpub.repository.WeatherDataRepository; import net.javahippie.fitpub.repository.WeatherDataRepository;
import org.locationtech.jts.geom.Point;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.net.URI; import java.math.RoundingMode;
import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId; import java.time.format.DateTimeFormatter;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
/** /**
* Service for fetching and managing weather data for activities. * Service for fetching and managing weather data for activities.
* Uses OpenWeatherMap API to retrieve historical weather data. * Uses Open-Meteo archive API to retrieve historical weather data.
*/ */
@Service @Service
@Slf4j @Slf4j
@ -36,14 +34,10 @@ public class WeatherService {
private final RestTemplate restTemplate = new RestTemplate(); private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
@Value("${fitpub.weather.api-key:}")
private String apiKey;
@Value("${fitpub.weather.enabled:false}") @Value("${fitpub.weather.enabled:false}")
private boolean weatherEnabled; private boolean weatherEnabled;
private static final String OPENWEATHERMAP_API_URL = "https://api.openweathermap.org/data/2.5/weather"; private static final String OPEN_METEO_API_URL = "https://archive-api.open-meteo.com/v1/archive?latitude={latitude}&longitude={longitude}&start_date={start_date}&end_date={end_date}&hourly={hourly}";
private static final String OPENWEATHERMAP_TIMEMACHINE_URL = "https://api.openweathermap.org/data/3.0/onecall/timemachine";
/** /**
* Fetch and store weather data for an activity. * Fetch and store weather data for an activity.
@ -55,24 +49,6 @@ public class WeatherService {
@Transactional @Transactional
public Optional<WeatherData> fetchWeatherForActivity(Activity activity) { public Optional<WeatherData> fetchWeatherForActivity(Activity activity) {
log.info("=== Weather fetch requested for activity {} ===", activity.getId()); log.info("=== Weather fetch requested for activity {} ===", activity.getId());
log.info("Weather configuration: enabled={}, API key configured={}, API key length={}",
weatherEnabled, (apiKey != null && !apiKey.isBlank()),
(apiKey != null ? apiKey.length() : 0));
if (!weatherEnabled) {
log.warn("Weather fetching is DISABLED in configuration (fitpub.weather.enabled=false). " +
"Set fitpub.weather.enabled=true in application properties to enable.");
return Optional.empty();
}
if (apiKey == null || apiKey.isBlank()) {
log.error("Weather API key is NOT CONFIGURED (fitpub.weather.api-key is empty). " +
"Please set fitpub.weather.api-key in application properties.");
return Optional.empty();
}
log.debug("Weather API key present: length={} chars, first 4 chars={}...",
apiKey.length(), apiKey.length() > 4 ? apiKey.substring(0, 4) : "???");
if (activity.getTrackPointsJson() == null || activity.getTrackPointsJson().isEmpty()) { if (activity.getTrackPointsJson() == null || activity.getTrackPointsJson().isEmpty()) {
log.warn("No track points available for activity {} - cannot fetch weather", activity.getId()); log.warn("No track points available for activity {} - cannot fetch weather", activity.getId());
@ -88,21 +64,7 @@ public class WeatherService {
return Optional.empty(); return Optional.empty();
} else { } else {
var resolvedTrackPoint = trackPoint.get(); var resolvedTrackPoint = trackPoint.get();
// Check if activity is recent (within 5 days) because it's free to use. Don't call other timeframes, expensive. WeatherData weatherData = fetchCurrentWeather(resolvedTrackPoint.lat(), resolvedTrackPoint.lon(), activity.getId(), activity.getStartedAt());
long activityTimestamp = activity.getStartedAt().atZone(ZoneId.systemDefault()).toEpochSecond();
long currentTimestamp = Instant.now().getEpochSecond();
long daysDifference = (currentTimestamp - activityTimestamp) / 86400;
log.info("Activity started at: {}, days ago: {}", activity.getStartedAt(), daysDifference);
WeatherData weatherData;
if (daysDifference <= 5) {
log.info("Activity is RECENT ({} days old, within 5 day threshold), fetching current weather from OpenWeatherMap", daysDifference);
weatherData = fetchCurrentWeather(resolvedTrackPoint.lat(), resolvedTrackPoint.lon(), activity.getId());
} else {
log.warn("Activity is {} days old (exceeds 5 day threshold). Historical weather data requires OpenWeatherMap paid API plan. Skipping weather fetch.", daysDifference);
return Optional.empty();
}
if (weatherData != null) { if (weatherData != null) {
log.info("Successfully fetched and parsed weather data. Attempting to save to database..."); log.info("Successfully fetched and parsed weather data. Attempting to save to database...");
@ -128,221 +90,218 @@ public class WeatherService {
} }
/** /**
* Fetch current weather data from OpenWeatherMap. * Fetch current weather data from Open-Meteo archive API.
*/ */
private WeatherData fetchCurrentWeather(double lat, double lon, UUID activityId) { private WeatherData fetchCurrentWeather(double lat, double lon, UUID activityId, LocalDateTime startedAt) {
log.info("=== fetchCurrentWeather START === activityId={}, lat={}, lon={}", activityId, lat, lon); log.info("=== fetchCurrentWeather START === activityId={}, lat={}, lon={}", activityId, lat, lon);
try { try {
String url = String.format("%s?lat=%f&lon=%f&appid=%s&units=metric",
OPENWEATHERMAP_API_URL, lat, lon, apiKey);
String maskedUrl = url.replace(apiKey, "***API_KEY***"); Map<String, Object> uriVariables = Map.of(
log.info("Constructed OpenWeatherMap API URL: {}", maskedUrl); "latitude", lat,
log.info("Request parameters: lat={}, lon={}, units=metric", lat, lon); "longitude", lon,
"start_date", startedAt.format(DateTimeFormatter.ISO_DATE),
"end_date", startedAt.format(DateTimeFormatter.ISO_DATE),
"hourly", "temperature_2m,apparent_temperature,relative_humidity_2m,surface_pressure,wind_speed_10m,wind_direction_10m,cloud_cover,rain,snowfall,precipitation,visibility,weather_code"
);
log.info("Request parameters: lat={}, lon={}, date={}", lat, lon, startedAt);
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
log.info("Sending HTTP GET request to OpenWeatherMap..."); log.info("Sending HTTP GET request to Open-Meteo...");
String response = restTemplate.getForObject(URI.create(url), String.class); String response = restTemplate.getForObject(OPEN_METEO_API_URL, String.class, uriVariables);
long duration = System.currentTimeMillis() - startTime; long duration = System.currentTimeMillis() - startTime;
log.info("HTTP request completed in {}ms, response received", duration); log.info("HTTP request completed in {}ms, response received", duration);
if (response == null) { if (response == null) {
log.error("API response is NULL - RestTemplate returned null, no data from OpenWeatherMap"); log.error("API response is NULL - RestTemplate returned null, no data from Open-Meteo");
return null; return null;
} }
log.info("API response received: {} characters", response.length());
log.info("API response (first 300 chars): {}", log.info("API response (first 300 chars): {}",
response.length() > 300 ? response.substring(0, 300) + "..." : response); response.length() > 300 ? response.substring(0, 300) + "..." : response);
log.info("Parsing weather response JSON..."); log.info("Parsing weather response JSON...");
WeatherData weatherData = parseWeatherResponse(response, activityId); WeatherData weatherData = parseWeatherResponse(response, activityId, startedAt);
if (weatherData == null) { if (weatherData == null) {
log.error("FAILED to parse weather response - see parsing errors above"); log.error("FAILED to parse weather response - see parsing errors above");
} else { } else {
log.info("Successfully parsed weather data: temp={}°C, feels_like={}°C, condition='{}', description='{}', humidity={}%, pressure={} hPa, wind={} m/s", log.info("Successfully parsed weather data: temp={}°C, condition='{}', wind={} m/s, precipitation={} mm",
weatherData.getTemperatureCelsius(), weatherData.getTemperatureCelsius(),
weatherData.getFeelsLikeCelsius(),
weatherData.getWeatherCondition(), weatherData.getWeatherCondition(),
weatherData.getWeatherDescription(), weatherData.getWindSpeedMps(),
weatherData.getHumidity(), weatherData.getPrecipitationMm());
weatherData.getPressure(),
weatherData.getWindSpeedMps());
} }
log.info("=== fetchCurrentWeather END === success={}", (weatherData != null)); log.info("=== fetchCurrentWeather END === success={}", (weatherData != null));
return weatherData; return weatherData;
} catch (org.springframework.web.client.HttpClientErrorException e) { } catch (org.springframework.web.client.HttpClientErrorException e) {
log.error("=== HTTP CLIENT ERROR (4xx) from OpenWeatherMap API ==="); log.error("HTTP client error (4xx) from Open-Meteo API: {} - {}", e.getStatusCode(), e.getResponseBodyAsString(), e);
log.error("Status Code: {}", e.getStatusCode());
log.error("Status Text: {}", e.getStatusText());
log.error("Response Body: {}", e.getResponseBodyAsString());
log.error("Request URL (masked): {}", OPENWEATHERMAP_API_URL + "?lat=" + lat + "&lon=" + lon + "&appid=***&units=metric");
if (e.getStatusCode().value() == 401) {
log.error("AUTHENTICATION FAILED - Check your OpenWeatherMap API key is valid and active");
} else if (e.getStatusCode().value() == 404) {
log.error("API ENDPOINT NOT FOUND - Check coordinates are valid: lat={}, lon={}", lat, lon);
} else if (e.getStatusCode().value() == 429) {
log.error("RATE LIMIT EXCEEDED - Too many API requests. Check your OpenWeatherMap plan limits.");
}
log.error("Exception details:", e);
return null; return null;
} catch (org.springframework.web.client.HttpServerErrorException e) { } catch (org.springframework.web.client.HttpServerErrorException e) {
log.error("=== HTTP SERVER ERROR (5xx) from OpenWeatherMap API ==="); log.error("HTTP server error (5xx) from Open-Meteo API: {} - {}", e.getStatusCode(), e.getResponseBodyAsString(), e);
log.error("Status Code: {}", e.getStatusCode());
log.error("Status Text: {}", e.getStatusText());
log.error("Response Body: {}", e.getResponseBodyAsString());
log.error("OpenWeatherMap service may be experiencing issues. Try again later.");
log.error("Exception details:", e);
return null; return null;
} catch (org.springframework.web.client.ResourceAccessException e) { } catch (org.springframework.web.client.ResourceAccessException e) {
log.error("=== NETWORK/CONNECTION ERROR accessing OpenWeatherMap API ==="); log.error("Network error accessing Open-Meteo API: {}", e.getMessage(), e);
log.error("Error message: {}", e.getMessage());
log.error("This could indicate: DNS resolution failure, network connectivity issues, firewall blocking, or SSL certificate problems");
log.error("API URL attempted: {}", OPENWEATHERMAP_API_URL);
log.error("Exception details:", e);
return null;
} catch (org.springframework.web.client.RestClientException e) {
log.error("=== REST CLIENT EXCEPTION calling OpenWeatherMap API ===");
log.error("Exception type: {}", e.getClass().getName());
log.error("Error message: {}", e.getMessage());
log.error("Exception details:", e);
return null; return null;
} catch (Exception e) { } catch (Exception e) {
log.error("=== UNEXPECTED EXCEPTION fetching current weather ==="); log.error("Unexpected exception fetching weather for activity {}: {}", activityId, e.getMessage(), e);
log.error("Exception type: {}", e.getClass().getName());
log.error("Error message: {}", e.getMessage());
log.error("Activity ID: {}", activityId);
log.error("Coordinates: lat={}, lon={}", lat, lon);
log.error("Full stack trace:", e);
return null; return null;
} }
} }
/** /**
* Parse OpenWeatherMap API response and create WeatherData entity. * Parse Open-Meteo archive API response and create WeatherData entity.
* Extracts the hourly data point matching the activity's start hour.
*/ */
private WeatherData parseWeatherResponse(String response, UUID activityId) { private WeatherData parseWeatherResponse(String response, UUID activityId, LocalDateTime startedAt) {
log.debug("=== parseWeatherResponse START === activityId={}", activityId); log.debug("=== parseWeatherResponse START === activityId={}", activityId);
try { try {
JsonNode root = objectMapper.readTree(response); JsonNode root = objectMapper.readTree(response);
log.debug("JSON parsed successfully, root node present: {}", root != null);
JsonNode hourly = root.get("hourly");
if (hourly == null) {
log.warn("Response JSON does not contain 'hourly' section");
return null;
}
// Find the index matching the activity start hour
JsonNode times = hourly.get("time");
String targetHour = startedAt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:00"));
int hourIndex = -1;
for (int i = 0; i < times.size(); i++) {
if (times.get(i).asText().equals(targetHour)) {
hourIndex = i;
break;
}
}
if (hourIndex == -1) {
log.warn("No matching hour found for {} in response", targetHour);
return null;
}
log.debug("Matched hour index {} for {}", hourIndex, targetHour);
WeatherData weatherData = new WeatherData(); WeatherData weatherData = new WeatherData();
weatherData.setActivityId(activityId); weatherData.setActivityId(activityId);
weatherData.setTemperatureCelsius(getHourlyBigDecimal(hourly, "temperature_2m", hourIndex));
weatherData.setFeelsLikeCelsius(getHourlyBigDecimal(hourly, "apparent_temperature", hourIndex));
weatherData.setHumidity(getHourlyInteger(hourly, "relative_humidity_2m", hourIndex));
weatherData.setPressure(getHourlyInteger(hourly, "surface_pressure", hourIndex));
weatherData.setWindDirection(getHourlyInteger(hourly, "wind_direction_10m", hourIndex));
weatherData.setCloudiness(getHourlyInteger(hourly, "cloud_cover", hourIndex));
weatherData.setVisibilityMeters(getHourlyInteger(hourly, "visibility", hourIndex));
weatherData.setPrecipitationMm(getHourlyBigDecimal(hourly, "precipitation", hourIndex));
weatherData.setWeatherCondition(mapWmoCodeToCondition(getHourlyInteger(hourly, "weather_code", hourIndex)));
weatherData.setWeatherDescription(mapWmoCodeToDescription(getHourlyInteger(hourly, "weather_code", hourIndex)));
// Main temperature data // Open-Meteo returns wind speed in km/h, convert to m/s
if (root.has("main")) { BigDecimal windKmh = getHourlyBigDecimal(hourly, "wind_speed_10m", hourIndex);
JsonNode main = root.get("main"); if (windKmh != null) {
log.debug("Parsing 'main' section: {}", main); weatherData.setWindSpeedMps(windKmh.divide(BigDecimal.valueOf(3.6), 2, RoundingMode.HALF_UP));
weatherData.setTemperatureCelsius(getBigDecimal(main, "temp"));
weatherData.setFeelsLikeCelsius(getBigDecimal(main, "feels_like"));
weatherData.setHumidity(getInteger(main, "humidity"));
weatherData.setPressure(getInteger(main, "pressure"));
log.debug("Extracted main data: temp={}, feels_like={}, humidity={}, pressure={}",
weatherData.getTemperatureCelsius(), weatherData.getFeelsLikeCelsius(),
weatherData.getHumidity(), weatherData.getPressure());
} else {
log.warn("Response JSON does not contain 'main' section");
} }
// Wind data // Open-Meteo returns snowfall in cm, convert to mm
if (root.has("wind")) { BigDecimal snowCm = getHourlyBigDecimal(hourly, "snowfall", hourIndex);
JsonNode wind = root.get("wind"); if (snowCm != null) {
log.debug("Parsing 'wind' section: {}", wind); weatherData.setSnowMm(snowCm.multiply(BigDecimal.TEN));
weatherData.setWindSpeedMps(getBigDecimal(wind, "speed"));
weatherData.setWindDirection(getInteger(wind, "deg"));
log.debug("Extracted wind data: speed={} m/s, direction={} degrees",
weatherData.getWindSpeedMps(), weatherData.getWindDirection());
} else {
log.debug("Response JSON does not contain 'wind' section");
}
// Weather condition
if (root.has("weather") && root.get("weather").isArray() && !root.get("weather").isEmpty()) {
JsonNode weather = root.get("weather").get(0);
log.debug("Parsing 'weather' array (first element): {}", weather);
weatherData.setWeatherCondition(getString(weather, "main"));
weatherData.setWeatherDescription(getString(weather, "description"));
weatherData.setWeatherIcon(getString(weather, "icon"));
log.debug("Extracted weather condition: main='{}', description='{}', icon='{}'",
weatherData.getWeatherCondition(), weatherData.getWeatherDescription(),
weatherData.getWeatherIcon());
} else {
log.warn("Response JSON does not contain valid 'weather' array");
}
// Clouds
if (root.has("clouds")) {
weatherData.setCloudiness(getInteger(root.get("clouds"), "all"));
log.debug("Extracted cloudiness: {}%", weatherData.getCloudiness());
}
// Visibility
if (root.has("visibility")) {
weatherData.setVisibilityMeters(root.get("visibility").asInt());
log.debug("Extracted visibility: {} meters", weatherData.getVisibilityMeters());
}
// Rain
if (root.has("rain")) {
JsonNode rain = root.get("rain");
if (rain.has("1h")) {
weatherData.setPrecipitationMm(BigDecimal.valueOf(rain.get("1h").asDouble()));
log.debug("Extracted rain: {} mm/h", weatherData.getPrecipitationMm());
}
}
// Snow
if (root.has("snow")) {
JsonNode snow = root.get("snow");
if (snow.has("1h")) {
weatherData.setSnowMm(BigDecimal.valueOf(snow.get("1h").asDouble()));
log.debug("Extracted snow: {} mm/h", weatherData.getSnowMm());
}
}
// Sun times
if (root.has("sys")) {
JsonNode sys = root.get("sys");
if (sys.has("sunrise")) {
weatherData.setSunrise(LocalDateTime.ofInstant(
Instant.ofEpochSecond(sys.get("sunrise").asLong()), ZoneId.systemDefault()));
log.debug("Extracted sunrise: {}", weatherData.getSunrise());
}
if (sys.has("sunset")) {
weatherData.setSunset(LocalDateTime.ofInstant(
Instant.ofEpochSecond(sys.get("sunset").asLong()), ZoneId.systemDefault()));
log.debug("Extracted sunset: {}", weatherData.getSunset());
}
} }
weatherData.setFetchedAt(LocalDateTime.now()); weatherData.setFetchedAt(LocalDateTime.now());
weatherData.setDataSource("openweathermap"); weatherData.setDataSource("open-meteo");
log.info("Successfully parsed complete weather data"); log.info("Successfully parsed weather data: temp={}°C, condition='{}', wind={} m/s",
log.debug("=== parseWeatherResponse END === success=true"); weatherData.getTemperatureCelsius(), weatherData.getWeatherCondition(), weatherData.getWindSpeedMps());
return weatherData; return weatherData;
} catch (com.fasterxml.jackson.core.JsonProcessingException e) { } catch (com.fasterxml.jackson.core.JsonProcessingException e) {
log.error("=== JSON PARSING ERROR ==="); log.error("Failed to parse weather response as JSON: {}", e.getMessage(), e);
log.error("Failed to parse weather response as JSON");
log.error("Response content: {}", response);
log.error("Parse error: {}", e.getMessage(), e);
return null; return null;
} catch (Exception e) { } catch (Exception e) {
log.error("=== UNEXPECTED ERROR parsing weather response ==="); log.error("Unexpected error parsing weather response: {}", e.getMessage(), e);
log.error("Exception type: {}", e.getClass().getName());
log.error("Error message: {}", e.getMessage());
log.error("Response content: {}", response);
log.error("Full stack trace:", e);
return null; return null;
} }
} }
private BigDecimal getHourlyBigDecimal(JsonNode hourly, String field, int index) {
JsonNode array = hourly.get(field);
if (array != null && index < array.size() && !array.get(index).isNull()) {
return BigDecimal.valueOf(array.get(index).asDouble());
}
return null;
}
private Integer getHourlyInteger(JsonNode hourly, String field, int index) {
JsonNode array = hourly.get(field);
if (array != null && index < array.size() && !array.get(index).isNull()) {
return array.get(index).asInt();
}
return null;
}
/**
* Map WMO weather code to a condition string.
* See https://open-meteo.com/en/docs#weathervariables
*/
private String mapWmoCodeToCondition(Integer code) {
if (code == null) return null;
return switch (code) {
case 0 -> "Clear";
case 1, 2, 3 -> "Clouds";
case 45, 48 -> "Fog";
case 51, 53, 55 -> "Drizzle";
case 56, 57 -> "Drizzle";
case 61, 63, 65 -> "Rain";
case 66, 67 -> "Rain";
case 71, 73, 75, 77 -> "Snow";
case 80, 81, 82 -> "Rain";
case 85, 86 -> "Snow";
case 95, 96, 99 -> "Thunderstorm";
default -> "Unknown";
};
}
/**
* Map WMO weather code to a human-readable description.
*/
private String mapWmoCodeToDescription(Integer code) {
if (code == null) return null;
return switch (code) {
case 0 -> "clear sky";
case 1 -> "mainly clear";
case 2 -> "partly cloudy";
case 3 -> "overcast";
case 45 -> "fog";
case 48 -> "depositing rime fog";
case 51 -> "light drizzle";
case 53 -> "moderate drizzle";
case 55 -> "dense drizzle";
case 56 -> "light freezing drizzle";
case 57 -> "dense freezing drizzle";
case 61 -> "slight rain";
case 63 -> "moderate rain";
case 65 -> "heavy rain";
case 66 -> "light freezing rain";
case 67 -> "heavy freezing rain";
case 71 -> "slight snow fall";
case 73 -> "moderate snow fall";
case 75 -> "heavy snow fall";
case 77 -> "snow grains";
case 80 -> "slight rain showers";
case 81 -> "moderate rain showers";
case 82 -> "violent rain showers";
case 85 -> "slight snow showers";
case 86 -> "heavy snow showers";
case 95 -> "thunderstorm";
case 96 -> "thunderstorm with slight hail";
case 99 -> "thunderstorm with heavy hail";
default -> "unknown";
};
}
/** /**
* Get weather data for an activity. * Get weather data for an activity.
* *
@ -363,43 +322,4 @@ public class WeatherService {
weatherDataRepository.deleteByActivityId(activityId); weatherDataRepository.deleteByActivityId(activityId);
} }
// Helper methods to safely extract values from JSON
private BigDecimal getBigDecimal(JsonNode node, String field) {
if (node != null && node.has(field) && !node.get(field).isNull()) {
try {
return BigDecimal.valueOf(node.get(field).asDouble());
} catch (Exception e) {
log.warn("Failed to extract BigDecimal from field '{}': {}", field, e.getMessage());
return null;
}
}
log.debug("Field '{}' not found or is null in node", field);
return null;
}
private Integer getInteger(JsonNode node, String field) {
if (node != null && node.has(field) && !node.get(field).isNull()) {
try {
return node.get(field).asInt();
} catch (Exception e) {
log.warn("Failed to extract Integer from field '{}': {}", field, e.getMessage());
return null;
}
}
log.debug("Field '{}' not found or is null in node", field);
return null;
}
private String getString(JsonNode node, String field) {
if (node != null && node.has(field) && !node.get(field).isNull()) {
try {
return node.get(field).asText();
} catch (Exception e) {
log.warn("Failed to extract String from field '{}': {}", field, e.getMessage());
return null;
}
}
log.debug("Field '{}' not found or is null in node", field);
return null;
}
} }

View file

@ -2,6 +2,8 @@
# Activated with: mvn spring-boot:run -Dspring-boot.run.profiles=dev # Activated with: mvn spring-boot:run -Dspring-boot.run.profiles=dev
spring: spring:
main:
allow-bean-definition-overriding: true
datasource: datasource:
# For dev: Start PostgreSQL with: docker run -d --name fitpub-postgres -p 5432:5432 -e POSTGRES_DB=fitpub -e POSTGRES_USER=fitpub -e POSTGRES_PASSWORD=change_me_in_production postgis/postgis:16-3.4 # For dev: Start PostgreSQL with: docker run -d --name fitpub-postgres -p 5432:5432 -e POSTGRES_DB=fitpub -e POSTGRES_USER=fitpub -e POSTGRES_PASSWORD=change_me_in_production postgis/postgis:16-3.4
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/fitpub} url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/fitpub}

View file

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