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.repository.UserRepository;
|
||||||
import org.operaton.fitpub.service.ActivityFileService;
|
import org.operaton.fitpub.service.ActivityFileService;
|
||||||
import org.operaton.fitpub.service.ActivityImageService;
|
import org.operaton.fitpub.service.ActivityImageService;
|
||||||
|
import org.operaton.fitpub.service.ActivityPostProcessingService;
|
||||||
import org.operaton.fitpub.service.FederationService;
|
import org.operaton.fitpub.service.FederationService;
|
||||||
import org.operaton.fitpub.service.FitFileService;
|
import org.operaton.fitpub.service.FitFileService;
|
||||||
import org.operaton.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;
|
||||||
import org.springframework.http.ResponseEntity;
|
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.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* REST controller for activity management.
|
* REST controller for activity management.
|
||||||
|
|
@ -43,6 +40,7 @@ public class ActivityController {
|
||||||
private final ActivityFileService activityFileService;
|
private final ActivityFileService activityFileService;
|
||||||
private final FitFileService fitFileService;
|
private final FitFileService fitFileService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
private final ActivityPostProcessingService activityPostProcessingService;
|
||||||
private final FederationService federationService;
|
private final FederationService federationService;
|
||||||
private final ActivityImageService activityImageService;
|
private final ActivityImageService activityImageService;
|
||||||
private final org.operaton.fitpub.service.WeatherService weatherService;
|
private final org.operaton.fitpub.service.WeatherService weatherService;
|
||||||
|
|
@ -90,122 +88,25 @@ public class ActivityController {
|
||||||
request.getVisibility()
|
request.getVisibility()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send ActivityPub Create activity to followers if public or followers-only
|
// Trigger async post-processing (non-blocking):
|
||||||
if (activity.getVisibility() == Activity.Visibility.PUBLIC ||
|
// - Personal Records checking
|
||||||
activity.getVisibility() == Activity.Visibility.FOLLOWERS) {
|
// - 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();
|
log.info("Activity {} created and queued for async post-processing", 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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ActivityDTO dto = ActivityDTO.fromEntity(activity);
|
ActivityDTO dto = ActivityDTO.fromEntity(activity);
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(dto);
|
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.
|
* Simple HTML escaping.
|
||||||
|
|
|
||||||
|
|
@ -98,12 +98,14 @@ public class ActivityFileService {
|
||||||
private final TrackSimplifier trackSimplifier;
|
private final TrackSimplifier trackSimplifier;
|
||||||
private final ActivityRepository activityRepository;
|
private final ActivityRepository activityRepository;
|
||||||
private final ObjectMapper objectMapper;
|
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 AchievementService achievementService;
|
||||||
private final TrainingLoadService trainingLoadService;
|
private final TrainingLoadService trainingLoadService;
|
||||||
private final ActivitySummaryService activitySummaryService;
|
private final ActivitySummaryService activitySummaryService;
|
||||||
private final WeatherService weatherService;
|
|
||||||
private final HeatmapGridService heatmapGridService;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes an uploaded activity file (FIT or GPX) and creates an activity.
|
* Processes an uploaded activity file (FIT or GPX) and creates an activity.
|
||||||
|
|
@ -301,15 +303,9 @@ public class ActivityFileService {
|
||||||
savedActivity.getId());
|
savedActivity.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute side effects based on processing options
|
// Execute synchronous side effects based on processing options
|
||||||
// In batch import mode, these are skipped and executed later as a batch
|
// 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.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());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options.isSkipAchievements()) {
|
if (!options.isSkipAchievements()) {
|
||||||
log.debug("Checking achievements for activity {}", savedActivity.getId());
|
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());
|
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()) {
|
if (!options.isSkipTrainingLoad()) {
|
||||||
log.debug("Updating training load for activity {}", savedActivity.getId());
|
log.debug("Updating training load for activity {}", savedActivity.getId());
|
||||||
trainingLoadService.updateTrainingLoad(savedActivity);
|
trainingLoadService.updateTrainingLoad(savedActivity);
|
||||||
|
|
@ -339,18 +328,9 @@ public class ActivityFileService {
|
||||||
log.debug("Skipping summaries update for activity {} (batch mode)", savedActivity.getId());
|
log.debug("Skipping summaries update for activity {} (batch mode)", savedActivity.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!options.isSkipWeather()) {
|
// Note: Async post-processing (Personal Records, Heatmap, Weather, Federation)
|
||||||
// Fetch weather data (async, non-blocking)
|
// is triggered by the caller (ActivityController) via ActivityPostProcessingService
|
||||||
try {
|
// This keeps ActivityFileService focused on file parsing and initial activity save
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
return savedActivity;
|
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