Fix federation payload for activities without proper metadata #48
12 changed files with 1047 additions and 15 deletions
|
|
@ -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;
|
||||
|
|
@ -29,6 +30,7 @@ import java.net.URI;
|
|||
import java.net.URISyntaxException;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
|
@ -51,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;
|
||||
|
|
@ -436,9 +439,10 @@ public class ActivityPubController {
|
|||
noteObject.put("id", activityUri);
|
||||
noteObject.put("type", "Note");
|
||||
noteObject.put("attributedTo", actorUri);
|
||||
noteObject.put("published", activity.getCreatedAt().toString());
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import org.springframework.beans.factory.annotation.Value;
|
|||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
|
@ -38,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;
|
||||
|
|
@ -199,9 +201,10 @@ public class ActivityPostProcessingService {
|
|||
noteObject.put("id", activityUri);
|
||||
noteObject.put("type", "Note");
|
||||
noteObject.put("attributedTo", actorUri);
|
||||
noteObject.put("published", activity.getCreatedAt().toString());
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -16,11 +16,20 @@ 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;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
|
|
@ -31,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;
|
||||
|
|
@ -411,15 +423,18 @@ public class InboxProcessor {
|
|||
|
||||
// Parse published timestamp
|
||||
String publishedStr = (String) noteObject.get("published");
|
||||
Instant publishedAt = publishedStr != null ? Instant.parse(publishedStr) : Instant.now();
|
||||
Instant publishedAt = parsePublishedAt(publishedStr);
|
||||
|
||||
// Build RemoteActivity entity
|
||||
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")))
|
||||
|
|
@ -431,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();
|
||||
|
|
@ -705,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.
|
||||
*/
|
||||
|
|
@ -824,6 +922,44 @@ public class InboxProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse ActivityPub published timestamps.
|
||||
*
|
||||
* <p>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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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<Map<String, Object>> 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<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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String, Object> exportedNote = objectMapper.readValue(exportResult.getResponse().getContentAsByteArray(), Map.class);
|
||||
|
||||
Map<String, Object> 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<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(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("<br\\s*/?>", "\n")
|
||||
.replaceAll("<p>", "")
|
||||
.replaceAll("</p>", "\n")
|
||||
.replaceAll("<[^>]+>", "")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("&", "&")
|
||||
.trim();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should process Undo Follow activity and remove follow relationship")
|
||||
void testProcessUndoFollowActivity() throws Exception {
|
||||
|
|
|
|||
|
|
@ -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<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()
|
||||
|
|
@ -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<java.util.Map<String, Object>> 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<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)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, Object> note = Map.of(
|
||||
"id", "https://fitpub.example.com/activities/123",
|
||||
"type", "Note",
|
||||
"name", "Lunch Run",
|
||||
"content", "<p>Sunny run</p>",
|
||||
"published", "2026-05-02T09:24:50.921241",
|
||||
"to", List.of("https://www.w3.org/ns/activitystreams#Public")
|
||||
);
|
||||
|
||||
Map<String, Object> activity = Map.of(
|
||||
"type", "Create",
|
||||
"actor", remoteActorUri,
|
||||
"object", note
|
||||
);
|
||||
|
||||
ArgumentCaptor<net.javahippie.fitpub.model.entity.RemoteActivity> 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<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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue