Async uploads, graph improvements

This commit is contained in:
Tim Zöller 2026-01-07 09:52:46 +01:00
parent 9dee8a7e84
commit 22f7f7c271
4 changed files with 251 additions and 104 deletions

View file

@ -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<Void> 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<Void> 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<Void> 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<Void> 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);
}
/**

View file

@ -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: {

View file

@ -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: '<div class="marker-pulse"></div>',
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

View file

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