coordinates = new java.util.ArrayList<>();
+ for (Object coordinateObj : coordinateList) {
+ Coordinate coordinate = parseCoordinate(coordinateObj);
+ if (coordinate == null) {
+ return null;
+ }
+ coordinates.add(coordinate);
+ }
+
+ if (coordinates.size() < 2) {
+ return null;
+ }
+
+ return GEOMETRY_FACTORY.createLineString(coordinates.toArray(new Coordinate[0]));
+ }
+
+ private Coordinate parseCoordinate(Object coordinateObj) {
+ if (!(coordinateObj instanceof java.util.List> coordinateValues) || coordinateValues.size() < 2) {
+ return null;
+ }
+
+ Double longitude = parseDouble(coordinateValues.get(0));
+ Double latitude = parseDouble(coordinateValues.get(1));
+ if (longitude == null || latitude == null) {
+ return null;
+ }
+
+ return new Coordinate(longitude, latitude);
+ }
+
+ private String firstNonBlank(String... values) {
+ for (String value : values) {
+ if (value != null && !value.isBlank()) {
+ return value;
+ }
+ }
+ return null;
+ }
+
/**
* Extract attachment URLs (map image, GeoJSON) from a Note object.
*/
@@ -766,6 +922,44 @@ public class InboxProcessor {
}
}
+ /**
+ * Parse ActivityPub published timestamps.
+ *
+ * Preferred input is a full ISO-8601 instant with timezone/offset. Some
+ * remote implementations still send zoneless timestamps, so we accept those
+ * as a compatibility fallback and interpret them as UTC.
+ */
+ private Instant parsePublishedAt(String publishedStr) {
+ if (publishedStr == null || publishedStr.isBlank()) {
+ return Instant.now();
+ }
+
+ try {
+ return Instant.parse(publishedStr);
+ } catch (DateTimeParseException ignored) {
+ // Fall through to compatibility parsers below.
+ }
+
+ try {
+ return OffsetDateTime.parse(publishedStr).toInstant();
+ } catch (DateTimeParseException ignored) {
+ // Fall through to compatibility parsers below.
+ }
+
+ try {
+ return ZonedDateTime.parse(publishedStr).toInstant();
+ } catch (DateTimeParseException ignored) {
+ // Fall through to compatibility parsers below.
+ }
+
+ try {
+ return LocalDateTime.parse(publishedStr).atOffset(ZoneOffset.UTC).toInstant();
+ } catch (DateTimeParseException e) {
+ log.warn("Failed to parse published timestamp: {}", publishedStr, e);
+ return Instant.now();
+ }
+ }
+
/**
* Serialize object to JSON string.
*/
diff --git a/src/main/java/net/javahippie/fitpub/service/TimelineResultMapper.java b/src/main/java/net/javahippie/fitpub/service/TimelineResultMapper.java
index 5fb0d8b..4146668 100644
--- a/src/main/java/net/javahippie/fitpub/service/TimelineResultMapper.java
+++ b/src/main/java/net/javahippie/fitpub/service/TimelineResultMapper.java
@@ -98,7 +98,7 @@ public class TimelineResultMapper {
.commentsCount(commentsCount)
.likedByCurrentUser(likedByCurrentUser)
.hasGpsTrack(true) // Will be refined based on actual data
- .activityLocation(activityLocation)
+ .activityLocation(activityLocation != null ? activityLocation : "")
.build();
} catch (Exception e) {
diff --git a/src/main/java/net/javahippie/fitpub/service/WeatherService.java b/src/main/java/net/javahippie/fitpub/service/WeatherService.java
index 8c3a65c..800f76b 100644
--- a/src/main/java/net/javahippie/fitpub/service/WeatherService.java
+++ b/src/main/java/net/javahippie/fitpub/service/WeatherService.java
@@ -1,6 +1,5 @@
package net.javahippie.fitpub.service;
-import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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.WeatherData;
import net.javahippie.fitpub.repository.WeatherDataRepository;
-import org.locationtech.jts.geom.Point;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import java.math.BigDecimal;
-import java.net.URI;
-import java.time.Instant;
+import java.math.RoundingMode;
import java.time.LocalDateTime;
-import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Map;
import java.util.Optional;
import java.util.UUID;
/**
* 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
@Slf4j
@@ -36,14 +34,10 @@ public class WeatherService {
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
- @Value("${fitpub.weather.api-key:}")
- private String apiKey;
-
@Value("${fitpub.weather.enabled:false}")
private boolean weatherEnabled;
- private static final String OPENWEATHERMAP_API_URL = "https://api.openweathermap.org/data/2.5/weather";
- private static final String OPENWEATHERMAP_TIMEMACHINE_URL = "https://api.openweathermap.org/data/3.0/onecall/timemachine";
+ 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}";
/**
* Fetch and store weather data for an activity.
@@ -55,24 +49,6 @@ public class WeatherService {
@Transactional
public Optional fetchWeatherForActivity(Activity activity) {
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()) {
log.warn("No track points available for activity {} - cannot fetch weather", activity.getId());
@@ -88,21 +64,7 @@ public class WeatherService {
return Optional.empty();
} else {
var resolvedTrackPoint = trackPoint.get();
- // Check if activity is recent (within 5 days) because it's free to use. Don't call other timeframes, expensive.
- 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();
- }
+ WeatherData weatherData = fetchCurrentWeather(resolvedTrackPoint.lat(), resolvedTrackPoint.lon(), activity.getId(), activity.getStartedAt());
if (weatherData != null) {
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);
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***");
- log.info("Constructed OpenWeatherMap API URL: {}", maskedUrl);
- log.info("Request parameters: lat={}, lon={}, units=metric", lat, lon);
+ Map uriVariables = Map.of(
+ "latitude", lat,
+ "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();
- log.info("Sending HTTP GET request to OpenWeatherMap...");
- String response = restTemplate.getForObject(URI.create(url), String.class);
+ log.info("Sending HTTP GET request to Open-Meteo...");
+ String response = restTemplate.getForObject(OPEN_METEO_API_URL, String.class, uriVariables);
long duration = System.currentTimeMillis() - startTime;
log.info("HTTP request completed in {}ms, response received", duration);
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;
}
- log.info("API response received: {} characters", response.length());
log.info("API response (first 300 chars): {}",
response.length() > 300 ? response.substring(0, 300) + "..." : response);
log.info("Parsing weather response JSON...");
- WeatherData weatherData = parseWeatherResponse(response, activityId);
+ WeatherData weatherData = parseWeatherResponse(response, activityId, startedAt);
if (weatherData == null) {
log.error("FAILED to parse weather response - see parsing errors above");
} 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.getFeelsLikeCelsius(),
weatherData.getWeatherCondition(),
- weatherData.getWeatherDescription(),
- weatherData.getHumidity(),
- weatherData.getPressure(),
- weatherData.getWindSpeedMps());
+ weatherData.getWindSpeedMps(),
+ weatherData.getPrecipitationMm());
}
log.info("=== fetchCurrentWeather END === success={}", (weatherData != null));
return weatherData;
} catch (org.springframework.web.client.HttpClientErrorException e) {
- log.error("=== HTTP CLIENT ERROR (4xx) from OpenWeatherMap API ===");
- 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);
+ log.error("HTTP client error (4xx) from Open-Meteo API: {} - {}", e.getStatusCode(), e.getResponseBodyAsString(), e);
return null;
} catch (org.springframework.web.client.HttpServerErrorException e) {
- log.error("=== HTTP SERVER ERROR (5xx) from OpenWeatherMap API ===");
- 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);
+ log.error("HTTP server error (5xx) from Open-Meteo API: {} - {}", e.getStatusCode(), e.getResponseBodyAsString(), e);
return null;
} catch (org.springframework.web.client.ResourceAccessException e) {
- log.error("=== NETWORK/CONNECTION ERROR accessing OpenWeatherMap API ===");
- 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);
+ log.error("Network error accessing Open-Meteo API: {}", e.getMessage(), e);
return null;
} catch (Exception e) {
- log.error("=== UNEXPECTED EXCEPTION fetching current weather ===");
- 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);
+ log.error("Unexpected exception fetching weather for activity {}: {}", activityId, e.getMessage(), e);
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);
try {
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.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
- if (root.has("main")) {
- JsonNode main = root.get("main");
- log.debug("Parsing 'main' section: {}", main);
- 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");
+ // Open-Meteo returns wind speed in km/h, convert to m/s
+ BigDecimal windKmh = getHourlyBigDecimal(hourly, "wind_speed_10m", hourIndex);
+ if (windKmh != null) {
+ weatherData.setWindSpeedMps(windKmh.divide(BigDecimal.valueOf(3.6), 2, RoundingMode.HALF_UP));
}
- // Wind data
- if (root.has("wind")) {
- JsonNode wind = root.get("wind");
- log.debug("Parsing 'wind' section: {}", wind);
- 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());
- }
+ // Open-Meteo returns snowfall in cm, convert to mm
+ BigDecimal snowCm = getHourlyBigDecimal(hourly, "snowfall", hourIndex);
+ if (snowCm != null) {
+ weatherData.setSnowMm(snowCm.multiply(BigDecimal.TEN));
}
weatherData.setFetchedAt(LocalDateTime.now());
- weatherData.setDataSource("openweathermap");
+ weatherData.setDataSource("open-meteo");
- log.info("Successfully parsed complete weather data");
- log.debug("=== parseWeatherResponse END === success=true");
+ log.info("Successfully parsed weather data: temp={}°C, condition='{}', wind={} m/s",
+ weatherData.getTemperatureCelsius(), weatherData.getWeatherCondition(), weatherData.getWindSpeedMps());
return weatherData;
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
- log.error("=== JSON PARSING ERROR ===");
- log.error("Failed to parse weather response as JSON");
- log.error("Response content: {}", response);
- log.error("Parse error: {}", e.getMessage(), e);
+ log.error("Failed to parse weather response as JSON: {}", e.getMessage(), e);
return null;
} catch (Exception e) {
- log.error("=== UNEXPECTED ERROR parsing weather response ===");
- log.error("Exception type: {}", e.getClass().getName());
- log.error("Error message: {}", e.getMessage());
- log.error("Response content: {}", response);
- log.error("Full stack trace:", e);
+ log.error("Unexpected error parsing weather response: {}", e.getMessage(), e);
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.
*
@@ -363,43 +322,4 @@ public class WeatherService {
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;
- }
}
diff --git a/src/main/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilder.java b/src/main/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilder.java
new file mode 100644
index 0000000..dd8752d
--- /dev/null
+++ b/src/main/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilder.java
@@ -0,0 +1,86 @@
+package net.javahippie.fitpub.service;
+
+import lombok.RequiredArgsConstructor;
+import net.javahippie.fitpub.model.dto.ActivityDTO;
+import net.javahippie.fitpub.model.entity.Activity;
+import net.javahippie.fitpub.model.entity.ActivityMetrics;
+import net.javahippie.fitpub.model.entity.PrivacyZone;
+import org.springframework.stereotype.Service;
+
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Builds the proprietary workoutData payload for outbound ActivityPub Notes.
+ */
+@Service
+@RequiredArgsConstructor
+public class WorkoutDataPayloadBuilder {
+
+ private final PrivacyZoneService privacyZoneService;
+ private final TrackPrivacyFilter trackPrivacyFilter;
+
+ public Map build(Activity activity) {
+ Map workoutData = new HashMap<>();
+ workoutData.put("activityType", activity.getActivityType().name());
+
+ if (activity.getDescription() != null && !activity.getDescription().isBlank()) {
+ workoutData.put("description", activity.getDescription());
+ }
+ if (activity.getTotalDistance() != null) {
+ workoutData.put("distance", activity.getTotalDistance().longValue());
+ }
+ if (activity.getTotalDurationSeconds() != null) {
+ workoutData.put("duration", Duration.ofSeconds(activity.getTotalDurationSeconds()).toString());
+ }
+ if (activity.getElevationGain() != null) {
+ workoutData.put("elevationGain", activity.getElevationGain().intValue());
+ }
+
+ ActivityMetrics metrics = activity.getMetrics();
+ if (metrics != null) {
+ if (metrics.getAveragePaceSeconds() != null) {
+ workoutData.put("averagePace", Duration.ofSeconds(metrics.getAveragePaceSeconds()).toString());
+ }
+ if (metrics.getAverageHeartRate() != null) {
+ workoutData.put("averageHeartRate", metrics.getAverageHeartRate());
+ }
+ if (metrics.getAverageSpeed() != null) {
+ workoutData.put("averageSpeed", metrics.getAverageSpeed().doubleValue());
+ }
+ if (metrics.getMaxSpeed() != null) {
+ workoutData.put("maxSpeed", metrics.getMaxSpeed().doubleValue());
+ }
+ if (metrics.getCalories() != null) {
+ workoutData.put("calories", metrics.getCalories());
+ }
+ }
+
+ Map route = buildRoutePayload(activity);
+ if (route != null) {
+ workoutData.put("route", route);
+ }
+
+ return workoutData;
+ }
+
+ private Map buildRoutePayload(Activity activity) {
+ List privacyZones = privacyZoneService.getActivePrivacyZones(activity.getUserId());
+ ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(activity, null, privacyZones, trackPrivacyFilter);
+
+ if (dto.getSimplifiedTrack() == null) {
+ return null;
+ }
+
+ Map feature = new HashMap<>();
+ feature.put("type", "Feature");
+ feature.put("geometry", dto.getSimplifiedTrack());
+
+ Map featureCollection = new HashMap<>();
+ featureCollection.put("type", "FeatureCollection");
+ featureCollection.put("features", List.of(feature));
+ return featureCollection;
+ }
+}
diff --git a/src/main/java/net/javahippie/fitpub/util/ActivityFormatter.java b/src/main/java/net/javahippie/fitpub/util/ActivityFormatter.java
index 324f5a1..0b32b3d 100644
--- a/src/main/java/net/javahippie/fitpub/util/ActivityFormatter.java
+++ b/src/main/java/net/javahippie/fitpub/util/ActivityFormatter.java
@@ -1,13 +1,14 @@
package net.javahippie.fitpub.util;
+import lombok.extern.slf4j.Slf4j;
import net.javahippie.fitpub.model.entity.Activity;
-import java.time.LocalDateTime;
-import java.time.LocalTime;
+import java.time.*;
/**
* Utility class for formatting activity-related data for display.
*/
+@Slf4j
public class ActivityFormatter {
/**
@@ -47,21 +48,25 @@ public class ActivityFormatter {
* Generates a default activity title based on the time of day and activity type.
* Format: "[Time of Day] [Activity Type]" (e.g., "Morning Run", "Evening Ride")
*
- * @param startedAt the activity start time
+ * @param startedAt the activity start time
+ * @param timezone the timezone ID of the activity
* @param activityType the activity type
* @return generated title
*/
- public static String generateActivityTitle(LocalDateTime startedAt, Activity.ActivityType activityType) {
+ public static String generateActivityTitle(LocalDateTime startedAt, String timezone, Activity.ActivityType activityType) {
if (startedAt == null || activityType == null) {
return "Activity";
}
- String timeOfDay = getTimeOfDay(startedAt.toLocalTime());
+ LocalDateTime startedAtLocal = getUtcDateTimeInZone(startedAt, timezone);
+ String timeOfDay = getTimeOfDay(startedAtLocal.toLocalTime());
String formattedType = formatActivityType(activityType);
return timeOfDay + " " + formattedType;
}
+
+
/**
* Determines the time of day based on the hour.
*
@@ -81,4 +86,29 @@ public class ActivityFormatter {
return "Night";
}
}
+
+ /**
+ * Attempts to convert the given LocalDateTime (which is assumed to be UTC) into a LocalDateTime in the given
+ * timezone
+ *
+ * @param utcDateTime The original date and time (UTC)
+ * @param timezone A timezone ID
+ * @return The original date and time adjusted to the timezone, if the zone ID could be parsed. The original date
+ * and time otherwise
+ *
+ */
+ private static LocalDateTime getUtcDateTimeInZone(LocalDateTime utcDateTime, String timezone) {
+ if (timezone == null || timezone.isBlank()) {
+ return utcDateTime;
+ }
+
+ try {
+ return utcDateTime.atZone(ZoneOffset.UTC)
+ .withZoneSameInstant(ZoneId.of(timezone))
+ .toLocalDateTime();
+ } catch (DateTimeException e) {
+ log.warn("Invalid time zone ID: {}", timezone);
+ return utcDateTime;
+ }
+ }
}
diff --git a/src/main/java/net/javahippie/fitpub/util/ActivityPubContexts.java b/src/main/java/net/javahippie/fitpub/util/ActivityPubContexts.java
index 84581bd..ce424c6 100644
--- a/src/main/java/net/javahippie/fitpub/util/ActivityPubContexts.java
+++ b/src/main/java/net/javahippie/fitpub/util/ActivityPubContexts.java
@@ -35,7 +35,8 @@ public final class ActivityPubContexts {
/**
* Returns the extended JSON-LD {@code @context} value for outbound objects
- * that carry interaction-policy declarations. Shape:
+ * that carry both interaction-policy declarations and FitPub's proprietary
+ * {@code workoutData} extension fields. Shape:
*
*
* [
@@ -45,7 +46,20 @@ public final class ActivityPubContexts {
* "interactionPolicy": { "@id": "gts:interactionPolicy", "@type": "@id" },
* "canQuote": { "@id": "gts:canQuote", "@type": "@id" },
* "automaticApproval": { "@id": "gts:automaticApproval", "@type": "@id" },
- * "manualApproval": { "@id": "gts:manualApproval", "@type": "@id" }
+ * "manualApproval": { "@id": "gts:manualApproval", "@type": "@id" },
+ * "fitpub": "https://fitpub.social/ns#",
+ * "workoutData": "fitpub:workoutData",
+ * "activityType": "fitpub:activityType",
+ * "description": "fitpub:description",
+ * "distance": "fitpub:distance",
+ * "duration": "fitpub:duration",
+ * "elevationGain": "fitpub:elevationGain",
+ * "averagePace": "fitpub:averagePace",
+ * "averageHeartRate": "fitpub:averageHeartRate",
+ * "averageSpeed": "fitpub:averageSpeed",
+ * "maxSpeed": "fitpub:maxSpeed",
+ * "calories": "fitpub:calories",
+ * "route": "fitpub:route"
* }
* ]
*
@@ -56,6 +70,12 @@ public final class ActivityPubContexts {
* Mastodon source, "interaction_policies" extension), so a Mastodon
* receiver compacting our object with its own context will recognise the
* field names and apply the policy.
+ *
+ * The {@code fitpub:} prefix is FitPub's own extension namespace
+ * ({@code https://fitpub.social/ns#}). It declares the proprietary
+ * {@code workoutData} object and its structured activity fields so FitPub
+ * instances can exchange machine-readable workout metadata without
+ * overloading the standard ActivityStreams fields.
*/
public static List extendedContext() {
Map extensions = new LinkedHashMap<>();
@@ -64,6 +84,19 @@ public final class ActivityPubContexts {
extensions.put("canQuote", typedRef("gts:canQuote"));
extensions.put("automaticApproval", typedRef("gts:automaticApproval"));
extensions.put("manualApproval", typedRef("gts:manualApproval"));
+ extensions.put("fitpub", "https://fitpub.social/ns#");
+ extensions.put("workoutData", "fitpub:workoutData");
+ extensions.put("activityType", "fitpub:activityType");
+ extensions.put("description", "fitpub:description");
+ extensions.put("distance", "fitpub:distance");
+ extensions.put("duration", "fitpub:duration");
+ extensions.put("elevationGain", "fitpub:elevationGain");
+ extensions.put("averagePace", "fitpub:averagePace");
+ extensions.put("averageHeartRate", "fitpub:averageHeartRate");
+ extensions.put("averageSpeed", "fitpub:averageSpeed");
+ extensions.put("maxSpeed", "fitpub:maxSpeed");
+ extensions.put("calories", "fitpub:calories");
+ extensions.put("route", "fitpub:route");
return List.of(
"https://www.w3.org/ns/activitystreams",
extensions
diff --git a/src/main/java/net/javahippie/fitpub/util/GpxParser.java b/src/main/java/net/javahippie/fitpub/util/GpxParser.java
index 0900af8..f66c4d6 100644
--- a/src/main/java/net/javahippie/fitpub/util/GpxParser.java
+++ b/src/main/java/net/javahippie/fitpub/util/GpxParser.java
@@ -26,6 +26,8 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
+import static net.javahippie.fitpub.util.ParsedActivityData.MAX_TITLE_LENGTH;
+
/**
* Parser for GPX (GPS Exchange Format) files.
* Extracts GPS coordinates, activity metrics from track points.
@@ -80,8 +82,12 @@ public class GpxParser {
// Calculate duration
parsedData.setTotalDuration(Duration.between(firstPoint.getTimestamp(), lastPoint.getTimestamp()));
- // Extract activity type from metadata
- extractActivityType(doc, parsedData);
+ // Extract activity type and title from metadata
+ Optional track = getFirstTrack(doc);
+ if (track.isPresent()) {
+ extractActivityType(track.get(), parsedData);
+ extractActivityTitle(track.get(), parsedData);
+ }
// Determine timezone from first GPS coordinate
determineTimezone(parsedData);
@@ -111,6 +117,8 @@ public class GpxParser {
}
}
+
+
/**
* Extracts track points from GPX document.
*/
@@ -245,21 +253,40 @@ public class GpxParser {
}
}
- /**
- * Extracts activity type from GPX metadata.
+ /*
+ * Returns the first element from the GPS XML
*/
- private void extractActivityType(Document doc, ParsedActivityData parsedData) {
+ private Optional getFirstTrack(Document doc) {
NodeList tracks = doc.getElementsByTagName("trk");
if (tracks.getLength() == 0) {
tracks = doc.getElementsByTagNameNS("*", "trk");
}
- if (tracks.getLength() > 0) {
- Element track = (Element) tracks.item(0);
- String type = getElementText(track, "type");
- if (type != null) {
- parsedData.setActivityType(mapGpxTypeToActivityType(type));
+ return tracks.getLength() > 0 ? Optional.of((Element) tracks.item(0)) : Optional.empty();
+ }
+
+ /**
+ * Extracts activity type from GPX metadata.
+ */
+ private void extractActivityType(Element track, ParsedActivityData parsedData) {
+ String type = getElementText(track, "type");
+ if (type != null) {
+ parsedData.setActivityType(mapGpxTypeToActivityType(type));
+ }
+ }
+
+ /**
+ * Extracts activity title from GPX metadata.
+ */
+ private void extractActivityTitle(Element track, ParsedActivityData parsedData) {
+ String title = getElementText(track, "name");
+ if (title != null) {
+ String shortenedTitle = title;
+ if (title.length() > MAX_TITLE_LENGTH) {
+ log.debug("Activity title was shortened to {} characters: {}", MAX_TITLE_LENGTH, title);
+ shortenedTitle = title.substring(0, MAX_TITLE_LENGTH);
}
+ parsedData.setTitle(shortenedTitle);
}
}
diff --git a/src/main/java/net/javahippie/fitpub/util/ParsedActivityData.java b/src/main/java/net/javahippie/fitpub/util/ParsedActivityData.java
index 12ff347..d345f91 100644
--- a/src/main/java/net/javahippie/fitpub/util/ParsedActivityData.java
+++ b/src/main/java/net/javahippie/fitpub/util/ParsedActivityData.java
@@ -20,6 +20,9 @@ import java.util.List;
*/
@Data
public class ParsedActivityData {
+
+ static final int MAX_TITLE_LENGTH = 255;
+
private List trackPoints = new ArrayList<>();
private LocalDateTime startTime;
private LocalDateTime endTime;
@@ -30,6 +33,7 @@ public class ParsedActivityData {
private BigDecimal elevationGain;
private BigDecimal elevationLoss;
private Activity.ActivityType activityType = Activity.ActivityType.OTHER;
+ private String title;
private ActivityMetricsData metrics;
private String sourceFormat; // "FIT" or "GPX"
private Boolean indoor = false; // Indicates if this is an indoor activity
diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml
index 7da0d67..9a8d09b 100644
--- a/src/main/resources/application-dev.yml
+++ b/src/main/resources/application-dev.yml
@@ -2,6 +2,8 @@
# Activated with: mvn spring-boot:run -Dspring-boot.run.profiles=dev
spring:
+ main:
+ allow-bean-definition-overriding: true
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
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/fitpub}
diff --git a/src/main/resources/db/migration/V32__add_simplified_track_to_remote_activities.sql b/src/main/resources/db/migration/V32__add_simplified_track_to_remote_activities.sql
new file mode 100644
index 0000000..49e3b7e
--- /dev/null
+++ b/src/main/resources/db/migration/V32__add_simplified_track_to_remote_activities.sql
@@ -0,0 +1,9 @@
+ALTER TABLE remote_activities
+ ADD COLUMN simplified_track geometry(LineString, 4326);
+
+CREATE INDEX idx_remote_activity_simplified_track
+ ON remote_activities
+ USING gist (simplified_track);
+
+COMMENT ON COLUMN remote_activities.simplified_track IS
+ 'Simplified remote route geometry for local map rendering';
diff --git a/src/main/resources/static/css/fitpub.css b/src/main/resources/static/css/fitpub.css
index 40ce267..475edfe 100644
--- a/src/main/resources/static/css/fitpub.css
+++ b/src/main/resources/static/css/fitpub.css
@@ -92,6 +92,11 @@ p,
letter-spacing: normal;
}
+/* Preserve line-breaks */
+.preserve-linebreaks {
+ white-space: pre-line;
+}
+
/* Navigation */
.navbar {
background: linear-gradient(135deg, var(--dark-color) 0%, #2d0052 100%) !important;
diff --git a/src/main/resources/static/js/fitpub.js b/src/main/resources/static/js/fitpub.js
index f903d08..4e6da91 100644
--- a/src/main/resources/static/js/fitpub.js
+++ b/src/main/resources/static/js/fitpub.js
@@ -434,7 +434,7 @@ function formatDateTimeWithTimezone(timestamp, timezone, options = {}) {
// Parse the timestamp - backend sends LocalDateTime without 'Z'
// We need to interpret it in the specified timezone
- const date = new Date(timestamp);
+ const date = new Date(ensureUTC(timestamp));
// Default options for date/time display
const defaultOptions = {
@@ -473,6 +473,17 @@ function formatDateWithTimezone(timestamp, timezone) {
});
}
+/**
+ * Ensures that a timestamp will be interpreted as UTC by new Date()
+ * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date (Date time string format)
+ *
+ * @param {string} timestamp - ISO timestamp or LocalDateTime string
+ * @returns {string} The input string, but with a trailing 'Z'
+ */
+function ensureUTC(timestamp) {
+ return timestamp.endsWith('Z') ? timestamp : timestamp + 'Z';
+}
+
// Make functions available globally for inline scripts
window.FitPub = {
createActivityMap,
@@ -482,5 +493,6 @@ window.FitPub = {
formatDistance,
formatPace,
formatDateTimeWithTimezone,
- formatDateWithTimezone
+ formatDateWithTimezone,
+ ensureUTC
};
diff --git a/src/main/resources/static/js/timeline.js b/src/main/resources/static/js/timeline.js
index fae5434..4a8fdb5 100644
--- a/src/main/resources/static/js/timeline.js
+++ b/src/main/resources/static/js/timeline.js
@@ -209,7 +209,7 @@ const FitPubTimeline = {
@${this.escapeHtml(activity.username)}
${!activity.isLocal ? ' Remote ' : ''}
- • ${this.formatTimeAgo(activity.startedAt)} • ${activity.activityLocation}
+ • ${this.formatTimeAgo(activity.startedAt)} ${activity.activityLocation ? '•' : ''} ${activity.activityLocation}
@@ -727,7 +727,7 @@ const FitPubTimeline = {
* @returns {string} Time ago string
*/
formatTimeAgo: function(timestamp) {
- const date = new Date(timestamp);
+ const date = new Date(FitPub.ensureUTC(timestamp));
const now = new Date();
const secondsAgo = Math.floor((now - date) / 1000);
diff --git a/src/main/resources/templates/activities/detail.html b/src/main/resources/templates/activities/detail.html
index b548cf3..a85d14c 100644
--- a/src/main/resources/templates/activities/detail.html
+++ b/src/main/resources/templates/activities/detail.html
@@ -53,7 +53,7 @@
-
+
diff --git a/src/test/java/net/javahippie/fitpub/config/TestcontainersConfiguration.java b/src/test/java/net/javahippie/fitpub/config/TestcontainersConfiguration.java
index 3053571..4819da6 100644
--- a/src/test/java/net/javahippie/fitpub/config/TestcontainersConfiguration.java
+++ b/src/test/java/net/javahippie/fitpub/config/TestcontainersConfiguration.java
@@ -4,7 +4,6 @@ import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer;
-import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy;
import org.testcontainers.utility.DockerImageName;
/**
@@ -23,8 +22,6 @@ public class TestcontainersConfiguration {
)
.withDatabaseName("testdb")
.withUsername("test")
- .withPassword("test")
- .waitingFor(new HostPortWaitStrategy())
- .withReuse(true);
+ .withPassword("test");
}
}
diff --git a/src/test/java/net/javahippie/fitpub/controller/ActivityPubControllerTest.java b/src/test/java/net/javahippie/fitpub/controller/ActivityPubControllerTest.java
new file mode 100644
index 0000000..a0d9129
--- /dev/null
+++ b/src/test/java/net/javahippie/fitpub/controller/ActivityPubControllerTest.java
@@ -0,0 +1,165 @@
+package net.javahippie.fitpub.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import net.javahippie.fitpub.model.entity.Activity;
+import net.javahippie.fitpub.model.entity.User;
+import net.javahippie.fitpub.repository.ActivityRepository;
+import net.javahippie.fitpub.repository.FollowRepository;
+import net.javahippie.fitpub.repository.UserRepository;
+import net.javahippie.fitpub.security.HttpSignatureValidator;
+import net.javahippie.fitpub.service.ActivityImageService;
+import net.javahippie.fitpub.service.FederationService;
+import net.javahippie.fitpub.service.InboxProcessor;
+import net.javahippie.fitpub.service.WorkoutDataPayloadBuilder;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.io.File;
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("ActivityPubController Tests")
+class ActivityPubControllerTest {
+
+ @Mock
+ private UserRepository userRepository;
+
+ @Mock
+ private ActivityRepository activityRepository;
+
+ @Mock
+ private ActivityImageService activityImageService;
+
+ @Mock
+ private InboxProcessor inboxProcessor;
+
+ @Mock
+ private FollowRepository followRepository;
+
+ @Mock
+ private HttpSignatureValidator signatureValidator;
+
+ @Mock
+ private FederationService federationService;
+
+ @Mock
+ private ObjectMapper objectMapper;
+
+ @Mock
+ private WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
+
+ @InjectMocks
+ private ActivityPubController controller;
+
+ private UUID activityId;
+ private UUID userId;
+ private Activity activity;
+ private User user;
+ private LocalDateTime createdAt;
+
+ @BeforeEach
+ void setUp() {
+ activityId = UUID.randomUUID();
+ userId = UUID.randomUUID();
+ createdAt = LocalDateTime.of(2026, 5, 2, 9, 24, 50, 921_241_000);
+
+ ReflectionTestUtils.setField(controller, "baseUrl", "https://fitpub.example");
+
+ activity = Activity.builder()
+ .id(activityId)
+ .userId(userId)
+ .activityType(Activity.ActivityType.RUN)
+ .title("Lunch Run")
+ .description("Sunny run")
+ .visibility(Activity.Visibility.PUBLIC)
+ .totalDistance(BigDecimal.valueOf(5000))
+ .totalDurationSeconds(1800L)
+ .createdAt(createdAt)
+ .build();
+
+ user = User.builder()
+ .id(userId)
+ .username("JaneDoe")
+ .email("janedoe@example.com")
+ .publicKey("public-key")
+ .privateKey("private-key")
+ .build();
+ }
+
+ @Test
+ @DisplayName("Should serialize activity published timestamp with timezone")
+ void getActivity_ShouldSerializePublishedTimestampWithTimezone() {
+ when(activityRepository.findById(activityId)).thenReturn(Optional.of(activity));
+ when(userRepository.findById(userId)).thenReturn(Optional.of(user));
+ when(activityImageService.getActivityImageFile(activityId)).thenReturn(new File("/definitely/nonexistent-fitpub-test-image"));
+
+ ResponseEntity> response = controller.getActivity(activityId);
+
+ assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody().get("published"))
+ .isEqualTo(createdAt.atOffset(ZoneOffset.UTC).toInstant().toString());
+ }
+
+ @Test
+ @DisplayName("Should include workoutData and FitPub context terms in activity note")
+ void getActivity_ShouldIncludeWorkoutDataAndExtendedContext() {
+ when(activityRepository.findById(activityId)).thenReturn(Optional.of(activity));
+ when(userRepository.findById(userId)).thenReturn(Optional.of(user));
+ when(activityImageService.getActivityImageFile(activityId)).thenReturn(new File("/definitely/nonexistent-fitpub-test-image"));
+ when(workoutDataPayloadBuilder.build(activity)).thenReturn(Map.of(
+ "activityType", "RUN",
+ "description", "Sunny run",
+ "distance", 5000L,
+ "duration", "PT30M",
+ "averagePace", "PT6M",
+ "route", Map.of(
+ "type", "FeatureCollection",
+ "features", List.of()
+ )
+ ));
+
+ ResponseEntity> response = controller.getActivity(activityId);
+
+ assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody().get("workoutData")).isEqualTo(Map.of(
+ "activityType", "RUN",
+ "description", "Sunny run",
+ "distance", 5000L,
+ "duration", "PT30M",
+ "averagePace", "PT6M",
+ "route", Map.of(
+ "type", "FeatureCollection",
+ "features", List.of()
+ )
+ ));
+
+ @SuppressWarnings("unchecked")
+ List context = (List) response.getBody().get("@context");
+ assertThat(context).hasSize(2);
+
+ @SuppressWarnings("unchecked")
+ Map extensions = (Map) context.get(1);
+ assertThat(extensions)
+ .containsEntry("fitpub", "https://fitpub.social/ns#")
+ .containsEntry("workoutData", "fitpub:workoutData")
+ .containsEntry("route", "fitpub:route");
+ }
+}
diff --git a/src/test/java/net/javahippie/fitpub/integration/FederationFollowFlowIntegrationTest.java b/src/test/java/net/javahippie/fitpub/integration/FederationFollowFlowIntegrationTest.java
index 99e3411..b07d325 100644
--- a/src/test/java/net/javahippie/fitpub/integration/FederationFollowFlowIntegrationTest.java
+++ b/src/test/java/net/javahippie/fitpub/integration/FederationFollowFlowIntegrationTest.java
@@ -2,19 +2,25 @@ package net.javahippie.fitpub.integration;
import com.fasterxml.jackson.databind.ObjectMapper;
import net.javahippie.fitpub.config.TestcontainersConfiguration;
+import net.javahippie.fitpub.model.entity.Activity;
+import net.javahippie.fitpub.service.ActivityImageService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import net.javahippie.fitpub.model.entity.Follow;
import net.javahippie.fitpub.model.entity.RemoteActor;
+import net.javahippie.fitpub.model.entity.RemoteActivity;
import net.javahippie.fitpub.model.entity.User;
+import net.javahippie.fitpub.repository.ActivityRepository;
import net.javahippie.fitpub.repository.FollowRepository;
+import net.javahippie.fitpub.repository.RemoteActivityRepository;
import net.javahippie.fitpub.repository.RemoteActorRepository;
import net.javahippie.fitpub.repository.UserRepository;
import net.javahippie.fitpub.security.HttpSignatureValidator;
import net.javahippie.fitpub.security.JwtTokenProvider;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
@@ -26,15 +32,21 @@ import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.transaction.annotation.Transactional;
+import java.io.File;
+import java.math.BigDecimal;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
+import java.time.LocalDateTime;
import java.util.Base64;
+import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@@ -63,6 +75,12 @@ class FederationFollowFlowIntegrationTest {
@Autowired
private RemoteActorRepository remoteActorRepository;
+ @Autowired
+ private RemoteActivityRepository remoteActivityRepository;
+
+ @Autowired
+ private ActivityRepository activityRepository;
+
@Autowired
private PasswordEncoder passwordEncoder;
@@ -72,6 +90,9 @@ class FederationFollowFlowIntegrationTest {
@Autowired
private HttpSignatureValidator signatureValidator;
+ @MockBean
+ private ActivityImageService activityImageService;
+
@Value("${fitpub.base-url}")
private String baseUrl;
@@ -101,6 +122,22 @@ class FederationFollowFlowIntegrationTest {
authToken = jwtTokenProvider.createToken(testUser.getUsername());
}
+ private User createFederatedUser(String username, String email, String displayName) throws NoSuchAlgorithmException {
+ KeyPair keyPair = generateRsaKeyPair();
+ String publicKey = encodePublicKey(keyPair.getPublic().getEncoded());
+ String privateKey = encodePrivateKey(keyPair.getPrivate().getEncoded());
+
+ return userRepository.save(User.builder()
+ .username(username)
+ .email(email)
+ .passwordHash(passwordEncoder.encode("password123"))
+ .displayName(displayName)
+ .publicKey(publicKey)
+ .privateKey(privateKey)
+ .enabled(true)
+ .build());
+ }
+
private KeyPair generateRsaKeyPair() throws NoSuchAlgorithmException {
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(2048);
@@ -270,6 +307,111 @@ class FederationFollowFlowIntegrationTest {
assertThat(updatedFollow.getStatus()).isEqualTo(Follow.FollowStatus.ACCEPTED);
}
+ @Test
+ @DisplayName("Should import its own exported public activity through inbox")
+ void testActivityRoundtripThroughExportAndInbox() throws Exception {
+ User importingUser = testUser;
+ User exportingUser = createFederatedUser("janedoe", "janedoe@example.com", "Jane Doe");
+
+ Activity activity = activityRepository.save(Activity.builder()
+ .userId(exportingUser.getId())
+ .activityType(Activity.ActivityType.RUN)
+ .title("Lunch Run")
+ .description("Sunny run in the city")
+ .startedAt(LocalDateTime.of(2026, 5, 2, 12, 0))
+ .endedAt(LocalDateTime.of(2026, 5, 2, 12, 30))
+ .createdAt(LocalDateTime.of(2026, 5, 2, 12, 31, 45, 123_000_000))
+ .visibility(Activity.Visibility.PUBLIC)
+ .totalDistance(BigDecimal.valueOf(5000))
+ .totalDurationSeconds(1800L)
+ .elevationGain(BigDecimal.valueOf(100))
+ .sourceFileFormat("FIT")
+ .published(true)
+ .build());
+
+ String exportingActorUri = baseUrl + "/users/" + exportingUser.getUsername();
+ when(activityImageService.getActivityImageFile(activity.getId()))
+ .thenReturn(new File("/definitely/nonexistent-fitpub-roundtrip-image"));
+
+ remoteActorRepository.save(RemoteActor.builder()
+ .actorUri(exportingActorUri)
+ .username(exportingUser.getUsername())
+ .domain(java.net.URI.create(baseUrl).getHost())
+ .displayName(exportingUser.getDisplayName())
+ .inboxUrl(exportingActorUri + "/inbox")
+ .outboxUrl(exportingActorUri + "/outbox")
+ .publicKey(exportingUser.getPublicKey())
+ .publicKeyId(exportingActorUri + "#main-key")
+ .lastFetchedAt(Instant.now())
+ .build());
+
+ followRepository.save(Follow.builder()
+ .followerId(importingUser.getId())
+ .followingActorUri(exportingActorUri)
+ .status(Follow.FollowStatus.ACCEPTED)
+ .activityId(baseUrl + "/activities/follow/" + UUID.randomUUID())
+ .build());
+
+ MvcResult exportResult = mockMvc.perform(get("/activities/" + activity.getId())
+ .accept("application/activity+json"))
+ .andExpect(status().isOk())
+ .andReturn();
+
+ @SuppressWarnings("unchecked")
+ Map exportedNote = objectMapper.readValue(exportResult.getResponse().getContentAsByteArray(), Map.class);
+
+ Map createActivity = Map.of(
+ "@context", "https://www.w3.org/ns/activitystreams",
+ "type", "Create",
+ "id", baseUrl + "/activities/create/" + UUID.randomUUID(),
+ "actor", exportingActorUri,
+ "object", exportedNote
+ );
+
+ String privateKeyPem = exportingUser.getPrivateKey();
+ String inboxPath = "/users/" + importingUser.getUsername() + "/inbox";
+ String inboxUrl = "http://localhost" + inboxPath;
+ String body = objectMapper.writeValueAsString(createActivity);
+ HttpSignatureValidator.SignatureHeaders sigHeaders = signatureValidator.signRequest(
+ "POST", inboxUrl, body, privateKeyPem, exportingActorUri + "#main-key"
+ );
+
+ mockMvc.perform(post(inboxPath)
+ .contentType("application/activity+json")
+ .header("Host", sigHeaders.host)
+ .header("Date", sigHeaders.date)
+ .header("Digest", sigHeaders.digest)
+ .header("Signature", sigHeaders.signature)
+ .content(body))
+ .andExpect(status().isAccepted());
+
+ RemoteActivity imported = remoteActivityRepository.findByActivityUri((String) exportedNote.get("id"))
+ .orElseThrow();
+
+ @SuppressWarnings("unchecked")
+ Map workoutData = (Map) exportedNote.get("workoutData");
+
+ assertThat(imported.getActivityUri()).isEqualTo(exportedNote.get("id"));
+ assertThat(imported.getRemoteActorUri()).isEqualTo(exportingActorUri);
+ assertThat(imported.getTitle()).isEqualTo(exportedNote.getOrDefault("name",
+ exportedNote.getOrDefault("summary", "Untitled Activity")));
+ assertThat(imported.getDescription()).isEqualTo(workoutData.get("description"));
+ assertThat(imported.getPublishedAt()).isEqualTo(Instant.parse((String) exportedNote.get("published")));
+ assertThat(imported.getVisibility()).isEqualTo(RemoteActivity.Visibility.PUBLIC);
+ assertThat(imported.getActivityType()).isEqualTo(workoutData.get("activityType"));
+ assertThat(imported.getTotalDistance()).isEqualTo(5000L);
+ assertThat(imported.getTotalDurationSeconds()).isEqualTo(1800L);
+ assertThat(imported.getElevationGain()).isEqualTo(workoutData.get("elevationGain"));
+ assertThat(imported.getAveragePaceSeconds()).isNull();
+ assertThat(imported.getAverageHeartRate()).isNull();
+ assertThat(imported.getMaxSpeed()).isNull();
+ assertThat(imported.getAverageSpeed()).isNull();
+ assertThat(imported.getCalories()).isNull();
+ assertThat(imported.getMapImageUrl()).isNull();
+ assertThat(imported.getTrackGeojsonUrl()).isNull();
+ assertThat(imported.getSimplifiedTrack()).isNull();
+ }
+
@Test
@DisplayName("Should reject inbox POST without HTTP signature with 401")
void testInboxRejectsUnsignedRequest() throws Exception {
@@ -310,6 +452,23 @@ class FederationFollowFlowIntegrationTest {
.andExpect(status().isUnauthorized());
}
+ private String stripHtml(String html) {
+ if (html == null) {
+ return "";
+ }
+ return html
+ .replaceAll(" ", "\n")
+ .replaceAll("", "")
+ .replaceAll("
", "\n")
+ .replaceAll("<[^>]+>", "")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace(""", "\"")
+ .replace("'", "'")
+ .replace("&", "&")
+ .trim();
+ }
+
@Test
@DisplayName("Should process Undo Follow activity and remove follow relationship")
void testProcessUndoFollowActivity() throws Exception {
diff --git a/src/test/java/net/javahippie/fitpub/service/ActivityImageServiceTest.java b/src/test/java/net/javahippie/fitpub/service/ActivityImageServiceTest.java
index 687eb45..0343ab4 100644
--- a/src/test/java/net/javahippie/fitpub/service/ActivityImageServiceTest.java
+++ b/src/test/java/net/javahippie/fitpub/service/ActivityImageServiceTest.java
@@ -27,12 +27,16 @@ import static org.junit.jupiter.api.Assertions.*;
/**
* Manual test for ActivityImageService.
* These tests are disabled by default and should only be run manually.
+ *
+ * To run this test manually:
+ * mvn test -Dtest=ActivityImageServiceTest
*/
@SpringBootTest(properties = {
"fitpub.image.osm-tiles.enabled=true"
})
@ActiveProfiles("test")
@Import(TestcontainersConfiguration.class)
+@Disabled("Manual test - run explicitly when needed")
class ActivityImageServiceTest {
@Autowired
@@ -55,7 +59,6 @@ class ActivityImageServiceTest {
* mvn test -Dtest=ActivityImageServiceTest#testGenerateActivityImage_Manual
*/
@Test
- @Disabled("Manual test - run explicitly when needed")
@DisplayName("Generate activity image from test FIT file")
void testGenerateActivityImage_Manual() throws Exception {
// Load test FIT file
diff --git a/src/test/java/net/javahippie/fitpub/service/ActivityPostProcessingServiceTest.java b/src/test/java/net/javahippie/fitpub/service/ActivityPostProcessingServiceTest.java
index 08ef492..5507c23 100644
--- a/src/test/java/net/javahippie/fitpub/service/ActivityPostProcessingServiceTest.java
+++ b/src/test/java/net/javahippie/fitpub/service/ActivityPostProcessingServiceTest.java
@@ -1,25 +1,42 @@
package net.javahippie.fitpub.service;
+import net.javahippie.fitpub.model.entity.Activity;
+import net.javahippie.fitpub.model.entity.ActivityMetrics;
+import net.javahippie.fitpub.model.entity.User;
+import net.javahippie.fitpub.repository.ActivityRepository;
+import net.javahippie.fitpub.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.locationtech.jts.geom.Coordinate;
+import org.locationtech.jts.geom.GeometryFactory;
+import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
-import net.javahippie.fitpub.model.entity.Activity;
-import net.javahippie.fitpub.model.entity.User;
-import net.javahippie.fitpub.repository.ActivityRepository;
-import net.javahippie.fitpub.repository.UserRepository;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
import java.util.Optional;
import java.util.UUID;
+import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.*;
+import static org.mockito.Mockito.anyBoolean;
+import static org.mockito.Mockito.anyString;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.eq;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
/**
* Unit tests for ActivityPostProcessingService.
@@ -49,6 +66,9 @@ class ActivityPostProcessingServiceTest {
@Mock
private UserRepository userRepository;
+ @Mock
+ private WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
+
@InjectMocks
private ActivityPostProcessingService service;
@@ -56,11 +76,13 @@ class ActivityPostProcessingServiceTest {
private UUID userId;
private Activity testActivity;
private User testUser;
+ private LocalDateTime createdAt;
@BeforeEach
void setUp() {
activityId = UUID.randomUUID();
userId = UUID.randomUUID();
+ createdAt = LocalDateTime.of(2026, 5, 2, 9, 24, 50, 921_241_000);
// Set baseUrl via reflection (since it's @Value injected)
ReflectionTestUtils.setField(service, "baseUrl", "https://test.example");
@@ -76,9 +98,39 @@ class ActivityPostProcessingServiceTest {
.totalDistance(BigDecimal.valueOf(5000))
.totalDurationSeconds(1800L)
.elevationGain(BigDecimal.valueOf(100))
- .startedAt(LocalDateTime.now())
- .createdAt(LocalDateTime.now())
+ .startedAt(createdAt.minusMinutes(30))
+ .createdAt(createdAt)
+ .simplifiedTrack(new GeometryFactory().createLineString(new Coordinate[]{
+ new Coordinate(8.55, 47.37),
+ new Coordinate(8.56, 47.38)
+ }))
.build();
+ testActivity.setMetrics(ActivityMetrics.builder()
+ .averagePaceSeconds(321L)
+ .build());
+ Map workoutData = new HashMap<>();
+ workoutData.put("activityType", "RUN");
+ workoutData.put("description", "Morning jog");
+ workoutData.put("distance", 5000L);
+ workoutData.put("duration", "PT30M");
+ workoutData.put("averagePace", "PT5M21S");
+ workoutData.put("elevationGain", 100);
+ workoutData.put("route", Map.of(
+ "type", "FeatureCollection",
+ "features", List.of(
+ Map.of(
+ "type", "Feature",
+ "geometry", Map.of(
+ "type", "LineString",
+ "coordinates", List.of(
+ List.of(8.55, 47.37),
+ List.of(8.56, 47.38)
+ )
+ )
+ )
+ )
+ ));
+ lenient().when(workoutDataPayloadBuilder.build(testActivity)).thenReturn(workoutData);
// Create test user
testUser = User.builder()
@@ -232,6 +284,24 @@ class ActivityPostProcessingServiceTest {
verify(federationService).sendCreateActivity(anyString(), any(), eq(testUser), eq(false));
}
+ @Test
+ @DisplayName("Should serialize federation note published timestamp with timezone")
+ void testPublishToFederationAsync_PublishedTimestampIncludesTimezone() {
+ when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity));
+ when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
+ when(activityImageService.generateActivityImage(testActivity)).thenReturn(null);
+ doNothing().when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
+
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor> noteCaptor = ArgumentCaptor.forClass(java.util.Map.class);
+
+ service.publishToFederationAsync(activityId, userId);
+
+ verify(federationService).sendCreateActivity(anyString(), noteCaptor.capture(), eq(testUser), eq(true));
+ assertThat(noteCaptor.getValue().get("published"))
+ .isEqualTo(createdAt.atOffset(ZoneOffset.UTC).toInstant().toString());
+ }
+
@Test
@DisplayName("Should skip federation for PRIVATE activity")
void testPublishToFederationAsync_PrivateActivity() {
@@ -317,4 +387,47 @@ class ActivityPostProcessingServiceTest {
// Then: Verify federation was called (content formatting is tested indirectly)
verify(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
}
+
+ @Test
+ @DisplayName("Should include workoutData payload in federation note")
+ void testPublishToFederationAsync_IncludesWorkoutDataPayload() {
+ when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity));
+ when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
+ when(activityImageService.generateActivityImage(testActivity)).thenReturn(null);
+ doNothing().when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
+
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor> noteCaptor = ArgumentCaptor.forClass(Map.class);
+
+ service.publishToFederationAsync(activityId, userId);
+
+ verify(federationService).sendCreateActivity(anyString(), noteCaptor.capture(), eq(testUser), eq(true));
+
+ @SuppressWarnings("unchecked")
+ Map workoutData = (Map) noteCaptor.getValue().get("workoutData");
+ assertThat(workoutData)
+ .containsEntry("activityType", "RUN")
+ .containsEntry("description", "Morning jog")
+ .containsEntry("distance", 5000L)
+ .containsEntry("duration", "PT30M")
+ .containsEntry("averagePace", "PT5M21S")
+ .containsEntry("elevationGain", 100);
+
+ @SuppressWarnings("unchecked")
+ Map route = (Map) workoutData.get("route");
+ assertThat(route).containsEntry("type", "FeatureCollection");
+
+ @SuppressWarnings("unchecked")
+ List> features = (List>) route.get("features");
+ assertThat(features).hasSize(1);
+ assertThat(features.get(0)).containsEntry("type", "Feature");
+
+ @SuppressWarnings("unchecked")
+ Map geometry = (Map) features.get(0).get("geometry");
+ assertThat(geometry).containsEntry("type", "LineString");
+ assertThat(geometry.get("coordinates")).isEqualTo(List.of(
+ List.of(8.55, 47.37),
+ List.of(8.56, 47.38)
+ ));
+ }
}
diff --git a/src/test/java/net/javahippie/fitpub/service/InboxProcessorTest.java b/src/test/java/net/javahippie/fitpub/service/InboxProcessorTest.java
new file mode 100644
index 0000000..f1ae088
--- /dev/null
+++ b/src/test/java/net/javahippie/fitpub/service/InboxProcessorTest.java
@@ -0,0 +1,217 @@
+package net.javahippie.fitpub.service;
+
+import net.javahippie.fitpub.model.entity.Follow;
+import net.javahippie.fitpub.model.entity.RemoteActivity;
+import net.javahippie.fitpub.model.entity.RemoteActor;
+import net.javahippie.fitpub.model.entity.User;
+import net.javahippie.fitpub.repository.ActivityRepository;
+import net.javahippie.fitpub.repository.CommentRepository;
+import net.javahippie.fitpub.repository.FollowRepository;
+import net.javahippie.fitpub.repository.LikeRepository;
+import net.javahippie.fitpub.repository.RemoteActivityRepository;
+import net.javahippie.fitpub.repository.RemoteActorRepository;
+import net.javahippie.fitpub.repository.UserRepository;
+import org.locationtech.jts.geom.LineString;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("InboxProcessor Tests")
+class InboxProcessorTest {
+
+ @Mock
+ private UserRepository userRepository;
+
+ @Mock
+ private FollowRepository followRepository;
+
+ @Mock
+ private FederationService federationService;
+
+ @Mock
+ private ActivityRepository activityRepository;
+
+ @Mock
+ private LikeRepository likeRepository;
+
+ @Mock
+ private CommentRepository commentRepository;
+
+ @Mock
+ private NotificationService notificationService;
+
+ @Mock
+ private RemoteActivityRepository remoteActivityRepository;
+
+ @Mock
+ private RemoteActorRepository remoteActorRepository;
+
+ @InjectMocks
+ private InboxProcessor inboxProcessor;
+
+ private User localUser;
+ private String remoteActorUri;
+
+ @BeforeEach
+ void setUp() {
+ localUser = User.builder()
+ .id(UUID.randomUUID())
+ .username("JaneDoe")
+ .email("janedoe@example.com")
+ .passwordHash("irrelevant")
+ .publicKey("public-key")
+ .privateKey("private-key")
+ .build();
+
+ remoteActorUri = "https://fitpub.example.com/users/JohnDoe";
+
+ ReflectionTestUtils.setField(inboxProcessor, "baseUrl", "https://fitpub.example");
+ }
+
+ @Test
+ @DisplayName("Should persist remote activity when published timestamp has fractional seconds but no timezone")
+ void processCreateRemoteActivity_WithPublishedTimestampWithoutTimezone_ShouldPersistRemoteActivity() {
+ when(remoteActivityRepository.existsByActivityUri("https://fitpub.example.com/activities/123"))
+ .thenReturn(false);
+ when(federationService.fetchRemoteActor(remoteActorUri)).thenReturn(RemoteActor.builder()
+ .actorUri(remoteActorUri)
+ .username("JohnDoe")
+ .domain("fitpub.example.com")
+ .inboxUrl("https://fitpub.example.com/users/JohnDoe/inbox")
+ .publicKey("public-key")
+ .build());
+ when(userRepository.findByUsername("JaneDoe")).thenReturn(Optional.of(localUser));
+ when(followRepository.findByFollowerIdAndFollowingActorUri(localUser.getId(), remoteActorUri))
+ .thenReturn(Optional.of(Follow.builder()
+ .followerId(localUser.getId())
+ .followingActorUri(remoteActorUri)
+ .status(Follow.FollowStatus.ACCEPTED)
+ .build()));
+
+ Map note = Map.of(
+ "id", "https://fitpub.example.com/activities/123",
+ "type", "Note",
+ "name", "Lunch Run",
+ "content", "Sunny run
",
+ "published", "2026-05-02T09:24:50.921241",
+ "to", List.of("https://www.w3.org/ns/activitystreams#Public")
+ );
+
+ Map activity = Map.of(
+ "type", "Create",
+ "actor", remoteActorUri,
+ "object", note
+ );
+
+ ArgumentCaptor remoteActivityCaptor =
+ ArgumentCaptor.forClass(net.javahippie.fitpub.model.entity.RemoteActivity.class);
+
+ inboxProcessor.processActivity("JaneDoe", activity);
+
+ verify(remoteActivityRepository).existsByActivityUri("https://fitpub.example.com/activities/123");
+ verify(federationService).fetchRemoteActor(remoteActorUri);
+ verify(remoteActivityRepository).save(remoteActivityCaptor.capture());
+
+ assertThat(remoteActivityCaptor.getValue().getPublishedAt())
+ .isEqualTo(Instant.parse("2026-05-02T09:24:50.921241Z"));
+ }
+
+ @Test
+ @DisplayName("Should prefer workoutData fields over legacy content parsing")
+ void processCreateRemoteActivity_WithWorkoutDataPayload_ShouldPreferWorkoutDataFields() {
+ when(remoteActivityRepository.existsByActivityUri("https://fitpub.example.com/activities/456"))
+ .thenReturn(false);
+ when(federationService.fetchRemoteActor(remoteActorUri)).thenReturn(RemoteActor.builder()
+ .actorUri(remoteActorUri)
+ .username("JohnDoe")
+ .domain("fitpub.example.com")
+ .inboxUrl("https://fitpub.example.com/users/JohnDoe/inbox")
+ .publicKey("public-key")
+ .build());
+ when(userRepository.findByUsername("JaneDoe")).thenReturn(Optional.of(localUser));
+ when(followRepository.findByFollowerIdAndFollowingActorUri(localUser.getId(), remoteActorUri))
+ .thenReturn(Optional.of(Follow.builder()
+ .followerId(localUser.getId())
+ .followingActorUri(remoteActorUri)
+ .status(Follow.FollowStatus.ACCEPTED)
+ .build()));
+
+ Map workoutData = new HashMap<>();
+ workoutData.put("activityType", "RUN");
+ workoutData.put("description", "Direct workoutData description");
+ workoutData.put("distance", 9800L);
+ workoutData.put("duration", "PT41M9S");
+ workoutData.put("averagePace", "PT4M12S");
+ workoutData.put("elevationGain", 123);
+ workoutData.put("route", Map.of(
+ "type", "FeatureCollection",
+ "features", List.of(Map.of(
+ "type", "Feature",
+ "geometry", Map.of(
+ "type", "LineString",
+ "coordinates", List.of(
+ List.of(8.55, 47.37),
+ List.of(8.56, 47.38),
+ List.of(8.57, 47.39)
+ )
+ )
+ ))
+ ));
+
+ Map note = Map.of(
+ "id", "https://fitpub.example.com/activities/456",
+ "type", "Note",
+ "name", "Kraremanns Lauf 2026",
+ "content", "Kraremanns Lauf 2026
Run · 9.80 km · 41:09
Legacy content fallback
",
+ "published", "2026-05-02T09:24:50.921241",
+ "to", List.of("https://www.w3.org/ns/activitystreams#Public"),
+ "workoutData", workoutData
+ );
+
+ Map activity = Map.of(
+ "type", "Create",
+ "actor", remoteActorUri,
+ "object", note
+ );
+
+ ArgumentCaptor remoteActivityCaptor =
+ ArgumentCaptor.forClass(RemoteActivity.class);
+
+ inboxProcessor.processActivity("JaneDoe", activity);
+
+ verify(remoteActivityRepository).save(remoteActivityCaptor.capture());
+
+ RemoteActivity remoteActivity = remoteActivityCaptor.getValue();
+ assertThat(remoteActivity.getTitle()).isEqualTo("Kraremanns Lauf 2026");
+ assertThat(remoteActivity.getDescription()).isEqualTo("Direct workoutData description");
+ assertThat(remoteActivity.getActivityType()).isEqualTo("RUN");
+ assertThat(remoteActivity.getTotalDistance()).isEqualTo(9800L);
+ assertThat(remoteActivity.getTotalDurationSeconds()).isEqualTo(2469L);
+ assertThat(remoteActivity.getAveragePaceSeconds()).isEqualTo(252L);
+ assertThat(remoteActivity.getElevationGain()).isEqualTo(123);
+ LineString simplifiedTrack = remoteActivity.getSimplifiedTrack();
+ assertThat(simplifiedTrack).isNotNull();
+ assertThat(simplifiedTrack.getNumPoints()).isEqualTo(3);
+ assertThat(simplifiedTrack.getSRID()).isEqualTo(4326);
+ assertThat(simplifiedTrack.getCoordinateN(0).x).isEqualTo(8.55);
+ assertThat(simplifiedTrack.getCoordinateN(0).y).isEqualTo(47.37);
+ }
+}
diff --git a/src/test/java/net/javahippie/fitpub/service/WeatherServiceTest.java b/src/test/java/net/javahippie/fitpub/service/WeatherServiceTest.java
index 2fec2cf..e8482ad 100644
--- a/src/test/java/net/javahippie/fitpub/service/WeatherServiceTest.java
+++ b/src/test/java/net/javahippie/fitpub/service/WeatherServiceTest.java
@@ -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 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 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 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 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 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 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));
}
}
diff --git a/src/test/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilderTest.java b/src/test/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilderTest.java
new file mode 100644
index 0000000..bc21615
--- /dev/null
+++ b/src/test/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilderTest.java
@@ -0,0 +1,100 @@
+package net.javahippie.fitpub.service;
+
+import net.javahippie.fitpub.model.entity.Activity;
+import net.javahippie.fitpub.model.entity.ActivityMetrics;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.locationtech.jts.geom.Coordinate;
+import org.locationtech.jts.geom.GeometryFactory;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+@DisplayName("WorkoutDataPayloadBuilder Tests")
+class WorkoutDataPayloadBuilderTest {
+
+ @Mock
+ private PrivacyZoneService privacyZoneService;
+
+ @Mock
+ private TrackPrivacyFilter trackPrivacyFilter;
+
+ @InjectMocks
+ private WorkoutDataPayloadBuilder builder;
+
+ private UUID userId;
+ private Activity activity;
+
+ @BeforeEach
+ void setUp() {
+ userId = UUID.randomUUID();
+ activity = Activity.builder()
+ .id(UUID.randomUUID())
+ .userId(userId)
+ .activityType(Activity.ActivityType.RUN)
+ .description("Morning jog")
+ .visibility(Activity.Visibility.PUBLIC)
+ .totalDistance(BigDecimal.valueOf(5000))
+ .totalDurationSeconds(1800L)
+ .elevationGain(BigDecimal.valueOf(100))
+ .simplifiedTrack(new GeometryFactory().createLineString(new Coordinate[]{
+ new Coordinate(8.55, 47.37),
+ new Coordinate(8.56, 47.38)
+ }))
+ .build();
+ activity.setMetrics(ActivityMetrics.builder()
+ .averagePaceSeconds(321L)
+ .averageHeartRate(150)
+ .averageSpeed(BigDecimal.valueOf(10.4))
+ .maxSpeed(BigDecimal.valueOf(14.2))
+ .calories(420)
+ .build());
+ }
+
+ @Test
+ @DisplayName("Should build workoutData payload with route and metrics")
+ void build_ShouldIncludeWorkoutDataRouteAndMetrics() {
+ when(privacyZoneService.getActivePrivacyZones(userId)).thenReturn(List.of());
+
+ Map workoutData = builder.build(activity);
+
+ assertThat(workoutData)
+ .containsEntry("activityType", "RUN")
+ .containsEntry("description", "Morning jog")
+ .containsEntry("distance", 5000L)
+ .containsEntry("duration", "PT30M")
+ .containsEntry("elevationGain", 100)
+ .containsEntry("averagePace", "PT5M21S")
+ .containsEntry("averageHeartRate", 150)
+ .containsEntry("averageSpeed", 10.4)
+ .containsEntry("maxSpeed", 14.2)
+ .containsEntry("calories", 420);
+
+ @SuppressWarnings("unchecked")
+ Map route = (Map) workoutData.get("route");
+ assertThat(route).containsEntry("type", "FeatureCollection");
+
+ @SuppressWarnings("unchecked")
+ List> features = (List>) route.get("features");
+ assertThat(features).hasSize(1);
+
+ @SuppressWarnings("unchecked")
+ Map geometry = (Map) features.get(0).get("geometry");
+ assertThat(geometry).containsEntry("type", "LineString");
+ assertThat(geometry.get("coordinates")).isEqualTo(List.of(
+ List.of(8.55, 47.37),
+ List.of(8.56, 47.38)
+ ));
+ }
+}
diff --git a/src/test/java/net/javahippie/fitpub/util/GpxParserIntegrationTest.java b/src/test/java/net/javahippie/fitpub/util/GpxParserIntegrationTest.java
index 55ab855..7bad2cb 100644
--- a/src/test/java/net/javahippie/fitpub/util/GpxParserIntegrationTest.java
+++ b/src/test/java/net/javahippie/fitpub/util/GpxParserIntegrationTest.java
@@ -112,6 +112,10 @@ class GpxParserIntegrationTest {
// Verify at least some basic data
assertNotNull(parsedData.getActivityType(), "Activity type should be determined");
+ String parsedTitle = parsedData.getTitle();
+ assertEquals(255, parsedTitle.length());
+ assertTrue(parsedTitle.startsWith("Einmal Frust loswerden"));
+ assertFalse(parsedTitle.contains("Shouldn't appear"));
assertEquals(Activity.ActivityType.RUN, parsedData.getActivityType(),
"Activity type should be RUN (from GPX running )");
assertTrue(parsedData.getTrackPoints().size() > 0, "Should have at least one track point");
diff --git a/src/test/resources/7410863774.gpx b/src/test/resources/7410863774.gpx
index 848716a..39647af 100644
--- a/src/test/resources/7410863774.gpx
+++ b/src/test/resources/7410863774.gpx
@@ -4,7 +4,7 @@
2022-07-03T19:47:51Z
- Einmal Frust loswerden
+ Einmal Frust loswerden blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel blafasel Shouldn't appear
running