diff --git a/src/main/java/org/operaton/fitpub/service/TimelineService.java b/src/main/java/org/operaton/fitpub/service/TimelineService.java index 53f4c5a..2c306a5 100644 --- a/src/main/java/org/operaton/fitpub/service/TimelineService.java +++ b/src/main/java/org/operaton/fitpub/service/TimelineService.java @@ -75,23 +75,25 @@ public class TimelineService { // 3. Fetch local activities from followed users (fetch more to account for merging) // We fetch double the page size to have enough items after merging - // Explicitly sort by startedAt DESC (latest first) - Pageable expandedPageable = PageRequest.of(0, pageable.getPageSize() * 2, + // Explicitly sort by startedAt DESC (latest first) for local activities + Pageable expandedPageableLocal = PageRequest.of(0, pageable.getPageSize() * 2, org.springframework.data.domain.Sort.by(org.springframework.data.domain.Sort.Direction.DESC, "startedAt")); Page localActivities = activityRepository.findByUserIdInAndVisibilityInOrderByStartedAtDesc( followedUserIds, List.of(Activity.Visibility.PUBLIC, Activity.Visibility.FOLLOWERS), - expandedPageable + expandedPageableLocal ); // 4. Fetch remote activities from followed remote actors (if any) List remoteActivities = new ArrayList<>(); if (!remoteActorUris.isEmpty()) { - // Use same pageable with explicit sort for remote activities + // Use publishedAt for sorting remote activities (not startedAt) + Pageable expandedPageableRemote = PageRequest.of(0, pageable.getPageSize() * 2, + org.springframework.data.domain.Sort.by(org.springframework.data.domain.Sort.Direction.DESC, "publishedAt")); Page remoteActivitiesPage = remoteActivityRepository.findByRemoteActorUriInAndVisibilityIn( remoteActorUris, List.of(RemoteActivity.Visibility.PUBLIC, RemoteActivity.Visibility.FOLLOWERS), - expandedPageable + expandedPageableRemote ); remoteActivities = remoteActivitiesPage.getContent(); } diff --git a/src/main/java/org/operaton/fitpub/util/FitParser.java b/src/main/java/org/operaton/fitpub/util/FitParser.java index ca2be5a..01d8ed0 100644 --- a/src/main/java/org/operaton/fitpub/util/FitParser.java +++ b/src/main/java/org/operaton/fitpub/util/FitParser.java @@ -210,10 +210,10 @@ public class FitParser { } } + BigDecimal totalDistance = null; if (session.getTotalDistance() != null) { - parsedData.setTotalDistance( - BigDecimal.valueOf(session.getTotalDistance()).setScale(2, RoundingMode.HALF_UP) - ); + totalDistance = BigDecimal.valueOf(session.getTotalDistance()).setScale(2, RoundingMode.HALF_UP); + parsedData.setTotalDistance(totalDistance); } if (session.getTotalAscent() != null) { @@ -231,12 +231,6 @@ public class FitParser { // Extract metrics ActivityMetricsData metrics = new ActivityMetricsData(); - if (session.getAvgSpeed() != null) { - metrics.setAverageSpeed( - BigDecimal.valueOf(session.getAvgSpeed() * MPS_TO_KPH).setScale(2, RoundingMode.HALF_UP) - ); - } - if (session.getMaxSpeed() != null) { metrics.setMaxSpeed( BigDecimal.valueOf(session.getMaxSpeed() * MPS_TO_KPH).setScale(2, RoundingMode.HALF_UP) @@ -275,17 +269,55 @@ public class FitParser { metrics.setCalories(session.getTotalCalories()); } + Duration movingTime = null; if (session.getTotalMovingTime() != null) { - metrics.setMovingTime(Duration.ofSeconds(session.getTotalMovingTime().longValue())); + movingTime = Duration.ofSeconds(session.getTotalMovingTime().longValue()); + metrics.setMovingTime(movingTime); + } else if (session.getTotalTimerTime() != null) { + // Fallback to timer time if moving time is not available + movingTime = Duration.ofSeconds(session.getTotalTimerTime().longValue()); + metrics.setMovingTime(movingTime); } else { // Fallback: Calculate moving time from track points if native value is not available - Duration calculatedMovingTime = calculateMovingTimeFromTrackPoints(parsedData); - if (calculatedMovingTime != null) { - metrics.setMovingTime(calculatedMovingTime); - log.debug("Calculated moving time from track points: {}", calculatedMovingTime); + movingTime = calculateMovingTimeFromTrackPoints(parsedData); + if (movingTime != null) { + metrics.setMovingTime(movingTime); + log.debug("Calculated moving time from track points: {}", movingTime); } } + // Calculate stopped time (elapsed - moving) + if (parsedData.getTotalDuration() != null && movingTime != null) { + Duration stoppedTime = parsedData.getTotalDuration().minus(movingTime); + if (stoppedTime.isNegative()) { + stoppedTime = Duration.ZERO; + } + metrics.setStoppedTime(stoppedTime); + } + + // Calculate average speed from distance and moving time (not from FIT SDK's avgSpeed) + // This ensures consistency with GPX parser and correct calculation based on moving time only + if (totalDistance != null && movingTime != null && movingTime.getSeconds() > 0) { + // distance in meters / time in seconds = m/s, then * 3.6 = km/h + double avgSpeedKmh = (totalDistance.doubleValue() / movingTime.getSeconds()) * 3.6; + metrics.setAverageSpeed( + BigDecimal.valueOf(avgSpeedKmh).setScale(2, RoundingMode.HALF_UP) + ); + + // Calculate average pace (min/km) for running activities + // pace = time (seconds) / distance (km) + double distanceKm = totalDistance.doubleValue() / 1000.0; + if (distanceKm > 0) { + long paceSecondsPerKm = (long) (movingTime.getSeconds() / distanceKm); + metrics.setAveragePace(Duration.ofSeconds(paceSecondsPerKm)); + } + } else if (session.getAvgSpeed() != null) { + // Fallback to FIT SDK's average speed if moving time is not available + metrics.setAverageSpeed( + BigDecimal.valueOf(session.getAvgSpeed() * MPS_TO_KPH).setScale(2, RoundingMode.HALF_UP) + ); + } + if (session.getTotalStrides() != null) { metrics.setTotalSteps(session.getTotalStrides().intValue() * 2); // Strides to steps } diff --git a/src/main/java/org/operaton/fitpub/util/GpxParser.java b/src/main/java/org/operaton/fitpub/util/GpxParser.java index 777f3ef..e02b598 100644 --- a/src/main/java/org/operaton/fitpub/util/GpxParser.java +++ b/src/main/java/org/operaton/fitpub/util/GpxParser.java @@ -414,8 +414,22 @@ public class GpxParser { parsedData.setElevationLoss(BigDecimal.valueOf(elevationLoss).setScale(2, RoundingMode.HALF_UP)); // Calculate average and max values + // Calculate average speed from total distance and moving time (not from all speed values) + if (totalDistance > 0 && movingTime.getSeconds() > 0) { + // Convert: distance (meters) / time (seconds) = m/s, then * 3.6 = km/h + double avgSpeedKmh = (totalDistance / movingTime.getSeconds()) * 3.6; + metrics.setAverageSpeed(BigDecimal.valueOf(avgSpeedKmh).setScale(2, RoundingMode.HALF_UP)); + + // Calculate average pace (min/km) for running activities + // pace = time (seconds) / distance (km) + double distanceKm = totalDistance / 1000.0; + if (distanceKm > 0) { + long paceSecondsPerKm = (long) (movingTime.getSeconds() / distanceKm); + metrics.setAveragePace(Duration.ofSeconds(paceSecondsPerKm)); + } + } + if (!speeds.isEmpty()) { - metrics.setAverageSpeed(calculateAverage(speeds)); metrics.setMaxSpeed(speeds.stream().max(BigDecimal::compareTo).orElse(null)); } diff --git a/src/test/java/org/operaton/fitpub/service/TimelineServiceTest.java b/src/test/java/org/operaton/fitpub/service/TimelineServiceTest.java new file mode 100644 index 0000000..d2f2479 --- /dev/null +++ b/src/test/java/org/operaton/fitpub/service/TimelineServiceTest.java @@ -0,0 +1,241 @@ +package org.operaton.fitpub.service; + +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.dto.TimelineActivityDTO; +import org.operaton.fitpub.model.entity.Activity; +import org.operaton.fitpub.model.entity.Follow; +import org.operaton.fitpub.model.entity.RemoteActivity; +import org.operaton.fitpub.model.entity.User; +import org.operaton.fitpub.repository.ActivityRepository; +import org.operaton.fitpub.repository.FollowRepository; +import org.operaton.fitpub.repository.RemoteActivityRepository; +import org.operaton.fitpub.repository.UserRepository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Unit tests for TimelineService. + * Tests timeline retrieval with mixed local and remote activities. + */ +@ExtendWith(MockitoExtension.class) +class TimelineServiceTest { + + @Mock + private ActivityRepository activityRepository; + + @Mock + private RemoteActivityRepository remoteActivityRepository; + + @Mock + private FollowRepository followRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private org.operaton.fitpub.repository.LikeRepository likeRepository; + + @Mock + private org.operaton.fitpub.repository.CommentRepository commentRepository; + + @Mock + private org.operaton.fitpub.repository.RemoteActorRepository remoteActorRepository; + + @InjectMocks + private TimelineService timelineService; + + private UUID userId; + private User testUser; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + testUser = User.builder() + .id(userId) + .username("testuser") + .email("test@example.com") + .displayName("Test User") + .enabled(true) + .build(); + } + + @Test + @DisplayName("Should retrieve federated timeline with remote activities without errors") + void testGetFederatedTimeline_WithRemoteActivities_NoError() { + // Given: User follows remote actors + List follows = List.of( + createRemoteFollow("https://remote.example/users/alice"), + createRemoteFollow("https://remote.example/users/bob") + ); + + when(followRepository.findAcceptedFollowingByUserId(eq(userId))) + .thenReturn(follows); + when(userRepository.findById(userId)).thenReturn(Optional.of(testUser)); + + // Mock local activities + List localActivities = List.of( + createLocalActivity("Morning Run", LocalDateTime.now().minusHours(2)) + ); + Page localActivitiesPage = new PageImpl<>(localActivities); + when(activityRepository.findByUserIdInAndVisibilityInOrderByStartedAtDesc( + anyList(), anyList(), any(Pageable.class))) + .thenReturn(localActivitiesPage); + + // Mock remote activities - this should use publishedAt for sorting + List remoteActivities = List.of( + createRemoteActivity("Remote Run 1", Instant.now().minusSeconds(3600)), + createRemoteActivity("Remote Run 2", Instant.now().minusSeconds(7200)) + ); + Page remoteActivitiesPage = new PageImpl<>(remoteActivities); + when(remoteActivityRepository.findByRemoteActorUriInAndVisibilityIn( + anyList(), anyList(), any(Pageable.class))) + .thenReturn(remoteActivitiesPage); + + Pageable pageable = PageRequest.of(0, 20); + + // When - This should NOT throw an exception about 'startedAt' not found + Page result = assertDoesNotThrow(() -> + timelineService.getFederatedTimeline(userId, pageable) + ); + + // Then + assertNotNull(result, "Result should not be null"); + assertFalse(result.isEmpty(), "Result should contain activities"); + + // Verify that both repositories were called + verify(activityRepository).findByUserIdInAndVisibilityInOrderByStartedAtDesc( + anyList(), anyList(), any(Pageable.class)); + verify(remoteActivityRepository).findByRemoteActorUriInAndVisibilityIn( + anyList(), anyList(), any(Pageable.class)); + } + + @Test + @DisplayName("Should handle empty remote activities gracefully") + void testGetFederatedTimeline_NoRemoteActivities() { + // Given: User has no remote follows + when(followRepository.findAcceptedFollowingByUserId(eq(userId))) + .thenReturn(new ArrayList<>()); + when(userRepository.findById(userId)).thenReturn(Optional.of(testUser)); + + // Mock local activities only + List localActivities = List.of( + createLocalActivity("Solo Run", LocalDateTime.now()) + ); + Page localActivitiesPage = new PageImpl<>(localActivities); + when(activityRepository.findByUserIdInAndVisibilityInOrderByStartedAtDesc( + anyList(), anyList(), any(Pageable.class))) + .thenReturn(localActivitiesPage); + + Pageable pageable = PageRequest.of(0, 20); + + // When + Page result = timelineService.getFederatedTimeline(userId, pageable); + + // Then + assertNotNull(result); + assertEquals(1, result.getContent().size()); + + // Verify remote repository was NOT called (no remote follows) + verify(remoteActivityRepository, never()).findByRemoteActorUriInAndVisibilityIn( + anyList(), anyList(), any(Pageable.class)); + } + + @Test + @DisplayName("Should merge local and remote activities without errors") + void testGetFederatedTimeline_MergedActivitiesSorted() { + // Given + List follows = List.of( + createRemoteFollow("https://remote.example/users/alice") + ); + when(followRepository.findAcceptedFollowingByUserId(eq(userId))) + .thenReturn(follows); + when(userRepository.findById(userId)).thenReturn(Optional.of(testUser)); + + // Local activity + Activity localActivity = createLocalActivity("Local Run", LocalDateTime.now().minusHours(1)); + Page localActivitiesPage = new PageImpl<>(List.of(localActivity)); + when(activityRepository.findByUserIdInAndVisibilityInOrderByStartedAtDesc( + anyList(), anyList(), any(Pageable.class))) + .thenReturn(localActivitiesPage); + + // Remote activity + RemoteActivity remoteActivity = createRemoteActivity("Remote Run", Instant.now().minusSeconds(7200)); + Page remoteActivitiesPage = new PageImpl<>(List.of(remoteActivity)); + when(remoteActivityRepository.findByRemoteActorUriInAndVisibilityIn( + anyList(), anyList(), any(Pageable.class))) + .thenReturn(remoteActivitiesPage); + + Pageable pageable = PageRequest.of(0, 20); + + // When - Should not throw exception + Page result = assertDoesNotThrow(() -> + timelineService.getFederatedTimeline(userId, pageable) + ); + + // Then - Should have at least one activity + assertNotNull(result); + assertFalse(result.isEmpty(), "Timeline should contain activities"); + assertTrue(result.getContent().size() >= 1, "Timeline should merge both local and remote activities"); + } + + // Helper methods + + private Follow createRemoteFollow(String remoteActorUri) { + Follow follow = new Follow(); + follow.setId(UUID.randomUUID()); + follow.setFollowerId(userId); + follow.setFollowingActorUri(remoteActorUri); + follow.setStatus(Follow.FollowStatus.ACCEPTED); + return follow; + } + + private Activity createLocalActivity(String title, LocalDateTime startedAt) { + Activity activity = new Activity(); + activity.setId(UUID.randomUUID()); + activity.setUserId(userId); + activity.setTitle(title); + activity.setDescription("Test activity"); + activity.setActivityType(Activity.ActivityType.RUN); + activity.setStartedAt(startedAt); + activity.setVisibility(Activity.Visibility.PUBLIC); + activity.setTotalDistance(java.math.BigDecimal.valueOf(5000)); + activity.setTotalDurationSeconds(1800L); + return activity; + } + + private RemoteActivity createRemoteActivity(String title, Instant publishedAt) { + return RemoteActivity.builder() + .id(UUID.randomUUID()) + .activityUri("https://remote.example/activities/" + UUID.randomUUID()) + .remoteActorUri("https://remote.example/users/alice") + .title(title) + .description("Remote test activity") + .activityType("RUN") + .publishedAt(publishedAt) + .visibility(RemoteActivity.Visibility.PUBLIC) + .totalDistance(5000L) + .totalDurationSeconds(1800L) + .build(); + } +}