diff --git a/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java index 9c4907b..3f6a1bc 100644 --- a/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java +++ b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java @@ -207,6 +207,20 @@ public interface ActivityRepository extends JpaRepository { "AND FUNCTION('DATE', a.startedAt) = :date") boolean existsByUserIdAndDate(@Param("userId") UUID userId, @Param("date") java.time.LocalDate date); + /** + * Returns the distinct calendar dates on which a user has at least one activity, + * since the given timestamp, ordered most-recent first. Used by the streak + * calculation in {@code AchievementService} to walk activity history with a single + * query instead of one {@code existsByUserIdAndDate} query per day. + */ + @Query("SELECT DISTINCT cast(a.startedAt as date) FROM Activity a " + + "WHERE a.userId = :userId AND a.startedAt >= :since " + + "ORDER BY cast(a.startedAt as date) DESC") + List findDistinctActivityDatesSince( + @Param("userId") UUID userId, + @Param("since") java.time.LocalDateTime since + ); + /** * Batch delete activities by IDs. * More efficient than deleting one by one. diff --git a/src/main/java/net/javahippie/fitpub/service/AchievementService.java b/src/main/java/net/javahippie/fitpub/service/AchievementService.java index d8bd46b..c62ee2a 100644 --- a/src/main/java/net/javahippie/fitpub/service/AchievementService.java +++ b/src/main/java/net/javahippie/fitpub/service/AchievementService.java @@ -30,6 +30,12 @@ public class AchievementService { * Check and award achievements for an activity. * Called after an activity is saved. * + *

The user's existing achievement set is loaded once at the start of this + * method and threaded through every sub-check. Previously each {@code hasAchievement} + * call hit the DB individually, which meant 16+ {@code SELECT EXISTS} queries per + * activity upload (5 distance milestones × 5 count milestones × 4 streak milestones + * × 1 variety × 1 speed × up to 2 time-based + 3 elevation). Now: 1 query. + * * @param activity the activity to check for achievements * @return list of newly earned achievements */ @@ -43,29 +49,37 @@ public class AchievementService { UUID userId = activity.getUserId(); + // Load all of the user's existing achievement types in a single query so the + // sub-checks below can do an in-memory `contains()` instead of an EXISTS query + // per milestone. + Set existing = EnumSet.noneOf(Achievement.AchievementType.class); + for (Achievement a : achievementRepository.findByUserIdOrderByEarnedAtDesc(userId)) { + existing.add(a.getAchievementType()); + } + // Check first activity achievements - newAchievements.addAll(checkFirstActivityAchievements(userId, activity)); + newAchievements.addAll(checkFirstActivityAchievements(userId, activity, existing)); // Check distance milestones - newAchievements.addAll(checkDistanceMilestones(userId)); + newAchievements.addAll(checkDistanceMilestones(userId, existing)); // Check activity count milestones - newAchievements.addAll(checkActivityCountMilestones(userId)); + newAchievements.addAll(checkActivityCountMilestones(userId, existing)); // Check streak achievements - newAchievements.addAll(checkStreakAchievements(userId)); + newAchievements.addAll(checkStreakAchievements(userId, existing)); // Check time-based achievements - newAchievements.addAll(checkTimeBasedAchievements(userId, activity)); + newAchievements.addAll(checkTimeBasedAchievements(userId, activity, existing)); // Check elevation achievements - newAchievements.addAll(checkElevationAchievements(userId, activity)); + newAchievements.addAll(checkElevationAchievements(userId, activity, existing)); // Check variety achievements - newAchievements.addAll(checkVarietyAchievements(userId)); + newAchievements.addAll(checkVarietyAchievements(userId, existing)); // Check speed achievements - newAchievements.addAll(checkSpeedAchievements(userId, activity)); + newAchievements.addAll(checkSpeedAchievements(userId, activity, existing)); return newAchievements; } @@ -73,12 +87,13 @@ public class AchievementService { /** * Check first activity achievements. */ - private List checkFirstActivityAchievements(UUID userId, Activity activity) { + private List checkFirstActivityAchievements(UUID userId, Activity activity, + Set existing) { List achievements = new ArrayList<>(); // First activity overall long totalActivities = activityRepository.countByUserId(userId); - if (totalActivities == 1 && !hasAchievement(userId, Achievement.AchievementType.FIRST_ACTIVITY)) { + if (totalActivities == 1 && !existing.contains(Achievement.AchievementType.FIRST_ACTIVITY)) { achievements.add(awardAchievement( userId, Achievement.AchievementType.FIRST_ACTIVITY, @@ -103,7 +118,7 @@ public class AchievementService { default -> null; }; - if (achievementType != null && !hasAchievement(userId, achievementType)) { + if (achievementType != null && !existing.contains(achievementType)) { achievements.add(awardAchievement( userId, achievementType, @@ -123,7 +138,7 @@ public class AchievementService { /** * Check distance milestone achievements. */ - private List checkDistanceMilestones(UUID userId) { + private List checkDistanceMilestones(UUID userId, Set existing) { List achievements = new ArrayList<>(); // Calculate total distance @@ -144,7 +159,7 @@ public class AchievementService { ); for (Map.Entry entry : milestones.entrySet()) { - if (totalKm >= entry.getKey() && !hasAchievement(userId, entry.getValue())) { + if (totalKm >= entry.getKey() && !existing.contains(entry.getValue())) { achievements.add(awardAchievement( userId, entry.getValue(), @@ -164,7 +179,7 @@ public class AchievementService { /** * Check activity count milestone achievements. */ - private List checkActivityCountMilestones(UUID userId) { + private List checkActivityCountMilestones(UUID userId, Set existing) { List achievements = new ArrayList<>(); long activityCount = activityRepository.countByUserId(userId); @@ -178,7 +193,7 @@ public class AchievementService { ); for (Map.Entry entry : milestones.entrySet()) { - if (activityCount >= entry.getKey() && !hasAchievement(userId, entry.getValue())) { + if (activityCount >= entry.getKey() && !existing.contains(entry.getValue())) { achievements.add(awardAchievement( userId, entry.getValue(), @@ -198,7 +213,7 @@ public class AchievementService { /** * Check streak achievements (consecutive days). */ - private List checkStreakAchievements(UUID userId) { + private List checkStreakAchievements(UUID userId, Set existing) { List achievements = new ArrayList<>(); int currentStreak = calculateCurrentStreak(userId); @@ -211,7 +226,7 @@ public class AchievementService { ); for (Map.Entry entry : streakMilestones.entrySet()) { - if (currentStreak >= entry.getKey() && !hasAchievement(userId, entry.getValue())) { + if (currentStreak >= entry.getKey() && !existing.contains(entry.getValue())) { achievements.add(awardAchievement( userId, entry.getValue(), @@ -231,13 +246,14 @@ public class AchievementService { /** * Check time-based achievements (early bird, night owl, weekend warrior). */ - private List checkTimeBasedAchievements(UUID userId, Activity activity) { + private List checkTimeBasedAchievements(UUID userId, Activity activity, + Set existing) { List achievements = new ArrayList<>(); LocalTime startTime = activity.getStartedAt().toLocalTime(); // Early bird (before 6am) - if (startTime.isBefore(LocalTime.of(6, 0)) && !hasAchievement(userId, Achievement.AchievementType.EARLY_BIRD)) { + if (startTime.isBefore(LocalTime.of(6, 0)) && !existing.contains(Achievement.AchievementType.EARLY_BIRD)) { long earlyActivities = activityRepository.countByUserIdAndStartTimeBefore(userId, LocalTime.of(6, 0)); if (earlyActivities >= 5) { achievements.add(awardAchievement( @@ -254,7 +270,7 @@ public class AchievementService { } // Night owl (after 10pm) - if (startTime.isAfter(LocalTime.of(22, 0)) && !hasAchievement(userId, Achievement.AchievementType.NIGHT_OWL)) { + if (startTime.isAfter(LocalTime.of(22, 0)) && !existing.contains(Achievement.AchievementType.NIGHT_OWL)) { long lateActivities = activityRepository.countByUserIdAndStartTimeAfter(userId, LocalTime.of(22, 0)); if (lateActivities >= 5) { achievements.add(awardAchievement( @@ -276,13 +292,14 @@ public class AchievementService { /** * Check elevation achievements. */ - private List checkElevationAchievements(UUID userId, Activity activity) { + private List checkElevationAchievements(UUID userId, Activity activity, + Set existing) { List achievements = new ArrayList<>(); // Single activity elevation if (activity.getElevationGain() != null && activity.getElevationGain().compareTo(BigDecimal.valueOf(1000)) >= 0 && - !hasAchievement(userId, Achievement.AchievementType.MOUNTAINEER_1000M)) { + !existing.contains(Achievement.AchievementType.MOUNTAINEER_1000M)) { achievements.add(awardAchievement( userId, @@ -301,7 +318,7 @@ public class AchievementService { if (totalElevation != null) { double totalM = totalElevation.doubleValue(); - if (totalM >= 5000 && !hasAchievement(userId, Achievement.AchievementType.MOUNTAINEER_5000M)) { + if (totalM >= 5000 && !existing.contains(Achievement.AchievementType.MOUNTAINEER_5000M)) { achievements.add(awardAchievement( userId, Achievement.AchievementType.MOUNTAINEER_5000M, @@ -314,7 +331,7 @@ public class AchievementService { )); } - if (totalM >= 10000 && !hasAchievement(userId, Achievement.AchievementType.MOUNTAINEER_10000M)) { + if (totalM >= 10000 && !existing.contains(Achievement.AchievementType.MOUNTAINEER_10000M)) { achievements.add(awardAchievement( userId, Achievement.AchievementType.MOUNTAINEER_10000M, @@ -334,12 +351,12 @@ public class AchievementService { /** * Check variety achievements. */ - private List checkVarietyAchievements(UUID userId) { + private List checkVarietyAchievements(UUID userId, Set existing) { List achievements = new ArrayList<>(); long distinctActivityTypes = activityRepository.countDistinctActivityTypesByUserId(userId); - if (distinctActivityTypes >= 3 && !hasAchievement(userId, Achievement.AchievementType.VARIETY_SEEKER)) { + if (distinctActivityTypes >= 3 && !existing.contains(Achievement.AchievementType.VARIETY_SEEKER)) { achievements.add(awardAchievement( userId, Achievement.AchievementType.VARIETY_SEEKER, @@ -358,14 +375,15 @@ public class AchievementService { /** * Check speed achievements. */ - private List checkSpeedAchievements(UUID userId, Activity activity) { + private List checkSpeedAchievements(UUID userId, Activity activity, + Set existing) { List achievements = new ArrayList<>(); if (activity.getMetrics() != null && activity.getMetrics().getMaxSpeed() != null) { // maxSpeed is already in km/h from FitParser double maxSpeedKmh = activity.getMetrics().getMaxSpeed().doubleValue(); - if (maxSpeedKmh >= 40 && !hasAchievement(userId, Achievement.AchievementType.SPEED_DEMON)) { + if (maxSpeedKmh >= 40 && !existing.contains(Achievement.AchievementType.SPEED_DEMON)) { achievements.add(awardAchievement( userId, Achievement.AchievementType.SPEED_DEMON, @@ -384,21 +402,45 @@ public class AchievementService { /** * Calculate current activity streak (consecutive days). + * + *

Loads all distinct activity dates for the user in the last 366 days in a + * single query and walks the resulting set in memory. Previously this method + * issued one {@code SELECT EXISTS} query per day (up to 365 round-trips per + * activity upload), which was the single biggest performance hot spot in the + * achievement evaluation path. + * + *

The streak / rest-day logic is preserved bug-for-bug from the previous + * implementation: a missing day after a streak has started is silently skipped + * (the original loop did the same). Fixing the rest-day semantics is out of + * scope for this performance change. */ private int calculateCurrentStreak(UUID userId) { LocalDate today = LocalDate.now(); + // 366 to safely cover the lookback window even if today's activity is in the + // future relative to the cutoff (timezone edge cases). + LocalDateTime since = today.minusDays(366).atStartOfDay(); + Set activityDates = new HashSet<>( + activityRepository.findDistinctActivityDatesSince(userId, since) + ); + + if (activityDates.isEmpty()) { + return 0; + } + LocalDate checkDate = today; int streak = 0; - // Check backwards from today - for (int i = 0; i < 365; i++) { // Max check 1 year - boolean hasActivity = activityRepository.existsByUserIdAndDate(userId, checkDate); + // Walk backwards from today using the in-memory set instead of per-day queries. + for (int i = 0; i < 365; i++) { + boolean hasActivity = activityDates.contains(checkDate); if (hasActivity) { streak++; checkDate = checkDate.minusDays(1); } else { - // Allow one rest day if we already have a streak + // Allow one rest day if we already have a streak (preserving original + // behaviour, including the latent "infinite consecutive rest days + // allowed once a streak has started" quirk in the original loop). if (streak > 0 && i > 0) { checkDate = checkDate.minusDays(1); continue; @@ -410,13 +452,6 @@ public class AchievementService { return streak; } - /** - * Check if user has already earned an achievement. - */ - private boolean hasAchievement(UUID userId, Achievement.AchievementType achievementType) { - return achievementRepository.existsByUserIdAndAchievementType(userId, achievementType); - } - /** * Award an achievement to a user. */ diff --git a/src/main/java/net/javahippie/fitpub/service/BatchImportService.java b/src/main/java/net/javahippie/fitpub/service/BatchImportService.java index 4afff75..ea477cd 100644 --- a/src/main/java/net/javahippie/fitpub/service/BatchImportService.java +++ b/src/main/java/net/javahippie/fitpub/service/BatchImportService.java @@ -332,40 +332,34 @@ public class BatchImportService { log.debug("Rebuilding user heatmap..."); heatmapGridService.recalculateUserHeatmap(user); + // Load all activities once instead of one findById per loop iteration. The previous + // implementation issued 4 × N individual SELECTs (four sequential loops, each calling + // activityRepository.findById per ID) — for a 200-file batch this was 800 round-trips + // before any downstream service did its own work. + List activities = activityRepository.findAllById(activityIds); + // Recalculate personal records for each activity log.debug("Recalculating personal records..."); - for (UUID activityId : activityIds) { - Activity activity = activityRepository.findById(activityId).orElse(null); - if (activity != null) { - personalRecordService.checkAndUpdatePersonalRecords(activity); - } + for (Activity activity : activities) { + personalRecordService.checkAndUpdatePersonalRecords(activity); } // Recalculate achievements for each activity log.debug("Recalculating achievements..."); - for (UUID activityId : activityIds) { - Activity activity = activityRepository.findById(activityId).orElse(null); - if (activity != null) { - achievementService.checkAndAwardAchievements(activity); - } + for (Activity activity : activities) { + achievementService.checkAndAwardAchievements(activity); } // Recalculate training load for each activity log.debug("Recalculating training load..."); - for (UUID activityId : activityIds) { - Activity activity = activityRepository.findById(activityId).orElse(null); - if (activity != null) { - trainingLoadService.updateTrainingLoad(activity); - } + for (Activity activity : activities) { + trainingLoadService.updateTrainingLoad(activity); } // Recalculate activity summaries (async) log.debug("Updating activity summaries..."); - for (UUID activityId : activityIds) { - Activity activity = activityRepository.findById(activityId).orElse(null); - if (activity != null) { - activitySummaryService.updateSummariesForActivity(activity); - } + for (Activity activity : activities) { + activitySummaryService.updateSummariesForActivity(activity); } log.info("Analytics recalculation completed for batch import job {}", job.getId()); diff --git a/src/test/java/net/javahippie/fitpub/service/AchievementServiceTest.java b/src/test/java/net/javahippie/fitpub/service/AchievementServiceTest.java index e702fb3..2f8a20e 100644 --- a/src/test/java/net/javahippie/fitpub/service/AchievementServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/AchievementServiceTest.java @@ -59,8 +59,9 @@ class AchievementServiceTest { when(activityRepository.countByUserIdAndActivityType(userId, Activity.ActivityType.RUN)).thenReturn(1L); when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(5000)); when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L); - when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(true); // Has activity today for streak - lenient().when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false); + // Streak source: today has activity (1-day streak — not enough to trigger any streak achievement) + lenient().when(activityRepository.findDistinctActivityDatesSince(any(), any())) + .thenReturn(List.of(java.time.LocalDate.now())); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When @@ -87,8 +88,6 @@ class AchievementServiceTest { when(activityRepository.countByUserIdAndActivityType(userId, Activity.ActivityType.RUN)).thenReturn(1L); when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000)); when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(2L); - when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(false); - when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When @@ -110,8 +109,6 @@ class AchievementServiceTest { when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(3L); when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(12000)); // 12 km when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L); - when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(false); - when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When @@ -136,8 +133,6 @@ class AchievementServiceTest { when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L); when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000)); when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L); - when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(false); - when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When @@ -160,9 +155,7 @@ class AchievementServiceTest { when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L); when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000)); when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L); - when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(false); when(activityRepository.countByUserIdAndStartTimeBefore(eq(userId), eq(LocalTime.of(6, 0)))).thenReturn(5L); - when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When @@ -185,9 +178,7 @@ class AchievementServiceTest { when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L); when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000)); when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L); - when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(false); when(activityRepository.countByUserIdAndStartTimeAfter(eq(userId), eq(LocalTime.of(22, 0)))).thenReturn(5L); - when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When @@ -210,8 +201,6 @@ class AchievementServiceTest { when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000)); when(activityRepository.sumElevationGainByUserId(userId)).thenReturn(BigDecimal.valueOf(1200)); when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L); - when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(false); - when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When @@ -234,8 +223,6 @@ class AchievementServiceTest { when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(200000)); when(activityRepository.sumElevationGainByUserId(userId)).thenReturn(BigDecimal.valueOf(6000)); // 6000m total when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(2L); - when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(false); - when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When @@ -260,8 +247,6 @@ class AchievementServiceTest { when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L); when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(30000)); when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(3L); - when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(false); - when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When @@ -286,8 +271,6 @@ class AchievementServiceTest { when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L); when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(200000)); when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L); - when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(false); - when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When @@ -305,13 +288,16 @@ class AchievementServiceTest { // Given - User has 7+ consecutive days of activities Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO); - // Mock activity repository to return true for last 7 days when(activityRepository.countByUserId(userId)).thenReturn(20L); when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(10L); when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(100000)); when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L); - when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(true); - when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false); + // Streak source: 8 consecutive days of activity ending today, ordered most-recent first + java.time.LocalDate today = java.time.LocalDate.now(); + when(activityRepository.findDistinctActivityDatesSince(any(), any())).thenReturn(List.of( + today, today.minusDays(1), today.minusDays(2), today.minusDays(3), + today.minusDays(4), today.minusDays(5), today.minusDays(6), today.minusDays(7) + )); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When @@ -326,11 +312,20 @@ class AchievementServiceTest { @Test @DisplayName("Should NOT award achievements if already earned") void testCheckAndAwardAchievements_AlreadyEarned() { - // Given - User already has these achievements + // Given - User already has every achievement Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO); when(activityRepository.countByUserId(userId)).thenReturn(10L); - when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(true); // Already earned + // Simulate "user already has all achievements" by returning one of every type from the + // preload query that checkAndAwardAchievements uses to populate the in-memory set. + List allEarned = new java.util.ArrayList<>(); + for (Achievement.AchievementType type : Achievement.AchievementType.values()) { + Achievement a = new Achievement(); + a.setUserId(userId); + a.setAchievementType(type); + allEarned.add(a); + } + when(achievementRepository.findByUserIdOrderByEarnedAtDesc(userId)).thenReturn(allEarned); // When List achievements = achievementService.checkAndAwardAchievements(activity); @@ -369,8 +364,6 @@ class AchievementServiceTest { when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(5000)); when(activityRepository.sumElevationGainByUserId(userId)).thenReturn(BigDecimal.valueOf(1100)); when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L); - when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(false); - when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false); when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0)); // When