Async uploads, graph improvements
This commit is contained in:
parent
9dee8a7e84
commit
22f7f7c271
4 changed files with 251 additions and 104 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue