diff --git a/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java b/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java index 9702429..c08e0ff 100644 --- a/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java +++ b/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java @@ -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 diff --git a/src/main/java/net/javahippie/fitpub/model/entity/RemoteActivity.java b/src/main/java/net/javahippie/fitpub/model/entity/RemoteActivity.java index 1fd8105..a3b74da 100644 --- a/src/main/java/net/javahippie/fitpub/model/entity/RemoteActivity.java +++ b/src/main/java/net/javahippie/fitpub/model/entity/RemoteActivity.java @@ -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. */ diff --git a/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java b/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java index eb0ec99..cad2bc9 100644 --- a/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java +++ b/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java @@ -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 hashtags = extractHashtags(activity); diff --git a/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java b/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java index d801143..27efc1e 100644 --- a/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java +++ b/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java @@ -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 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 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. */ 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/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/resources/db/migration/V32_2__add_simplified_track_to_remote_activities.sql b/src/main/resources/db/migration/V32_2__add_simplified_track_to_remote_activities.sql new file mode 100644 index 0000000..49e3b7e --- /dev/null +++ b/src/main/resources/db/migration/V32_2__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/test/java/net/javahippie/fitpub/controller/ActivityPubControllerTest.java b/src/test/java/net/javahippie/fitpub/controller/ActivityPubControllerTest.java index eb52f67..a0d9129 100644 --- a/src/test/java/net/javahippie/fitpub/controller/ActivityPubControllerTest.java +++ b/src/test/java/net/javahippie/fitpub/controller/ActivityPubControllerTest.java @@ -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> 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 9c5d596..b07d325 100644 --- a/src/test/java/net/javahippie/fitpub/integration/FederationFollowFlowIntegrationTest.java +++ b/src/test/java/net/javahippie/fitpub/integration/FederationFollowFlowIntegrationTest.java @@ -388,17 +388,20 @@ class FederationFollowFlowIntegrationTest { 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(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 diff --git a/src/test/java/net/javahippie/fitpub/service/ActivityPostProcessingServiceTest.java b/src/test/java/net/javahippie/fitpub/service/ActivityPostProcessingServiceTest.java index b2e6c84..5507c23 100644 --- a/src/test/java/net/javahippie/fitpub/service/ActivityPostProcessingServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/ActivityPostProcessingServiceTest.java @@ -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 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> 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 index 2d4f524..f1ae088 100644 --- a/src/test/java/net/javahippie/fitpub/service/InboxProcessorTest.java +++ b/src/test/java/net/javahippie/fitpub/service/InboxProcessorTest.java @@ -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 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/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) + )); + } +}