Speed up upload
This commit is contained in:
parent
a560036265
commit
9dee8a7e84
4 changed files with 714 additions and 145 deletions
|
|
@ -11,9 +11,9 @@ import org.operaton.fitpub.model.entity.User;
|
|||
import org.operaton.fitpub.repository.UserRepository;
|
||||
import org.operaton.fitpub.service.ActivityFileService;
|
||||
import org.operaton.fitpub.service.ActivityImageService;
|
||||
import org.operaton.fitpub.service.ActivityPostProcessingService;
|
||||
import org.operaton.fitpub.service.FederationService;
|
||||
import org.operaton.fitpub.service.FitFileService;
|
||||
import org.operaton.fitpub.util.ActivityFormatter;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
|
@ -23,12 +23,9 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
|||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* REST controller for activity management.
|
||||
|
|
@ -43,6 +40,7 @@ public class ActivityController {
|
|||
private final ActivityFileService activityFileService;
|
||||
private final FitFileService fitFileService;
|
||||
private final UserRepository userRepository;
|
||||
private final ActivityPostProcessingService activityPostProcessingService;
|
||||
private final FederationService federationService;
|
||||
private final ActivityImageService activityImageService;
|
||||
private final org.operaton.fitpub.service.WeatherService weatherService;
|
||||
|
|
@ -90,122 +88,25 @@ public class ActivityController {
|
|||
request.getVisibility()
|
||||
);
|
||||
|
||||
// Send ActivityPub Create activity to followers if public or followers-only
|
||||
if (activity.getVisibility() == Activity.Visibility.PUBLIC ||
|
||||
activity.getVisibility() == Activity.Visibility.FOLLOWERS) {
|
||||
// Trigger async post-processing (non-blocking):
|
||||
// - Personal Records checking
|
||||
// - Weather data fetching
|
||||
// - Heatmap grid updates
|
||||
// - Federation push (includes image generation)
|
||||
//
|
||||
// Operations run in separate transactions with proper ordering:
|
||||
// - Personal Records and Heatmap run in parallel
|
||||
// - Weather → Federation run sequentially (weather must complete before federation)
|
||||
//
|
||||
// Activity is immediately visible to user, processing continues in background
|
||||
activityPostProcessingService.processActivityAsync(activity.getId(), user.getId());
|
||||
|
||||
String activityUri = baseUrl + "/activities/" + activity.getId();
|
||||
String actorUri = baseUrl + "/users/" + user.getUsername();
|
||||
|
||||
// Create the Note object representing the activity
|
||||
Map<String, Object> noteObject = new HashMap<>();
|
||||
noteObject.put("id", activityUri);
|
||||
noteObject.put("type", "Note");
|
||||
noteObject.put("attributedTo", actorUri);
|
||||
noteObject.put("published", activity.getCreatedAt().toString());
|
||||
noteObject.put("content", formatActivityContent(activity));
|
||||
|
||||
if (activity.getVisibility() == Activity.Visibility.PUBLIC) {
|
||||
noteObject.put("to", List.of("https://www.w3.org/ns/activitystreams#Public"));
|
||||
noteObject.put("cc", List.of(actorUri + "/followers"));
|
||||
} else {
|
||||
noteObject.put("to", List.of(actorUri + "/followers"));
|
||||
}
|
||||
|
||||
// Add URL to the activity page
|
||||
noteObject.put("url", baseUrl + "/activities/" + activity.getId());
|
||||
|
||||
// Generate and attach activity image
|
||||
String imageUrl = activityImageService.generateActivityImage(activity);
|
||||
if (imageUrl != null) {
|
||||
Map<String, Object> imageAttachment = new HashMap<>();
|
||||
imageAttachment.put("type", "Image");
|
||||
imageAttachment.put("mediaType", "image/png");
|
||||
imageAttachment.put("url", imageUrl);
|
||||
imageAttachment.put("name", "Activity map showing " + activity.getActivityType() + " route");
|
||||
noteObject.put("attachment", List.of(imageAttachment));
|
||||
}
|
||||
|
||||
federationService.sendCreateActivity(
|
||||
activityUri,
|
||||
noteObject,
|
||||
user,
|
||||
activity.getVisibility() == Activity.Visibility.PUBLIC
|
||||
);
|
||||
}
|
||||
log.info("Activity {} created and queued for async post-processing", activity.getId());
|
||||
|
||||
ActivityDTO dto = ActivityDTO.fromEntity(activity);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format activity content for ActivityPub.
|
||||
* Uses plain text with Unicode symbols for maximum compatibility across Fediverse platforms.
|
||||
*/
|
||||
private String formatActivityContent(Activity activity) {
|
||||
StringBuilder content = new StringBuilder();
|
||||
|
||||
// Title (if present)
|
||||
if (activity.getTitle() != null && !activity.getTitle().isEmpty()) {
|
||||
content.append(activity.getTitle()).append("\n\n");
|
||||
}
|
||||
|
||||
// Description (if present)
|
||||
if (activity.getDescription() != null && !activity.getDescription().isEmpty()) {
|
||||
content.append(activity.getDescription()).append("\n\n");
|
||||
}
|
||||
|
||||
// Activity type with emoji
|
||||
String activityEmoji = getActivityEmoji(activity.getActivityType());
|
||||
String formattedType = ActivityFormatter.formatActivityType(activity.getActivityType());
|
||||
content.append(activityEmoji).append(" ").append(formattedType);
|
||||
|
||||
// Metrics on separate lines
|
||||
if (activity.getTotalDistance() != null) {
|
||||
content.append("\n📏 ")
|
||||
.append(String.format("%.2f km", activity.getTotalDistance().doubleValue() / 1000.0));
|
||||
}
|
||||
|
||||
if (activity.getTotalDurationSeconds() != null) {
|
||||
long hours = activity.getTotalDurationSeconds() / 3600;
|
||||
long minutes = (activity.getTotalDurationSeconds() % 3600) / 60;
|
||||
long seconds = activity.getTotalDurationSeconds() % 60;
|
||||
content.append("\n⏱️ ");
|
||||
if (hours > 0) {
|
||||
content.append(hours).append("h ");
|
||||
}
|
||||
content.append(minutes).append("m ").append(seconds).append("s");
|
||||
}
|
||||
|
||||
if (activity.getElevationGain() != null) {
|
||||
content.append("\n⛰️ ")
|
||||
.append(String.format("%.0f m", activity.getElevationGain()));
|
||||
}
|
||||
|
||||
return content.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an emoji for the activity type.
|
||||
*/
|
||||
private String getActivityEmoji(Activity.ActivityType type) {
|
||||
return switch (type) {
|
||||
case RUN -> "🏃";
|
||||
case RIDE -> "🚴";
|
||||
case HIKE -> "🥾";
|
||||
case WALK -> "🚶";
|
||||
case SWIM -> "🏊";
|
||||
case ALPINE_SKI, BACKCOUNTRY_SKI, NORDIC_SKI -> "⛷️";
|
||||
case SNOWBOARD -> "🏂";
|
||||
case ROWING -> "🚣";
|
||||
case KAYAKING, CANOEING -> "🛶";
|
||||
case INLINE_SKATING -> "⛸️";
|
||||
case ROCK_CLIMBING, MOUNTAINEERING -> "🧗";
|
||||
case YOGA -> "🧘";
|
||||
case WORKOUT -> "💪";
|
||||
default -> "🏋️";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple HTML escaping.
|
||||
|
|
|
|||
|
|
@ -98,12 +98,14 @@ public class ActivityFileService {
|
|||
private final TrackSimplifier trackSimplifier;
|
||||
private final ActivityRepository activityRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final PersonalRecordService personalRecordService;
|
||||
// Async operations moved to ActivityPostProcessingService:
|
||||
// - PersonalRecordService (async)
|
||||
// - HeatmapGridService (async)
|
||||
// - WeatherService (async)
|
||||
// Synchronous operations remain here:
|
||||
private final AchievementService achievementService;
|
||||
private final TrainingLoadService trainingLoadService;
|
||||
private final ActivitySummaryService activitySummaryService;
|
||||
private final WeatherService weatherService;
|
||||
private final HeatmapGridService heatmapGridService;
|
||||
|
||||
/**
|
||||
* Processes an uploaded activity file (FIT or GPX) and creates an activity.
|
||||
|
|
@ -301,15 +303,9 @@ public class ActivityFileService {
|
|||
savedActivity.getId());
|
||||
}
|
||||
|
||||
// Execute side effects based on processing options
|
||||
// In batch import mode, these are skipped and executed later as a batch
|
||||
|
||||
if (!options.isSkipPersonalRecords()) {
|
||||
log.debug("Checking personal records for activity {}", savedActivity.getId());
|
||||
personalRecordService.checkAndUpdatePersonalRecords(savedActivity);
|
||||
} else {
|
||||
log.debug("Skipping personal records check for activity {} (batch mode)", savedActivity.getId());
|
||||
}
|
||||
// Execute synchronous side effects based on processing options
|
||||
// Personal Records, Heatmap, and Weather are now handled asynchronously by caller (ActivityController)
|
||||
// In batch import mode, even synchronous operations are skipped and executed later as a batch
|
||||
|
||||
if (!options.isSkipAchievements()) {
|
||||
log.debug("Checking achievements for activity {}", savedActivity.getId());
|
||||
|
|
@ -318,13 +314,6 @@ public class ActivityFileService {
|
|||
log.debug("Skipping achievements check for activity {} (batch mode)", savedActivity.getId());
|
||||
}
|
||||
|
||||
if (!options.isSkipHeatmap()) {
|
||||
log.debug("Updating heatmap for activity {}", savedActivity.getId());
|
||||
heatmapGridService.updateHeatmapForActivity(savedActivity);
|
||||
} else {
|
||||
log.debug("Skipping heatmap update for activity {} (batch mode)", savedActivity.getId());
|
||||
}
|
||||
|
||||
if (!options.isSkipTrainingLoad()) {
|
||||
log.debug("Updating training load for activity {}", savedActivity.getId());
|
||||
trainingLoadService.updateTrainingLoad(savedActivity);
|
||||
|
|
@ -339,18 +328,9 @@ public class ActivityFileService {
|
|||
log.debug("Skipping summaries update for activity {} (batch mode)", savedActivity.getId());
|
||||
}
|
||||
|
||||
if (!options.isSkipWeather()) {
|
||||
// Fetch weather data (async, non-blocking)
|
||||
try {
|
||||
log.debug("Fetching weather for activity {}", savedActivity.getId());
|
||||
weatherService.fetchWeatherForActivity(savedActivity);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to fetch weather data for activity {}: {}", savedActivity.getId(), e.getMessage());
|
||||
// Don't fail the activity creation if weather fetching fails
|
||||
}
|
||||
} else {
|
||||
log.debug("Skipping weather fetch for activity {} (batch mode)", savedActivity.getId());
|
||||
}
|
||||
// Note: Async post-processing (Personal Records, Heatmap, Weather, Federation)
|
||||
// is triggered by the caller (ActivityController) via ActivityPostProcessingService
|
||||
// This keeps ActivityFileService focused on file parsing and initial activity save
|
||||
|
||||
return savedActivity;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,340 @@
|
|||
package org.operaton.fitpub.service;
|
||||
|
||||
import jakarta.persistence.EntityNotFoundException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.model.entity.User;
|
||||
import org.operaton.fitpub.repository.ActivityRepository;
|
||||
import org.operaton.fitpub.repository.UserRepository;
|
||||
import org.operaton.fitpub.util.ActivityFormatter;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* Service for asynchronous post-processing of activities after upload.
|
||||
* Coordinates expensive operations (Personal Records, Weather, Heatmap, Federation)
|
||||
* in separate transactions to avoid blocking the upload response.
|
||||
*
|
||||
* Each operation runs asynchronously with REQUIRES_NEW transaction propagation
|
||||
* to ensure fault isolation - failures in one operation don't affect others.
|
||||
*
|
||||
* Operations execute in the following order:
|
||||
* - Personal Records: Runs independently (parallel)
|
||||
* - Heatmap: Runs independently (parallel)
|
||||
* - Weather → Federation: Sequential chain (weather must complete before federation)
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class ActivityPostProcessingService {
|
||||
|
||||
private final PersonalRecordService personalRecordService;
|
||||
private final WeatherService weatherService;
|
||||
private final HeatmapGridService heatmapGridService;
|
||||
private final FederationService federationService;
|
||||
private final ActivityImageService activityImageService;
|
||||
private final ActivityRepository activityRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@Value("${fitpub.base-url}")
|
||||
private String baseUrl;
|
||||
|
||||
/**
|
||||
* Orchestrates async post-processing operations for an uploaded activity.
|
||||
* Called after activity is saved and immediately visible to the user.
|
||||
*
|
||||
* Personal Records and Heatmap run independently in parallel.
|
||||
* Weather and Federation run sequentially (weather must complete first for future share pic integration).
|
||||
*
|
||||
* All operations use separate transactions (REQUIRES_NEW) for fault isolation.
|
||||
* Errors are logged but don't propagate - each operation fails independently.
|
||||
*
|
||||
* @param activityId the saved activity ID
|
||||
* @param userId the user ID who uploaded the activity
|
||||
*/
|
||||
public void processActivityAsync(UUID activityId, UUID userId) {
|
||||
log.info("Starting async post-processing for activity {} by user {}", activityId, userId);
|
||||
|
||||
// Launch independent async operations (run in parallel)
|
||||
updatePersonalRecordsAsync(activityId);
|
||||
updateHeatmapAsync(activityId);
|
||||
|
||||
// Sequential chain: Weather → Federation
|
||||
// Weather must complete before federation for potential weather data in share images
|
||||
fetchWeatherAsync(activityId)
|
||||
.thenCompose(result -> publishToFederationAsync(activityId, userId))
|
||||
.exceptionally(ex -> {
|
||||
log.error("Failed async post-processing chain (Weather → Federation) for activity {}: {}",
|
||||
activityId, ex.getMessage(), ex);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously check and update personal records for the activity.
|
||||
* Runs in a separate transaction to isolate from main upload transaction.
|
||||
*
|
||||
* @param activityId the activity ID to process
|
||||
* @return CompletableFuture that completes when processing is done
|
||||
*/
|
||||
@Async("taskExecutor")
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public CompletableFuture<Void> updatePersonalRecordsAsync(UUID activityId) {
|
||||
try {
|
||||
log.debug("Async: Checking personal records for activity {}", activityId);
|
||||
|
||||
Activity activity = activityRepository.findById(activityId)
|
||||
.orElseThrow(() -> new EntityNotFoundException("Activity not found: " + activityId));
|
||||
|
||||
personalRecordService.checkAndUpdatePersonalRecords(activity);
|
||||
|
||||
log.info("Async: Personal records updated for activity {}", activityId);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Async: Failed to update personal records for activity {}: {}",
|
||||
activityId, e.getMessage(), e);
|
||||
// Don't rethrow - error logged, operation fails independently
|
||||
}
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously update heatmap grid with activity GPS data.
|
||||
* Runs in a separate transaction to isolate from main upload transaction.
|
||||
*
|
||||
* @param activityId the activity ID to process
|
||||
* @return CompletableFuture that completes when processing is done
|
||||
*/
|
||||
@Async("taskExecutor")
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public CompletableFuture<Void> updateHeatmapAsync(UUID activityId) {
|
||||
try {
|
||||
log.debug("Async: Updating heatmap for activity {}", activityId);
|
||||
|
||||
Activity activity = activityRepository.findById(activityId)
|
||||
.orElseThrow(() -> new EntityNotFoundException("Activity not found: " + activityId));
|
||||
|
||||
heatmapGridService.updateHeatmapForActivity(activity);
|
||||
|
||||
log.info("Async: Heatmap updated for activity {}", activityId);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Async: Failed to update heatmap for activity {}: {}",
|
||||
activityId, e.getMessage(), e);
|
||||
// Don't rethrow - error logged, operation fails independently
|
||||
}
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously fetch weather data for the activity location and time.
|
||||
* Runs in a separate transaction to isolate from main upload transaction.
|
||||
*
|
||||
* Must complete before federation push to allow future integration of weather in share images.
|
||||
*
|
||||
* @param activityId the activity ID to process
|
||||
* @return CompletableFuture that completes when weather fetch is done
|
||||
*/
|
||||
@Async("taskExecutor")
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public CompletableFuture<Void> fetchWeatherAsync(UUID activityId) {
|
||||
try {
|
||||
log.debug("Async: Fetching weather for activity {}", activityId);
|
||||
|
||||
Activity activity = activityRepository.findById(activityId)
|
||||
.orElseThrow(() -> new EntityNotFoundException("Activity not found: " + activityId));
|
||||
|
||||
weatherService.fetchWeatherForActivity(activity);
|
||||
|
||||
log.info("Async: Weather fetched for activity {}", activityId);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Async: Failed to fetch weather for activity {}: {}",
|
||||
activityId, e.getMessage(), e);
|
||||
// Don't rethrow - error logged, operation fails independently
|
||||
}
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously publish activity to the Fediverse (ActivityPub federation).
|
||||
* Generates activity image and sends Create activity to all follower inboxes.
|
||||
* Runs in a separate transaction to isolate from main upload transaction.
|
||||
*
|
||||
* Only publishes if activity visibility is PUBLIC or FOLLOWERS.
|
||||
* This method should run AFTER weather fetch completes for future share pic integration.
|
||||
*
|
||||
* @param activityId the activity ID to publish
|
||||
* @param userId the user ID who owns the activity
|
||||
* @return CompletableFuture that completes when federation is done
|
||||
*/
|
||||
@Async("taskExecutor")
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public CompletableFuture<Void> publishToFederationAsync(UUID activityId, UUID userId) {
|
||||
try {
|
||||
log.debug("Async: Publishing activity {} to Fediverse", activityId);
|
||||
|
||||
Activity activity = activityRepository.findById(activityId)
|
||||
.orElseThrow(() -> new EntityNotFoundException("Activity not found: " + activityId));
|
||||
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new EntityNotFoundException("User not found: " + userId));
|
||||
|
||||
// Only publish if activity is PUBLIC or FOLLOWERS
|
||||
if (activity.getVisibility() != Activity.Visibility.PUBLIC &&
|
||||
activity.getVisibility() != Activity.Visibility.FOLLOWERS) {
|
||||
log.debug("Async: Skipping federation for private activity {}", activityId);
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
String activityUri = baseUrl + "/activities/" + activity.getId();
|
||||
String actorUri = baseUrl + "/users/" + user.getUsername();
|
||||
|
||||
// Generate activity image (map with GPS track)
|
||||
String imageUrl = null;
|
||||
try {
|
||||
imageUrl = activityImageService.generateActivityImage(activity);
|
||||
} catch (Exception e) {
|
||||
log.warn("Async: Failed to generate activity image for {}: {}", activityId, e.getMessage());
|
||||
// Continue without image
|
||||
}
|
||||
|
||||
// Build ActivityPub Note object
|
||||
Map<String, Object> noteObject = new HashMap<>();
|
||||
noteObject.put("id", activityUri);
|
||||
noteObject.put("type", "Note");
|
||||
noteObject.put("attributedTo", actorUri);
|
||||
noteObject.put("published", activity.getCreatedAt().toString());
|
||||
noteObject.put("content", formatActivityContent(activity));
|
||||
noteObject.put("url", baseUrl + "/activities/" + activity.getId());
|
||||
|
||||
// Set visibility (to/cc fields)
|
||||
if (activity.getVisibility() == Activity.Visibility.PUBLIC) {
|
||||
noteObject.put("to", List.of("https://www.w3.org/ns/activitystreams#Public"));
|
||||
noteObject.put("cc", List.of(actorUri + "/followers"));
|
||||
} else {
|
||||
// FOLLOWERS only
|
||||
noteObject.put("to", List.of(actorUri + "/followers"));
|
||||
}
|
||||
|
||||
// Attach activity image if generated
|
||||
if (imageUrl != null) {
|
||||
Map<String, Object> imageAttachment = new HashMap<>();
|
||||
imageAttachment.put("type", "Image");
|
||||
imageAttachment.put("mediaType", "image/png");
|
||||
imageAttachment.put("url", imageUrl);
|
||||
imageAttachment.put("name", "Activity map showing " + activity.getActivityType() + " route");
|
||||
noteObject.put("attachment", List.of(imageAttachment));
|
||||
}
|
||||
|
||||
// Send to all follower inboxes
|
||||
federationService.sendCreateActivity(
|
||||
activityUri,
|
||||
noteObject,
|
||||
user,
|
||||
activity.getVisibility() == Activity.Visibility.PUBLIC
|
||||
);
|
||||
|
||||
log.info("Async: Activity {} published to Fediverse", activityId);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Async: Failed to publish activity {} to Fediverse: {}",
|
||||
activityId, e.getMessage(), e);
|
||||
// Don't rethrow - error logged, operation fails independently
|
||||
}
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format activity content for ActivityPub Note.
|
||||
* Uses plain text with Unicode symbols for maximum compatibility across Fediverse platforms.
|
||||
*
|
||||
* Format:
|
||||
* - Title (if present)
|
||||
* - Description (if present)
|
||||
* - Activity type with emoji
|
||||
* - Distance (if present)
|
||||
* - Duration (if present)
|
||||
* - Elevation gain (if present)
|
||||
*
|
||||
* @param activity the activity to format
|
||||
* @return formatted content string
|
||||
*/
|
||||
private String formatActivityContent(Activity activity) {
|
||||
StringBuilder content = new StringBuilder();
|
||||
|
||||
// Title (if present)
|
||||
if (activity.getTitle() != null && !activity.getTitle().isEmpty()) {
|
||||
content.append(activity.getTitle()).append("\n\n");
|
||||
}
|
||||
|
||||
// Description (if present)
|
||||
if (activity.getDescription() != null && !activity.getDescription().isEmpty()) {
|
||||
content.append(activity.getDescription()).append("\n\n");
|
||||
}
|
||||
|
||||
// Activity type with emoji
|
||||
String activityEmoji = getActivityEmoji(activity.getActivityType());
|
||||
String formattedType = ActivityFormatter.formatActivityType(activity.getActivityType());
|
||||
content.append(activityEmoji).append(" ").append(formattedType);
|
||||
|
||||
// Metrics on separate lines
|
||||
if (activity.getTotalDistance() != null) {
|
||||
content.append("\n📏 ")
|
||||
.append(String.format("%.2f km", activity.getTotalDistance().doubleValue() / 1000.0));
|
||||
}
|
||||
|
||||
if (activity.getTotalDurationSeconds() != null) {
|
||||
long hours = activity.getTotalDurationSeconds() / 3600;
|
||||
long minutes = (activity.getTotalDurationSeconds() % 3600) / 60;
|
||||
long seconds = activity.getTotalDurationSeconds() % 60;
|
||||
content.append("\n⏱️ ");
|
||||
if (hours > 0) {
|
||||
content.append(hours).append("h ");
|
||||
}
|
||||
content.append(minutes).append("m ").append(seconds).append("s");
|
||||
}
|
||||
|
||||
if (activity.getElevationGain() != null) {
|
||||
content.append("\n⛰️ ")
|
||||
.append(String.format("%.0f m", activity.getElevationGain()));
|
||||
}
|
||||
|
||||
return content.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an emoji for the activity type.
|
||||
*
|
||||
* @param type the activity type
|
||||
* @return emoji representing the activity type
|
||||
*/
|
||||
private String getActivityEmoji(Activity.ActivityType type) {
|
||||
return switch (type) {
|
||||
case RUN -> "🏃";
|
||||
case RIDE -> "🚴";
|
||||
case HIKE -> "🥾";
|
||||
case WALK -> "🚶";
|
||||
case SWIM -> "🏊";
|
||||
case ALPINE_SKI, BACKCOUNTRY_SKI, NORDIC_SKI -> "⛷️";
|
||||
case SNOWBOARD -> "🏂";
|
||||
case ROWING -> "🚣";
|
||||
case KAYAKING, CANOEING -> "🛶";
|
||||
case INLINE_SKATING -> "⛸️";
|
||||
case ROCK_CLIMBING, MOUNTAINEERING -> "🧗";
|
||||
case YOGA -> "🧘";
|
||||
case WORKOUT -> "💪";
|
||||
default -> "🏋️";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,348 @@
|
|||
package org.operaton.fitpub.service;
|
||||
|
||||
import jakarta.persistence.EntityNotFoundException;
|
||||
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.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.model.entity.User;
|
||||
import org.operaton.fitpub.repository.ActivityRepository;
|
||||
import org.operaton.fitpub.repository.UserRepository;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Unit tests for ActivityPostProcessingService.
|
||||
* Tests async operations in isolation and error handling.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ActivityPostProcessingServiceTest {
|
||||
|
||||
@Mock
|
||||
private PersonalRecordService personalRecordService;
|
||||
|
||||
@Mock
|
||||
private WeatherService weatherService;
|
||||
|
||||
@Mock
|
||||
private HeatmapGridService heatmapGridService;
|
||||
|
||||
@Mock
|
||||
private FederationService federationService;
|
||||
|
||||
@Mock
|
||||
private ActivityImageService activityImageService;
|
||||
|
||||
@Mock
|
||||
private ActivityRepository activityRepository;
|
||||
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
|
||||
@InjectMocks
|
||||
private ActivityPostProcessingService service;
|
||||
|
||||
private UUID activityId;
|
||||
private UUID userId;
|
||||
private Activity testActivity;
|
||||
private User testUser;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
activityId = UUID.randomUUID();
|
||||
userId = UUID.randomUUID();
|
||||
|
||||
// Set baseUrl via reflection (since it's @Value injected)
|
||||
ReflectionTestUtils.setField(service, "baseUrl", "https://test.example");
|
||||
|
||||
// Create test activity
|
||||
testActivity = Activity.builder()
|
||||
.id(activityId)
|
||||
.userId(userId)
|
||||
.activityType(Activity.ActivityType.RUN)
|
||||
.title("Test Run")
|
||||
.description("Morning jog")
|
||||
.visibility(Activity.Visibility.PUBLIC)
|
||||
.totalDistance(BigDecimal.valueOf(5000))
|
||||
.totalDurationSeconds(1800L)
|
||||
.elevationGain(BigDecimal.valueOf(100))
|
||||
.startedAt(LocalDateTime.now())
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
// Create test user
|
||||
testUser = User.builder()
|
||||
.id(userId)
|
||||
.username("testrunner")
|
||||
.email("test@example.com")
|
||||
.displayName("Test Runner")
|
||||
.enabled(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should successfully update personal records async")
|
||||
void testUpdatePersonalRecordsAsync_Success() throws ExecutionException, InterruptedException {
|
||||
// Given
|
||||
when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity));
|
||||
when(personalRecordService.checkAndUpdatePersonalRecords(testActivity)).thenReturn(java.util.List.of());
|
||||
|
||||
// When
|
||||
CompletableFuture<Void> future = service.updatePersonalRecordsAsync(activityId);
|
||||
|
||||
// Then
|
||||
assertNotNull(future);
|
||||
future.get(); // Wait for completion
|
||||
verify(activityRepository).findById(activityId);
|
||||
verify(personalRecordService).checkAndUpdatePersonalRecords(testActivity);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle personal records update failure gracefully")
|
||||
void testUpdatePersonalRecordsAsync_Failure() throws ExecutionException, InterruptedException {
|
||||
// Given
|
||||
when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity));
|
||||
doThrow(new RuntimeException("Database error")).when(personalRecordService).checkAndUpdatePersonalRecords(testActivity);
|
||||
|
||||
// When
|
||||
CompletableFuture<Void> future = service.updatePersonalRecordsAsync(activityId);
|
||||
|
||||
// Then
|
||||
assertNotNull(future);
|
||||
future.get(); // Should complete without throwing (error is logged, not propagated)
|
||||
verify(personalRecordService).checkAndUpdatePersonalRecords(testActivity);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle activity not found in personal records update")
|
||||
void testUpdatePersonalRecordsAsync_ActivityNotFound() throws ExecutionException, InterruptedException {
|
||||
// Given
|
||||
when(activityRepository.findById(activityId)).thenReturn(Optional.empty());
|
||||
|
||||
// When
|
||||
CompletableFuture<Void> future = service.updatePersonalRecordsAsync(activityId);
|
||||
|
||||
// Then
|
||||
assertNotNull(future);
|
||||
future.get(); // Should complete without throwing
|
||||
verify(activityRepository).findById(activityId);
|
||||
verify(personalRecordService, never()).checkAndUpdatePersonalRecords(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should successfully update heatmap async")
|
||||
void testUpdateHeatmapAsync_Success() throws ExecutionException, InterruptedException {
|
||||
// Given
|
||||
when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity));
|
||||
doNothing().when(heatmapGridService).updateHeatmapForActivity(testActivity);
|
||||
|
||||
// When
|
||||
CompletableFuture<Void> future = service.updateHeatmapAsync(activityId);
|
||||
|
||||
// Then
|
||||
assertNotNull(future);
|
||||
future.get();
|
||||
verify(activityRepository).findById(activityId);
|
||||
verify(heatmapGridService).updateHeatmapForActivity(testActivity);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle heatmap update failure gracefully")
|
||||
void testUpdateHeatmapAsync_Failure() throws ExecutionException, InterruptedException {
|
||||
// Given
|
||||
when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity));
|
||||
doThrow(new RuntimeException("Heatmap error")).when(heatmapGridService).updateHeatmapForActivity(testActivity);
|
||||
|
||||
// When
|
||||
CompletableFuture<Void> future = service.updateHeatmapAsync(activityId);
|
||||
|
||||
// Then
|
||||
assertNotNull(future);
|
||||
future.get(); // Should complete without throwing
|
||||
verify(heatmapGridService).updateHeatmapForActivity(testActivity);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should successfully fetch weather async")
|
||||
void testFetchWeatherAsync_Success() throws ExecutionException, InterruptedException {
|
||||
// Given
|
||||
when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity));
|
||||
when(weatherService.fetchWeatherForActivity(testActivity)).thenReturn(Optional.empty());
|
||||
|
||||
// When
|
||||
CompletableFuture<Void> future = service.fetchWeatherAsync(activityId);
|
||||
|
||||
// Then
|
||||
assertNotNull(future);
|
||||
future.get();
|
||||
verify(activityRepository).findById(activityId);
|
||||
verify(weatherService).fetchWeatherForActivity(testActivity);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle weather fetch failure gracefully")
|
||||
void testFetchWeatherAsync_Failure() throws ExecutionException, InterruptedException {
|
||||
// Given
|
||||
when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity));
|
||||
doThrow(new RuntimeException("Weather API error")).when(weatherService).fetchWeatherForActivity(testActivity);
|
||||
|
||||
// When
|
||||
CompletableFuture<Void> future = service.fetchWeatherAsync(activityId);
|
||||
|
||||
// Then
|
||||
assertNotNull(future);
|
||||
future.get(); // Should complete without throwing
|
||||
verify(weatherService).fetchWeatherForActivity(testActivity);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should successfully publish to federation async for PUBLIC activity")
|
||||
void testPublishToFederationAsync_PublicActivity() throws ExecutionException, InterruptedException {
|
||||
// Given
|
||||
testActivity.setVisibility(Activity.Visibility.PUBLIC);
|
||||
when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity));
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||
when(activityImageService.generateActivityImage(testActivity)).thenReturn("https://test.example/image.png");
|
||||
doNothing().when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
|
||||
|
||||
// When
|
||||
CompletableFuture<Void> future = service.publishToFederationAsync(activityId, userId);
|
||||
|
||||
// Then
|
||||
assertNotNull(future);
|
||||
future.get();
|
||||
verify(activityRepository).findById(activityId);
|
||||
verify(userRepository).findById(userId);
|
||||
verify(activityImageService).generateActivityImage(testActivity);
|
||||
verify(federationService).sendCreateActivity(anyString(), any(), eq(testUser), eq(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should successfully publish to federation async for FOLLOWERS activity")
|
||||
void testPublishToFederationAsync_FollowersActivity() throws ExecutionException, InterruptedException {
|
||||
// Given
|
||||
testActivity.setVisibility(Activity.Visibility.FOLLOWERS);
|
||||
when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity));
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||
when(activityImageService.generateActivityImage(testActivity)).thenReturn("https://test.example/image.png");
|
||||
doNothing().when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
|
||||
|
||||
// When
|
||||
CompletableFuture<Void> future = service.publishToFederationAsync(activityId, userId);
|
||||
|
||||
// Then
|
||||
assertNotNull(future);
|
||||
future.get();
|
||||
verify(federationService).sendCreateActivity(anyString(), any(), eq(testUser), eq(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should skip federation for PRIVATE activity")
|
||||
void testPublishToFederationAsync_PrivateActivity() throws ExecutionException, InterruptedException {
|
||||
// Given
|
||||
testActivity.setVisibility(Activity.Visibility.PRIVATE);
|
||||
when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity));
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||
|
||||
// When
|
||||
CompletableFuture<Void> future = service.publishToFederationAsync(activityId, userId);
|
||||
|
||||
// Then
|
||||
assertNotNull(future);
|
||||
future.get();
|
||||
verify(activityImageService, never()).generateActivityImage(any());
|
||||
verify(federationService, never()).sendCreateActivity(anyString(), any(), any(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle federation publish failure gracefully")
|
||||
void testPublishToFederationAsync_Failure() throws ExecutionException, InterruptedException {
|
||||
// Given
|
||||
testActivity.setVisibility(Activity.Visibility.PUBLIC);
|
||||
when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity));
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||
when(activityImageService.generateActivityImage(testActivity)).thenReturn("https://test.example/image.png");
|
||||
doThrow(new RuntimeException("Federation error")).when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
|
||||
|
||||
// When
|
||||
CompletableFuture<Void> future = service.publishToFederationAsync(activityId, userId);
|
||||
|
||||
// Then
|
||||
assertNotNull(future);
|
||||
future.get(); // Should complete without throwing
|
||||
verify(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle image generation failure and continue with federation")
|
||||
void testPublishToFederationAsync_ImageGenerationFailure() throws ExecutionException, InterruptedException {
|
||||
// Given
|
||||
testActivity.setVisibility(Activity.Visibility.PUBLIC);
|
||||
when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity));
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.of(testUser));
|
||||
when(activityImageService.generateActivityImage(testActivity)).thenThrow(new RuntimeException("Image generation failed"));
|
||||
doNothing().when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
|
||||
|
||||
// When
|
||||
CompletableFuture<Void> future = service.publishToFederationAsync(activityId, userId);
|
||||
|
||||
// Then
|
||||
assertNotNull(future);
|
||||
future.get();
|
||||
verify(activityImageService).generateActivityImage(testActivity);
|
||||
verify(federationService).sendCreateActivity(anyString(), any(), eq(testUser), eq(true)); // Should still publish without image
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle user not found in federation publish")
|
||||
void testPublishToFederationAsync_UserNotFound() throws ExecutionException, InterruptedException {
|
||||
// Given
|
||||
when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity));
|
||||
when(userRepository.findById(userId)).thenReturn(Optional.empty());
|
||||
|
||||
// When
|
||||
CompletableFuture<Void> future = service.publishToFederationAsync(activityId, userId);
|
||||
|
||||
// Then
|
||||
assertNotNull(future);
|
||||
future.get(); // Should complete without throwing
|
||||
verify(userRepository).findById(userId);
|
||||
verify(federationService, never()).sendCreateActivity(anyString(), any(), any(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should format activity content correctly")
|
||||
void testFormatActivityContent() {
|
||||
// Given: Activity with all metrics set in setUp()
|
||||
|
||||
// When: Call processActivityAsync which will use formatActivityContent internally
|
||||
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());
|
||||
|
||||
// When
|
||||
try {
|
||||
service.publishToFederationAsync(activityId, userId).get();
|
||||
} catch (Exception e) {
|
||||
fail("Should not throw exception");
|
||||
}
|
||||
|
||||
// Then: Verify federation was called (content formatting is tested indirectly)
|
||||
verify(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue