Speed up upload

This commit is contained in:
Tim Zöller 2026-01-07 09:07:19 +01:00
parent a560036265
commit 9dee8a7e84
4 changed files with 714 additions and 145 deletions

View file

@ -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.

View file

@ -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;
}

View file

@ -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 -> "🏋️";
};
}
}

View file

@ -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());
}
}