diff --git a/.gitignore b/.gitignore index fa122a4..cd24195 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,10 @@ target/ .kotlin ### IntelliJ IDEA ### -.idea/ +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ *.iws *.iml *.ipr @@ -46,10 +49,3 @@ logs/ /gadm_410.gpkg /.postgresdata/ /peaks_worldwide.geojson - -### Coding Assistants ### -.codex/ -.aider* -.cursor/ -.roo/ -.windsurf/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..06a2c34 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,17 @@ + + + + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://localhost:51826/testdb + + + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/data_source_mapping.xml b/.idea/data_source_mapping.xml new file mode 100644 index 0000000..ed1c16b --- /dev/null +++ b/.idea/data_source_mapping.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..aa00ffa --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..ad4a613 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,17 @@ + + + + IDE + + + + + + + + + \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..27a4b8c --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/pom.xml b/pom.xml index 8ecf1b2..008ac47 100644 --- a/pom.xml +++ b/pom.xml @@ -23,7 +23,7 @@ UTF-8 17 0.12.3 - 2.0.5 + 2.0.3 @@ -170,14 +170,15 @@ org.testcontainers testcontainers-junit-jupiter - ${testcontainers.version} + 2.0.2 test + org.testcontainers testcontainers-postgresql - ${testcontainers.version} + 2.0.1 test @@ -192,4 +193,4 @@ - + \ No newline at end of file diff --git a/src/main/java/net/javahippie/fitpub/FitPubApplication.java b/src/main/java/net/javahippie/fitpub/FitPubApplication.java index 79756a4..462a721 100644 --- a/src/main/java/net/javahippie/fitpub/FitPubApplication.java +++ b/src/main/java/net/javahippie/fitpub/FitPubApplication.java @@ -10,6 +10,7 @@ import org.apache.hc.client5.http.io.HttpClientConnectionManager; import org.apache.hc.core5.util.Timeout; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.context.annotation.Bean; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.scheduling.annotation.EnableAsync; @@ -22,6 +23,7 @@ import org.springframework.web.client.RestTemplate; * through the ActivityPub protocol. */ @SpringBootApplication +@ConfigurationPropertiesScan @EnableAsync @EnableScheduling @Slf4j diff --git a/src/main/java/net/javahippie/fitpub/config/FitPubTextLimitsProperties.java b/src/main/java/net/javahippie/fitpub/config/FitPubTextLimitsProperties.java new file mode 100644 index 0000000..978dad6 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/config/FitPubTextLimitsProperties.java @@ -0,0 +1,41 @@ +package net.javahippie.fitpub.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configurable max-length limits for user and activity text fields. + */ +@Data +@ConfigurationProperties(prefix = "fitpub") +public class FitPubTextLimitsProperties { + + private User user = new User(); + private Activity activity = new Activity(); + + @Data + public static class User { + private Bio bio = new Bio(); + } + + @Data + public static class Bio { + private int maxLength = 500; + } + + @Data + public static class Activity { + private Title title = new Title(); + private Description description = new Description(); + } + + @Data + public static class Title { + private int maxLength = 200; + } + + @Data + public static class Description { + private int maxLength = 5000; + } +} diff --git a/src/main/java/net/javahippie/fitpub/controller/ActivitiesViewController.java b/src/main/java/net/javahippie/fitpub/controller/ActivitiesViewController.java index 359f652..6e797e7 100644 --- a/src/main/java/net/javahippie/fitpub/controller/ActivitiesViewController.java +++ b/src/main/java/net/javahippie/fitpub/controller/ActivitiesViewController.java @@ -1,5 +1,8 @@ package net.javahippie.fitpub.controller; +import lombok.RequiredArgsConstructor; +import net.javahippie.fitpub.service.TextValidationService; +import org.springframework.ui.Model; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -10,8 +13,11 @@ import org.springframework.web.bind.annotation.RequestMapping; */ @Controller @RequestMapping("/activities") +@RequiredArgsConstructor public class ActivitiesViewController { + private final TextValidationService textValidationService; + /** * Show activities list page */ @@ -24,7 +30,9 @@ public class ActivitiesViewController { * Show activity upload page */ @GetMapping("/upload") - public String uploadActivity() { + public String uploadActivity(Model model) { + model.addAttribute("activityTitleMaxLength", textValidationService.getActivityTitleMaxLength()); + model.addAttribute("activityDescriptionMaxLength", textValidationService.getActivityDescriptionMaxLength()); return "activities/upload"; } @@ -41,8 +49,10 @@ public class ActivitiesViewController { * Show activity edit page */ @GetMapping("/{id}/edit") - public String editActivity(@PathVariable String id) { + public String editActivity(@PathVariable String id, Model model) { // The activity data will be loaded via JavaScript API calls + model.addAttribute("activityTitleMaxLength", textValidationService.getActivityTitleMaxLength()); + model.addAttribute("activityDescriptionMaxLength", textValidationService.getActivityDescriptionMaxLength()); return "activities/edit"; } } diff --git a/src/main/java/net/javahippie/fitpub/controller/ActivityController.java b/src/main/java/net/javahippie/fitpub/controller/ActivityController.java index f6181f7..f764bc3 100644 --- a/src/main/java/net/javahippie/fitpub/controller/ActivityController.java +++ b/src/main/java/net/javahippie/fitpub/controller/ActivityController.java @@ -21,6 +21,7 @@ import net.javahippie.fitpub.service.WeatherService; import net.javahippie.fitpub.service.FitFileService; import net.javahippie.fitpub.service.PrivacyZoneService; import net.javahippie.fitpub.service.ReactionEnricher; +import net.javahippie.fitpub.service.TextValidationService; import net.javahippie.fitpub.service.TrackPrivacyFilter; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; @@ -48,6 +49,7 @@ import java.util.UUID; @Slf4j public class ActivityController { + private final TextValidationService textValidationService; private final ActivityFileService activityFileService; private final FitFileService fitFileService; private final UserRepository userRepository; @@ -153,6 +155,9 @@ public class ActivityController { ) { log.info("User {} uploading activity file: {}", userDetails.getUsername(), file.getOriginalFilename()); + textValidationService.validateActivityTitle(request.getTitle()); + textValidationService.validateActivityDescription(request.getDescription()); + User user = userRepository.findByUsername(userDetails.getUsername()) .orElseThrow(() -> new UsernameNotFoundException("User not found")); @@ -311,6 +316,9 @@ public class ActivityController { UUID userId = getUserId(userDetails); + textValidationService.validateActivityTitle(request.getTitle()); + textValidationService.validateActivityDescription(request.getDescription()); + try { Activity updated = fitFileService.updateActivity( id, diff --git a/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java b/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java index c08e0ff..4cf3717 100644 --- a/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java +++ b/src/main/java/net/javahippie/fitpub/controller/ActivityPubController.java @@ -10,14 +10,13 @@ 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.ActivityRepository; import net.javahippie.fitpub.repository.FollowRepository; +import net.javahippie.fitpub.repository.ActivityRepository; 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; @@ -30,7 +29,6 @@ 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; @@ -53,7 +51,6 @@ 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; @@ -439,10 +436,9 @@ public class ActivityPubController { noteObject.put("id", activityUri); noteObject.put("type", "Note"); noteObject.put("attributedTo", actorUri); - noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString()); + noteObject.put("published", activity.getCreatedAt().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/controller/ApiExceptionHandler.java b/src/main/java/net/javahippie/fitpub/controller/ApiExceptionHandler.java new file mode 100644 index 0000000..70aeffc --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/controller/ApiExceptionHandler.java @@ -0,0 +1,19 @@ +package net.javahippie.fitpub.controller; + +import net.javahippie.fitpub.exception.ApiValidationException; +import net.javahippie.fitpub.model.dto.ApiError; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * Central exception mapping for REST APIs. + */ +@RestControllerAdvice +public class ApiExceptionHandler { + + @ExceptionHandler(ApiValidationException.class) + public ResponseEntity handleApiValidation(ApiValidationException e) { + return ResponseEntity.badRequest().body(new ApiError("BAD_REQUEST", e.getMessage())); + } +} diff --git a/src/main/java/net/javahippie/fitpub/controller/ProfileViewController.java b/src/main/java/net/javahippie/fitpub/controller/ProfileViewController.java index d39ef1e..a3dcbee 100644 --- a/src/main/java/net/javahippie/fitpub/controller/ProfileViewController.java +++ b/src/main/java/net/javahippie/fitpub/controller/ProfileViewController.java @@ -1,5 +1,7 @@ package net.javahippie.fitpub.controller; +import lombok.RequiredArgsConstructor; +import net.javahippie.fitpub.service.TextValidationService; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; @@ -9,8 +11,11 @@ import org.springframework.web.bind.annotation.PathVariable; * Controller for user profile view pages. */ @Controller +@RequiredArgsConstructor public class ProfileViewController { + private final TextValidationService textValidationService; + /** * Current user's profile page. * Shows own profile with edit capabilities. @@ -34,6 +39,7 @@ public class ProfileViewController { @GetMapping("/profile/edit") public String editProfile(Model model) { model.addAttribute("pageTitle", "Edit Profile"); + model.addAttribute("bioMaxLength", textValidationService.getUserBioMaxLength()); return "profile/edit"; } diff --git a/src/main/java/net/javahippie/fitpub/controller/UserController.java b/src/main/java/net/javahippie/fitpub/controller/UserController.java index 432c035..e64023c 100644 --- a/src/main/java/net/javahippie/fitpub/controller/UserController.java +++ b/src/main/java/net/javahippie/fitpub/controller/UserController.java @@ -14,6 +14,7 @@ import net.javahippie.fitpub.repository.FollowRepository; import net.javahippie.fitpub.repository.RemoteActorRepository; import net.javahippie.fitpub.repository.UserRepository; import net.javahippie.fitpub.service.FederationService; +import net.javahippie.fitpub.service.TextValidationService; import net.javahippie.fitpub.service.WebFingerClient; import net.javahippie.fitpub.service.UserService; import org.springframework.beans.factory.annotation.Value; @@ -47,6 +48,7 @@ public class UserController { private final WebFingerClient webFingerClient; private final FederationService federationService; private final UserService userService; + private final TextValidationService textValidationService; private final net.javahippie.fitpub.repository.ActivityPeakRepository activityPeakRepository; @Value("${fitpub.base-url}") @@ -101,6 +103,8 @@ public class UserController { ) { log.info("User {} updating profile", userDetails.getUsername()); + textValidationService.validateUserBio(request.getBio()); + User user = userRepository.findByUsername(userDetails.getUsername()) .orElseThrow(() -> new UsernameNotFoundException("User not found")); diff --git a/src/main/java/net/javahippie/fitpub/exception/ApiValidationException.java b/src/main/java/net/javahippie/fitpub/exception/ApiValidationException.java new file mode 100644 index 0000000..3f8229d --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/exception/ApiValidationException.java @@ -0,0 +1,11 @@ +package net.javahippie.fitpub.exception; + +/** + * Exception for API request validation that depends on runtime configuration. + */ +public class ApiValidationException extends RuntimeException { + + public ApiValidationException(String message) { + super(message); + } +} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/ActivityUpdateRequest.java b/src/main/java/net/javahippie/fitpub/model/dto/ActivityUpdateRequest.java index 6798064..ae215ff 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/ActivityUpdateRequest.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/ActivityUpdateRequest.java @@ -1,7 +1,6 @@ package net.javahippie.fitpub.model.dto; import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -18,10 +17,8 @@ import net.javahippie.fitpub.model.entity.Activity; public class ActivityUpdateRequest { @NotNull(message = "Title is required") - @Size(min = 1, max = 200, message = "Title must be between 1 and 200 characters") private String title; - @Size(max = 5000, message = "Description must not exceed 5000 characters") private String description; @NotNull(message = "Visibility is required") diff --git a/src/main/java/net/javahippie/fitpub/model/dto/ActivityUploadRequest.java b/src/main/java/net/javahippie/fitpub/model/dto/ActivityUploadRequest.java index e271d52..7f0779d 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/ActivityUploadRequest.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/ActivityUploadRequest.java @@ -1,6 +1,4 @@ package net.javahippie.fitpub.model.dto; - -import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -17,10 +15,8 @@ import net.javahippie.fitpub.model.entity.Activity; @AllArgsConstructor public class ActivityUploadRequest { - @Size(max = 200, message = "Title must not exceed 200 characters") private String title; - @Size(max = 5000, message = "Description must not exceed 5000 characters") private String description; private Activity.Visibility visibility; diff --git a/src/main/java/net/javahippie/fitpub/model/dto/ApiError.java b/src/main/java/net/javahippie/fitpub/model/dto/ApiError.java new file mode 100644 index 0000000..31cef10 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/ApiError.java @@ -0,0 +1,13 @@ +package net.javahippie.fitpub.model.dto; + +import lombok.Value; + +/** + * Standard error payload for API responses. + */ +@Value +public class ApiError { + + String code; + String message; +} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/UserUpdateRequest.java b/src/main/java/net/javahippie/fitpub/model/dto/UserUpdateRequest.java index 8f4c812..13fb1f7 100644 --- a/src/main/java/net/javahippie/fitpub/model/dto/UserUpdateRequest.java +++ b/src/main/java/net/javahippie/fitpub/model/dto/UserUpdateRequest.java @@ -21,7 +21,6 @@ public class UserUpdateRequest { @Size(max = 100, message = "Display name must not exceed 100 characters") private String displayName; - @Size(max = 500, message = "Bio must not exceed 500 characters") private String bio; @URL(message = "Avatar URL must be a valid URL") 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 a3b74da..1fd8105 100644 --- a/src/main/java/net/javahippie/fitpub/model/entity/RemoteActivity.java +++ b/src/main/java/net/javahippie/fitpub/model/entity/RemoteActivity.java @@ -9,7 +9,6 @@ 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; @@ -138,12 +137,6 @@ 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 cad2bc9..8a582bc 100644 --- a/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java +++ b/src/main/java/net/javahippie/fitpub/service/ActivityPostProcessingService.java @@ -12,7 +12,6 @@ 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; @@ -39,7 +38,6 @@ 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; @@ -201,10 +199,9 @@ public class ActivityPostProcessingService { noteObject.put("id", activityUri); noteObject.put("type", "Note"); noteObject.put("attributedTo", actorUri); - noteObject.put("published", activity.getCreatedAt().atOffset(ZoneOffset.UTC).toInstant().toString()); + noteObject.put("published", activity.getCreatedAt().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 27efc1e..8dff712 100644 --- a/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java +++ b/src/main/java/net/javahippie/fitpub/service/InboxProcessor.java @@ -16,20 +16,11 @@ 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; @@ -40,9 +31,6 @@ 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; @@ -423,18 +411,15 @@ public class InboxProcessor { // Parse published timestamp String publishedStr = (String) noteObject.get("published"); - Instant publishedAt = parsePublishedAt(publishedStr); + Instant publishedAt = publishedStr != null ? Instant.parse(publishedStr) : Instant.now(); // Build RemoteActivity entity RemoteActivity remoteActivity = RemoteActivity.builder() .activityUri(activityUri) .remoteActorUri(actor) - .activityType(stringValue(workoutData.get("activityType"))) + .activityType((String) workoutData.get("activityType")) .title((String) noteObject.getOrDefault("name", noteObject.getOrDefault("summary", "Untitled Activity"))) - .description(firstNonBlank( - stringValue(workoutData.get("description")), - stripHtml((String) noteObject.get("content")) - )) + .description(stripHtml((String) noteObject.get("content"))) .publishedAt(publishedAt) .totalDistance(parseLong(workoutData.get("distance"))) .totalDurationSeconds(parseDurationSeconds((String) workoutData.get("duration"))) @@ -446,7 +431,6 @@ 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(); @@ -721,88 +705,6 @@ 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. */ @@ -922,44 +824,6 @@ public class InboxProcessor { } } - /** - * Parse ActivityPub published timestamps. - * - *

