Compare commits
1 commit
main
...
McPringle/
| Author | SHA1 | Date | |
|---|---|---|---|
| c05ac79d75 |
44 changed files with 352 additions and 1088 deletions
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -5,7 +5,10 @@ target/
|
||||||
.kotlin
|
.kotlin
|
||||||
|
|
||||||
### IntelliJ IDEA ###
|
### IntelliJ IDEA ###
|
||||||
.idea/
|
.idea/modules.xml
|
||||||
|
.idea/jarRepositories.xml
|
||||||
|
.idea/compiler.xml
|
||||||
|
.idea/libraries/
|
||||||
*.iws
|
*.iws
|
||||||
*.iml
|
*.iml
|
||||||
*.ipr
|
*.ipr
|
||||||
|
|
@ -46,10 +49,3 @@ logs/
|
||||||
/gadm_410.gpkg
|
/gadm_410.gpkg
|
||||||
/.postgresdata/
|
/.postgresdata/
|
||||||
/peaks_worldwide.geojson
|
/peaks_worldwide.geojson
|
||||||
|
|
||||||
### Coding Assistants ###
|
|
||||||
.codex/
|
|
||||||
.aider*
|
|
||||||
.cursor/
|
|
||||||
.roo/
|
|
||||||
.windsurf/
|
|
||||||
|
|
|
||||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
|
|
@ -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
|
||||||
17
.idea/dataSources.xml
generated
Normal file
17
.idea/dataSources.xml
generated
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="testdb@localhost" uuid="2564811a-81f9-4d83-b1b1-04cb2763e3fa">
|
||||||
|
<driver-ref>postgresql</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:postgresql://localhost:51826/testdb</jdbc-url>
|
||||||
|
<jdbc-additional-properties>
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.container.port" />
|
||||||
|
</jdbc-additional-properties>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/data_source_mapping.xml
generated
Normal file
6
.idea/data_source_mapping.xml
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourcePerFileMappings">
|
||||||
|
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/2564811a-81f9-4d83-b1b1-04cb2763e3fa/console_1.sql" value="2564811a-81f9-4d83-b1b1-04cb2763e3fa" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
7
.idea/encodings.xml
generated
Normal file
7
.idea/encodings.xml
generated
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Encoding">
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
17
.idea/misc.xml
generated
Normal file
17
.idea/misc.xml
generated
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ClojureProjectResolveSettings">
|
||||||
|
<currentScheme>IDE</currentScheme>
|
||||||
|
</component>
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="MavenProjectsManager">
|
||||||
|
<option name="originalFiles">
|
||||||
|
<list>
|
||||||
|
<option value="$PROJECT_DIR$/pom.xml" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_23" default="true" project-jdk-name="temurin-23" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/sqldialects.xml
generated
Normal file
8
.idea/sqldialects.xml
generated
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="SqlDialectMappings">
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/resources/db/migration/V23__add_gadm_table.sql" dialect="PostgreSQL" />
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/resources/db/migration/V24__add_location_to_activity.sql" dialect="PostgreSQL" />
|
||||||
|
<file url="file://$PROJECT_DIR$/src/main/resources/db/migration/V26__add_published_to_activities.sql" dialect="H2" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
9
pom.xml
9
pom.xml
|
|
@ -23,7 +23,7 @@
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
<java.version>17</java.version>
|
<java.version>17</java.version>
|
||||||
<jjwt.version>0.12.3</jjwt.version>
|
<jjwt.version>0.12.3</jjwt.version>
|
||||||
<testcontainers.version>2.0.5</testcontainers.version>
|
<testcontainers.version>2.0.3</testcontainers.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
|
@ -170,14 +170,15 @@
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.testcontainers</groupId>
|
<groupId>org.testcontainers</groupId>
|
||||||
<artifactId>testcontainers-junit-jupiter</artifactId>
|
<artifactId>testcontainers-junit-jupiter</artifactId>
|
||||||
<version>${testcontainers.version}</version>
|
<version>2.0.2</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.testcontainers</groupId>
|
<groupId>org.testcontainers</groupId>
|
||||||
<artifactId>testcontainers-postgresql</artifactId>
|
<artifactId>testcontainers-postgresql</artifactId>
|
||||||
<version>${testcontainers.version}</version>
|
<version>2.0.1</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
|
@ -192,4 +193,4 @@
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
|
|
@ -10,6 +10,7 @@ import org.apache.hc.client5.http.io.HttpClientConnectionManager;
|
||||||
import org.apache.hc.core5.util.Timeout;
|
import org.apache.hc.core5.util.Timeout;
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
|
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
|
||||||
import org.springframework.scheduling.annotation.EnableAsync;
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
|
@ -22,6 +23,7 @@ import org.springframework.web.client.RestTemplate;
|
||||||
* through the ActivityPub protocol.
|
* through the ActivityPub protocol.
|
||||||
*/
|
*/
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@ConfigurationPropertiesScan
|
||||||
@EnableAsync
|
@EnableAsync
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
@Slf4j
|
@Slf4j
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
package net.javahippie.fitpub.controller;
|
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.stereotype.Controller;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
|
@ -10,8 +13,11 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
*/
|
*/
|
||||||
@Controller
|
@Controller
|
||||||
@RequestMapping("/activities")
|
@RequestMapping("/activities")
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class ActivitiesViewController {
|
public class ActivitiesViewController {
|
||||||
|
|
||||||
|
private final TextValidationService textValidationService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show activities list page
|
* Show activities list page
|
||||||
*/
|
*/
|
||||||
|
|
@ -24,7 +30,9 @@ public class ActivitiesViewController {
|
||||||
* Show activity upload page
|
* Show activity upload page
|
||||||
*/
|
*/
|
||||||
@GetMapping("/upload")
|
@GetMapping("/upload")
|
||||||
public String uploadActivity() {
|
public String uploadActivity(Model model) {
|
||||||
|
model.addAttribute("activityTitleMaxLength", textValidationService.getActivityTitleMaxLength());
|
||||||
|
model.addAttribute("activityDescriptionMaxLength", textValidationService.getActivityDescriptionMaxLength());
|
||||||
return "activities/upload";
|
return "activities/upload";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -41,8 +49,10 @@ public class ActivitiesViewController {
|
||||||
* Show activity edit page
|
* Show activity edit page
|
||||||
*/
|
*/
|
||||||
@GetMapping("/{id}/edit")
|
@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
|
// The activity data will be loaded via JavaScript API calls
|
||||||
|
model.addAttribute("activityTitleMaxLength", textValidationService.getActivityTitleMaxLength());
|
||||||
|
model.addAttribute("activityDescriptionMaxLength", textValidationService.getActivityDescriptionMaxLength());
|
||||||
return "activities/edit";
|
return "activities/edit";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import net.javahippie.fitpub.service.WeatherService;
|
||||||
import net.javahippie.fitpub.service.FitFileService;
|
import net.javahippie.fitpub.service.FitFileService;
|
||||||
import net.javahippie.fitpub.service.PrivacyZoneService;
|
import net.javahippie.fitpub.service.PrivacyZoneService;
|
||||||
import net.javahippie.fitpub.service.ReactionEnricher;
|
import net.javahippie.fitpub.service.ReactionEnricher;
|
||||||
|
import net.javahippie.fitpub.service.TextValidationService;
|
||||||
import net.javahippie.fitpub.service.TrackPrivacyFilter;
|
import net.javahippie.fitpub.service.TrackPrivacyFilter;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
@ -48,6 +49,7 @@ import java.util.UUID;
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class ActivityController {
|
public class ActivityController {
|
||||||
|
|
||||||
|
private final TextValidationService textValidationService;
|
||||||
private final ActivityFileService activityFileService;
|
private final ActivityFileService activityFileService;
|
||||||
private final FitFileService fitFileService;
|
private final FitFileService fitFileService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
|
@ -153,6 +155,9 @@ public class ActivityController {
|
||||||
) {
|
) {
|
||||||
log.info("User {} uploading activity file: {}", userDetails.getUsername(), file.getOriginalFilename());
|
log.info("User {} uploading activity file: {}", userDetails.getUsername(), file.getOriginalFilename());
|
||||||
|
|
||||||
|
textValidationService.validateActivityTitle(request.getTitle());
|
||||||
|
textValidationService.validateActivityDescription(request.getDescription());
|
||||||
|
|
||||||
User user = userRepository.findByUsername(userDetails.getUsername())
|
User user = userRepository.findByUsername(userDetails.getUsername())
|
||||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||||
|
|
||||||
|
|
@ -311,6 +316,9 @@ public class ActivityController {
|
||||||
|
|
||||||
UUID userId = getUserId(userDetails);
|
UUID userId = getUserId(userDetails);
|
||||||
|
|
||||||
|
textValidationService.validateActivityTitle(request.getTitle());
|
||||||
|
textValidationService.validateActivityDescription(request.getDescription());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Activity updated = fitFileService.updateActivity(
|
Activity updated = fitFileService.updateActivity(
|
||||||
id,
|
id,
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,13 @@ import net.javahippie.fitpub.model.activitypub.OrderedCollection;
|
||||||
import net.javahippie.fitpub.model.entity.Activity;
|
import net.javahippie.fitpub.model.entity.Activity;
|
||||||
import net.javahippie.fitpub.model.entity.RemoteActor;
|
import net.javahippie.fitpub.model.entity.RemoteActor;
|
||||||
import net.javahippie.fitpub.model.entity.User;
|
import net.javahippie.fitpub.model.entity.User;
|
||||||
import net.javahippie.fitpub.repository.ActivityRepository;
|
|
||||||
import net.javahippie.fitpub.repository.FollowRepository;
|
import net.javahippie.fitpub.repository.FollowRepository;
|
||||||
|
import net.javahippie.fitpub.repository.ActivityRepository;
|
||||||
import net.javahippie.fitpub.repository.UserRepository;
|
import net.javahippie.fitpub.repository.UserRepository;
|
||||||
import net.javahippie.fitpub.security.HttpSignatureValidator;
|
import net.javahippie.fitpub.security.HttpSignatureValidator;
|
||||||
import net.javahippie.fitpub.service.ActivityImageService;
|
import net.javahippie.fitpub.service.ActivityImageService;
|
||||||
import net.javahippie.fitpub.service.FederationService;
|
import net.javahippie.fitpub.service.FederationService;
|
||||||
import net.javahippie.fitpub.service.InboxProcessor;
|
import net.javahippie.fitpub.service.InboxProcessor;
|
||||||
import net.javahippie.fitpub.service.WorkoutDataPayloadBuilder;
|
|
||||||
import net.javahippie.fitpub.util.ActivityFormatter;
|
import net.javahippie.fitpub.util.ActivityFormatter;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
@ -30,7 +29,6 @@ import java.net.URI;
|
||||||
import java.net.URISyntaxException;
|
import java.net.URISyntaxException;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
|
@ -53,7 +51,6 @@ public class ActivityPubController {
|
||||||
private final HttpSignatureValidator signatureValidator;
|
private final HttpSignatureValidator signatureValidator;
|
||||||
private final FederationService federationService;
|
private final FederationService federationService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
|
|
||||||
|
|
||||||
@Value("${fitpub.base-url}")
|
@Value("${fitpub.base-url}")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
|
@ -439,10 +436,9 @@ public class ActivityPubController {
|
||||||
noteObject.put("id", activityUri);
|
noteObject.put("id", activityUri);
|
||||||
noteObject.put("type", "Note");
|
noteObject.put("type", "Note");
|
||||||
noteObject.put("attributedTo", actorUri);
|
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("content", formatActivityContent(activity));
|
||||||
noteObject.put("url", activityUri);
|
noteObject.put("url", activityUri);
|
||||||
noteObject.put("workoutData", workoutDataPayloadBuilder.build(activity));
|
|
||||||
|
|
||||||
// Audience — only PUBLIC activities reach this endpoint (the visibility
|
// Audience — only PUBLIC activities reach this endpoint (the visibility
|
||||||
// check above returned 403 for anything else), so audience is always
|
// check above returned 403 for anything else), so audience is always
|
||||||
|
|
|
||||||
|
|
@ -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<ApiError> handleApiValidation(ApiValidationException e) {
|
||||||
|
return ResponseEntity.badRequest().body(new ApiError("BAD_REQUEST", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package net.javahippie.fitpub.controller;
|
package net.javahippie.fitpub.controller;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import net.javahippie.fitpub.service.TextValidationService;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
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 for user profile view pages.
|
||||||
*/
|
*/
|
||||||
@Controller
|
@Controller
|
||||||
|
@RequiredArgsConstructor
|
||||||
public class ProfileViewController {
|
public class ProfileViewController {
|
||||||
|
|
||||||
|
private final TextValidationService textValidationService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Current user's profile page.
|
* Current user's profile page.
|
||||||
* Shows own profile with edit capabilities.
|
* Shows own profile with edit capabilities.
|
||||||
|
|
@ -34,6 +39,7 @@ public class ProfileViewController {
|
||||||
@GetMapping("/profile/edit")
|
@GetMapping("/profile/edit")
|
||||||
public String editProfile(Model model) {
|
public String editProfile(Model model) {
|
||||||
model.addAttribute("pageTitle", "Edit Profile");
|
model.addAttribute("pageTitle", "Edit Profile");
|
||||||
|
model.addAttribute("bioMaxLength", textValidationService.getUserBioMaxLength());
|
||||||
return "profile/edit";
|
return "profile/edit";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import net.javahippie.fitpub.repository.FollowRepository;
|
||||||
import net.javahippie.fitpub.repository.RemoteActorRepository;
|
import net.javahippie.fitpub.repository.RemoteActorRepository;
|
||||||
import net.javahippie.fitpub.repository.UserRepository;
|
import net.javahippie.fitpub.repository.UserRepository;
|
||||||
import net.javahippie.fitpub.service.FederationService;
|
import net.javahippie.fitpub.service.FederationService;
|
||||||
|
import net.javahippie.fitpub.service.TextValidationService;
|
||||||
import net.javahippie.fitpub.service.WebFingerClient;
|
import net.javahippie.fitpub.service.WebFingerClient;
|
||||||
import net.javahippie.fitpub.service.UserService;
|
import net.javahippie.fitpub.service.UserService;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
|
@ -47,6 +48,7 @@ public class UserController {
|
||||||
private final WebFingerClient webFingerClient;
|
private final WebFingerClient webFingerClient;
|
||||||
private final FederationService federationService;
|
private final FederationService federationService;
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
|
private final TextValidationService textValidationService;
|
||||||
private final net.javahippie.fitpub.repository.ActivityPeakRepository activityPeakRepository;
|
private final net.javahippie.fitpub.repository.ActivityPeakRepository activityPeakRepository;
|
||||||
|
|
||||||
@Value("${fitpub.base-url}")
|
@Value("${fitpub.base-url}")
|
||||||
|
|
@ -101,6 +103,8 @@ public class UserController {
|
||||||
) {
|
) {
|
||||||
log.info("User {} updating profile", userDetails.getUsername());
|
log.info("User {} updating profile", userDetails.getUsername());
|
||||||
|
|
||||||
|
textValidationService.validateUserBio(request.getBio());
|
||||||
|
|
||||||
User user = userRepository.findByUsername(userDetails.getUsername())
|
User user = userRepository.findByUsername(userDetails.getUsername())
|
||||||
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package net.javahippie.fitpub.model.dto;
|
package net.javahippie.fitpub.model.dto;
|
||||||
|
|
||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.validation.constraints.Size;
|
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
@ -18,10 +17,8 @@ import net.javahippie.fitpub.model.entity.Activity;
|
||||||
public class ActivityUpdateRequest {
|
public class ActivityUpdateRequest {
|
||||||
|
|
||||||
@NotNull(message = "Title is required")
|
@NotNull(message = "Title is required")
|
||||||
@Size(min = 1, max = 200, message = "Title must be between 1 and 200 characters")
|
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
@Size(max = 5000, message = "Description must not exceed 5000 characters")
|
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
@NotNull(message = "Visibility is required")
|
@NotNull(message = "Visibility is required")
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
package net.javahippie.fitpub.model.dto;
|
package net.javahippie.fitpub.model.dto;
|
||||||
|
|
||||||
import jakarta.validation.constraints.Size;
|
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
@ -17,10 +15,8 @@ import net.javahippie.fitpub.model.entity.Activity;
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public class ActivityUploadRequest {
|
public class ActivityUploadRequest {
|
||||||
|
|
||||||
@Size(max = 200, message = "Title must not exceed 200 characters")
|
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
@Size(max = 5000, message = "Description must not exceed 5000 characters")
|
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
private Activity.Visibility visibility;
|
private Activity.Visibility visibility;
|
||||||
|
|
|
||||||
13
src/main/java/net/javahippie/fitpub/model/dto/ApiError.java
Normal file
13
src/main/java/net/javahippie/fitpub/model/dto/ApiError.java
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,6 @@ public class UserUpdateRequest {
|
||||||
@Size(max = 100, message = "Display name must not exceed 100 characters")
|
@Size(max = 100, message = "Display name must not exceed 100 characters")
|
||||||
private String displayName;
|
private String displayName;
|
||||||
|
|
||||||
@Size(max = 500, message = "Bio must not exceed 500 characters")
|
|
||||||
private String bio;
|
private String bio;
|
||||||
|
|
||||||
@URL(message = "Avatar URL must be a valid URL")
|
@URL(message = "Avatar URL must be a valid URL")
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import lombok.NoArgsConstructor;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
import org.hibernate.annotations.Type;
|
import org.hibernate.annotations.Type;
|
||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
import org.locationtech.jts.geom.LineString;
|
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
@ -138,12 +137,6 @@ public class RemoteActivity {
|
||||||
@Column(name = "track_geojson_url", length = 512)
|
@Column(name = "track_geojson_url", length = 512)
|
||||||
private String trackGeojsonUrl;
|
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.
|
* Visibility level of the activity.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
@ -39,7 +38,6 @@ public class ActivityPostProcessingService {
|
||||||
private final ActivityImageService activityImageService;
|
private final ActivityImageService activityImageService;
|
||||||
private final ActivityRepository activityRepository;
|
private final ActivityRepository activityRepository;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
|
|
||||||
|
|
||||||
@Value("${fitpub.base-url}")
|
@Value("${fitpub.base-url}")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
|
@ -201,10 +199,9 @@ public class ActivityPostProcessingService {
|
||||||
noteObject.put("id", activityUri);
|
noteObject.put("id", activityUri);
|
||||||
noteObject.put("type", "Note");
|
noteObject.put("type", "Note");
|
||||||
noteObject.put("attributedTo", actorUri);
|
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("content", formatActivityContent(activity));
|
||||||
noteObject.put("url", baseUrl + "/activities/" + activity.getId());
|
noteObject.put("url", baseUrl + "/activities/" + activity.getId());
|
||||||
noteObject.put("workoutData", workoutDataPayloadBuilder.build(activity));
|
|
||||||
|
|
||||||
// Extract hashtags from user text and add as tags
|
// Extract hashtags from user text and add as tags
|
||||||
List<String> hashtags = extractHashtags(activity);
|
List<String> hashtags = extractHashtags(activity);
|
||||||
|
|
|
||||||
|
|
@ -16,20 +16,11 @@ import net.javahippie.fitpub.repository.CommentRepository;
|
||||||
import net.javahippie.fitpub.repository.FollowRepository;
|
import net.javahippie.fitpub.repository.FollowRepository;
|
||||||
import net.javahippie.fitpub.repository.LikeRepository;
|
import net.javahippie.fitpub.repository.LikeRepository;
|
||||||
import net.javahippie.fitpub.repository.UserRepository;
|
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.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.Instant;
|
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.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
|
@ -40,9 +31,6 @@ import java.util.UUID;
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class InboxProcessor {
|
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 UserRepository userRepository;
|
||||||
private final FollowRepository followRepository;
|
private final FollowRepository followRepository;
|
||||||
|
|
@ -423,18 +411,15 @@ public class InboxProcessor {
|
||||||
|
|
||||||
// Parse published timestamp
|
// Parse published timestamp
|
||||||
String publishedStr = (String) noteObject.get("published");
|
String publishedStr = (String) noteObject.get("published");
|
||||||
Instant publishedAt = parsePublishedAt(publishedStr);
|
Instant publishedAt = publishedStr != null ? Instant.parse(publishedStr) : Instant.now();
|
||||||
|
|
||||||
// Build RemoteActivity entity
|
// Build RemoteActivity entity
|
||||||
RemoteActivity remoteActivity = RemoteActivity.builder()
|
RemoteActivity remoteActivity = RemoteActivity.builder()
|
||||||
.activityUri(activityUri)
|
.activityUri(activityUri)
|
||||||
.remoteActorUri(actor)
|
.remoteActorUri(actor)
|
||||||
.activityType(stringValue(workoutData.get("activityType")))
|
.activityType((String) workoutData.get("activityType"))
|
||||||
.title((String) noteObject.getOrDefault("name", noteObject.getOrDefault("summary", "Untitled Activity")))
|
.title((String) noteObject.getOrDefault("name", noteObject.getOrDefault("summary", "Untitled Activity")))
|
||||||
.description(firstNonBlank(
|
.description(stripHtml((String) noteObject.get("content")))
|
||||||
stringValue(workoutData.get("description")),
|
|
||||||
stripHtml((String) noteObject.get("content"))
|
|
||||||
))
|
|
||||||
.publishedAt(publishedAt)
|
.publishedAt(publishedAt)
|
||||||
.totalDistance(parseLong(workoutData.get("distance")))
|
.totalDistance(parseLong(workoutData.get("distance")))
|
||||||
.totalDurationSeconds(parseDurationSeconds((String) workoutData.get("duration")))
|
.totalDurationSeconds(parseDurationSeconds((String) workoutData.get("duration")))
|
||||||
|
|
@ -446,7 +431,6 @@ public class InboxProcessor {
|
||||||
.calories(parseInteger(workoutData.get("calories")))
|
.calories(parseInteger(workoutData.get("calories")))
|
||||||
.mapImageUrl(attachments.get("mapImage"))
|
.mapImageUrl(attachments.get("mapImage"))
|
||||||
.trackGeojsonUrl(attachments.get("trackGeojson"))
|
.trackGeojsonUrl(attachments.get("trackGeojson"))
|
||||||
.simplifiedTrack(extractRoute(workoutData))
|
|
||||||
.visibility(visibility)
|
.visibility(visibility)
|
||||||
.activityPubObject(serializeToJson(noteObject))
|
.activityPubObject(serializeToJson(noteObject))
|
||||||
.build();
|
.build();
|
||||||
|
|
@ -721,88 +705,6 @@ public class InboxProcessor {
|
||||||
return workoutData;
|
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.
|
* Extract attachment URLs (map image, GeoJSON) from a Note object.
|
||||||
*/
|
*/
|
||||||
|
|
@ -922,44 +824,6 @@ 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.
|
* Serialize object to JSON string.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -98,10 +98,6 @@ public class ActivityFormatter {
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
private static LocalDateTime getUtcDateTimeInZone(LocalDateTime utcDateTime, String timezone) {
|
private static LocalDateTime getUtcDateTimeInZone(LocalDateTime utcDateTime, String timezone) {
|
||||||
if (timezone == null || timezone.isBlank()) {
|
|
||||||
return utcDateTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return utcDateTime.atZone(ZoneOffset.UTC)
|
return utcDateTime.atZone(ZoneOffset.UTC)
|
||||||
.withZoneSameInstant(ZoneId.of(timezone))
|
.withZoneSameInstant(ZoneId.of(timezone))
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,7 @@ public final class ActivityPubContexts {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the extended JSON-LD {@code @context} value for outbound objects
|
* Returns the extended JSON-LD {@code @context} value for outbound objects
|
||||||
* that carry both interaction-policy declarations and FitPub's proprietary
|
* that carry interaction-policy declarations. Shape:
|
||||||
* {@code workoutData} extension fields. Shape:
|
|
||||||
*
|
*
|
||||||
* <pre>
|
* <pre>
|
||||||
* [
|
* [
|
||||||
|
|
@ -46,20 +45,7 @@ public final class ActivityPubContexts {
|
||||||
* "interactionPolicy": { "@id": "gts:interactionPolicy", "@type": "@id" },
|
* "interactionPolicy": { "@id": "gts:interactionPolicy", "@type": "@id" },
|
||||||
* "canQuote": { "@id": "gts:canQuote", "@type": "@id" },
|
* "canQuote": { "@id": "gts:canQuote", "@type": "@id" },
|
||||||
* "automaticApproval": { "@id": "gts:automaticApproval", "@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>
|
* </pre>
|
||||||
|
|
@ -70,12 +56,6 @@ public final class ActivityPubContexts {
|
||||||
* Mastodon source, "interaction_policies" extension), so a Mastodon
|
* Mastodon source, "interaction_policies" extension), so a Mastodon
|
||||||
* receiver compacting our object with its own context will recognise the
|
* receiver compacting our object with its own context will recognise the
|
||||||
* field names and apply the policy.
|
* 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() {
|
public static List<Object> extendedContext() {
|
||||||
Map<String, Object> extensions = new LinkedHashMap<>();
|
Map<String, Object> extensions = new LinkedHashMap<>();
|
||||||
|
|
@ -84,19 +64,6 @@ public final class ActivityPubContexts {
|
||||||
extensions.put("canQuote", typedRef("gts:canQuote"));
|
extensions.put("canQuote", typedRef("gts:canQuote"));
|
||||||
extensions.put("automaticApproval", typedRef("gts:automaticApproval"));
|
extensions.put("automaticApproval", typedRef("gts:automaticApproval"));
|
||||||
extensions.put("manualApproval", typedRef("gts:manualApproval"));
|
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(
|
return List.of(
|
||||||
"https://www.w3.org/ns/activitystreams",
|
"https://www.w3.org/ns/activitystreams",
|
||||||
extensions
|
extensions
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,18 @@ fitpub:
|
||||||
# Leave empty to allow open registration
|
# Leave empty to allow open registration
|
||||||
password: ${REGISTRATION_PASSWORD:}
|
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 settings
|
||||||
storage:
|
storage:
|
||||||
fit-files:
|
fit-files:
|
||||||
|
|
|
||||||
|
|
@ -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';
|
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
id="title"
|
id="title"
|
||||||
name="title"
|
name="title"
|
||||||
placeholder="e.g., Morning Run"
|
placeholder="e.g., Morning Run"
|
||||||
maxlength="200"
|
th:attr="maxlength=${activityTitleMaxLength}"
|
||||||
required>
|
required>
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
Please provide a title for your activity.
|
Please provide a title for your activity.
|
||||||
|
|
@ -83,9 +83,9 @@
|
||||||
name="description"
|
name="description"
|
||||||
rows="4"
|
rows="4"
|
||||||
placeholder="Share details about your activity..."
|
placeholder="Share details about your activity..."
|
||||||
maxlength="5000"></textarea>
|
th:attr="maxlength=${activityDescriptionMaxLength}"></textarea>
|
||||||
<div class="form-text">
|
<div class="form-text">
|
||||||
<span id="descCharCount">0</span>/5000 characters
|
<span id="descCharCount">0</span>/<span th:text="${activityDescriptionMaxLength}">5000</span> characters
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@
|
||||||
id="title"
|
id="title"
|
||||||
name="title"
|
name="title"
|
||||||
placeholder="e.g., Morning Run"
|
placeholder="e.g., Morning Run"
|
||||||
maxlength="200">
|
th:attr="maxlength=${activityTitleMaxLength}">
|
||||||
<div class="invalid-feedback">
|
<div class="invalid-feedback">
|
||||||
Please provide a title for your activity.
|
Please provide a title for your activity.
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -126,9 +126,9 @@
|
||||||
name="description"
|
name="description"
|
||||||
rows="4"
|
rows="4"
|
||||||
placeholder="Share details about your activity..."
|
placeholder="Share details about your activity..."
|
||||||
maxlength="5000"></textarea>
|
th:attr="maxlength=${activityDescriptionMaxLength}"></textarea>
|
||||||
<div class="form-text">
|
<div class="form-text">
|
||||||
<span id="descCharCount">0</span>/5000 characters
|
<span id="descCharCount">0</span>/<span th:text="${activityDescriptionMaxLength}">5000</span> characters
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,10 @@
|
||||||
<!-- Bio -->
|
<!-- Bio -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="bio" class="form-label">Bio</label>
|
<label for="bio" class="form-label">Bio</label>
|
||||||
<textarea class="form-control" id="bio" name="bio" rows="4" maxlength="500"></textarea>
|
<textarea class="form-control" id="bio" name="bio" rows="4"
|
||||||
|
th:attr="maxlength=${bioMaxLength}"></textarea>
|
||||||
<div class="form-text">
|
<div class="form-text">
|
||||||
<span id="bioCharCount">0</span>/500 characters
|
<span id="bioCharCount">0</span>/<span th:text="${bioMaxLength}">500</span> characters
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
<p class="text-muted mb-2">
|
<p class="text-muted mb-2">
|
||||||
<span id="username"></span>
|
<span id="username"></span>
|
||||||
</p>
|
</p>
|
||||||
<p id="bio" class="mb-3 preserve-linebreaks"></p>
|
<p id="bio" class="mb-3"></p>
|
||||||
</div>
|
</div>
|
||||||
<div id="followButtonContainer" class="d-none">
|
<div id="followButtonContainer" class="d-none">
|
||||||
<button class="btn btn-primary" id="followBtn">
|
<button class="btn btn-primary" id="followBtn">
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
<p class="text-muted mb-2">
|
<p class="text-muted mb-2">
|
||||||
<span id="username"></span>
|
<span id="username"></span>
|
||||||
</p>
|
</p>
|
||||||
<p id="bio" class="mb-3 preserve-linebreaks"></p>
|
<p id="bio" class="mb-3"></p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<a th:href="@{/profile/edit}" class="btn btn-outline-primary">
|
<a th:href="@{/profile/edit}" class="btn btn-outline-primary">
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import org.springframework.boot.test.context.TestConfiguration;
|
||||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.testcontainers.containers.PostgreSQLContainer;
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.containers.wait.strategy.HostPortWaitStrategy;
|
||||||
import org.testcontainers.utility.DockerImageName;
|
import org.testcontainers.utility.DockerImageName;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -22,6 +23,8 @@ public class TestcontainersConfiguration {
|
||||||
)
|
)
|
||||||
.withDatabaseName("testdb")
|
.withDatabaseName("testdb")
|
||||||
.withUsername("test")
|
.withUsername("test")
|
||||||
.withPassword("test");
|
.withPassword("test")
|
||||||
|
.waitingFor(new HostPortWaitStrategy())
|
||||||
|
.withReuse(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<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,25 +2,19 @@ package net.javahippie.fitpub.integration;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import net.javahippie.fitpub.config.TestcontainersConfiguration;
|
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.BeforeEach;
|
||||||
import org.junit.jupiter.api.Disabled;
|
import org.junit.jupiter.api.Disabled;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import net.javahippie.fitpub.model.entity.Follow;
|
import net.javahippie.fitpub.model.entity.Follow;
|
||||||
import net.javahippie.fitpub.model.entity.RemoteActor;
|
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.model.entity.User;
|
||||||
import net.javahippie.fitpub.repository.ActivityRepository;
|
|
||||||
import net.javahippie.fitpub.repository.FollowRepository;
|
import net.javahippie.fitpub.repository.FollowRepository;
|
||||||
import net.javahippie.fitpub.repository.RemoteActivityRepository;
|
|
||||||
import net.javahippie.fitpub.repository.RemoteActorRepository;
|
import net.javahippie.fitpub.repository.RemoteActorRepository;
|
||||||
import net.javahippie.fitpub.repository.UserRepository;
|
import net.javahippie.fitpub.repository.UserRepository;
|
||||||
import net.javahippie.fitpub.security.HttpSignatureValidator;
|
import net.javahippie.fitpub.security.HttpSignatureValidator;
|
||||||
import net.javahippie.fitpub.security.JwtTokenProvider;
|
import net.javahippie.fitpub.security.JwtTokenProvider;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
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.beans.factory.annotation.Value;
|
||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
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.test.web.servlet.MvcResult;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.security.KeyPair;
|
import java.security.KeyPair;
|
||||||
import java.security.KeyPairGenerator;
|
import java.security.KeyPairGenerator;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.request.MockMvcRequestBuilders.*;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
|
|
@ -75,12 +63,6 @@ class FederationFollowFlowIntegrationTest {
|
||||||
@Autowired
|
@Autowired
|
||||||
private RemoteActorRepository remoteActorRepository;
|
private RemoteActorRepository remoteActorRepository;
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private RemoteActivityRepository remoteActivityRepository;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private ActivityRepository activityRepository;
|
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private PasswordEncoder passwordEncoder;
|
private PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
|
@ -90,9 +72,6 @@ class FederationFollowFlowIntegrationTest {
|
||||||
@Autowired
|
@Autowired
|
||||||
private HttpSignatureValidator signatureValidator;
|
private HttpSignatureValidator signatureValidator;
|
||||||
|
|
||||||
@MockBean
|
|
||||||
private ActivityImageService activityImageService;
|
|
||||||
|
|
||||||
@Value("${fitpub.base-url}")
|
@Value("${fitpub.base-url}")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
|
||||||
|
|
@ -122,22 +101,6 @@ class FederationFollowFlowIntegrationTest {
|
||||||
authToken = jwtTokenProvider.createToken(testUser.getUsername());
|
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 {
|
private KeyPair generateRsaKeyPair() throws NoSuchAlgorithmException {
|
||||||
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
|
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
|
||||||
keyGen.initialize(2048);
|
keyGen.initialize(2048);
|
||||||
|
|
@ -307,111 +270,6 @@ class FederationFollowFlowIntegrationTest {
|
||||||
assertThat(updatedFollow.getStatus()).isEqualTo(Follow.FollowStatus.ACCEPTED);
|
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
|
@Test
|
||||||
@DisplayName("Should reject inbox POST without HTTP signature with 401")
|
@DisplayName("Should reject inbox POST without HTTP signature with 401")
|
||||||
void testInboxRejectsUnsignedRequest() throws Exception {
|
void testInboxRejectsUnsignedRequest() throws Exception {
|
||||||
|
|
@ -452,23 +310,6 @@ class FederationFollowFlowIntegrationTest {
|
||||||
.andExpect(status().isUnauthorized());
|
.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
|
@Test
|
||||||
@DisplayName("Should process Undo Follow activity and remove follow relationship")
|
@DisplayName("Should process Undo Follow activity and remove follow relationship")
|
||||||
void testProcessUndoFollowActivity() throws Exception {
|
void testProcessUndoFollowActivity() throws Exception {
|
||||||
|
|
|
||||||
|
|
@ -27,16 +27,12 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||||
/**
|
/**
|
||||||
* Manual test for ActivityImageService.
|
* Manual test for ActivityImageService.
|
||||||
* These tests are disabled by default and should only be run manually.
|
* These tests are disabled by default and should only be run manually.
|
||||||
*
|
|
||||||
* To run this test manually:
|
|
||||||
* mvn test -Dtest=ActivityImageServiceTest
|
|
||||||
*/
|
*/
|
||||||
@SpringBootTest(properties = {
|
@SpringBootTest(properties = {
|
||||||
"fitpub.image.osm-tiles.enabled=true"
|
"fitpub.image.osm-tiles.enabled=true"
|
||||||
})
|
})
|
||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
@Import(TestcontainersConfiguration.class)
|
@Import(TestcontainersConfiguration.class)
|
||||||
@Disabled("Manual test - run explicitly when needed")
|
|
||||||
class ActivityImageServiceTest {
|
class ActivityImageServiceTest {
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
|
|
@ -59,6 +55,7 @@ class ActivityImageServiceTest {
|
||||||
* mvn test -Dtest=ActivityImageServiceTest#testGenerateActivityImage_Manual
|
* mvn test -Dtest=ActivityImageServiceTest#testGenerateActivityImage_Manual
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
|
@Disabled("Manual test - run explicitly when needed")
|
||||||
@DisplayName("Generate activity image from test FIT file")
|
@DisplayName("Generate activity image from test FIT file")
|
||||||
void testGenerateActivityImage_Manual() throws Exception {
|
void testGenerateActivityImage_Manual() throws Exception {
|
||||||
// Load test FIT file
|
// Load test FIT file
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,25 @@
|
||||||
package net.javahippie.fitpub.service;
|
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.BeforeEach;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
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.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
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 org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.LocalDateTime;
|
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.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.anyBoolean;
|
import static org.mockito.Mockito.*;
|
||||||
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.
|
* Unit tests for ActivityPostProcessingService.
|
||||||
|
|
@ -66,9 +49,6 @@ class ActivityPostProcessingServiceTest {
|
||||||
@Mock
|
@Mock
|
||||||
private UserRepository userRepository;
|
private UserRepository userRepository;
|
||||||
|
|
||||||
@Mock
|
|
||||||
private WorkoutDataPayloadBuilder workoutDataPayloadBuilder;
|
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private ActivityPostProcessingService service;
|
private ActivityPostProcessingService service;
|
||||||
|
|
||||||
|
|
@ -76,13 +56,11 @@ class ActivityPostProcessingServiceTest {
|
||||||
private UUID userId;
|
private UUID userId;
|
||||||
private Activity testActivity;
|
private Activity testActivity;
|
||||||
private User testUser;
|
private User testUser;
|
||||||
private LocalDateTime createdAt;
|
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
activityId = UUID.randomUUID();
|
activityId = UUID.randomUUID();
|
||||||
userId = 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)
|
// Set baseUrl via reflection (since it's @Value injected)
|
||||||
ReflectionTestUtils.setField(service, "baseUrl", "https://test.example");
|
ReflectionTestUtils.setField(service, "baseUrl", "https://test.example");
|
||||||
|
|
@ -98,39 +76,9 @@ class ActivityPostProcessingServiceTest {
|
||||||
.totalDistance(BigDecimal.valueOf(5000))
|
.totalDistance(BigDecimal.valueOf(5000))
|
||||||
.totalDurationSeconds(1800L)
|
.totalDurationSeconds(1800L)
|
||||||
.elevationGain(BigDecimal.valueOf(100))
|
.elevationGain(BigDecimal.valueOf(100))
|
||||||
.startedAt(createdAt.minusMinutes(30))
|
.startedAt(LocalDateTime.now())
|
||||||
.createdAt(createdAt)
|
.createdAt(LocalDateTime.now())
|
||||||
.simplifiedTrack(new GeometryFactory().createLineString(new Coordinate[]{
|
|
||||||
new Coordinate(8.55, 47.37),
|
|
||||||
new Coordinate(8.56, 47.38)
|
|
||||||
}))
|
|
||||||
.build();
|
.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
|
// Create test user
|
||||||
testUser = User.builder()
|
testUser = User.builder()
|
||||||
|
|
@ -284,24 +232,6 @@ class ActivityPostProcessingServiceTest {
|
||||||
verify(federationService).sendCreateActivity(anyString(), any(), eq(testUser), eq(false));
|
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
|
@Test
|
||||||
@DisplayName("Should skip federation for PRIVATE activity")
|
@DisplayName("Should skip federation for PRIVATE activity")
|
||||||
void testPublishToFederationAsync_PrivateActivity() {
|
void testPublishToFederationAsync_PrivateActivity() {
|
||||||
|
|
@ -387,47 +317,4 @@ class ActivityPostProcessingServiceTest {
|
||||||
// Then: Verify federation was called (content formatting is tested indirectly)
|
// Then: Verify federation was called (content formatting is tested indirectly)
|
||||||
verify(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
|
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)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<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,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<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