diff --git a/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java b/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java index 3847ba4..aff2bae 100644 --- a/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java +++ b/src/main/java/net/javahippie/fitpub/config/SecurityConfig.java @@ -87,6 +87,7 @@ public class SecurityConfig { // Protected view pages - require authentication .requestMatchers("/activities", "/activities/upload").authenticated() + .requestMatchers("/komoot-import").authenticated() .requestMatchers("/profile", "/profile/**", "/settings").authenticated() .requestMatchers("/notifications").authenticated() .requestMatchers("/analytics", "/analytics/**").authenticated() @@ -149,6 +150,7 @@ public class SecurityConfig { // Protected endpoints - Batch Import API .requestMatchers("/api/batch-import/**").authenticated() + .requestMatchers("/api/komoot-import/**").authenticated() // Protected endpoints - Privacy Zones API .requestMatchers("/api/privacy-zones/**").authenticated() diff --git a/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java b/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java new file mode 100644 index 0000000..99c1f9e --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/controller/KomootImportController.java @@ -0,0 +1,90 @@ +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; +import net.javahippie.fitpub.repository.UserRepository; +import net.javahippie.fitpub.service.KomootImportService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +/** + * REST API for loading and importing Komoot activities. + */ +@RestController +@RequestMapping("/api/komoot-import") +@RequiredArgsConstructor +@Slf4j +public class KomootImportController { + + private final KomootImportService komootImportService; + private final UserRepository userRepository; + + @PostMapping("/activities") + public ResponseEntity listActivities( + @Valid @RequestBody KomootImportRequest request, + Authentication authentication + ) { + UUID fitPubUserId = userRepository.findByUsername(authentication.getName()) + .orElseThrow(() -> new IllegalArgumentException("Authenticated user not found")) + .getId(); + + log.info("User {} requested Komoot activity preview for Komoot ID {}", + authentication.getName(), request.userId()); + KomootActivitiesResponse response = komootImportService.fetchCompletedActivities(request, fitPubUserId); + return ResponseEntity.ok(response); + } + + @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 activity {}", + authentication.getName(), request.activityId()); + + KomootImportExecutionResponse response = komootImportService.importActivity( + request, + fitPubUserId + ); + return ResponseEntity.ok(response); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException e) { + return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .findFirst() + .map(error -> error.getDefaultMessage() != null ? error.getDefaultMessage() : "Invalid request") + .orElse("Invalid request"); + return ResponseEntity.badRequest().body(new ErrorResponse(message)); + } + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity handleIllegalState(IllegalStateException e) { + return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(new ErrorResponse(e.getMessage())); + } + + record ErrorResponse(String error) {} +} diff --git a/src/main/java/net/javahippie/fitpub/controller/KomootImportViewController.java b/src/main/java/net/javahippie/fitpub/controller/KomootImportViewController.java new file mode 100644 index 0000000..d3ad006 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/controller/KomootImportViewController.java @@ -0,0 +1,18 @@ +package net.javahippie.fitpub.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +/** + * Serves the Komoot import preview page. + */ +@Controller +public class KomootImportViewController { + + @GetMapping("/komoot-import") + public String komootImportPage(Model model) { + model.addAttribute("pageTitle", "Komoot Import"); + return "activities/komoot"; + } +} diff --git a/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitiesResponse.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitiesResponse.java new file mode 100644 index 0000000..c9223d7 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitiesResponse.java @@ -0,0 +1,13 @@ +package net.javahippie.fitpub.model.dto; + +import java.util.List; + +/** + * Response payload for the Komoot import preview. + */ +public record KomootActivitiesResponse( + String userId, + int totalCount, + List activities +) { +} 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/KomootActivitySummaryDTO.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java new file mode 100644 index 0000000..a75ae5b --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootActivitySummaryDTO.java @@ -0,0 +1,24 @@ +package net.javahippie.fitpub.model.dto; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Reduced activity representation returned by the Komoot import preview. + */ +public record KomootActivitySummaryDTO( + long id, + String name, + String sport, + String mappedActivityType, + String status, + String type, + OffsetDateTime date, + Double distanceMeters, + Integer durationSeconds, + Integer timeInMotionSeconds, + Double elevationUp, + boolean imported, + UUID fitPubActivityId +) { +} 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/model/dto/KomootImportRequest.java b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java new file mode 100644 index 0000000..345f1b9 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/model/dto/KomootImportRequest.java @@ -0,0 +1,39 @@ +package net.javahippie.fitpub.model.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +import java.time.LocalDate; + +/** + * Request payload for fetching completed activities from Komoot. + * + *

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

+ */ +public record KomootImportRequest( + @NotBlank + @Email + String email, + + @NotBlank + String password, + + @NotBlank + @Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only") + String userId, + + LocalDate startDate, + + LocalDate endDate +) { + public KomootImportRequest { + boolean onlyOneDateProvided = (startDate == null) != (endDate == null); + if (onlyOneDateProvided) { + throw new IllegalArgumentException("Start date and end date must either both be set or both be empty."); + } + if (startDate != null && startDate.isAfter(endDate)) { + throw new IllegalArgumentException("Start date must be before or equal to end date."); + } + } +} diff --git a/src/main/java/net/javahippie/fitpub/model/entity/Activity.java b/src/main/java/net/javahippie/fitpub/model/entity/Activity.java index 045bd7a..6b2fb52 100644 --- a/src/main/java/net/javahippie/fitpub/model/entity/Activity.java +++ b/src/main/java/net/javahippie/fitpub/model/entity/Activity.java @@ -117,6 +117,13 @@ public class Activity { @Column(name = "source_file_format", nullable = false, length = 10) private String sourceFileFormat; + /** + * Optional internal reference to the originating Komoot activity. + * Used for import matching and deduplication only. + */ + @Column(name = "komoot_activity_id") + private Long komootActivityId; + /** * Indicates if this is an indoor activity (e.g., virtual rides, indoor trainer sessions). * Indoor activities are displayed in timeline but excluded from heatmap generation. diff --git a/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java index 55483a2..7a8c03d 100644 --- a/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java +++ b/src/main/java/net/javahippie/fitpub/repository/ActivityRepository.java @@ -20,9 +20,37 @@ import java.util.UUID; @Repository public interface ActivityRepository extends JpaRepository { + interface KomootImportLinkProjection { + UUID getId(); + Long getKomootActivityId(); + } + @Query("SELECT a.id FROM Activity a") List findAllIds(); + /** + * Returns all imported Komoot activity IDs for the given local user. + */ + @Query("SELECT a.komootActivityId FROM Activity a " + + "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); + + /** + * Finds imported Komoot activities for the given user and Komoot IDs. + */ + @Query("SELECT a.id AS id, a.komootActivityId AS komootActivityId " + + "FROM Activity a " + + "WHERE a.userId = :userId AND a.komootActivityId IN :komootActivityIds") + List findKomootImportLinksByUserIdAndKomootActivityIdIn( + @Param("userId") UUID userId, + @Param("komootActivityIds") List komootActivityIds + ); + /** * Find all activities for a specific user. * diff --git a/src/main/java/net/javahippie/fitpub/service/KomootImportService.java b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java new file mode 100644 index 0000000..7f92073 --- /dev/null +++ b/src/main/java/net/javahippie/fitpub/service/KomootImportService.java @@ -0,0 +1,473 @@ +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; +import net.javahippie.fitpub.model.dto.KomootImportRequest; +import net.javahippie.fitpub.model.entity.Activity; +import net.javahippie.fitpub.repository.ActivityRepository; +import net.javahippie.fitpub.util.ByteArrayMultipartFile; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** + * Fetches a temporary preview of completed Komoot activities for an authenticated FitPub user. + * + *

Komoot does not expose a public API for this use case. This service currently talks to the + * same web API endpoints used by the Komoot website and therefore depends on their current + * behavior.

+ */ +@Service +@RequiredArgsConstructor +@Slf4j +public class KomootImportService { + + private static final int PAGE_SIZE = 100; + private static final String KOMOOT_LANGUAGE = "en"; + private static final DateTimeFormatter KOMOOT_DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX"); + private final RestTemplate restTemplate; + private final ActivityRepository activityRepository; + private final ActivityFileService activityFileService; + private final ActivityPostProcessingService activityPostProcessingService; + + @Value("${fitpub.komoot.base-url:https://www.komoot.com}") + private String komootBaseUrl; + + @Value("${fitpub.komoot.paginated-request-delay-ms:1000}") + private long paginatedRequestDelayMillis; + + @Value("${fitpub.komoot.detail-to-gpx-delay-ms:500}") + private long detailToGpxDelayMillis; + + @Value("${fitpub.komoot.activity-import-delay-ms:3000}") + private long activityImportDelayMillis; + + public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request, UUID fitPubUserId) { + List activities = new ArrayList<>(); + Set importedKomootActivityIds = new HashSet<>( + activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId)); + Map fitPubActivityIdsByKomootId = new HashMap<>(); + if (!importedKomootActivityIds.isEmpty()) { + activityRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn( + fitPubUserId, + new ArrayList<>(importedKomootActivityIds) + ) + .forEach(link -> fitPubActivityIdsByKomootId.put(link.getKomootActivityId(), link.getId())); + } + + URI nextUri = buildInitialUri(request); + HttpEntity httpEntity = new HttpEntity<>(buildHeaders(request.email(), request.password())); + + try { + while (nextUri != null) { + ResponseEntity response = restTemplate.exchange( + nextUri, HttpMethod.GET, httpEntity, JsonNode.class); + + JsonNode root = response.getBody(); + if (root == null) { + throw new IllegalStateException("Komoot returned an empty response body."); + } + extractActivities(root, activities, importedKomootActivityIds, fitPubActivityIdsByKomootId); + nextUri = extractNextUri(root); + if (nextUri != null) { + pauseBeforeNextPageRequest(); + } + } + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new IllegalArgumentException("Komoot login failed. Check email, password and Komoot ID.", e); + } catch (HttpClientErrorException.NotFound e) { + throw new IllegalArgumentException("Komoot user or activities endpoint not found for the given Komoot ID.", e); + } catch (RestClientException e) { + throw new IllegalStateException("Failed to reach Komoot. The remote service may be unavailable.", e); + } catch (Exception e) { + throw new IllegalStateException("Failed to parse Komoot activity list.", e); + } + + log.info("Fetched {} completed Komoot activities for user ID {}", activities.size(), request.userId()); + return new KomootActivitiesResponse(request.userId(), activities.size(), activities); + } + + void pauseBeforeNextPageRequest() { + pause(paginatedRequestDelayMillis, "Interrupted while throttling paginated Komoot requests."); + } + + public KomootImportExecutionResponse importActivity(KomootActivityImportRequest request, UUID fitPubUserId) { + Activity existingActivity = activityRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.activityId()).orElse(null); + if (existingActivity != null) { + return new KomootImportExecutionResponse( + existingActivity.getId(), + request.activityId(), + "SKIPPED_ALREADY_IMPORTED", + "Komoot activity " + request.activityId() + " was already imported." + ); + } + + JsonNode details = fetchActivityDetails(request.email(), request.password(), request.activityId()); + pauseBetweenDetailAndGpxRequest(); + byte[] gpxData = fetchActivityGpx(request.email(), request.password(), request.activityId()); + + ByteArrayMultipartFile gpxFile = new ByteArrayMultipartFile( + "file", + "komoot-" + request.activityId() + ".gpx", + "application/gpx+xml", + gpxData + ); + + Activity.Visibility mappedVisibility = mapVisibility(nullableText(details, "status")); + String mappedTitle = firstNonBlank(nullableText(details, "name"), null, "Komoot Activity " + request.activityId()); + String mappedDescription = nullableText(details, "description"); + Activity.ActivityType mappedActivityType = mapKomootSportToActivityType(nullableText(details, "sport")); + + Activity importedActivity = activityFileService.processActivityFile( + gpxFile, + fitPubUserId, + mappedTitle, + mappedDescription, + mappedVisibility + ); + + importedActivity.setKomootActivityId(request.activityId()); + importedActivity.setTitle(mappedTitle); + importedActivity.setDescription(mappedDescription); + importedActivity.setVisibility(mappedVisibility); + importedActivity.setActivityType(mappedActivityType); + + importedActivity = activityRepository.save(importedActivity); + activityPostProcessingService.processActivityAsync(importedActivity.getId(), fitPubUserId); + + log.info( + "Imported Komoot activity {} into FitPub activity {} with visibility {} and type {}", + request.activityId(), + importedActivity.getId(), + importedActivity.getVisibility(), + importedActivity.getActivityType() + ); + + pauseAfterActivityImport(); + + return new KomootImportExecutionResponse( + importedActivity.getId(), + request.activityId(), + "IMPORTED", + "Imported Komoot activity " + request.activityId() + " into FitPub activity " + importedActivity.getId() + ); + } + + void pauseBetweenDetailAndGpxRequest() { + pause(detailToGpxDelayMillis, "Interrupted while throttling Komoot detail and GPX requests."); + } + + void pauseAfterActivityImport() { + pause(activityImportDelayMillis, "Interrupted while throttling Komoot activity imports."); + } + + private URI buildInitialUri(KomootImportRequest request) { + String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl; + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/users/" + request.userId() + "/tours/") + .queryParam("type", "tour_recorded") + .queryParam("sort_field", "date") + .queryParam("sort_direction", "desc") + .queryParam("limit", PAGE_SIZE); + + if (request.startDate() != null && request.endDate() != null) { + builder.queryParam("start_date", formatKomootStartDate(request.startDate())) + .queryParam("end_date", formatKomootEndDate(request.endDate())); + } else { + builder.queryParam("status", "private") + .queryParam("name", "") + .queryParam("hl", KOMOOT_LANGUAGE) + .queryParam("page", 0); + } + + return builder.build().toUri(); + } + + private URI buildDetailUri(long activityId) { + String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl; + return UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/tours/" + activityId) + .queryParam("hl", KOMOOT_LANGUAGE) + .build() + .toUri(); + } + + private List buildGpxCandidateUris(long activityId) { + String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl; + String apiBaseUrl = normalizedBaseUrl.replace("://www.komoot.com", "://api.komoot.de"); + + return List.of( + URI.create(normalizedBaseUrl + "/api/v007/tours/" + activityId + ".gpx"), + URI.create(apiBaseUrl + "/v007/tours/" + activityId + ".gpx"), + URI.create(normalizedBaseUrl + "/tour/" + activityId + ".gpx") + ); + } + + private HttpHeaders buildHeaders(String email, String password) { + HttpHeaders headers = new HttpHeaders(); + headers.setAccept(List.of(MediaType.parseMediaType("application/hal+json"), MediaType.APPLICATION_JSON)); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setAcceptLanguageAsLocales(List.of(java.util.Locale.ENGLISH)); + headers.set(HttpHeaders.USER_AGENT, + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"); + headers.set(HttpHeaders.AUTHORIZATION, basicAuth(email, password)); + return headers; + } + + private HttpHeaders buildGpxHeaders(String email, String password) { + HttpHeaders headers = buildHeaders(email, password); + headers.setAccept(List.of( + MediaType.parseMediaType("application/gpx+xml"), + MediaType.APPLICATION_XML, + MediaType.TEXT_XML + )); + return headers; + } + + private String basicAuth(String email, String password) { + String credentials = email + ":" + password; + String encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + return "Basic " + encoded; + } + + private void extractActivities( + JsonNode root, + List activities, + Set importedKomootActivityIds, + Map fitPubActivityIdsByKomootId + ) { + JsonNode tours = root.path("_embedded").path("tours"); + if (!tours.isArray()) { + return; + } + + for (JsonNode tour : tours) { + long activityId = tour.path("id").asLong(); + activities.add(new KomootActivitySummaryDTO( + activityId, + nullableText(tour, "name"), + nullableText(tour, "sport"), + mapKomootSportToActivityType(nullableText(tour, "sport")).name(), + nullableText(tour, "status"), + nullableText(tour, "type"), + parseDate(tour.path("date").asText(null)), + nullableDouble(tour, "distance"), + nullableInteger(tour, "duration"), + nullableInteger(tour, "time_in_motion"), + nullableDouble(tour, "elevation_up"), + importedKomootActivityIds.contains(activityId), + fitPubActivityIdsByKomootId.get(activityId) + )); + } + } + + private JsonNode fetchActivityDetails(String email, String password, long activityId) { + try { + ResponseEntity response = restTemplate.exchange( + buildDetailUri(activityId), + HttpMethod.GET, + new HttpEntity<>(buildHeaders(email, password)), + JsonNode.class + ); + + JsonNode body = response.getBody(); + if (body == null) { + throw new IllegalStateException("Komoot returned an empty activity detail response."); + } + return body; + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new IllegalArgumentException("Komoot login failed while loading activity details.", e); + } catch (HttpClientErrorException.NotFound e) { + throw new IllegalArgumentException("Komoot activity details could not be found.", e); + } catch (RestClientException e) { + throw new IllegalStateException("Failed to reach Komoot while loading activity details.", e); + } + } + + private byte[] fetchActivityGpx(String email, String password, long activityId) { + HttpEntity httpEntity = new HttpEntity<>(buildGpxHeaders(email, password)); + List candidateUris = buildGpxCandidateUris(activityId); + Exception lastException = null; + + for (URI candidateUri : candidateUris) { + try { + byte[] body = restTemplate.execute( + candidateUri, + HttpMethod.GET, + request -> request.getHeaders().putAll(httpEntity.getHeaders()), + response -> { + if (response.getBody() == null) { + return null; + } + return response.getBody().readAllBytes(); + } + ); + if (body == null || body.length == 0) { + throw new IllegalStateException("Komoot returned an empty GPX response."); + } + + String gpxText = new String(body, StandardCharsets.UTF_8); + if (!gpxText.contains(" Activity.Visibility.PUBLIC; + case "friends", "followers", "close_friends" -> Activity.Visibility.FOLLOWERS; + default -> Activity.Visibility.PRIVATE; + }; + } + + private Activity.ActivityType mapKomootSportToActivityType(String komootSport) { + if (komootSport == null || komootSport.isBlank()) { + return Activity.ActivityType.OTHER; + } + + return switch (komootSport.toLowerCase(java.util.Locale.ROOT)) { + case "hike" -> Activity.ActivityType.HIKE; + case "walk" -> Activity.ActivityType.WALK; + case "run", "trailrunning", "jogging" -> Activity.ActivityType.RUN; + case "touringbicycle", "road_bike", "bike", "bicycle", "gravel", "mtb", "mtb_easy", "mtb_advanced", "ebike" -> + Activity.ActivityType.RIDE; + case "alpine_ski" -> Activity.ActivityType.ALPINE_SKI; + case "backcountry_ski" -> Activity.ActivityType.BACKCOUNTRY_SKI; + case "nordic_ski", "cross_country_ski" -> Activity.ActivityType.NORDIC_SKI; + case "snowboard" -> Activity.ActivityType.SNOWBOARD; + case "swim" -> Activity.ActivityType.SWIM; + case "rowing" -> Activity.ActivityType.ROWING; + case "kayak", "kayaking" -> Activity.ActivityType.KAYAKING; + case "canoe", "canoeing" -> Activity.ActivityType.CANOEING; + case "inline_skate", "inline_skating" -> Activity.ActivityType.INLINE_SKATING; + case "rock_climbing" -> Activity.ActivityType.ROCK_CLIMBING; + case "mountaineering" -> Activity.ActivityType.MOUNTAINEERING; + case "yoga" -> Activity.ActivityType.YOGA; + case "workout", "gym" -> Activity.ActivityType.WORKOUT; + default -> Activity.ActivityType.OTHER; + }; + } + + private String firstNonBlank(String first, String second, String fallback) { + if (first != null && !first.isBlank()) { + return first; + } + if (second != null && !second.isBlank()) { + return second; + } + return fallback; + } + + private URI extractNextUri(JsonNode root) { + String nextHref = root.path("_links").path("next").path("href").asText(null); + if (nextHref == null || nextHref.isBlank()) { + return null; + } + + if (nextHref.startsWith("http://") || nextHref.startsWith("https://")) { + return URI.create(nextHref); + } + + String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl; + String normalizedNextHref = nextHref.startsWith("/") ? nextHref : "/" + nextHref; + return URI.create(normalizedBaseUrl + normalizedNextHref); + } + + private OffsetDateTime parseDate(String value) { + if (value == null || value.isBlank()) { + return null; + } + return OffsetDateTime.parse(value); + } + + private String nullableText(JsonNode node, String field) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? null : value.asText(); + } + + private Double nullableDouble(JsonNode node, String field) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? null : value.asDouble(); + } + + private Integer nullableInteger(JsonNode node, String field) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? null : value.asInt(); + } + + private void pause(long delayMillis, String interruptedMessage) { + if (delayMillis <= 0) { + return; + } + + try { + Thread.sleep(delayMillis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(interruptedMessage, e); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3a4e4e5..572481a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -104,6 +104,12 @@ fitpub: enabled: ${WEATHER_ENABLED:false} api-key: ${OPENWEATHERMAP_API_KEY:} + komoot: + base-url: ${KOMOOT_BASE_URL:https://www.komoot.com} + paginated-request-delay-ms: ${KOMOOT_PAGINATED_REQUEST_DELAY_MS:1000} + detail-to-gpx-delay-ms: ${KOMOOT_DETAIL_TO_GPX_DELAY_MS:500} + activity-import-delay-ms: ${KOMOOT_ACTIVITY_IMPORT_DELAY_MS:3000} + # Logging configuration logging: level: diff --git a/src/main/resources/db/migration/V32__add_komoot_activity_id_to_activities.sql b/src/main/resources/db/migration/V32__add_komoot_activity_id_to_activities.sql new file mode 100644 index 0000000..24ef05f --- /dev/null +++ b/src/main/resources/db/migration/V32__add_komoot_activity_id_to_activities.sql @@ -0,0 +1,15 @@ +-- Add optional internal reference to the originating Komoot activity. +-- +-- This field is only used for import matching and deduplication. It is not +-- intended for public display or API exposure. + +ALTER TABLE activities + ADD COLUMN komoot_activity_id BIGINT; + +-- A Komoot activity may only be imported once per local user. +CREATE UNIQUE INDEX idx_activities_user_komoot_activity_id + ON activities(user_id, komoot_activity_id) + WHERE komoot_activity_id IS NOT NULL; + +COMMENT ON COLUMN activities.komoot_activity_id IS + 'Optional internal Komoot activity ID used for import matching and deduplication'; diff --git a/src/main/resources/templates/activities/komoot.html b/src/main/resources/templates/activities/komoot.html new file mode 100644 index 0000000..ec70fcc --- /dev/null +++ b/src/main/resources/templates/activities/komoot.html @@ -0,0 +1,493 @@ + + + + + Komoot Import + + + +
+
+
+

+ + Komoot Import +

+ +
+
Important
+
+ Your Komoot credentials are only used for this request and are not stored in FitPub. +
+
+ The import currently runs in this browser tab. If you leave or reload the page, remaining activities will not continue importing automatically. +
+
+ +
    +
  • Import starts with the oldest new activities, so progress begins at the bottom of the list.
  • +
  • FitPub adds short delays between Komoot requests during loading and import to reduce rate limiting.
  • +
  • This integration depends on Komoot web endpoints and may stop working if Komoot changes them.
  • +
+ + + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
You can find the Komoot ID in your Komoot account settings.
+
+
+
+
+ + +
+
+ + +
+
+
Both dates must be set together. Inclusive, day-based filter.
+
+
+ +
+ + + +
+
+
+
+ +
+
+ Loading... +
+

Loading Komoot activities...

+
+ +
+
+

+ + Komoot Activities +

+ 0 +
+ +
+ + + + + + + + + + + + + +
NameDateTypeDistanceDurationElevationStatus
+
+
+
+
+
+ + + + + + diff --git a/src/main/resources/templates/layout.html b/src/main/resources/templates/layout.html index 2ddd461..f78e5d6 100644 --- a/src/main/resources/templates/layout.html +++ b/src/main/resources/templates/layout.html @@ -97,6 +97,11 @@ Batch Import +
  • + + Komoot Import + +
  • diff --git a/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java new file mode 100644 index 0000000..37d43d7 --- /dev/null +++ b/src/test/java/net/javahippie/fitpub/service/KomootImportServiceTest.java @@ -0,0 +1,398 @@ +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; +import net.javahippie.fitpub.model.entity.Activity; +import net.javahippie.fitpub.repository.ActivityRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +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; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.client.ExpectedCount.once; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +class KomootImportServiceTest { + + private static ActivityRepository.KomootImportLinkProjection importLink(UUID activityId, Long komootActivityId) { + return new ActivityRepository.KomootImportLinkProjection() { + @Override + public UUID getId() { + return activityId; + } + + @Override + public Long getKomootActivityId() { + return komootActivityId; + } + }; + } + + private MockRestServiceServer server; + private KomootImportService service; + private ActivityRepository activityRepository; + private ActivityFileService activityFileService; + private ActivityPostProcessingService activityPostProcessingService; + private TimeZone originalTimeZone; + + @BeforeEach + void setUp() { + originalTimeZone = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("Europe/Zurich")); + RestTemplate restTemplate = new RestTemplate(); + server = MockRestServiceServer.bindTo(restTemplate).build(); + activityRepository = mock(ActivityRepository.class); + activityFileService = mock(ActivityFileService.class); + activityPostProcessingService = mock(ActivityPostProcessingService.class); + service = new KomootImportService(restTemplate, activityRepository, activityFileService, activityPostProcessingService); + ReflectionTestUtils.setField(service, "komootBaseUrl", "https://www.komoot.com"); + ReflectionTestUtils.setField(service, "paginatedRequestDelayMillis", 0L); + ReflectionTestUtils.setField(service, "detailToGpxDelayMillis", 0L); + ReflectionTestUtils.setField(service, "activityImportDelayMillis", 0L); + } + + @AfterEach + void tearDown() { + TimeZone.setDefault(originalTimeZone); + } + + @Test + void shouldFetchAndMergePagedCompletedActivities() { + String authHeader = "Basic " + Base64.getEncoder() + .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); + UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + KomootImportService throttledService = spy(service); + doNothing().when(throttledService).pauseBeforeNextPageRequest(); + UUID existingActivityId = UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"); + + when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1002L)); + when(activityRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn(userId, List.of(1002L))) + .thenReturn(List.of(importLink(existingActivityId, 1002L))); + + 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": 1001, + "name": "Evening Ride", + "sport": "touringbicycle", + "status": "private", + "type": "tour_recorded", + "date": "2026-04-27T18:15:00+02:00", + "distance": 42350.4, + "duration": 8120, + "time_in_motion": 7800, + "elevation_up": 520.2 + } + ] + }, + "_links": { + "next": { + "href": "/api/v007/users/123456/tours?type=tour_recorded&page=1&limit=100" + } + } + } + """, MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours?type=tour_recorded&page=1&limit=100")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) + .andRespond(withSuccess(""" + { + "_embedded": { + "tours": [ + { + "id": 1002, + "name": "Lunch Walk", + "sport": "hike", + "status": "private", + "type": "tour_recorded", + "date": "2026-04-26T12:30:00+02:00", + "distance": 5120.0, + "duration": 3600, + "time_in_motion": 3400, + "elevation_up": 75.0 + } + ] + }, + "_links": {} + } + """, MediaType.APPLICATION_JSON)); + + KomootActivitiesResponse response = throttledService.fetchCompletedActivities( + new KomootImportRequest("user@example.com", "secret", "123456", null, null), + userId); + + assertThat(response.totalCount()).isEqualTo(2); + assertThat(response.activities()).hasSize(2); + assertThat(response.activities().get(0).id()).isEqualTo(1001L); + assertThat(response.activities().get(0).imported()).isFalse(); + assertThat(response.activities().get(0).fitPubActivityId()).isNull(); + assertThat(response.activities().get(0).timeInMotionSeconds()).isEqualTo(7800); + assertThat(response.activities().get(1).name()).isEqualTo("Lunch Walk"); + assertThat(response.activities().get(1).imported()).isTrue(); + assertThat(response.activities().get(1).fitPubActivityId()).isEqualTo(existingActivityId); + + verify(throttledService).pauseBeforeNextPageRequest(); + server.verify(); + } + + @Test + @DisplayName("Should filter loaded Komoot activities by inclusive date range") + void shouldFilterCompletedActivitiesByInclusiveDateRange() { + String authHeader = "Basic " + Base64.getEncoder() + .encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8)); + UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + UUID existingActivityId = UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff"); + + when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1003L)); + when(activityRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn(userId, List.of(1003L))) + .thenReturn(List.of(importLink(existingActivityId, 1003L))); + + 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-25T22: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": 1002, + "name": "Included Start", + "sport": "hike", + "status": "private", + "type": "tour_recorded", + "date": "2026-04-26T00:00:00+02:00" + }, + { + "id": 1003, + "name": "Included End", + "sport": "run", + "status": "private", + "type": "tour_recorded", + "date": "2026-04-27T23:59:59+02:00" + } + ] + }, + "_links": {} + } + """, MediaType.APPLICATION_JSON)); + + KomootActivitiesResponse response = service.fetchCompletedActivities( + new KomootImportRequest( + "user@example.com", + "secret", + "123456", + LocalDate.of(2026, 4, 26), + LocalDate.of(2026, 4, 27) + ), + userId); + + assertThat(response.totalCount()).isEqualTo(2); + assertThat(response.activities()).extracting("id").containsExactly(1002L, 1003L); + assertThat(response.activities().get(0).imported()).isFalse(); + assertThat(response.activities().get(1).imported()).isTrue(); + assertThat(response.activities().get(1).fitPubActivityId()).isEqualTo(existingActivityId); + + server.verify(); + } + + @Test + @DisplayName("Should reject incomplete Komoot date range") + void shouldRejectIncompleteDateRange() { + assertThatThrownBy(() -> new KomootImportRequest( + "user@example.com", + "secret", + "123456", + LocalDate.of(2026, 4, 27), + null + )).isInstanceOf(IllegalArgumentException.class) + .hasMessage("Start date and end date must either both be set or both be empty."); + } + + @Test + @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"); + KomootImportService throttledService = spy(service); + doNothing().when(throttledService).pauseBetweenDetailAndGpxRequest(); + doNothing().when(throttledService).pauseAfterActivityImport(); + + 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)) + .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) + .andRespond(withSuccess(""" + { + "id": "2880957035", + "name": "Latest Ride", + "description": "Imported from Komoot", + "status": "public", + "sport": "mtb_easy" + } + """, MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957035.gpx")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) + .andExpect(header(HttpHeaders.ACCEPT, "application/gpx+xml, application/xml, text/xml")) + .andRespond(withSuccess(""" + + + Latest Ride + + """, 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 = throttledService.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"); + assertThat(importedActivity.getVisibility()).isEqualTo(Activity.Visibility.PUBLIC); + assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.RIDE); + + verify(throttledService).pauseBetweenDetailAndGpxRequest(); + verify(throttledService).pauseAfterActivityImport(); + verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId); + server.verify(); + } + + @Test + @DisplayName("Should skip already imported Komoot activity") + void shouldSkipAlreadyImportedKomootActivity() { + UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + UUID existingActivityId = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"); + + when(activityRepository.findByUserIdAndKomootActivityId(userId, 3002L)).thenReturn( + Optional.of(Activity.builder().id(existingActivityId).userId(userId).komootActivityId(3002L).build()) + ); + + KomootImportExecutionResponse response = service.importActivity( + new KomootActivityImportRequest("user@example.com", "secret", "123456", 3002L), + userId + ); + + assertThat(response.importedActivityId()).isEqualTo(existingActivityId); + assertThat(response.importedKomootActivityId()).isEqualTo(3002L); + assertThat(response.status()).isEqualTo("SKIPPED_ALREADY_IMPORTED"); + } + + @Test + @DisplayName("Should fall back to OTHER when Komoot sport cannot be mapped") + void shouldFallbackToOtherForUnknownKomootSport() { + 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("cccccccc-cccc-cccc-cccc-cccccccccccc"); + KomootImportService throttledService = spy(service); + doNothing().when(throttledService).pauseBetweenDetailAndGpxRequest(); + doNothing().when(throttledService).pauseAfterActivityImport(); + + 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)) + .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) + .andRespond(withSuccess(""" + { + "id": "2880957036", + "name": "Unknown Sport", + "description": "No mapping available", + "status": "private", + "sport": "space_biking" + } + """, MediaType.APPLICATION_JSON)); + + server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957036.gpx")) + .andExpect(method(HttpMethod.GET)) + .andExpect(header(HttpHeaders.AUTHORIZATION, authHeader)) + .andExpect(header(HttpHeaders.ACCEPT, "application/gpx+xml, application/xml, text/xml")) + .andRespond(withSuccess(""" + + + Unknown Sport + + """, MediaType.APPLICATION_XML)); + + Activity importedActivity = Activity.builder() + .id(importedActivityId) + .userId(userId) + .activityType(Activity.ActivityType.RIDE) + .title("GPX Title") + .description(null) + .visibility(Activity.Visibility.PUBLIC) + .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 = throttledService.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(throttledService).pauseBetweenDetailAndGpxRequest(); + verify(throttledService).pauseAfterActivityImport(); + verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId); + server.verify(); + } +} 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