Add Komoot activity import with guided browser-based batch flow #25

Open
McPringle wants to merge 23 commits from McPringle/komoot-import into main
6 changed files with 542 additions and 12 deletions
Showing only changes of commit 803caf06b1 - Show all commits

View file

@ -4,7 +4,9 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.javahippie.fitpub.model.dto.KomootActivitiesResponse; 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.dto.KomootImportRequest;
import net.javahippie.fitpub.repository.UserRepository;
import net.javahippie.fitpub.service.KomootImportService; import net.javahippie.fitpub.service.KomootImportService;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@ -16,8 +18,10 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/** /**
* REST API for previewing completed Komoot activities. * REST API for loading and importing Komoot activities.
*/ */
@RestController @RestController
@RequestMapping("/api/komoot-import") @RequestMapping("/api/komoot-import")
@ -26,6 +30,7 @@ import org.springframework.web.bind.annotation.RestController;
public class KomootImportController { public class KomootImportController {
private final KomootImportService komootImportService; private final KomootImportService komootImportService;
private final UserRepository userRepository;
@PostMapping("/activities") @PostMapping("/activities")
public ResponseEntity<KomootActivitiesResponse> listActivities( public ResponseEntity<KomootActivitiesResponse> listActivities(
@ -38,6 +43,25 @@ public class KomootImportController {
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }
@PostMapping("/activities/import-first")
public ResponseEntity<KomootImportExecutionResponse> importFirstNewActivity(
@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 import for the first new activity",
authentication.getName());
KomootImportExecutionResponse response = komootImportService.importFirstNewActivity(
request,
fitPubUserId
);
return ResponseEntity.ok(response);
}
@ExceptionHandler(IllegalArgumentException.class) @ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException e) { public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException e) {
return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage())); return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));

View file

@ -117,6 +117,13 @@ public class Activity {
@Column(name = "source_file_format", nullable = false, length = 10) @Column(name = "source_file_format", nullable = false, length = 10)
javahippie commented 2026-04-29 09:50:59 +02:00 (Migrated from github.com)

I don't think that this belongs into the activity table. I'd prefer a separate table to track the Komoot import status – there it could also be properly non-nullable

I don't think that this belongs into the activity table. I'd prefer a separate table to track the Komoot import status – there it could also be properly non-nullable
McPringle commented 2026-04-29 11:08:42 +02:00 (Migrated from github.com)

I thought about it yesterday, too. I'll change it accordingly.

I thought about it yesterday, too. I'll change it accordingly.
private String sourceFileFormat; 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). * 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. * Indoor activities are displayed in timeline but excluded from heatmap generation.

View file