Preferred input is a full ISO-8601 instant with timezone/offset. Some - * remote implementations still send zoneless timestamps, so we accept those - * as a compatibility fallback and interpret them as UTC. - */ - private Instant parsePublishedAt(String publishedStr) { - if (publishedStr == null || publishedStr.isBlank()) { - return Instant.now(); - } - - try { - return Instant.parse(publishedStr); - } catch (DateTimeParseException ignored) { - // Fall through to compatibility parsers below. - } - - try { - return OffsetDateTime.parse(publishedStr).toInstant(); - } catch (DateTimeParseException ignored) { - // Fall through to compatibility parsers below. - } - - try { - return ZonedDateTime.parse(publishedStr).toInstant(); - } catch (DateTimeParseException ignored) { - // Fall through to compatibility parsers below. - } - - try { - return LocalDateTime.parse(publishedStr).atOffset(ZoneOffset.UTC).toInstant(); - } catch (DateTimeParseException e) { - log.warn("Failed to parse published timestamp: {}", publishedStr, e); - return Instant.now(); - } - } - /** * Serialize object to JSON string. */ diff --git a/src/main/java/net/javahippie/fitpub/service/TextValidationService.java b/src/main/java/net/javahippie/fitpub/service/TextValidationService.java new file mode 100644 index 0000000..8f79eeb --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/service/TextValidationService.java @@ -0,0 +1,46 @@ +package net.javahippie.fitpub.service; + +import lombok.RequiredArgsConstructor; +import net.javahippie.fitpub.config.FitPubTextLimitsProperties; +import net.javahippie.fitpub.exception.ApiValidationException; +import org.springframework.stereotype.Service; + +/** + * Central validation policy for configurable text fields. + */ +@Service +@RequiredArgsConstructor +public class TextValidationService { + + private final FitPubTextLimitsProperties textLimitsProperties; + + public int getUserBioMaxLength() { + return textLimitsProperties.getUser().getBio().getMaxLength(); + } + + public int getActivityTitleMaxLength() { + return textLimitsProperties.getActivity().getTitle().getMaxLength(); + } + + public int getActivityDescriptionMaxLength() { + return textLimitsProperties.getActivity().getDescription().getMaxLength(); + } + + public void validateUserBio(String bio) { + validateMaxLength(bio, getUserBioMaxLength(), "Bio"); + } + + public void validateActivityTitle(String title) { + validateMaxLength(title, getActivityTitleMaxLength(), "Title"); + } + + public void validateActivityDescription(String description) { + validateMaxLength(description, getActivityDescriptionMaxLength(), "Description"); + } + + private void validateMaxLength(String value, int maxLength, String fieldName) { + if (value != null && value.length() > maxLength) { + throw new ApiValidationException(fieldName + " must not exceed " + maxLength + " characters"); + } + } +} diff --git a/src/main/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilder.java b/src/main/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilder.java deleted file mode 100644 index dd8752d..0000000 --- a/src/main/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilder.java +++ /dev/null @@ -1,86 +0,0 @@ -package net.javahippie.fitpub.service; - -import lombok.RequiredArgsConstructor; -import net.javahippie.fitpub.model.dto.ActivityDTO; -import net.javahippie.fitpub.model.entity.Activity; -import net.javahippie.fitpub.model.entity.ActivityMetrics; -import net.javahippie.fitpub.model.entity.PrivacyZone; -import org.springframework.stereotype.Service; - -import java.time.Duration; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * Builds the proprietary workoutData payload for outbound ActivityPub Notes. - */ -@Service -@RequiredArgsConstructor -public class WorkoutDataPayloadBuilder { - - private final PrivacyZoneService privacyZoneService; - private final TrackPrivacyFilter trackPrivacyFilter; - - public Map build(Activity activity) { - Map workoutData = new HashMap<>(); - workoutData.put("activityType", activity.getActivityType().name()); - - if (activity.getDescription() != null && !activity.getDescription().isBlank()) { - workoutData.put("description", activity.getDescription()); - } - if (activity.getTotalDistance() != null) { - workoutData.put("distance", activity.getTotalDistance().longValue()); - } - if (activity.getTotalDurationSeconds() != null) { - workoutData.put("duration", Duration.ofSeconds(activity.getTotalDurationSeconds()).toString()); - } - if (activity.getElevationGain() != null) { - workoutData.put("elevationGain", activity.getElevationGain().intValue()); - } - - ActivityMetrics metrics = activity.getMetrics(); - if (metrics != null) { - if (metrics.getAveragePaceSeconds() != null) { - workoutData.put("averagePace", Duration.ofSeconds(metrics.getAveragePaceSeconds()).toString()); - } - if (metrics.getAverageHeartRate() != null) { - workoutData.put("averageHeartRate", metrics.getAverageHeartRate()); - } - if (metrics.getAverageSpeed() != null) { - workoutData.put("averageSpeed", metrics.getAverageSpeed().doubleValue()); - } - if (metrics.getMaxSpeed() != null) { - workoutData.put("maxSpeed", metrics.getMaxSpeed().doubleValue()); - } - if (metrics.getCalories() != null) { - workoutData.put("calories", metrics.getCalories()); - } - } - - Map route = buildRoutePayload(activity); - if (route != null) { - workoutData.put("route", route); - } - - return workoutData; - } - - private Map buildRoutePayload(Activity activity) { - List privacyZones = privacyZoneService.getActivePrivacyZones(activity.getUserId()); - ActivityDTO dto = ActivityDTO.fromEntityWithFiltering(activity, null, privacyZones, trackPrivacyFilter); - - if (dto.getSimplifiedTrack() == null) { - return null; - } - - Map feature = new HashMap<>(); - feature.put("type", "Feature"); - feature.put("geometry", dto.getSimplifiedTrack()); - - Map featureCollection = new HashMap<>(); - featureCollection.put("type", "FeatureCollection"); - featureCollection.put("features", List.of(feature)); - return featureCollection; - } -} diff --git a/src/main/java/net/javahippie/fitpub/util/ActivityFormatter.java b/src/main/java/net/javahippie/fitpub/util/ActivityFormatter.java index 0b32b3d..26e4f32 100644 --- a/src/main/java/net/javahippie/fitpub/util/ActivityFormatter.java +++ b/src/main/java/net/javahippie/fitpub/util/ActivityFormatter.java @@ -98,10 +98,6 @@ public class ActivityFormatter { * */ private static LocalDateTime getUtcDateTimeInZone(LocalDateTime utcDateTime, String timezone) { - if (timezone == null || timezone.isBlank()) { - return utcDateTime; - } - try { return utcDateTime.atZone(ZoneOffset.UTC) .withZoneSameInstant(ZoneId.of(timezone)) diff --git a/src/main/java/net/javahippie/fitpub/util/ActivityPubContexts.java b/src/main/java/net/javahippie/fitpub/util/ActivityPubContexts.java index ce424c6..84581bd 100644 --- a/src/main/java/net/javahippie/fitpub/util/ActivityPubContexts.java +++ b/src/main/java/net/javahippie/fitpub/util/ActivityPubContexts.java @@ -35,8 +35,7 @@ public final class ActivityPubContexts { /** * Returns the extended JSON-LD {@code @context} value for outbound objects - * that carry both interaction-policy declarations and FitPub's proprietary - * {@code workoutData} extension fields. Shape: + * that carry interaction-policy declarations. Shape: * *

      * [
@@ -46,20 +45,7 @@ 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" },
-     *     "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"
+     *     "manualApproval":     { "@id": "gts:manualApproval",     "@type": "@id" }
      *   }
      * ]
      * 
@@ -70,12 +56,6 @@ 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<>(); @@ -84,19 +64,6 @@ 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/application.yml b/src/main/resources/application.yml index 3a4e4e5..a1129a3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -84,6 +84,18 @@ fitpub: # Leave empty to allow open registration password: ${REGISTRATION_PASSWORD:} +# User settings + user: + bio: + max-length: ${FITPUB_USER_BIO_MAX_LENGTH:500} + +# Activity settings + activity: + title: + max-length: ${FITPUB_ACTIVITY_TITLE_MAX_LENGTH:200} + description: + max-length: ${FITPUB_ACTIVITY_DESCRIPTION_MAX_LENGTH:5000} + # Storage settings storage: fit-files: diff --git a/src/main/resources/db/migration/V32__add_simplified_track_to_remote_activities.sql b/src/main/resources/db/migration/V32__add_simplified_track_to_remote_activities.sql deleted file mode 100644 index 49e3b7e..0000000 --- a/src/main/resources/db/migration/V32__add_simplified_track_to_remote_activities.sql +++ /dev/null @@ -1,9 +0,0 @@ -ALTER TABLE remote_activities - ADD COLUMN simplified_track geometry(LineString, 4326); - -CREATE INDEX idx_remote_activity_simplified_track - ON remote_activities - USING gist (simplified_track); - -COMMENT ON COLUMN remote_activities.simplified_track IS - 'Simplified remote route geometry for local map rendering'; diff --git a/src/main/resources/templates/activities/edit.html b/src/main/resources/templates/activities/edit.html index fb97558..5858c3d 100644 --- a/src/main/resources/templates/activities/edit.html +++ b/src/main/resources/templates/activities/edit.html @@ -51,7 +51,7 @@ id="title" name="title" placeholder="e.g., Morning Run" - maxlength="200" + th:attr="maxlength=${activityTitleMaxLength}" required>
Please provide a title for your activity. @@ -83,9 +83,9 @@ name="description" rows="4" placeholder="Share details about your activity..." - maxlength="5000"> + th:attr="maxlength=${activityDescriptionMaxLength}">
- 0/5000 characters + 0/5000 characters
diff --git a/src/main/resources/templates/activities/upload.html b/src/main/resources/templates/activities/upload.html index b45774f..eab2902 100644 --- a/src/main/resources/templates/activities/upload.html +++ b/src/main/resources/templates/activities/upload.html @@ -83,7 +83,7 @@ id="title" name="title" placeholder="e.g., Morning Run" - maxlength="200"> + th:attr="maxlength=${activityTitleMaxLength}">
Please provide a title for your activity.
@@ -126,9 +126,9 @@ name="description" rows="4" placeholder="Share details about your activity..." - maxlength="5000"> + th:attr="maxlength=${activityDescriptionMaxLength}">
- 0/5000 characters + 0/5000 characters
diff --git a/src/main/resources/templates/profile/edit.html b/src/main/resources/templates/profile/edit.html index e7fc0dc..617286a 100644 --- a/src/main/resources/templates/profile/edit.html +++ b/src/main/resources/templates/profile/edit.html @@ -39,9 +39,10 @@
- +
- 0/500 characters + 0/500 characters
diff --git a/src/main/resources/templates/profile/public.html b/src/main/resources/templates/profile/public.html index 828877d..ef43a1b 100644 --- a/src/main/resources/templates/profile/public.html +++ b/src/main/resources/templates/profile/public.html @@ -46,7 +46,7 @@

-

+

diff --git a/src/test/java/net/javahippie/fitpub/config/TestcontainersConfiguration.java b/src/test/java/net/javahippie/fitpub/config/TestcontainersConfiguration.java index 4819da6..3053571 100644 --- a/src/test/java/net/javahippie/fitpub/config/TestcontainersConfiguration.java +++ b/src/test/java/net/javahippie/fitpub/config/TestcontainersConfiguration.java @@ -4,6 +4,7 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.context.annotation.Bean; import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy; import org.testcontainers.utility.DockerImageName; /** @@ -22,6 +23,8 @@ public class TestcontainersConfiguration { ) .withDatabaseName("testdb") .withUsername("test") - .withPassword("test"); + .withPassword("test") + .waitingFor(new HostPortWaitStrategy()) + .withReuse(true); } } diff --git a/src/test/java/net/javahippie/fitpub/controller/ActivityPubControllerTest.java b/src/test/java/net/javahippie/fitpub/controller/ActivityPubControllerTest.java deleted file mode 100644 index a0d9129..0000000 --- a/src/test/java/net/javahippie/fitpub/controller/ActivityPubControllerTest.java +++ /dev/null @@ -1,165 +0,0 @@ -package net.javahippie.fitpub.controller; - -import com.fasterxml.jackson.databind.ObjectMapper; -import net.javahippie.fitpub.model.entity.Activity; -import net.javahippie.fitpub.model.entity.User; -import net.javahippie.fitpub.repository.ActivityRepository; -import net.javahippie.fitpub.repository.FollowRepository; -import net.javahippie.fitpub.repository.UserRepository; -import net.javahippie.fitpub.security.HttpSignatureValidator; -import net.javahippie.fitpub.service.ActivityImageService; -import net.javahippie.fitpub.service.FederationService; -import net.javahippie.fitpub.service.InboxProcessor; -import net.javahippie.fitpub.service.WorkoutDataPayloadBuilder; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.ResponseEntity; -import org.springframework.test.util.ReflectionTestUtils; - -import java.io.File; -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.time.ZoneOffset; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -@DisplayName("ActivityPubController Tests") -class ActivityPubControllerTest { - - @Mock - private UserRepository userRepository; - - @Mock - private ActivityRepository activityRepository; - - @Mock - private ActivityImageService activityImageService; - - @Mock - private InboxProcessor inboxProcessor; - - @Mock - private FollowRepository followRepository; - - @Mock - private HttpSignatureValidator signatureValidator; - - @Mock - private FederationService federationService; - - @Mock - private ObjectMapper objectMapper; - - @Mock - private WorkoutDataPayloadBuilder workoutDataPayloadBuilder; - - @InjectMocks - private ActivityPubController controller; - - private UUID activityId; - private UUID userId; - private Activity activity; - private User user; - private LocalDateTime createdAt; - - @BeforeEach - void setUp() { - activityId = UUID.randomUUID(); - userId = UUID.randomUUID(); - createdAt = LocalDateTime.of(2026, 5, 2, 9, 24, 50, 921_241_000); - - ReflectionTestUtils.setField(controller, "baseUrl", "https://fitpub.example"); - - activity = Activity.builder() - .id(activityId) - .userId(userId) - .activityType(Activity.ActivityType.RUN) - .title("Lunch Run") - .description("Sunny run") - .visibility(Activity.Visibility.PUBLIC) - .totalDistance(BigDecimal.valueOf(5000)) - .totalDurationSeconds(1800L) - .createdAt(createdAt) - .build(); - - user = User.builder() - .id(userId) - .username("JaneDoe") - .email("janedoe@example.com") - .publicKey("public-key") - .privateKey("private-key") - .build(); - } - - @Test - @DisplayName("Should serialize activity published timestamp with timezone") - void getActivity_ShouldSerializePublishedTimestampWithTimezone() { - when(activityRepository.findById(activityId)).thenReturn(Optional.of(activity)); - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - when(activityImageService.getActivityImageFile(activityId)).thenReturn(new File("/definitely/nonexistent-fitpub-test-image")); - - ResponseEntity> response = controller.getActivity(activityId); - - assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); - assertThat(response.getBody()).isNotNull(); - assertThat(response.getBody().get("published")) - .isEqualTo(createdAt.atOffset(ZoneOffset.UTC).toInstant().toString()); - } - - @Test - @DisplayName("Should include workoutData and FitPub context terms in activity note") - void getActivity_ShouldIncludeWorkoutDataAndExtendedContext() { - when(activityRepository.findById(activityId)).thenReturn(Optional.of(activity)); - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - when(activityImageService.getActivityImageFile(activityId)).thenReturn(new File("/definitely/nonexistent-fitpub-test-image")); - when(workoutDataPayloadBuilder.build(activity)).thenReturn(Map.of( - "activityType", "RUN", - "description", "Sunny run", - "distance", 5000L, - "duration", "PT30M", - "averagePace", "PT6M", - "route", Map.of( - "type", "FeatureCollection", - "features", List.of() - ) - )); - - ResponseEntity> response = controller.getActivity(activityId); - - assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); - assertThat(response.getBody()).isNotNull(); - assertThat(response.getBody().get("workoutData")).isEqualTo(Map.of( - "activityType", "RUN", - "description", "Sunny run", - "distance", 5000L, - "duration", "PT30M", - "averagePace", "PT6M", - "route", Map.of( - "type", "FeatureCollection", - "features", List.of() - ) - )); - - @SuppressWarnings("unchecked") - List context = (List) response.getBody().get("@context"); - assertThat(context).hasSize(2); - - @SuppressWarnings("unchecked") - Map extensions = (Map) context.get(1); - assertThat(extensions) - .containsEntry("fitpub", "https://fitpub.social/ns#") - .containsEntry("workoutData", "fitpub:workoutData") - .containsEntry("route", "fitpub:route"); - } -} diff --git a/src/test/java/net/javahippie/fitpub/integration/FederationFollowFlowIntegrationTest.java b/src/test/java/net/javahippie/fitpub/integration/FederationFollowFlowIntegrationTest.java index b07d325..99e3411 100644 --- a/src/test/java/net/javahippie/fitpub/integration/FederationFollowFlowIntegrationTest.java +++ b/src/test/java/net/javahippie/fitpub/integration/FederationFollowFlowIntegrationTest.java @@ -2,25 +2,19 @@ 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; @@ -32,21 +26,15 @@ 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.*; @@ -75,12 +63,6 @@ class FederationFollowFlowIntegrationTest { @Autowired private RemoteActorRepository remoteActorRepository; - @Autowired - private RemoteActivityRepository remoteActivityRepository; - - @Autowired - private ActivityRepository activityRepository; - @Autowired private PasswordEncoder passwordEncoder; @@ -90,9 +72,6 @@ class FederationFollowFlowIntegrationTest { @Autowired private HttpSignatureValidator signatureValidator; - @MockBean - private ActivityImageService activityImageService; - @Value("${fitpub.base-url}") private String baseUrl; @@ -122,22 +101,6 @@ 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); @@ -307,111 +270,6 @@ class FederationFollowFlowIntegrationTest { assertThat(updatedFollow.getStatus()).isEqualTo(Follow.FollowStatus.ACCEPTED); } - @Test - @DisplayName("Should import its own exported public activity through inbox") - void testActivityRoundtripThroughExportAndInbox() throws Exception { - User importingUser = testUser; - User exportingUser = createFederatedUser("janedoe", "janedoe@example.com", "Jane Doe"); - - Activity activity = activityRepository.save(Activity.builder() - .userId(exportingUser.getId()) - .activityType(Activity.ActivityType.RUN) - .title("Lunch Run") - .description("Sunny run in the city") - .startedAt(LocalDateTime.of(2026, 5, 2, 12, 0)) - .endedAt(LocalDateTime.of(2026, 5, 2, 12, 30)) - .createdAt(LocalDateTime.of(2026, 5, 2, 12, 31, 45, 123_000_000)) - .visibility(Activity.Visibility.PUBLIC) - .totalDistance(BigDecimal.valueOf(5000)) - .totalDurationSeconds(1800L) - .elevationGain(BigDecimal.valueOf(100)) - .sourceFileFormat("FIT") - .published(true) - .build()); - - String exportingActorUri = baseUrl + "/users/" + exportingUser.getUsername(); - when(activityImageService.getActivityImageFile(activity.getId())) - .thenReturn(new File("/definitely/nonexistent-fitpub-roundtrip-image")); - - remoteActorRepository.save(RemoteActor.builder() - .actorUri(exportingActorUri) - .username(exportingUser.getUsername()) - .domain(java.net.URI.create(baseUrl).getHost()) - .displayName(exportingUser.getDisplayName()) - .inboxUrl(exportingActorUri + "/inbox") - .outboxUrl(exportingActorUri + "/outbox") - .publicKey(exportingUser.getPublicKey()) - .publicKeyId(exportingActorUri + "#main-key") - .lastFetchedAt(Instant.now()) - .build()); - - followRepository.save(Follow.builder() - .followerId(importingUser.getId()) - .followingActorUri(exportingActorUri) - .status(Follow.FollowStatus.ACCEPTED) - .activityId(baseUrl + "/activities/follow/" + UUID.randomUUID()) - .build()); - - MvcResult exportResult = mockMvc.perform(get("/activities/" + activity.getId()) - .accept("application/activity+json")) - .andExpect(status().isOk()) - .andReturn(); - - @SuppressWarnings("unchecked") - Map exportedNote = objectMapper.readValue(exportResult.getResponse().getContentAsByteArray(), Map.class); - - Map createActivity = Map.of( - "@context", "https://www.w3.org/ns/activitystreams", - "type", "Create", - "id", baseUrl + "/activities/create/" + UUID.randomUUID(), - "actor", exportingActorUri, - "object", exportedNote - ); - - String privateKeyPem = exportingUser.getPrivateKey(); - String inboxPath = "/users/" + importingUser.getUsername() + "/inbox"; - String inboxUrl = "http://localhost" + inboxPath; - String body = objectMapper.writeValueAsString(createActivity); - HttpSignatureValidator.SignatureHeaders sigHeaders = signatureValidator.signRequest( - "POST", inboxUrl, body, privateKeyPem, exportingActorUri + "#main-key" - ); - - mockMvc.perform(post(inboxPath) - .contentType("application/activity+json") - .header("Host", sigHeaders.host) - .header("Date", sigHeaders.date) - .header("Digest", sigHeaders.digest) - .header("Signature", sigHeaders.signature) - .content(body)) - .andExpect(status().isAccepted()); - - RemoteActivity imported = remoteActivityRepository.findByActivityUri((String) exportedNote.get("id")) - .orElseThrow(); - - @SuppressWarnings("unchecked") - Map workoutData = (Map) exportedNote.get("workoutData"); - - assertThat(imported.getActivityUri()).isEqualTo(exportedNote.get("id")); - assertThat(imported.getRemoteActorUri()).isEqualTo(exportingActorUri); - assertThat(imported.getTitle()).isEqualTo(exportedNote.getOrDefault("name", - exportedNote.getOrDefault("summary", "Untitled Activity"))); - assertThat(imported.getDescription()).isEqualTo(workoutData.get("description")); - assertThat(imported.getPublishedAt()).isEqualTo(Instant.parse((String) exportedNote.get("published"))); - assertThat(imported.getVisibility()).isEqualTo(RemoteActivity.Visibility.PUBLIC); - assertThat(imported.getActivityType()).isEqualTo(workoutData.get("activityType")); - assertThat(imported.getTotalDistance()).isEqualTo(5000L); - assertThat(imported.getTotalDurationSeconds()).isEqualTo(1800L); - assertThat(imported.getElevationGain()).isEqualTo(workoutData.get("elevationGain")); - assertThat(imported.getAveragePaceSeconds()).isNull(); - assertThat(imported.getAverageHeartRate()).isNull(); - assertThat(imported.getMaxSpeed()).isNull(); - assertThat(imported.getAverageSpeed()).isNull(); - assertThat(imported.getCalories()).isNull(); - assertThat(imported.getMapImageUrl()).isNull(); - assertThat(imported.getTrackGeojsonUrl()).isNull(); - assertThat(imported.getSimplifiedTrack()).isNull(); - } - @Test @DisplayName("Should reject inbox POST without HTTP signature with 401") void testInboxRejectsUnsignedRequest() throws Exception { @@ -452,23 +310,6 @@ class FederationFollowFlowIntegrationTest { .andExpect(status().isUnauthorized()); } - private String stripHtml(String html) { - if (html == null) { - return ""; - } - return html - .replaceAll("", "\n") - .replaceAll("

", "") - .replaceAll("

", "\n") - .replaceAll("<[^>]+>", "") - .replace("<", "<") - .replace(">", ">") - .replace(""", "\"") - .replace("'", "'") - .replace("&", "&") - .trim(); - } - @Test @DisplayName("Should process Undo Follow activity and remove follow relationship") void testProcessUndoFollowActivity() throws Exception { diff --git a/src/test/java/net/javahippie/fitpub/service/ActivityImageServiceTest.java b/src/test/java/net/javahippie/fitpub/service/ActivityImageServiceTest.java index 0343ab4..687eb45 100644 --- a/src/test/java/net/javahippie/fitpub/service/ActivityImageServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/ActivityImageServiceTest.java @@ -27,16 +27,12 @@ import static org.junit.jupiter.api.Assertions.*; /** * Manual test for ActivityImageService. * These tests are disabled by default and should only be run manually. - * - * To run this test manually: - * mvn test -Dtest=ActivityImageServiceTest */ @SpringBootTest(properties = { "fitpub.image.osm-tiles.enabled=true" }) @ActiveProfiles("test") @Import(TestcontainersConfiguration.class) -@Disabled("Manual test - run explicitly when needed") class ActivityImageServiceTest { @Autowired @@ -59,6 +55,7 @@ class ActivityImageServiceTest { * mvn test -Dtest=ActivityImageServiceTest#testGenerateActivityImage_Manual */ @Test + @Disabled("Manual test - run explicitly when needed") @DisplayName("Generate activity image from test FIT file") void testGenerateActivityImage_Manual() throws Exception { // Load test FIT file diff --git a/src/test/java/net/javahippie/fitpub/service/ActivityPostProcessingServiceTest.java b/src/test/java/net/javahippie/fitpub/service/ActivityPostProcessingServiceTest.java index 5507c23..08ef492 100644 --- a/src/test/java/net/javahippie/fitpub/service/ActivityPostProcessingServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/ActivityPostProcessingServiceTest.java @@ -1,42 +1,25 @@ 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.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; +import static org.mockito.Mockito.*; /** * Unit tests for ActivityPostProcessingService. @@ -66,9 +49,6 @@ class ActivityPostProcessingServiceTest { @Mock private UserRepository userRepository; - @Mock - private WorkoutDataPayloadBuilder workoutDataPayloadBuilder; - @InjectMocks private ActivityPostProcessingService service; @@ -76,13 +56,11 @@ 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"); @@ -98,39 +76,9 @@ class ActivityPostProcessingServiceTest { .totalDistance(BigDecimal.valueOf(5000)) .totalDurationSeconds(1800L) .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) - })) + .startedAt(LocalDateTime.now()) + .createdAt(LocalDateTime.now()) .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() @@ -284,24 +232,6 @@ class ActivityPostProcessingServiceTest { verify(federationService).sendCreateActivity(anyString(), any(), eq(testUser), eq(false)); } - @Test - @DisplayName("Should serialize federation note published timestamp with timezone") - void testPublishToFederationAsync_PublishedTimestampIncludesTimezone() { - when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity)); - when(userRepository.findById(userId)).thenReturn(Optional.of(testUser)); - when(activityImageService.generateActivityImage(testActivity)).thenReturn(null); - doNothing().when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean()); - - @SuppressWarnings("unchecked") - ArgumentCaptor> noteCaptor = ArgumentCaptor.forClass(java.util.Map.class); - - service.publishToFederationAsync(activityId, userId); - - verify(federationService).sendCreateActivity(anyString(), noteCaptor.capture(), eq(testUser), eq(true)); - assertThat(noteCaptor.getValue().get("published")) - .isEqualTo(createdAt.atOffset(ZoneOffset.UTC).toInstant().toString()); - } - @Test @DisplayName("Should skip federation for PRIVATE activity") void testPublishToFederationAsync_PrivateActivity() { @@ -387,47 +317,4 @@ 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 deleted file mode 100644 index f1ae088..0000000 --- a/src/test/java/net/javahippie/fitpub/service/InboxProcessorTest.java +++ /dev/null @@ -1,217 +0,0 @@ -package net.javahippie.fitpub.service; - -import net.javahippie.fitpub.model.entity.Follow; -import net.javahippie.fitpub.model.entity.RemoteActivity; -import net.javahippie.fitpub.model.entity.RemoteActor; -import net.javahippie.fitpub.model.entity.User; -import net.javahippie.fitpub.repository.ActivityRepository; -import net.javahippie.fitpub.repository.CommentRepository; -import net.javahippie.fitpub.repository.FollowRepository; -import net.javahippie.fitpub.repository.LikeRepository; -import net.javahippie.fitpub.repository.RemoteActivityRepository; -import net.javahippie.fitpub.repository.RemoteActorRepository; -import net.javahippie.fitpub.repository.UserRepository; -import org.locationtech.jts.geom.LineString; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; - -import java.time.Instant; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -@DisplayName("InboxProcessor Tests") -class InboxProcessorTest { - - @Mock - private UserRepository userRepository; - - @Mock - private FollowRepository followRepository; - - @Mock - private FederationService federationService; - - @Mock - private ActivityRepository activityRepository; - - @Mock - private LikeRepository likeRepository; - - @Mock - private CommentRepository commentRepository; - - @Mock - private NotificationService notificationService; - - @Mock - private RemoteActivityRepository remoteActivityRepository; - - @Mock - private RemoteActorRepository remoteActorRepository; - - @InjectMocks - private InboxProcessor inboxProcessor; - - private User localUser; - private String remoteActorUri; - - @BeforeEach - void setUp() { - localUser = User.builder() - .id(UUID.randomUUID()) - .username("JaneDoe") - .email("janedoe@example.com") - .passwordHash("irrelevant") - .publicKey("public-key") - .privateKey("private-key") - .build(); - - remoteActorUri = "https://fitpub.example.com/users/JohnDoe"; - - ReflectionTestUtils.setField(inboxProcessor, "baseUrl", "https://fitpub.example"); - } - - @Test - @DisplayName("Should persist remote activity when published timestamp has fractional seconds but no timezone") - void processCreateRemoteActivity_WithPublishedTimestampWithoutTimezone_ShouldPersistRemoteActivity() { - when(remoteActivityRepository.existsByActivityUri("https://fitpub.example.com/activities/123")) - .thenReturn(false); - when(federationService.fetchRemoteActor(remoteActorUri)).thenReturn(RemoteActor.builder() - .actorUri(remoteActorUri) - .username("JohnDoe") - .domain("fitpub.example.com") - .inboxUrl("https://fitpub.example.com/users/JohnDoe/inbox") - .publicKey("public-key") - .build()); - when(userRepository.findByUsername("JaneDoe")).thenReturn(Optional.of(localUser)); - when(followRepository.findByFollowerIdAndFollowingActorUri(localUser.getId(), remoteActorUri)) - .thenReturn(Optional.of(Follow.builder() - .followerId(localUser.getId()) - .followingActorUri(remoteActorUri) - .status(Follow.FollowStatus.ACCEPTED) - .build())); - - Map note = Map.of( - "id", "https://fitpub.example.com/activities/123", - "type", "Note", - "name", "Lunch Run", - "content", "

Sunny run

", - "published", "2026-05-02T09:24:50.921241", - "to", List.of("https://www.w3.org/ns/activitystreams#Public") - ); - - Map activity = Map.of( - "type", "Create", - "actor", remoteActorUri, - "object", note - ); - - ArgumentCaptor remoteActivityCaptor = - ArgumentCaptor.forClass(net.javahippie.fitpub.model.entity.RemoteActivity.class); - - inboxProcessor.processActivity("JaneDoe", activity); - - verify(remoteActivityRepository).existsByActivityUri("https://fitpub.example.com/activities/123"); - verify(federationService).fetchRemoteActor(remoteActorUri); - verify(remoteActivityRepository).save(remoteActivityCaptor.capture()); - - assertThat(remoteActivityCaptor.getValue().getPublishedAt()) - .isEqualTo(Instant.parse("2026-05-02T09:24:50.921241Z")); - } - - @Test - @DisplayName("Should prefer workoutData fields over legacy content parsing") - void processCreateRemoteActivity_WithWorkoutDataPayload_ShouldPreferWorkoutDataFields() { - when(remoteActivityRepository.existsByActivityUri("https://fitpub.example.com/activities/456")) - .thenReturn(false); - when(federationService.fetchRemoteActor(remoteActorUri)).thenReturn(RemoteActor.builder() - .actorUri(remoteActorUri) - .username("JohnDoe") - .domain("fitpub.example.com") - .inboxUrl("https://fitpub.example.com/users/JohnDoe/inbox") - .publicKey("public-key") - .build()); - when(userRepository.findByUsername("JaneDoe")).thenReturn(Optional.of(localUser)); - when(followRepository.findByFollowerIdAndFollowingActorUri(localUser.getId(), remoteActorUri)) - .thenReturn(Optional.of(Follow.builder() - .followerId(localUser.getId()) - .followingActorUri(remoteActorUri) - .status(Follow.FollowStatus.ACCEPTED) - .build())); - - Map workoutData = new HashMap<>(); - workoutData.put("activityType", "RUN"); - workoutData.put("description", "Direct workoutData description"); - workoutData.put("distance", 9800L); - workoutData.put("duration", "PT41M9S"); - workoutData.put("averagePace", "PT4M12S"); - workoutData.put("elevationGain", 123); - workoutData.put("route", Map.of( - "type", "FeatureCollection", - "features", List.of(Map.of( - "type", "Feature", - "geometry", Map.of( - "type", "LineString", - "coordinates", List.of( - List.of(8.55, 47.37), - List.of(8.56, 47.38), - List.of(8.57, 47.39) - ) - ) - )) - )); - - Map note = Map.of( - "id", "https://fitpub.example.com/activities/456", - "type", "Note", - "name", "Kraremanns Lauf 2026", - "content", "

Kraremanns Lauf 2026

Run · 9.80 km · 41:09

Legacy content fallback

", - "published", "2026-05-02T09:24:50.921241", - "to", List.of("https://www.w3.org/ns/activitystreams#Public"), - "workoutData", workoutData - ); - - Map activity = Map.of( - "type", "Create", - "actor", remoteActorUri, - "object", note - ); - - ArgumentCaptor remoteActivityCaptor = - ArgumentCaptor.forClass(RemoteActivity.class); - - inboxProcessor.processActivity("JaneDoe", activity); - - verify(remoteActivityRepository).save(remoteActivityCaptor.capture()); - - RemoteActivity remoteActivity = remoteActivityCaptor.getValue(); - assertThat(remoteActivity.getTitle()).isEqualTo("Kraremanns Lauf 2026"); - assertThat(remoteActivity.getDescription()).isEqualTo("Direct workoutData description"); - assertThat(remoteActivity.getActivityType()).isEqualTo("RUN"); - assertThat(remoteActivity.getTotalDistance()).isEqualTo(9800L); - assertThat(remoteActivity.getTotalDurationSeconds()).isEqualTo(2469L); - assertThat(remoteActivity.getAveragePaceSeconds()).isEqualTo(252L); - assertThat(remoteActivity.getElevationGain()).isEqualTo(123); - LineString simplifiedTrack = remoteActivity.getSimplifiedTrack(); - assertThat(simplifiedTrack).isNotNull(); - assertThat(simplifiedTrack.getNumPoints()).isEqualTo(3); - assertThat(simplifiedTrack.getSRID()).isEqualTo(4326); - assertThat(simplifiedTrack.getCoordinateN(0).x).isEqualTo(8.55); - assertThat(simplifiedTrack.getCoordinateN(0).y).isEqualTo(47.37); - } -} diff --git a/src/test/java/net/javahippie/fitpub/service/TextValidationServiceTest.java b/src/test/java/net/javahippie/fitpub/service/TextValidationServiceTest.java new file mode 100644 index 0000000..d4cf8b0 --- /dev/null +++ b/src/test/java/net/javahippie/fitpub/service/TextValidationServiceTest.java @@ -0,0 +1,69 @@ +package net.javahippie.fitpub.service; + +import net.javahippie.fitpub.config.FitPubTextLimitsProperties; +import net.javahippie.fitpub.exception.ApiValidationException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DisplayName("TextValidationService Tests") +class TextValidationServiceTest { + + @Test + @DisplayName("Should allow user bio up to configured max length") + void shouldAllowUserBioUpToConfiguredMaxLength() { + TextValidationService service = new TextValidationService(properties(12, 20, 30)); + + assertDoesNotThrow(() -> service.validateUserBio("123456789012")); + } + + @Test + @DisplayName("Should reject user bio longer than configured max length") + void shouldRejectUserBioLongerThanConfiguredMaxLength() { + TextValidationService service = new TextValidationService(properties(12, 20, 30)); + + assertThrows(ApiValidationException.class, () -> service.validateUserBio("1234567890123")); + } + + @Test + @DisplayName("Should allow activity title up to configured max length") + void shouldAllowActivityTitleUpToConfiguredMaxLength() { + TextValidationService service = new TextValidationService(properties(12, 10, 30)); + + assertDoesNotThrow(() -> service.validateActivityTitle("1234567890")); + } + + @Test + @DisplayName("Should reject activity title longer than configured max length") + void shouldRejectActivityTitleLongerThanConfiguredMaxLength() { + TextValidationService service = new TextValidationService(properties(12, 10, 30)); + + assertThrows(ApiValidationException.class, () -> service.validateActivityTitle("12345678901")); + } + + @Test + @DisplayName("Should allow activity description up to configured max length") + void shouldAllowActivityDescriptionUpToConfiguredMaxLength() { + TextValidationService service = new TextValidationService(properties(12, 10, 20)); + + assertDoesNotThrow(() -> service.validateActivityDescription("12345678901234567890")); + } + + @Test + @DisplayName("Should reject activity description longer than configured max length") + void shouldRejectActivityDescriptionLongerThanConfiguredMaxLength() { + TextValidationService service = new TextValidationService(properties(12, 10, 20)); + + assertThrows(ApiValidationException.class, () -> service.validateActivityDescription("123456789012345678901")); + } + + private FitPubTextLimitsProperties properties(int bioMaxLength, int titleMaxLength, int descriptionMaxLength) { + FitPubTextLimitsProperties properties = new FitPubTextLimitsProperties(); + properties.getUser().getBio().setMaxLength(bioMaxLength); + properties.getActivity().getTitle().setMaxLength(titleMaxLength); + properties.getActivity().getDescription().setMaxLength(descriptionMaxLength); + return properties; + } +} diff --git a/src/test/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilderTest.java b/src/test/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilderTest.java deleted file mode 100644 index bc21615..0000000 --- a/src/test/java/net/javahippie/fitpub/service/WorkoutDataPayloadBuilderTest.java +++ /dev/null @@ -1,100 +0,0 @@ -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) - )); - } -}