From 9dee8a7e8456d078217e29b540cf8f12ddfaf885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Z=C3=B6ller?= Date: Wed, 7 Jan 2026 09:07:19 +0100 Subject: [PATCH] Speed up upload --- .../fitpub/controller/ActivityController.java | 129 +------ .../fitpub/service/ActivityFileService.java | 42 +-- .../ActivityPostProcessingService.java | 340 +++++++++++++++++ .../ActivityPostProcessingServiceTest.java | 348 ++++++++++++++++++ 4 files changed, 714 insertions(+), 145 deletions(-) create mode 100644 src/main/java/org/operaton/fitpub/service/ActivityPostProcessingService.java create mode 100644 src/test/java/org/operaton/fitpub/service/ActivityPostProcessingServiceTest.java diff --git a/src/main/java/org/operaton/fitpub/controller/ActivityController.java b/src/main/java/org/operaton/fitpub/controller/ActivityController.java index 4eec5b9..1432c20 100644 --- a/src/main/java/org/operaton/fitpub/controller/ActivityController.java +++ b/src/main/java/org/operaton/fitpub/controller/ActivityController.java @@ -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 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 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. diff --git a/src/main/java/org/operaton/fitpub/service/ActivityFileService.java b/src/main/java/org/operaton/fitpub/service/ActivityFileService.java index 1aff813..b427504 100644 --- a/src/main/java/org/operaton/fitpub/service/ActivityFileService.java +++ b/src/main/java/org/operaton/fitpub/service/ActivityFileService.java @@ -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; } diff --git a/src/main/java/org/operaton/fitpub/service/ActivityPostProcessingService.java b/src/main/java/org/operaton/fitpub/service/ActivityPostProcessingService.java new file mode 100644 index 0000000..89cf991 --- /dev/null +++ b/src/main/java/org/operaton/fitpub/service/ActivityPostProcessingService.java @@ -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 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 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 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 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 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 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 -> "πŸ‹οΈ"; + }; + } +} diff --git a/src/test/java/org/operaton/fitpub/service/ActivityPostProcessingServiceTest.java b/src/test/java/org/operaton/fitpub/service/ActivityPostProcessingServiceTest.java new file mode 100644 index 0000000..fc622e5 --- /dev/null +++ b/src/test/java/org/operaton/fitpub/service/ActivityPostProcessingServiceTest.java @@ -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 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 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 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 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 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 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 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 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 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 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 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 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 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()); + } +}