@ -23,6 +23,13 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
@Query("SELECT a.id FROM Activity a") @Query("SELECT a.id FROM Activity a")
List<UUID> findAllIds(); List<UUID> 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<Long> findImportedKomootActivityIdsByUserId(@Param("userId") UUID userId);
/** /**
* Find all activities for a specific user. * Find all activities for a specific user.
* *

View file

@ -5,7 +5,11 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import net.javahippie.fitpub.model.dto.KomootActivitiesResponse; import net.javahippie.fitpub.model.dto.KomootActivitiesResponse;
import net.javahippie.fitpub.model.dto.KomootActivitySummaryDTO; 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.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.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
@ -23,7 +27,11 @@ import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.UUID;
/** /**
* Fetches a temporary preview of completed Komoot activities for an authenticated FitPub user. * Fetches a temporary preview of completed Komoot activities for an authenticated FitPub user.
@ -39,8 +47,10 @@ public class KomootImportService {
private static final int PAGE_SIZE = 100; private static final int PAGE_SIZE = 100;
private static final String KOMOOT_LANGUAGE = "en"; private static final String KOMOOT_LANGUAGE = "en";
private final RestTemplate restTemplate; 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}") @Value("${fitpub.komoot.base-url:https://www.komoot.com}")
private String komootBaseUrl; private String komootBaseUrl;
@ -77,6 +87,68 @@ public class KomootImportService {
return new KomootActivitiesResponse(request.userId(), activities.size(), activities); return new KomootActivitiesResponse(request.userId(), activities.size(), activities);
} }
public KomootImportExecutionResponse importFirstNewActivity(KomootImportRequest request, UUID fitPubUserId) {
ImportCandidateContext context = buildImportCandidateContext(request, fitPubUserId);
if (context.candidate() == null) {
return new KomootImportExecutionResponse(
null,
null,
context.importedKomootActivityIds().size(),
context.activities().size(),
"No new Komoot activities found to import."
);
}
KomootActivitySummaryDTO candidate = context.candidate();
JsonNode details = fetchActivityDetails(request, candidate.id());
byte[] gpxData = fetchActivityGpx(request, candidate.id());
ByteArrayMultipartFile gpxFile = new ByteArrayMultipartFile(
"file",
"komoot-" + candidate.id() + ".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 mappedDescription = nullableText(details, "description");
Activity.ActivityType mappedActivityType = mapKomootSportToActivityType(nullableText(details, "sport"));
Activity importedActivity = activityFileService.processActivityFile(
gpxFile,
fitPubUserId,
mappedTitle,
mappedDescription,
mappedVisibility
);
importedActivity.setKomootActivityId(candidate.id());
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 {}",
candidate.id(),
importedActivity.getId(),
importedActivity.getVisibility(),
importedActivity.getActivityType()
);
return new KomootImportExecutionResponse(
importedActivity.getId(),
candidate.id(),
context.importedKomootActivityIds().size() + 1,
context.activities().size(),
"Imported Komoot activity " + candidate.id() + " into FitPub activity " + importedActivity.getId()
);
}
private URI buildInitialUri(String userId) { private URI buildInitialUri(String userId) {
String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl; String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl;
return UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/users/" + userId + "/tours/") return UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/users/" + userId + "/tours/")
@ -92,6 +164,25 @@ public class KomootImportService {
.toUri(); .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<URI> 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) { private HttpHeaders buildHeaders(String email, String password) {
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setAccept(List.of(MediaType.parseMediaType("application/hal+json"), MediaType.APPLICATION_JSON)); headers.setAccept(List.of(MediaType.parseMediaType("application/hal+json"), MediaType.APPLICATION_JSON));
@ -104,6 +195,16 @@ public class KomootImportService {
return headers; 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) { private String basicAuth(String email, String password) {
String credentials = email + ":" + password; String credentials = email + ":" + password;
String encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); String encoded = Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
@ -132,6 +233,144 @@ public class KomootImportService {
} }
} }
private JsonNode fetchActivityDetails(KomootImportRequest request, long activityId) {
try {
ResponseEntity<JsonNode> response = restTemplate.exchange(
buildDetailUri(activityId),
HttpMethod.GET,
new HttpEntity<>(buildHeaders(request.email(), request.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(KomootImportRequest request, long activityId) {
HttpEntity<Void> httpEntity = new HttpEntity<>(buildGpxHeaders(request.email(), request.password()));
List<URI> candidateUris = buildGpxCandidateUris(activityId);
Exception lastException = null;
for (URI candidateUri : candidateUris) {
try {
ResponseEntity<byte[]> response = restTemplate.exchange(
candidateUri,
HttpMethod.GET,
httpEntity,
byte[].class
);
byte[] body = response.getBody();
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("<gpx")) {
throw new IllegalStateException("Komoot response did not contain GPX XML.");
}
log.info("Downloaded Komoot GPX for activity {} from {}", activityId, candidateUri);
return body;
} catch (HttpClientErrorException.NotFound e) {
lastException = e;
log.debug("Komoot GPX candidate not found for activity {} at {}", activityId, candidateUri);
} catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) {
throw new IllegalArgumentException("Komoot login failed while downloading GPX.", e);
} catch (RestClientException | IllegalStateException e) {
lastException = e;
log.debug("Komoot GPX candidate failed for activity {} at {}: {}", activityId, candidateUri, e.getMessage());
}
}
throw new IllegalStateException("Failed to download GPX from Komoot for activity " + activityId, lastException);
}
private ImportCandidateContext buildImportCandidateContext(KomootImportRequest request, UUID fitPubUserId) {
Set<Long> importedKomootActivityIds = new HashSet<>(
activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId));
List<KomootActivitySummaryDTO> activities = new ArrayList<>(fetchCompletedActivities(request).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 Activity.Visibility mapVisibility(String komootStatus) {
if (komootStatus == null) {
return Activity.Visibility.PRIVATE;
}
return switch (komootStatus.toLowerCase(java.util.Locale.ROOT)) {
case "public" -> 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 record ImportCandidateContext(
Set<Long> importedKomootActivityIds,
List<KomootActivitySummaryDTO> activities,
KomootActivitySummaryDTO candidate
) {
}
private URI extractNextUri(JsonNode root) { private URI extractNextUri(JsonNode root) {
String nextHref = root.path("_links").path("next").path("href").asText(null); String nextHref = root.path("_links").path("next").path("href").asText(null);
if (nextHref == null || nextHref.isBlank()) { if (nextHref == null || nextHref.isBlank()) {

View file

@ -18,12 +18,12 @@
</h2> </h2>
<div class="alert alert-secondary"> <div class="alert alert-secondary">
<div class="fw-semibold mb-1">Phase 1: Preview only</div> <div class="fw-semibold mb-1">Komoot Import</div>
<div class="small mb-2"> <div class="small mb-2">
Your Komoot credentials are only used for this request and are not stored in FitPub. Your Komoot credentials are only used for this request and are not stored in FitPub.
</div> </div>
<div class="small mb-0"> <div class="small mb-0">
Komoot does not provide a public API for this flow. This preview currently depends on the Komoot does not provide a public API for this flow. This import currently depends on the
same web API endpoints used by the Komoot website and may stop working if Komoot changes them. same web API endpoints used by the Komoot website and may stop working if Komoot changes them.
</div> </div>
</div> </div>
@ -49,7 +49,16 @@
</div> </div>
</div> </div>
<div class="mt-4 d-flex justify-content-end"> <div class="mt-4 d-flex justify-content-end gap-2 flex-wrap">
<button type="button" class="btn btn-success" id="importFirstBtn">
<span id="importFirstText">
<i class="bi bi-download"></i> Import First New Activity
</span>
<span id="importFirstSpinner" class="d-none">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Importing...
</span>
</button>
<button type="submit" class="btn btn-primary" id="loadActivitiesBtn"> <button type="submit" class="btn btn-primary" id="loadActivitiesBtn">
<span id="loadActivitiesText"> <span id="loadActivitiesText">
<i class="bi bi-arrow-repeat"></i> Load Completed Activities <i class="bi bi-arrow-repeat"></i> Load Completed Activities
@ -104,6 +113,9 @@
const loadActivitiesBtn = document.getElementById('loadActivitiesBtn'); const loadActivitiesBtn = document.getElementById('loadActivitiesBtn');
const loadActivitiesText = document.getElementById('loadActivitiesText'); const loadActivitiesText = document.getElementById('loadActivitiesText');
const loadActivitiesSpinner = document.getElementById('loadActivitiesSpinner'); const loadActivitiesSpinner = document.getElementById('loadActivitiesSpinner');
const importFirstBtn = document.getElementById('importFirstBtn');
const importFirstText = document.getElementById('importFirstText');
const importFirstSpinner = document.getElementById('importFirstSpinner');
function setLoading(loading) { function setLoading(loading) {
loadActivitiesBtn.disabled = loading; loadActivitiesBtn.disabled = loading;
@ -111,6 +123,12 @@
loadActivitiesSpinner.classList.toggle('d-none', !loading); loadActivitiesSpinner.classList.toggle('d-none', !loading);
} }
function setImportLoading(loading) {
importFirstBtn.disabled = loading;
importFirstText.classList.toggle('d-none', loading);
importFirstSpinner.classList.toggle('d-none', !loading);
}
function showError(message) { function showError(message) {
errorAlert.textContent = message; errorAlert.textContent = message;
errorAlert.classList.remove('d-none'); errorAlert.classList.remove('d-none');
@ -121,6 +139,18 @@
errorAlert.classList.add('d-none'); errorAlert.classList.add('d-none');
} }
function showStatus(message) {
errorAlert.textContent = message;
errorAlert.classList.remove('d-none');
errorAlert.classList.remove('alert-danger');
errorAlert.classList.add('alert-info');
}
function resetAlertToError() {
errorAlert.classList.remove('alert-info');
errorAlert.classList.add('alert-danger');
}
function formatDistance(meters) { function formatDistance(meters) {
if (meters == null) { if (meters == null) {
return '-'; return '-';
@ -193,17 +223,22 @@
resultsSection.classList.remove('d-none'); resultsSection.classList.remove('d-none');
} }
form.addEventListener('submit', async function(event) { function buildPayload() {
event.preventDefault();
clearError();
resultsSection.classList.add('d-none');
const formData = new FormData(form); const formData = new FormData(form);
const payload = { return {
email: formData.get('email'), email: formData.get('email'),
password: formData.get('password'), password: formData.get('password'),
userId: formData.get('userId') userId: formData.get('userId')
}; };
}
form.addEventListener('submit', async function(event) {
event.preventDefault();
clearError();
resetAlertToError();
resultsSection.classList.add('d-none');
const payload = buildPayload();
setLoading(true); setLoading(true);
@ -239,6 +274,45 @@
setLoading(false); setLoading(false);
} }
}); });
importFirstBtn.addEventListener('click', async function() {
clearError();
resetAlertToError();
const payload = buildPayload();
setImportLoading(true);
try {
const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities/import-first', {
method: 'POST',
body: payload
});
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();
showStatus(data.message || 'Komoot activity imported.');
} catch (error) {
let message = error instanceof Error ? error.message : 'Failed to import Komoot activity.';
if (error instanceof Error && error.message === 'Authentication failed') {
return;
}
showError(message);
} finally {
setImportLoading(false);
}
});
}); });
</script> </script>
</th:block> </th:block>

View file

@ -1,7 +1,11 @@
package net.javahippie.fitpub.service; package net.javahippie.fitpub.service;
import net.javahippie.fitpub.model.dto.KomootActivitiesResponse; 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.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.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
@ -13,8 +17,14 @@ import org.springframework.web.client.RestTemplate;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Base64; import java.util.Base64;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
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.ExpectedCount.once;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header; 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.method;
@ -26,12 +36,18 @@ class KomootImportServiceTest {
private RestTemplate restTemplate; private RestTemplate restTemplate;
private MockRestServiceServer server; private MockRestServiceServer server;
private KomootImportService service; private KomootImportService service;
private ActivityRepository activityRepository;
private ActivityFileService activityFileService;
private ActivityPostProcessingService activityPostProcessingService;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
restTemplate = new RestTemplate(); restTemplate = new RestTemplate();
server = MockRestServiceServer.bindTo(restTemplate).build(); server = MockRestServiceServer.bindTo(restTemplate).build();
service = new KomootImportService(restTemplate); 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, "komootBaseUrl", "https://www.komoot.com");
} }
@ -105,4 +121,167 @@ class KomootImportServiceTest {
server.verify(); server.verify();
} }
@Test
@DisplayName("Should import newest not-yet-imported Komoot activity via GPX and override metadata")
void shouldImportNewestNotYetImportedActivity() {
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&status=private&name=&hl=en&sort_field=date&sort_direction=desc&page=0&limit=100"))
.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));
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))
.andRespond(withSuccess("""
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="komoot">
<trk><name>Latest Ride</name></trk>
</gpx>
""", 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"),
userId
);
assertThat(response.importedActivityId()).isEqualTo(importedActivityId);
assertThat(response.importedKomootActivityId()).isEqualTo(2880957035L);
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(activityPostProcessingService).processActivityAsync(importedActivityId, userId);
server.verify();
}
@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");
when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of());
server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&status=private&name=&hl=en&sort_field=date&sort_direction=desc&page=0&limit=100"))
.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));
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))
.andRespond(withSuccess("""
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="komoot">
<trk><name>Unknown Sport</name></trk>
</gpx>
""", 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 = service.importFirstNewActivity(
new KomootImportRequest("user@example.com", "secret", "123456"),
userId
);
assertThat(response.importedActivityId()).isEqualTo(importedActivityId);
assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.OTHER);
verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId);
server.verify();
}
} }