fix(federation): exchange remote activity metadata and routes via workoutData (#47)

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
This commit is contained in:
Marcus Fihlon 2026-05-04 14:09:54 +02:00
parent 8d1c27d0be
commit c4fe14312e
Signed by: McPringle
GPG key ID: C6B7F469EE363E1F
12 changed files with 577 additions and 15 deletions

View file

@ -10,13 +10,14 @@ import net.javahippie.fitpub.model.activitypub.OrderedCollection;
import net.javahippie.fitpub.model.entity.Activity;
import net.javahippie.fitpub.model.entity.RemoteActor;
import net.javahippie.fitpub.model.entity.User;
import net.javahippie.fitpub.repository.FollowRepository;
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 net.javahippie.fitpub.util.ActivityFormatter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
@ -52,6 +53,7 @@ public class ActivityPubController {
private final HttpSignatureValidator signatureValidator;
private final FederationService federationService;
private final ObjectMapper objectMapper;
private final WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
@Value("${fitpub.base-url}")
private String baseUrl;
@ -440,6 +442,7 @@ public class ActivityPubController {
noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString());
noteObject.put("content", formatActivityContent(activity));
noteObject.put("url", activityUri);
noteObject.put("workoutData", workoutDataPayloadBuilder.build(activity));
// Audience only PUBLIC activities reach this endpoint (the visibility
// check above returned 403 for anything else), so audience is always

View file

@ -9,6 +9,7 @@ import lombok.NoArgsConstructor;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.Type;
import org.hibernate.annotations.UpdateTimestamp;
import org.locationtech.jts.geom.LineString;
import java.time.Instant;
import java.time.LocalDateTime;
@ -137,6 +138,12 @@ public class RemoteActivity {
@Column(name = "track_geojson_url", length = 512)
private String trackGeojsonUrl;
/**
* Simplified remote route geometry for local map rendering.
*/
@Column(name = "simplified_track", columnDefinition = "geometry(LineString, 4326)")
private LineString simplifiedTrack;
/**
* Visibility level of the activity.
*/

View file

@ -39,6 +39,7 @@ public class ActivityPostProcessingService {
private final ActivityImageService activityImageService;
private final ActivityRepository activityRepository;
private final UserRepository userRepository;
private final WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
@Value("${fitpub.base-url}")
private String baseUrl;
@ -203,6 +204,7 @@ public class ActivityPostProcessingService {
noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString());
noteObject.put("content", formatActivityContent(activity));
noteObject.put("url", baseUrl + "/activities/" + activity.getId());
noteObject.put("workoutData", workoutDataPayloadBuilder.build(activity));
// Extract hashtags from user text and add as tags
List<String> hashtags = extractHashtags(activity);

View file

@ -16,6 +16,10 @@ import net.javahippie.fitpub.repository.CommentRepository;
import net.javahippie.fitpub.repository.FollowRepository;
import net.javahippie.fitpub.repository.LikeRepository;
import net.javahippie.fitpub.repository.UserRepository;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.GeometryFactory;
import org.locationtech.jts.geom.LineString;
import org.locationtech.jts.geom.PrecisionModel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -36,6 +40,9 @@ import java.util.UUID;
@RequiredArgsConstructor
@Slf4j
public class InboxProcessor {
private static final int GEOMETRY_SRID = 4326;
private static final GeometryFactory GEOMETRY_FACTORY =
new GeometryFactory(new PrecisionModel(), GEOMETRY_SRID);
private final UserRepository userRepository;
private final FollowRepository followRepository;
@ -422,9 +429,12 @@ public class InboxProcessor {
RemoteActivity remoteActivity = RemoteActivity.builder()
.activityUri(activityUri)
.remoteActorUri(actor)
.activityType((String) workoutData.get("activityType"))
.activityType(stringValue(workoutData.get("activityType")))
.title((String) noteObject.getOrDefault("name", noteObject.getOrDefault("summary", "Untitled Activity")))
.description(stripHtml((String) noteObject.get("content")))
.description(firstNonBlank(
stringValue(workoutData.get("description")),
stripHtml((String) noteObject.get("content"))
))
.publishedAt(publishedAt)
.totalDistance(parseLong(workoutData.get("distance")))
.totalDurationSeconds(parseDurationSeconds((String) workoutData.get("duration")))
@ -436,6 +446,7 @@ public class InboxProcessor {
.calories(parseInteger(workoutData.get("calories")))
.mapImageUrl(attachments.get("mapImage"))
.trackGeojsonUrl(attachments.get("trackGeojson"))
.simplifiedTrack(extractRoute(workoutData))
.visibility(visibility)
.activityPubObject(serializeToJson(noteObject))
.build();
@ -710,6 +721,88 @@ public class InboxProcessor {
return workoutData;
}
private String stringValue(Object value) {
return value != null ? String.valueOf(value) : null;
}
private LineString extractRoute(Map<String, Object> workoutData) {
Object routeObj = workoutData.get("route");
if (!(routeObj instanceof Map<?, ?> routeMap)) {
return null;
}
Object featuresObj = routeMap.get("features");
if (!(featuresObj instanceof java.util.List<?> features) || features.isEmpty()) {
return null;
}
for (Object featureObj : features) {
if (!(featureObj instanceof Map<?, ?> featureMap)) {
continue;
}
Object geometryObj = featureMap.get("geometry");
if (!(geometryObj instanceof Map<?, ?> geometryMap)) {
continue;
}
if (!"LineString".equals(geometryMap.get("type"))) {
continue;
}
LineString lineString = parseLineStringCoordinates(geometryMap.get("coordinates"));
if (lineString != null) {
return lineString;
}
}
return null;
}
private LineString parseLineStringCoordinates(Object coordinatesObj) {
if (!(coordinatesObj instanceof java.util.List<?> coordinateList) || coordinateList.size() < 2) {
return null;
}
java.util.List<Coordinate> 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.
*/

View file

@ -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<String, Object> build(Activity activity) {
Map<String, Object> 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<String, Object> route = buildRoutePayload(activity);
if (route != null) {
workoutData.put("route", route);
}
return workoutData;
}
private Map<String, Object> buildRoutePayload(Activity activity) {
List<PrivacyZone> privacyZones = privacyZoneService.getActivePrivacyZones(activity.getUserId());
ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(activity, null, privacyZones, trackPrivacyFilter);
if (dto.getSimplifiedTrack() == null) {
return null;
}
Map<String, Object> feature = new HashMap<>();
feature.put("type", "Feature");
feature.put("geometry", dto.getSimplifiedTrack());
Map<String, Object> featureCollection = new HashMap<>();
featureCollection.put("type", "FeatureCollection");
featureCollection.put("features", List.of(feature));
return featureCollection;
}
}

View file

@ -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:
*
* <pre>
* [
@ -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"
* }
* ]
* </pre>
@ -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.
*
* <p>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<Object> extendedContext() {
Map<String, Object> 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

View file

@ -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';

View file

@ -10,6 +10,7 @@ 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;
@ -24,6 +25,7 @@ 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;
@ -59,6 +61,9 @@ class ActivityPubControllerTest {
@Mock
private ObjectMapper objectMapper;
@Mock
private WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
@InjectMocks
private ActivityPubController controller;
@ -111,4 +116,50 @@ class ActivityPubControllerTest {
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<Map<String, Object>> 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<Object> context = (List<Object>) response.getBody().get("@context");
assertThat(context).hasSize(2);
@SuppressWarnings("unchecked")
Map<String, Object> extensions = (Map<String, Object>) context.get(1);
assertThat(extensions)
.containsEntry("fitpub", "https://fitpub.social/ns#")
.containsEntry("workoutData", "fitpub:workoutData")
.containsEntry("route", "fitpub:route");
}
}

View file

@ -388,17 +388,20 @@ class FederationFollowFlowIntegrationTest {
RemoteActivity imported = remoteActivityRepository.findByActivityUri((String) exportedNote.get("id"))
.orElseThrow();
@SuppressWarnings("unchecked")
Map<String, Object> workoutData = (Map<String, Object>) 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(stripHtml((String) exportedNote.get("content")));
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()).isNull();
assertThat(imported.getTotalDistance()).isNull();
assertThat(imported.getTotalDurationSeconds()).isNull();
assertThat(imported.getElevationGain()).isNull();
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();
@ -406,6 +409,7 @@ class FederationFollowFlowIntegrationTest {
assertThat(imported.getCalories()).isNull();
assertThat(imported.getMapImageUrl()).isNull();
assertThat(imported.getTrackGeojsonUrl()).isNull();
assertThat(imported.getSimplifiedTrack()).isNull();
}
@Test

View file

@ -1,28 +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.
@ -52,6 +66,9 @@ class ActivityPostProcessingServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
@InjectMocks
private ActivityPostProcessingService service;
@ -83,7 +100,37 @@ class ActivityPostProcessingServiceTest {
.elevationGain(BigDecimal.valueOf(100))
.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<String, Object> 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()
@ -340,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<Map<String, Object>> noteCaptor = ArgumentCaptor.forClass(Map.class);
service.publishToFederationAsync(activityId, userId);
verify(federationService).sendCreateActivity(anyString(), noteCaptor.capture(), eq(testUser), eq(true));
@SuppressWarnings("unchecked")
Map<String, Object> workoutData = (Map<String, Object>) 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<String, Object> route = (Map<String, Object>) workoutData.get("route");
assertThat(route).containsEntry("type", "FeatureCollection");
@SuppressWarnings("unchecked")
List<Map<String, Object>> features = (List<Map<String, Object>>) route.get("features");
assertThat(features).hasSize(1);
assertThat(features.get(0)).containsEntry("type", "Feature");
@SuppressWarnings("unchecked")
Map<String, Object> geometry = (Map<String, Object>) 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)
));
}
}

View file

@ -1,6 +1,7 @@
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;
@ -10,6 +11,7 @@ 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;
@ -21,6 +23,7 @@ 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;
@ -130,4 +133,85 @@ class InboxProcessorTest {
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<String, Object> 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<String, Object> note = Map.of(
"id", "https://fitpub.example.com/activities/456",
"type", "Note",
"name", "Kraremanns Lauf 2026",
"content", "<p>Kraremanns Lauf 2026</p><p>Run · 9.80 km · 41:09</p><p>Legacy content fallback</p>",
"published", "2026-05-02T09:24:50.921241",
"to", List.of("https://www.w3.org/ns/activitystreams#Public"),
"workoutData", workoutData
);
Map<String, Object> activity = Map.of(
"type", "Create",
"actor", remoteActorUri,
"object", note
);
ArgumentCaptor<RemoteActivity> 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);
}
}

View file

@ -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<String, Object> 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<String, Object> route = (Map<String, Object>) workoutData.get("route");
assertThat(route).containsEntry("type", "FeatureCollection");
@SuppressWarnings("unchecked")
List<Map<String, Object>> features = (List<Map<String, Object>>) route.get("features");
assertThat(features).hasSize(1);
@SuppressWarnings("unchecked")
Map<String, Object> geometry = (Map<String, Object>) 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)
));
}
}