Merge branch 'refs/heads/24-fix-achievement-date' into sattelgeschichten

This commit is contained in:
Marcus Fihlon 2026-04-29 12:12:11 +02:00
commit f37b20de62
Signed by: McPringle
GPG key ID: C6B7F469EE363E1F
5 changed files with 172 additions and 13 deletions

View file

@ -0,0 +1,82 @@
package net.javahippie.fitpub.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* Recalculates derived analytics after an activity has been deleted.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ActivityDeleteRecalculationService {
private final AchievementService achievementService;
private final ActivitySummaryService activitySummaryService;
private final ConcurrentMap<UUID, PendingUserRecalculation> pendingRecalculations = new ConcurrentHashMap<>();
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleActivityDeleted(ActivityDeletedEvent event) {
PendingUserRecalculation pending = pendingRecalculations.computeIfAbsent(
event.userId(),
ignored -> new PendingUserRecalculation()
);
if (!pending.enqueueAndShouldStart(event.activityDate())) {
log.debug("Queued additional activity delete recalculation for user {}", event.userId());
return;
}
do {
Set<LocalDate> datesToRecalculate = pending.drainDates();
achievementService.rebuildAchievementsForUser(event.userId());
for (LocalDate date : datesToRecalculate) {
activitySummaryService.updateWeeklySummary(event.userId(), date);
activitySummaryService.updateMonthlySummary(event.userId(), date);
activitySummaryService.updateYearlySummary(event.userId(), date);
}
} while (pending.keepProcessing());
log.info("Recalculated achievements and summaries after deleting activities for user {}", event.userId());
}
private static final class PendingUserRecalculation {
private final Set<LocalDate> pendingDates = new HashSet<>();
private boolean processing;
synchronized boolean enqueueAndShouldStart(LocalDate activityDate) {
pendingDates.add(activityDate);
if (processing) {
return false;
}
processing = true;
return true;
}
synchronized Set<LocalDate> drainDates() {
Set<LocalDate> dates = new HashSet<>(pendingDates);
pendingDates.clear();
return dates;
}
synchronized boolean keepProcessing() {
if (pendingDates.isEmpty()) {
processing = false;
return false;
}
return true;
}
}
}

View file

@ -0,0 +1,7 @@
package net.javahippie.fitpub.service;
import java.time.LocalDate;
import java.util.UUID;
public record ActivityDeletedEvent(UUID userId, LocalDate activityDate) {
}

View file

