From 0387ca01e3fb1c236ebf0c36c0855bfd4e56d0dd Mon Sep 17 00:00:00 2001 From: Marcus Fihlon Date: Tue, 28 Apr 2026 14:59:05 +0200 Subject: [PATCH] feat(komoot): import listed activities sequentially with per-row status updates Signed-off-by: Marcus Fihlon --- .../controller/KomootImportController.java | 13 +- .../dto/KomootActivityImportRequest.java | 28 ++++ .../dto/KomootImportExecutionResponse.java | 14 ++ .../fitpub/repository/ActivityRepository.java | 5 + .../service/ActivitySummaryService.java | 66 ++++---- .../fitpub/service/KomootImportService.java | 67 +++----- .../templates/activities/komoot.html | 140 +++++++++++++---- .../service/ActivitySummaryServiceTest.java | 95 +++++++++++ .../service/KomootImportServiceTest.java | 147 +++--------------- .../org.mockito.plugins.MockMaker | 1 + 10 files changed, 336 insertions(+), 240 deletions(-) create mode 100644 src/main/java/net/javahippie/fitpub/model/dto/KomootActivityImportRequest.java create mode 100644 src/main/java/net/javahippie/fitpub/model/dto/KomootImportExecutionResponse.java create mode 100644 src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java create mode 100644 src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java b/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java index 4ad57a0..99c1f9e 100644 --- a/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java +++ b/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java @@ -3,6 +3,7 @@ package net.javahippie.fitpub.controller; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import net.javahippie.fitpub.model.dto.KomootActivityImportRequest; import net.javahippie.fitpub.model.dto.KomootActivitiesResponse; import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse; import net.javahippie.fitpub.model.dto.KomootImportRequest; @@ -47,19 +48,19 @@ public class KomootImportController { return ResponseEntity.ok(response); } - @PostMapping("/activities/import-first") - public ResponseEntity importFirstNewActivity( - @Valid @RequestBody KomootImportRequest request, + @PostMapping("/activities/import") + public ResponseEntity importActivity( + @Valid @RequestBody KomootActivityImportRequest request, Authentication authentication ) { UUID fitPubUserId = userRepository.findByUsername(authentication.getName()) .orElseThrow(() -> new IllegalArgumentException("Authenticated user not found")) .getId(); - log.info("User {} requested Komoot import for the first new activity", - authentication.getName()); + log.info("User {} requested Komoot import for activity {}", + authentication.getName(), request.activityId()); - KomootImportExecutionResponse response = komootImportService.importFirstNewActivity( + KomootImportExecutionResponse response = komootImportService.importActivity( request, fitPubUserId ); diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivityImportRequest.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivityImportRequest.java new file mode 100644 index 0000000..1c67621 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivityImportRequest.java @@ -0,0 +1,28 @@ +package net.javahippie.fitpub.model.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +/** + * Request payload for importing one specific Komoot activity. + * + *

The password is only used for the current request and is never persisted.

+ */ +public record KomootActivityImportRequest( + @NotBlank + @Email + String email, + + @NotBlank + String password, + + @NotBlank + @Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only") + String userId, + + @NotNull + Long activityId +) { +} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootImportExecutionResponse.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportExecutionResponse.java new file mode 100644 index 0000000..dc80fe3 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportExecutionResponse.java @@ -0,0 +1,14 @@ +package net.javahippie.fitpub.model.dto; + +import java.util.UUID; + +/** + * Response for importing exactly one Komoot activity into FitPub. + */ +public record KomootImportExecutionResponse( + UUID importedActivityId, + Long importedKomootActivityId, + String status, + String message +) { +} diff --git a/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java index 555a6ce..d44dc1f 100644 --- a/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java +++ b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java @@ -30,6 +30,11 @@ public interface ActivityRepository extends JpaRepository { "WHERE a.userId = :userId AND a.komootActivityId IS NOT NULL") List findImportedKomootActivityIdsByUserId(@Param("userId") UUID userId); + /** + * Finds a previously imported Komoot activity for the given user. + */ + Optional findByUserIdAndKomootActivityId(UUID userId, Long komootActivityId); + /** * Find all activities for a specific user. * diff --git a/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java b/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java index 357bc8d..ead4891 100644 --- a/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java +++ b/src/main/java/net/javahippie/fitpub/service/ActivitySummaryService.java @@ -8,6 +8,7 @@ import net.javahippie.fitpub.repository.ActivityRepository; import net.javahippie.fitpub.repository.ActivitySummaryRepository; import net.javahippie.fitpub.repository.AchievementRepository; import net.javahippie.fitpub.repository.PersonalRecordRepository; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -67,17 +68,7 @@ public class ActivitySummaryService { LocalDate weekStart = date.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)); LocalDate weekEnd = weekStart.plusDays(6); - ActivitySummary summary = activitySummaryRepository - .findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.WEEK, weekStart) - .orElse(ActivitySummary.builder() - .userId(userId) - .periodType(ActivitySummary.PeriodType.WEEK) - .periodStart(weekStart) - .periodEnd(weekEnd) - .build()); - - calculateAndUpdateSummary(summary, userId, weekStart.atStartOfDay(), weekEnd.plusDays(1).atStartOfDay()); - activitySummaryRepository.save(summary); + saveSummaryWithRetry(userId, ActivitySummary.PeriodType.WEEK, weekStart, weekEnd); } /** @@ -88,17 +79,7 @@ public class ActivitySummaryService { LocalDate monthStart = date.with(TemporalAdjusters.firstDayOfMonth()); LocalDate monthEnd = date.with(TemporalAdjusters.lastDayOfMonth()); - ActivitySummary summary = activitySummaryRepository - .findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.MONTH, monthStart) - .orElse(ActivitySummary.builder() - .userId(userId) - .periodType(ActivitySummary.PeriodType.MONTH) - .periodStart(monthStart) - .periodEnd(monthEnd) - .build()); - - calculateAndUpdateSummary(summary, userId, monthStart.atStartOfDay(), monthEnd.plusDays(1).atStartOfDay()); - activitySummaryRepository.save(summary); + saveSummaryWithRetry(userId, ActivitySummary.PeriodType.MONTH, monthStart, monthEnd); } /** @@ -109,17 +90,46 @@ public class ActivitySummaryService { LocalDate yearStart = date.with(TemporalAdjusters.firstDayOfYear()); LocalDate yearEnd = date.with(TemporalAdjusters.lastDayOfYear()); + saveSummaryWithRetry(userId, ActivitySummary.PeriodType.YEAR, yearStart, yearEnd); + } + + private void saveSummaryWithRetry( + UUID userId, + ActivitySummary.PeriodType periodType, + LocalDate periodStart, + LocalDate periodEnd + ) { ActivitySummary summary = activitySummaryRepository - .findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.YEAR, yearStart) + .findByUserIdAndPeriodTypeAndPeriodStart(userId, periodType, periodStart) .orElse(ActivitySummary.builder() .userId(userId) - .periodType(ActivitySummary.PeriodType.YEAR) - .periodStart(yearStart) - .periodEnd(yearEnd) + .periodType(periodType) + .periodStart(periodStart) + .periodEnd(periodEnd) .build()); - calculateAndUpdateSummary(summary, userId, yearStart.atStartOfDay(), yearEnd.plusDays(1).atStartOfDay()); - activitySummaryRepository.save(summary); + LocalDateTime startDateTime = periodStart.atStartOfDay(); + LocalDateTime endDateTime = periodEnd.plusDays(1).atStartOfDay(); + + calculateAndUpdateSummary(summary, userId, startDateTime, endDateTime); + + try { + activitySummaryRepository.save(summary); + } catch (DataIntegrityViolationException e) { + log.debug( + "Summary already created concurrently for user {}, period {} starting {}. Retrying as update.", + userId, + periodType, + periodStart + ); + + ActivitySummary existingSummary = activitySummaryRepository + .findByUserIdAndPeriodTypeAndPeriodStart(userId, periodType, periodStart) + .orElseThrow(() -> e); + + calculateAndUpdateSummary(existingSummary, userId, startDateTime, endDateTime); + activitySummaryRepository.save(existingSummary); + } } /** diff --git a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java index 47e06f3..26bae1c 100644 --- a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java +++ b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java @@ -3,6 +3,7 @@ package net.javahippie.fitpub.service; import com.fasterxml.jackson.databind.JsonNode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import net.javahippie.fitpub.model.dto.KomootActivityImportRequest; import net.javahippie.fitpub.model.dto.KomootActivitiesResponse; import net.javahippie.fitpub.model.dto.KomootActivitySummaryDTO; import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse; @@ -32,7 +33,6 @@ import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Base64; -import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -95,31 +95,28 @@ public class KomootImportService { return new KomootActivitiesResponse(request.userId(), activities.size(), activities); } - public KomootImportExecutionResponse importFirstNewActivity(KomootImportRequest request, UUID fitPubUserId) { - ImportCandidateContext context = buildImportCandidateContext(request, fitPubUserId); - if (context.candidate() == null) { + public KomootImportExecutionResponse importActivity(KomootActivityImportRequest request, UUID fitPubUserId) { + if (activityRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.activityId()).isPresent()) { return new KomootImportExecutionResponse( null, - null, - context.importedKomootActivityIds().size(), - context.activities().size(), - "No new Komoot activities found to import." + request.activityId(), + "SKIPPED_ALREADY_IMPORTED", + "Komoot activity " + request.activityId() + " was already imported." ); } - KomootActivitySummaryDTO candidate = context.candidate(); - JsonNode details = fetchActivityDetails(request, candidate.id()); - byte[] gpxData = fetchActivityGpx(request, candidate.id()); + JsonNode details = fetchActivityDetails(request.email(), request.password(), request.activityId()); + byte[] gpxData = fetchActivityGpx(request.email(), request.password(), request.activityId()); ByteArrayMultipartFile gpxFile = new ByteArrayMultipartFile( "file", - "komoot-" + candidate.id() + ".gpx", + "komoot-" + request.activityId() + ".gpx", "application/gpx+xml", gpxData ); Activity.Visibility mappedVisibility = mapVisibility(nullableText(details, "status")); - String mappedTitle = firstNonBlank(nullableText(details, "name"), candidate.name(), "Komoot Activity " + candidate.id()); + String mappedTitle = firstNonBlank(nullableText(details, "name"), null, "Komoot Activity " + request.activityId()); String mappedDescription = nullableText(details, "description"); Activity.ActivityType mappedActivityType = mapKomootSportToActivityType(nullableText(details, "sport")); @@ -131,7 +128,7 @@ public class KomootImportService { mappedVisibility ); - importedActivity.setKomootActivityId(candidate.id()); + importedActivity.setKomootActivityId(request.activityId()); importedActivity.setTitle(mappedTitle); importedActivity.setDescription(mappedDescription); importedActivity.setVisibility(mappedVisibility); @@ -142,7 +139,7 @@ public class KomootImportService { log.info( "Imported Komoot activity {} into FitPub activity {} with visibility {} and type {}", - candidate.id(), + request.activityId(), importedActivity.getId(), importedActivity.getVisibility(), importedActivity.getActivityType() @@ -150,10 +147,9 @@ public class KomootImportService { return new KomootImportExecutionResponse( importedActivity.getId(), - candidate.id(), - context.importedKomootActivityIds().size() + 1, - context.activities().size(), - "Imported Komoot activity " + candidate.id() + " into FitPub activity " + importedActivity.getId() + request.activityId(), + "IMPORTED", + "Imported Komoot activity " + request.activityId() + " into FitPub activity " + importedActivity.getId() ); } @@ -250,12 +246,12 @@ public class KomootImportService { } } - private JsonNode fetchActivityDetails(KomootImportRequest request, long activityId) { + private JsonNode fetchActivityDetails(String email, String password, long activityId) { try { ResponseEntity response = restTemplate.exchange( buildDetailUri(activityId), HttpMethod.GET, - new HttpEntity<>(buildHeaders(request.email(), request.password())), + new HttpEntity<>(buildHeaders(email, password)), JsonNode.class ); @@ -273,8 +269,8 @@ public class KomootImportService { } } - private byte[] fetchActivityGpx(KomootImportRequest request, long activityId) { - HttpEntity httpEntity = new HttpEntity<>(buildGpxHeaders(request.email(), request.password())); + private byte[] fetchActivityGpx(String email, String password, long activityId) { + HttpEntity httpEntity = new HttpEntity<>(buildGpxHeaders(email, password)); List candidateUris = buildGpxCandidateUris(activityId); Exception lastException = null; @@ -313,24 +309,6 @@ public class KomootImportService { throw new IllegalStateException("Failed to download GPX from Komoot for activity " + activityId, lastException); } - private ImportCandidateContext buildImportCandidateContext(KomootImportRequest request, UUID fitPubUserId) { - Set importedKomootActivityIds = new HashSet<>( - activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId)); - - List activities = new ArrayList<>(fetchCompletedActivities(request, fitPubUserId).activities()); - activities.sort(Comparator.comparing( - KomootActivitySummaryDTO::date, - Comparator.nullsLast(Comparator.reverseOrder()) - )); - - KomootActivitySummaryDTO candidate = activities.stream() - .filter(activity -> !importedKomootActivityIds.contains(activity.id())) - .findFirst() - .orElse(null); - - return new ImportCandidateContext(importedKomootActivityIds, activities, candidate); - } - private String formatKomootStartDate(LocalDate localDate) { return localDate.atStartOfDay(ZoneId.systemDefault()) .toInstant() @@ -396,13 +374,6 @@ public class KomootImportService { return fallback; } - private record ImportCandidateContext( - Set importedKomootActivityIds, - List activities, - KomootActivitySummaryDTO candidate - ) { - } - private URI extractNextUri(JsonNode root) { String nextHref = root.path("_links").path("next").path("href").asText(null); if (nextHref == null || nextHref.isBlank()) { diff --git a/src/main/resources/templates/activities/komoot.html b/src/main/resources/templates/activities/komoot.html index 2bfdbfd..595462c 100644 --- a/src/main/resources/templates/activities/komoot.html +++ b/src/main/resources/templates/activities/komoot.html @@ -18,7 +18,7 @@
-
Komoot Import
+
Important
Your Komoot credentials are only used for this request and are not stored in FitPub.
@@ -63,15 +63,6 @@
- +
@@ -130,6 +130,11 @@ const importFirstBtn = document.getElementById('importFirstBtn'); const importFirstText = document.getElementById('importFirstText'); const importFirstSpinner = document.getElementById('importFirstSpinner'); + let currentActivities = []; + + function updateImportButtonState() { + importFirstBtn.disabled = currentActivities.length === 0; + } function setLoading(loading) { loadActivitiesBtn.disabled = loading; @@ -216,6 +221,23 @@ return `${escapeHtml(activityType)}`; } + function renderImportStatus(activity) { + if (activity.uiImportStatus === 'importing') { + return ''; + } + + if (activity.uiImportStatus === 'error') { + const title = escapeHtml(activity.uiImportError || 'Import failed'); + return ``; + } + + if (activity.imported) { + return ''; + } + + return ''; + } + function renderActivities(activities) { resultCount.textContent = activities.length; @@ -233,11 +255,7 @@ ${formatDistance(activity.distanceMeters)} ${formatDuration(activity.timeInMotionSeconds || activity.durationSeconds)} ${formatElevation(activity.elevationUp)} - - ${activity.imported - ? '' - : ''} - + ${renderImportStatus(activity)} `).join(''); @@ -257,6 +275,16 @@ }; } + function buildImportPayload(activityId) { + const payload = buildPayload(); + return { + email: payload.email, + password: payload.password, + userId: payload.userId, + activityId: activityId + }; + } + function hasIncompleteDateRange(payload) { return Boolean(payload.startDate) !== Boolean(payload.endDate); } @@ -274,6 +302,8 @@ } setLoading(true); + currentActivities = []; + updateImportButtonState(); try { const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities', { @@ -293,8 +323,13 @@ } const data = await response.json(); - renderActivities(data.activities || []); - form.querySelector('#password').value = ''; + currentActivities = (data.activities || []).map(activity => ({ + ...activity, + uiImportStatus: null, + uiImportError: null + })); + updateImportButtonState(); + renderActivities(currentActivities); } catch (error) { let message = error instanceof Error ? error.message : 'Failed to load Komoot activities.'; @@ -317,29 +352,66 @@ showError('Start date and end date must either both be set or both be empty.'); return; } + if (currentActivities.length === 0) { + showError('Load Komoot activities before starting the import.'); + return; + } setImportLoading(true); try { - const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities/import-first', { - method: 'POST', - body: payload - }); + const activitiesToImport = currentActivities + .filter(activity => !activity.imported) + .sort((left, right) => new Date(left.date).getTime() - new Date(right.date).getTime()); - if (!response.ok) { - let message = 'Failed to import Komoot activity.'; - try { - const body = await response.json(); - message = body.error || message; - } catch (ignored) { - // Ignore parse errors and show the generic message. - } - throw new Error(message); + if (activitiesToImport.length === 0) { + showStatus('All listed Komoot activities are already imported.'); + return; } - const data = await response.json(); - showStatus(data.message || 'Komoot activity imported.'); + let importedCount = 0; + let failedCount = 0; + + for (const activity of activitiesToImport) { + activity.uiImportStatus = 'importing'; + activity.uiImportError = null; + renderActivities(currentActivities); + + try { + const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities/import', { + method: 'POST', + body: buildImportPayload(activity.id) + }); + + if (!response.ok) { + let message = 'Failed to import Komoot activity.'; + try { + const body = await response.json(); + message = body.error || message; + } catch (ignored) { + // Ignore parse errors and show the generic message. + } + throw new Error(message); + } + + const data = await response.json(); + activity.imported = data.status === 'IMPORTED' || data.status === 'SKIPPED_ALREADY_IMPORTED'; + activity.uiImportStatus = activity.imported ? 'imported' : null; + activity.uiImportError = null; + if (data.status === 'IMPORTED') { + importedCount += 1; + } + } catch (error) { + failedCount += 1; + activity.uiImportStatus = 'error'; + activity.uiImportError = error instanceof Error ? error.message : 'Failed to import Komoot activity.'; + } + + renderActivities(currentActivities); + } + + showStatus(`Imported ${importedCount} Komoot activit${importedCount === 1 ? 'y' : 'ies'}${failedCount > 0 ? `, ${failedCount} failed` : ''}.`); } catch (error) { - let message = error instanceof Error ? error.message : 'Failed to import Komoot activity.'; + let message = error instanceof Error ? error.message : 'Failed to import Komoot activities.'; if (error instanceof Error && error.message === 'Authentication failed') { return; @@ -350,6 +422,8 @@ setImportLoading(false); } }); + + updateImportButtonState(); }); diff --git a/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java b/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java new file mode 100644 index 0000000..e5296de --- /dev/null +++ b/src/test/java/net/javahippie/fitpub/service/ActivitySummaryServiceTest.java @@ -0,0 +1,95 @@ +package net.javahippie.fitpub.service; + +import net.javahippie.fitpub.model.entity.Activity; +import net.javahippie.fitpub.model.entity.ActivitySummary; +import net.javahippie.fitpub.repository.ActivityRepository; +import net.javahippie.fitpub.repository.ActivitySummaryRepository; +import net.javahippie.fitpub.repository.AchievementRepository; +import net.javahippie.fitpub.repository.PersonalRecordRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ActivitySummaryServiceTest { + + private ActivitySummaryRepository activitySummaryRepository; + private ActivityRepository activityRepository; + private PersonalRecordRepository personalRecordRepository; + private AchievementRepository achievementRepository; + private ActivitySummaryService service; + + @BeforeEach + void setUp() { + activitySummaryRepository = mock(ActivitySummaryRepository.class); + activityRepository = mock(ActivityRepository.class); + personalRecordRepository = mock(PersonalRecordRepository.class); + achievementRepository = mock(AchievementRepository.class); + service = new ActivitySummaryService( + activitySummaryRepository, + activityRepository, + personalRecordRepository, + achievementRepository + ); + } + + @Test + @DisplayName("Should retry summary save as update when concurrent insert hits unique constraint") + void shouldRetrySummarySaveAfterConcurrentInsert() { + UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + LocalDate date = LocalDate.of(2025, 10, 8); + LocalDate weekStart = LocalDate.of(2025, 10, 6); + LocalDate weekEnd = LocalDate.of(2025, 10, 12); + + ActivitySummary existingSummary = ActivitySummary.builder() + .id(UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")) + .userId(userId) + .periodType(ActivitySummary.PeriodType.WEEK) + .periodStart(weekStart) + .periodEnd(weekEnd) + .build(); + + when(activitySummaryRepository.findByUserIdAndPeriodTypeAndPeriodStart( + userId, ActivitySummary.PeriodType.WEEK, weekStart + )).thenReturn(Optional.empty(), Optional.of(existingSummary)); + + when(activityRepository.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc( + eq(userId), + eq(weekStart.atStartOfDay()), + eq(weekEnd.plusDays(1).atStartOfDay()) + )).thenReturn(List.of( + Activity.builder() + .userId(userId) + .activityType(Activity.ActivityType.RIDE) + .startedAt(LocalDateTime.of(2025, 10, 8, 9, 0)) + .totalDurationSeconds(3600L) + .build() + )); + + when(personalRecordRepository.countByUserIdAndDateRange(any(), any(), any())).thenReturn(0L); + when(achievementRepository.countByUserIdAndDateRange(any(), any(), any())).thenReturn(0L); + + when(activitySummaryRepository.save(any(ActivitySummary.class))) + .thenThrow(new DataIntegrityViolationException("duplicate")) + .thenReturn(existingSummary); + + service.updateWeeklySummary(userId, date); + + verify(activitySummaryRepository, times(2)) + .findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.WEEK, weekStart); + verify(activitySummaryRepository, times(2)).save(any(ActivitySummary.class)); + } +} diff --git a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java index 35f9b18..aeb035a 100644 --- a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java +++ b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java @@ -1,5 +1,6 @@ package net.javahippie.fitpub.service; +import net.javahippie.fitpub.model.dto.KomootActivityImportRequest; import net.javahippie.fitpub.model.dto.KomootActivitiesResponse; import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse; import net.javahippie.fitpub.model.dto.KomootImportRequest; @@ -20,6 +21,7 @@ import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.util.Base64; import java.util.List; +import java.util.Optional; import java.util.TimeZone; import java.util.UUID; @@ -210,35 +212,14 @@ class KomootImportServiceTest { } @Test - @DisplayName("Should import newest not-yet-imported Komoot activity via GPX and override metadata") - void shouldImportNewestNotYetImportedActivity() { + @DisplayName("Should import a specific Komoot activity via GPX and override metadata") + void shouldImportSpecificKomootActivity() { String authHeader = "Basic " + Base64.getEncoder() .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); UUID importedActivityId = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); - when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of()); - - server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&status=private&name=&hl=en&page=0")) - .andExpect(method(HttpMethod.GET)) - .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) - .andRespond(withSuccess(""" - { - "_embedded": { - "tours": [ - { - "id": 2880957035, - "name": "Latest Ride", - "sport": "mtb_easy", - "status": "public", - "type": "tour_recorded", - "date": "2026-04-27T18:15:00+02:00" - } - ] - }, - "_links": {} - } - """, MediaType.APPLICATION_JSON)); + when(activityRepository.findByUserIdAndKomootActivityId(userId, 2880957035L)).thenReturn(Optional.empty()); server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957035?hl=en")) .andExpect(method(HttpMethod.GET)) @@ -276,13 +257,14 @@ class KomootImportServiceTest { when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity); when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); - KomootImportExecutionResponse response = service.importFirstNewActivity( - new KomootImportRequest("user@example.com", "secret", "123456", null, null), + KomootImportExecutionResponse response = service.importActivity( + new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957035L), userId ); assertThat(response.importedActivityId()).isEqualTo(importedActivityId); assertThat(response.importedKomootActivityId()).isEqualTo(2880957035L); + assertThat(response.status()).isEqualTo("IMPORTED"); assertThat(importedActivity.getKomootActivityId()).isEqualTo(2880957035L); assertThat(importedActivity.getTitle()).isEqualTo("Latest Ride"); assertThat(importedActivity.getDescription()).isEqualTo("Imported from Komoot"); @@ -294,88 +276,23 @@ class KomootImportServiceTest { } @Test - @DisplayName("Should respect date range when choosing Komoot import candidate") - void shouldRespectDateRangeWhenImportingFirstNewActivity() { - String authHeader = "Basic " + Base64.getEncoder() - .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); + @DisplayName("Should skip already imported Komoot activity") + void shouldSkipAlreadyImportedKomootActivity() { UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); - UUID importedActivityId = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"); + UUID existingActivityId = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"); - when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of()); + when(activityRepository.findByUserIdAndKomootActivityId(userId, 3002L)).thenReturn( + Optional.of(Activity.builder().id(existingActivityId).userId(userId).komootActivityId(3002L).build()) + ); - server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&start_date=2026-04-26T22:00:00.000Z&end_date=2026-04-27T21:59:59.999Z")) - .andExpect(method(HttpMethod.GET)) - .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) - .andRespond(withSuccess(""" - { - "_embedded": { - "tours": [ - { - "id": 3002, - "name": "Inside Range Candidate", - "sport": "mtb_easy", - "status": "public", - "type": "tour_recorded", - "date": "2026-04-27T18:15:00+02:00" - } - ] - }, - "_links": {} - } - """, MediaType.APPLICATION_JSON)); - - server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/3002?hl=en")) - .andExpect(method(HttpMethod.GET)) - .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) - .andRespond(withSuccess(""" - { - "id": "3002", - "name": "Inside Range Candidate", - "description": "Imported from Komoot", - "status": "public", - "sport": "mtb_easy" - } - """, MediaType.APPLICATION_JSON)); - - server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/3002.gpx")) - .andExpect(method(HttpMethod.GET)) - .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) - .andRespond(withSuccess(""" - - - Inside Range Candidate - - """, MediaType.APPLICATION_XML)); - - Activity importedActivity = Activity.builder() - .id(importedActivityId) - .userId(userId) - .activityType(Activity.ActivityType.OTHER) - .title("GPX Title") - .description(null) - .visibility(Activity.Visibility.PRIVATE) - .sourceFileFormat("GPX") - .build(); - - when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity); - when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - KomootImportExecutionResponse response = service.importFirstNewActivity( - new KomootImportRequest( - "user@example.com", - "secret", - "123456", - LocalDate.of(2026, 4, 27), - LocalDate.of(2026, 4, 27) - ), + KomootImportExecutionResponse response = service.importActivity( + new KomootActivityImportRequest("user@example.com", "secret", "123456", 3002L), userId ); + assertThat(response.importedActivityId()).isNull(); assertThat(response.importedKomootActivityId()).isEqualTo(3002L); - assertThat(importedActivity.getKomootActivityId()).isEqualTo(3002L); - - verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId); - server.verify(); + assertThat(response.status()).isEqualTo("SKIPPED_ALREADY_IMPORTED"); } @Test @@ -386,28 +303,7 @@ class KomootImportServiceTest { UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); UUID importedActivityId = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc"); - when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of()); - - server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&status=private&name=&hl=en&page=0")) - .andExpect(method(HttpMethod.GET)) - .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) - .andRespond(withSuccess(""" - { - "_embedded": { - "tours": [ - { - "id": 2880957036, - "name": "Unknown Sport", - "sport": "space_biking", - "status": "private", - "type": "tour_recorded", - "date": "2026-04-27T18:15:00+02:00" - } - ] - }, - "_links": {} - } - """, MediaType.APPLICATION_JSON)); + when(activityRepository.findByUserIdAndKomootActivityId(userId, 2880957036L)).thenReturn(Optional.empty()); server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957036?hl=en")) .andExpect(method(HttpMethod.GET)) @@ -445,12 +341,13 @@ class KomootImportServiceTest { when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity); when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0)); - KomootImportExecutionResponse response = service.importFirstNewActivity( - new KomootImportRequest("user@example.com", "secret", "123456", null, null), + KomootImportExecutionResponse response = service.importActivity( + new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957036L), userId ); assertThat(response.importedActivityId()).isEqualTo(importedActivityId); + assertThat(response.status()).isEqualTo("IMPORTED"); assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.OTHER); verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId); diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 0000000..fdbd0b1 --- /dev/null +++ b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass