From 22f7f7c271ad6e6f42e645be5d809f5ce1ac5840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Z=C3=B6ller?= Date: Wed, 7 Jan 2026 09:52:46 +0100 Subject: [PATCH] Async uploads, graph improvements --- .../ActivityPostProcessingService.java | 53 ++--- src/main/resources/static/js/fitpub.js | 5 +- .../templates/activities/detail.html | 205 ++++++++++++++++-- .../ActivityPostProcessingServiceTest.java | 92 +++----- 4 files changed, 251 insertions(+), 104 deletions(-) diff --git a/src/main/java/org/operaton/fitpub/service/ActivityPostProcessingService.java b/src/main/java/org/operaton/fitpub/service/ActivityPostProcessingService.java index 89cf991..2085230 100644 --- a/src/main/java/org/operaton/fitpub/service/ActivityPostProcessingService.java +++ b/src/main/java/org/operaton/fitpub/service/ActivityPostProcessingService.java @@ -18,7 +18,6 @@ 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. @@ -62,34 +61,33 @@ public class ActivityPostProcessingService { * @param activityId the saved activity ID * @param userId the user ID who uploaded the activity */ + @Async("taskExecutor") 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) + // Run post-processing operations in background thread + // All operations run sequentially with separate transactions (REQUIRES_NEW) + // for fault isolation - failures in one operation don't affect others + 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; - }); + fetchWeatherAsync(activityId); + publishToFederationAsync(activityId, userId); + + log.info("Completed async post-processing for activity {}", activityId); } /** - * Asynchronously check and update personal records for the activity. + * Check and update personal records for the activity. + * Called internally from processActivityAsync background thread. * 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) { + void updatePersonalRecordsAsync(UUID activityId) { try { log.debug("Async: Checking personal records for activity {}", activityId); @@ -105,19 +103,17 @@ public class ActivityPostProcessingService { activityId, e.getMessage(), e); // Don't rethrow - error logged, operation fails independently } - return CompletableFuture.completedFuture(null); } /** - * Asynchronously update heatmap grid with activity GPS data. + * Update heatmap grid with activity GPS data. + * Called internally from processActivityAsync background thread. * 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) { + void updateHeatmapAsync(UUID activityId) { try { log.debug("Async: Updating heatmap for activity {}", activityId); @@ -133,21 +129,19 @@ public class ActivityPostProcessingService { 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. + * Fetch weather data for the activity location and time. + * Called internally from processActivityAsync background thread. * 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) { + void fetchWeatherAsync(UUID activityId) { try { log.debug("Async: Fetching weather for activity {}", activityId); @@ -163,12 +157,12 @@ public class ActivityPostProcessingService { activityId, e.getMessage(), e); // Don't rethrow - error logged, operation fails independently } - return CompletableFuture.completedFuture(null); } /** - * Asynchronously publish activity to the Fediverse (ActivityPub federation). + * Publish activity to the Fediverse (ActivityPub federation). * Generates activity image and sends Create activity to all follower inboxes. + * Called internally from processActivityAsync background thread. * Runs in a separate transaction to isolate from main upload transaction. * * Only publishes if activity visibility is PUBLIC or FOLLOWERS. @@ -176,11 +170,9 @@ public class ActivityPostProcessingService { * * @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) { + void publishToFederationAsync(UUID activityId, UUID userId) { try { log.debug("Async: Publishing activity {} to Fediverse", activityId); @@ -194,7 +186,7 @@ public class ActivityPostProcessingService { if (activity.getVisibility() != Activity.Visibility.PUBLIC && activity.getVisibility() != Activity.Visibility.FOLLOWERS) { log.debug("Async: Skipping federation for private activity {}", activityId); - return CompletableFuture.completedFuture(null); + return; } String activityUri = baseUrl + "/activities/" + activity.getId(); @@ -252,7 +244,6 @@ public class ActivityPostProcessingService { activityId, e.getMessage(), e); // Don't rethrow - error logged, operation fails independently } - return CompletableFuture.completedFuture(null); } /** diff --git a/src/main/resources/static/js/fitpub.js b/src/main/resources/static/js/fitpub.js index 89dfa8c..f903d08 100644 --- a/src/main/resources/static/js/fitpub.js +++ b/src/main/resources/static/js/fitpub.js @@ -303,8 +303,11 @@ function createElevationChart(canvasId, elevationData) { data: elevationData.map(d => d.elevation), borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.1)', + borderWidth: 2, fill: true, - tension: 0.4 + tension: 0.3, + pointRadius: 0, + pointHoverRadius: 5 }] }, options: { diff --git a/src/main/resources/templates/activities/detail.html b/src/main/resources/templates/activities/detail.html index 48a6518..3aebc52 100644 --- a/src/main/resources/templates/activities/detail.html +++ b/src/main/resources/templates/activities/detail.html @@ -439,6 +439,11 @@ const errorMessage = document.getElementById('errorMessage'); const activityContent = document.getElementById('activityContent'); + // Global variables for map interaction + let activityMap = null; + let hoverMarker = null; + let currentTrackPoints = null; + // Load activity details loadActivity(); @@ -562,6 +567,9 @@ // Render elevation chart if data exists if (activity.trackPoints && activity.trackPoints.length > 0) { + // Store track points globally for map marker updates + currentTrackPoints = activity.trackPoints; + const hasElevation = activity.trackPoints.some(p => p.elevation != null); if (hasElevation) { document.getElementById('elevationSection').style.display = 'block'; @@ -694,18 +702,59 @@ // Create map (needs to be done after container is visible) setTimeout(() => { - const map = FitPub.createActivityMap('activityMap', geoJson, { + activityMap = FitPub.createActivityMap('activityMap', geoJson, { showStartEnd: false, // Privacy: Do not show start/end markers fitBounds: true }); + // Create a hover marker (initially hidden) + if (activityMap) { + const pulsingIcon = L.divIcon({ + className: 'chart-hover-marker', + html: '
', + iconSize: [20, 20], + iconAnchor: [10, 10] + }); + + hoverMarker = L.marker([0, 0], { + icon: pulsingIcon, + opacity: 0 + }).addTo(activityMap); + + // Add CSS for the pulsing marker + if (!document.getElementById('chart-hover-marker-style')) { + const style = document.createElement('style'); + style.id = 'chart-hover-marker-style'; + style.textContent = ` + .chart-hover-marker { + background: transparent; + border: none; + } + .marker-pulse { + width: 20px; + height: 20px; + border-radius: 50%; + background: rgba(255, 69, 0, 0.8); + border: 3px solid white; + box-shadow: 0 0 10px rgba(255, 69, 0, 0.6); + animation: pulse 1.5s ease-in-out infinite; + } + @keyframes pulse { + 0%, 100% { transform: scale(1); opacity: 0.8; } + 50% { transform: scale(1.2); opacity: 1; } + } + `; + document.head.appendChild(style); + } + } + // Force fit bounds again after map is fully rendered - if (map && map.trackLayer) { + if (activityMap && activityMap.trackLayer) { setTimeout(() => { try { - const bounds = map.trackLayer.getBounds(); + const bounds = activityMap.trackLayer.getBounds(); if (bounds.isValid()) { - map.fitBounds(bounds, { padding: [50, 50] }); + activityMap.fitBounds(bounds, { padding: [50, 50] }); } } catch (e) { console.warn('Could not fit bounds on second attempt:', e); @@ -739,7 +788,8 @@ if (point.elevation != null) { elevationData.push({ distance: cumulativeDistance, - elevation: point.elevation + elevation: point.elevation, + trackPointIndex: i // Store the original track point index }); } } @@ -747,13 +797,75 @@ if (elevationData.length > 0) { // Smooth elevation data to remove zero/invalid values const smoothedData = smoothElevationData(elevationData); - FitPub.createElevationChart('elevationChart', smoothedData); + + // Create elevation chart with hover interaction + const ctx = document.getElementById('elevationChart').getContext('2d'); + new Chart(ctx, { + type: 'line', + data: { + labels: smoothedData.map(d => (d.distance / 1000).toFixed(2)), + datasets: [{ + label: 'Elevation (m)', + data: smoothedData.map(d => d.elevation), + borderColor: '#10b981', + backgroundColor: 'rgba(16, 185, 129, 0.1)', + borderWidth: 2, + fill: true, + tension: 0.3, + pointRadius: 0, + pointHoverRadius: 5 + }] + }, + options: { + responsive: true, + maintainAspectRatio: true, + onHover: (event, activeElements) => { + if (activeElements && activeElements.length > 0) { + const dataIndex = activeElements[0].index; + if (smoothedData[dataIndex]) { + updateMapMarker(smoothedData[dataIndex].trackPointIndex); + } + } else { + hideMapMarker(); + } + }, + plugins: { + legend: { + display: false + }, + tooltip: { + callbacks: { + title: (context) => { + return `Distance: ${context[0].label} km`; + }, + label: (context) => { + return `Elevation: ${context.parsed.y.toFixed(1)} m`; + } + } + } + }, + scales: { + x: { + title: { + display: true, + text: 'Distance (km)' + } + }, + y: { + title: { + display: true, + text: 'Elevation (m)' + } + } + } + } + }); } } /** * Smooth elevation data by interpolating zero/invalid values and applying moving average - * @param {Array} data - Array of {distance, elevation} objects + * @param {Array} data - Array of {distance, elevation, trackPointIndex} objects * @returns {Array} Smoothed elevation data */ function smoothElevationData(data) { @@ -812,13 +924,57 @@ smoothed.push({ distance: interpolated[i].distance, - elevation: count > 0 ? sum / count : interpolated[i].elevation + elevation: count > 0 ? sum / count : interpolated[i].elevation, + trackPointIndex: interpolated[i].trackPointIndex // Preserve track point index }); } return smoothed; } + /** + * Update the hover marker position on the map + * @param {number} trackPointIndex - Index of the track point to show + */ + function updateMapMarker(trackPointIndex) { + if (!activityMap || !hoverMarker || !currentTrackPoints) return; + + const point = currentTrackPoints[trackPointIndex]; + if (point && point.latitude != null && point.longitude != null) { + hoverMarker.setLatLng([point.latitude, point.longitude]); + hoverMarker.setOpacity(1); + } + } + + /** + * Hide the hover marker on the map + */ + function hideMapMarker() { + if (hoverMarker) { + hoverMarker.setOpacity(0); + } + } + + /** + * Format elapsed time in minutes to mm:ss or hh:mm:ss + * @param {number} minutes - Elapsed time in decimal minutes + * @param {number} totalMinutes - Total duration in minutes (to determine if hours are needed) + * @returns {string} Formatted time string + */ + function formatElapsedTime(minutes, totalMinutes) { + const totalSeconds = Math.floor(minutes * 60); + const hours = Math.floor(totalSeconds / 3600); + const mins = Math.floor((totalSeconds % 3600) / 60); + const secs = totalSeconds % 60; + + // Use hh:mm:ss format if total duration is 1 hour or more + if (totalMinutes >= 60) { + return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } else { + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + } + /** * Render heart rate chart over time * @param {Array} trackPoints - Array of track point objects @@ -844,18 +1000,22 @@ heartRateData.push({ time: elapsedMinutes, - heartRate: point.heartRate + heartRate: point.heartRate, + trackPointIndex: i // Store the original track point index }); } } if (heartRateData.length > 0) { + // Calculate total duration to determine time format + const totalMinutes = heartRateData[heartRateData.length - 1].time; + // Create heart rate chart using Chart.js const ctx = document.getElementById('heartRateChart').getContext('2d'); new Chart(ctx, { type: 'line', data: { - labels: heartRateData.map(d => d.time.toFixed(1)), + labels: heartRateData.map(d => formatElapsedTime(d.time, totalMinutes)), datasets: [{ label: 'Heart Rate (bpm)', data: heartRateData.map(d => d.heartRate), @@ -871,6 +1031,16 @@ options: { responsive: true, maintainAspectRatio: true, + onHover: (event, activeElements) => { + if (activeElements && activeElements.length > 0) { + const dataIndex = activeElements[0].index; + if (heartRateData[dataIndex]) { + updateMapMarker(heartRateData[dataIndex].trackPointIndex); + } + } else { + hideMapMarker(); + } + }, plugins: { legend: { display: false @@ -879,6 +1049,9 @@ mode: 'index', intersect: false, callbacks: { + title: function(context) { + return 'Time: ' + context[0].label; + }, label: function(context) { return context.parsed.y + ' bpm'; } @@ -889,7 +1062,7 @@ x: { title: { display: true, - text: 'Time (minutes)' + text: 'Time' }, ticks: { maxTicksLimit: 10 @@ -950,12 +1123,15 @@ // Apply moving average smoothing to speed data (window size 5) const smoothedSpeedData = smoothSpeedData(speedData); + // Calculate total duration to determine time format + const totalMinutes = smoothedSpeedData[smoothedSpeedData.length - 1].time; + // Create speed chart using Chart.js const ctx = document.getElementById('speedChart').getContext('2d'); new Chart(ctx, { type: 'line', data: { - labels: smoothedSpeedData.map(d => d.time.toFixed(1)), + labels: smoothedSpeedData.map(d => formatElapsedTime(d.time, totalMinutes)), datasets: [{ label: 'Speed (km/h)', data: smoothedSpeedData.map(d => d.speed), @@ -979,6 +1155,9 @@ mode: 'index', intersect: false, callbacks: { + title: function(context) { + return 'Time: ' + context[0].label; + }, label: function(context) { const speedKmh = context.parsed.y; // Calculate pace (min/km) @@ -994,7 +1173,7 @@ x: { title: { display: true, - text: 'Time (minutes)' + text: 'Time' }, ticks: { maxTicksLimit: 10 diff --git a/src/test/java/org/operaton/fitpub/service/ActivityPostProcessingServiceTest.java b/src/test/java/org/operaton/fitpub/service/ActivityPostProcessingServiceTest.java index fc622e5..1e95f55 100644 --- a/src/test/java/org/operaton/fitpub/service/ActivityPostProcessingServiceTest.java +++ b/src/test/java/org/operaton/fitpub/service/ActivityPostProcessingServiceTest.java @@ -18,8 +18,6 @@ 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; @@ -96,122 +94,112 @@ class ActivityPostProcessingServiceTest { @Test @DisplayName("Should successfully update personal records async") - void testUpdatePersonalRecordsAsync_Success() throws ExecutionException, InterruptedException { + void testUpdatePersonalRecordsAsync_Success() { // Given when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity)); when(personalRecordService.checkAndUpdatePersonalRecords(testActivity)).thenReturn(java.util.List.of()); // When - CompletableFuture future = service.updatePersonalRecordsAsync(activityId); + 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 { + void testUpdatePersonalRecordsAsync_Failure() { // Given when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity)); doThrow(new RuntimeException("Database error")).when(personalRecordService).checkAndUpdatePersonalRecords(testActivity); // When - CompletableFuture future = service.updatePersonalRecordsAsync(activityId); + service.updatePersonalRecordsAsync(activityId); // Then - assertNotNull(future); - future.get(); // Should complete without throwing (error is logged, not propagated) + // 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 { + void testUpdatePersonalRecordsAsync_ActivityNotFound() { // Given when(activityRepository.findById(activityId)).thenReturn(Optional.empty()); // When - CompletableFuture future = service.updatePersonalRecordsAsync(activityId); + service.updatePersonalRecordsAsync(activityId); // Then - assertNotNull(future); - future.get(); // Should complete without throwing + // 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 { + void testUpdateHeatmapAsync_Success() { // Given when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity)); doNothing().when(heatmapGridService).updateHeatmapForActivity(testActivity); // When - CompletableFuture future = service.updateHeatmapAsync(activityId); + 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 { + void testUpdateHeatmapAsync_Failure() { // Given when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity)); doThrow(new RuntimeException("Heatmap error")).when(heatmapGridService).updateHeatmapForActivity(testActivity); // When - CompletableFuture future = service.updateHeatmapAsync(activityId); + service.updateHeatmapAsync(activityId); // Then - assertNotNull(future); - future.get(); // Should complete without throwing + // Should complete without throwing verify(heatmapGridService).updateHeatmapForActivity(testActivity); } @Test @DisplayName("Should successfully fetch weather async") - void testFetchWeatherAsync_Success() throws ExecutionException, InterruptedException { + void testFetchWeatherAsync_Success() { // Given when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity)); when(weatherService.fetchWeatherForActivity(testActivity)).thenReturn(Optional.empty()); // When - CompletableFuture future = service.fetchWeatherAsync(activityId); + 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 { + void testFetchWeatherAsync_Failure() { // 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); + service.fetchWeatherAsync(activityId); // Then - assertNotNull(future); - future.get(); // Should complete without throwing + // 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 { + void testPublishToFederationAsync_PublicActivity() { // Given testActivity.setVisibility(Activity.Visibility.PUBLIC); when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity)); @@ -220,11 +208,9 @@ class ActivityPostProcessingServiceTest { doNothing().when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean()); // When - CompletableFuture future = service.publishToFederationAsync(activityId, userId); + service.publishToFederationAsync(activityId, userId); // Then - assertNotNull(future); - future.get(); verify(activityRepository).findById(activityId); verify(userRepository).findById(userId); verify(activityImageService).generateActivityImage(testActivity); @@ -233,7 +219,7 @@ class ActivityPostProcessingServiceTest { @Test @DisplayName("Should successfully publish to federation async for FOLLOWERS activity") - void testPublishToFederationAsync_FollowersActivity() throws ExecutionException, InterruptedException { + void testPublishToFederationAsync_FollowersActivity() { // Given testActivity.setVisibility(Activity.Visibility.FOLLOWERS); when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity)); @@ -242,35 +228,31 @@ class ActivityPostProcessingServiceTest { doNothing().when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean()); // When - CompletableFuture future = service.publishToFederationAsync(activityId, userId); + 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 { + void testPublishToFederationAsync_PrivateActivity() { // 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); + 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 { + void testPublishToFederationAsync_Failure() { // Given testActivity.setVisibility(Activity.Visibility.PUBLIC); when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity)); @@ -279,17 +261,16 @@ class ActivityPostProcessingServiceTest { doThrow(new RuntimeException("Federation error")).when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean()); // When - CompletableFuture future = service.publishToFederationAsync(activityId, userId); + service.publishToFederationAsync(activityId, userId); // Then - assertNotNull(future); - future.get(); // Should complete without throwing + // 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 { + void testPublishToFederationAsync_ImageGenerationFailure() { // Given testActivity.setVisibility(Activity.Visibility.PUBLIC); when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity)); @@ -298,28 +279,25 @@ class ActivityPostProcessingServiceTest { doNothing().when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean()); // When - CompletableFuture future = service.publishToFederationAsync(activityId, userId); + 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 { + void testPublishToFederationAsync_UserNotFound() { // Given when(activityRepository.findById(activityId)).thenReturn(Optional.of(testActivity)); when(userRepository.findById(userId)).thenReturn(Optional.empty()); // When - CompletableFuture future = service.publishToFederationAsync(activityId, userId); + service.publishToFederationAsync(activityId, userId); // Then - assertNotNull(future); - future.get(); // Should complete without throwing + // Should complete without throwing verify(userRepository).findById(userId); verify(federationService, never()).sendCreateActivity(anyString(), any(), any(), anyBoolean()); } @@ -336,11 +314,7 @@ class ActivityPostProcessingServiceTest { doNothing().when(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean()); // When - try { - service.publishToFederationAsync(activityId, userId).get(); - } catch (Exception e) { - fail("Should not throw exception"); - } + service.publishToFederationAsync(activityId, userId); // Then: Verify federation was called (content formatting is tested indirectly) verify(federationService).sendCreateActivity(anyString(), any(), any(), anyBoolean());