@ -18,6 +18,7 @@ import net.javahippie.fitpub.util.FitFileValidator;
import net.javahippie.fitpub.util.FitParser;
import net.javahippie.fitpub.util.ParsedActivityData;
import net.javahippie.fitpub.util.TrackSimplifier;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
@ -52,6 +53,7 @@ public class FitFileService {
private final ActivitySummaryService activitySummaryService;
private final WeatherService weatherService;
private final HeatmapGridService heatmapGridService;
private final ApplicationEventPublisher applicationEventPublisher;
/**
* Processes an uploaded FIT file and creates an activity.
@ -319,12 +321,10 @@ public class FitFileService {
return activityRepository.findByIdAndUserId(activityId, userId)
.map(activity -> {
activityRepository.delete(activity);
achievementService.rebuildAchievementsForUser(userId);
if (activity.getStartedAt() != null) {
java.time.LocalDate activityDate = activity.getStartedAt().toLocalDate();
activitySummaryService.updateWeeklySummary(userId, activityDate);
activitySummaryService.updateMonthlySummary(userId, activityDate);
activitySummaryService.updateYearlySummary(userId, activityDate);
applicationEventPublisher.publishEvent(
new ActivityDeletedEvent(userId, activity.getStartedAt().toLocalDate())
);
}
log.info("Deleted activity {} for user {}", activityId, userId);
return true;

View file

@ -0,0 +1,69 @@
package net.javahippie.fitpub.service;
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 java.time.LocalDate;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class ActivityDeleteRecalculationServiceTest {
@Mock
private AchievementService achievementService;
@Mock
private ActivitySummaryService activitySummaryService;
@InjectMocks
private ActivityDeleteRecalculationService activityDeleteRecalculationService;
@Test
@DisplayName("Should rebuild achievements and summaries after activity deletion")
void shouldRebuildAchievementsAndSummariesAfterActivityDeletion() {
UUID userId = UUID.randomUUID();
LocalDate activityDate = LocalDate.of(2025, 12, 3);
activityDeleteRecalculationService.handleActivityDeleted(new ActivityDeletedEvent(userId, activityDate));
verify(achievementService).rebuildAchievementsForUser(userId);
verify(activitySummaryService).updateWeeklySummary(userId, activityDate);
verify(activitySummaryService).updateMonthlySummary(userId, activityDate);
verify(activitySummaryService).updateYearlySummary(userId, activityDate);
}
@Test
@DisplayName("Should serialize recalculations per user and replay queued deletions")
void shouldSerializeRecalculationsPerUserAndReplayQueuedDeletions() {
UUID userId = UUID.randomUUID();
LocalDate firstDate = LocalDate.of(2025, 12, 3);
LocalDate secondDate = LocalDate.of(2025, 12, 4);
AtomicBoolean queuedSecondDelete = new AtomicBoolean(false);
doAnswer(invocation -> {
if (queuedSecondDelete.compareAndSet(false, true)) {
activityDeleteRecalculationService.handleActivityDeleted(new ActivityDeletedEvent(userId, secondDate));
}
return null;
}).when(achievementService).rebuildAchievementsForUser(userId);
activityDeleteRecalculationService.handleActivityDeleted(new ActivityDeletedEvent(userId, firstDate));
verify(achievementService, times(2)).rebuildAchievementsForUser(userId);
verify(activitySummaryService).updateWeeklySummary(userId, firstDate);
verify(activitySummaryService).updateMonthlySummary(userId, firstDate);
verify(activitySummaryService).updateYearlySummary(userId, firstDate);
verify(activitySummaryService).updateWeeklySummary(userId, secondDate);
verify(activitySummaryService).updateMonthlySummary(userId, secondDate);
verify(activitySummaryService).updateYearlySummary(userId, secondDate);
}
}

View file

@ -21,6 +21,7 @@ import net.javahippie.fitpub.util.FitFileValidator;
import net.javahippie.fitpub.util.FitParser;
import net.javahippie.fitpub.util.ParsedActivityData;
import net.javahippie.fitpub.util.TrackSimplifier;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.mock.web.MockMultipartFile;
import java.math.BigDecimal;
@ -74,6 +75,9 @@ class FitFileServiceTest {
@Mock
private HeatmapGridService heatmapGridService;
@Mock
private ApplicationEventPublisher applicationEventPublisher;
@Spy
private ObjectMapper objectMapper;
@ -240,10 +244,10 @@ class FitFileServiceTest {
// Assert
assertTrue(result);
verify(activityRepository).delete(activity);
verify(achievementService).rebuildAchievementsForUser(testUserId);
verify(activitySummaryService).updateWeeklySummary(testUserId, startedAt.toLocalDate());
verify(activitySummaryService).updateMonthlySummary(testUserId, startedAt.toLocalDate());
verify(activitySummaryService).updateYearlySummary(testUserId, startedAt.toLocalDate());
ArgumentCaptor<ActivityDeletedEvent> eventCaptor = ArgumentCaptor.forClass(ActivityDeletedEvent.class);
verify(applicationEventPublisher).publishEvent(eventCaptor.capture());
assertEquals(testUserId, eventCaptor.getValue().userId());
assertEquals(startedAt.toLocalDate(), eventCaptor.getValue().activityDate());
}
@Test
@ -260,10 +264,7 @@ class FitFileServiceTest {
// Assert
assertFalse(result);
verify(activityRepository, never()).delete(any());
verify(achievementService, never()).rebuildAchievementsForUser(any());
verify(activitySummaryService, never()).updateWeeklySummary(any(), any());
verify(activitySummaryService, never()).updateMonthlySummary(any(), any());
verify(activitySummaryService, never()).updateYearlySummary(any(), any());
verify(applicationEventPublisher, never()).publishEvent(any());
}
@Test