feat(komoot): import first new activity via GPX and override metadata

Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
This commit is contained in:
Marcus Fihlon 2026-04-28 13:24:57 +02:00
parent 0cea88d033
commit 803caf06b1
Signed by: McPringle
GPG key ID: C6B7F469EE363E1F
6 changed files with 542 additions and 12 deletions

View file

@ -4,7 +4,9 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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;
@ -16,8 +18,10 @@ 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 previewing completed Komoot activities.
* REST API for loading and importing Komoot activities.
*/
@RestController
@RequestMapping("/api/komoot-import")
@ -26,6 +30,7 @@ import org.springframework.web.bind.annotation.RestController;
public class KomootImportController {
private final KomootImportService komootImportService;
private final UserRepository userRepository;
@PostMapping("/activities")
public ResponseEntity<KomootActivitiesResponse> listActivities(
@ -38,6 +43,25 @@ public class KomootImportController {
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)
public ResponseEntity<ErrorResponse> handleIllegalArgument(IllegalArgumentException e) {
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)
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.

View file

@ -23,6 +23,13 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
@Query("SELECT a.id FROM Activity a")
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.
*

View file

@ -5,7 +5,11 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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;
@ -23,7 +27,11 @@ import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Comparator;
import java.util.HashSet;
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.
@ -39,8 +47,10 @@ public class KomootImportService {
private static final int PAGE_SIZE = 100;
private static final String KOMOOT_LANGUAGE = "en";
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;
@ -77,6 +87,68 @@ 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) {
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) {
String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl;
return UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/users/" + userId + "/tours/")
@ -92,6 +164,25 @@ public class KomootImportService {
.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) {
HttpHeaders headers = new HttpHeaders();
headers.setAccept(List.of(MediaType.parseMediaType("application/hal+json"), MediaType.APPLICATION_JSON));
@ -104,6 +195,16 @@ public class KomootImportService {
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));
@ -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) {
String nextHref = root.path("_links").path("next").path("href").asText(null);
if (nextHref == null || nextHref.isBlank()) {

View file

@ -18,12 +18,12 @@
</h2>
<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">
Your Komoot credentials are only used for this request and are not stored in FitPub.
</div>
<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.
</div>
</div>
@ -49,7 +49,16 @@
</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">
<span id="loadActivitiesText">
<i class="bi bi-arrow-repeat"></i> Load Completed Activities
@ -104,6 +113,9 @@
const loadActivitiesBtn = document.getElementById('loadActivitiesBtn');
const loadActivitiesText = document.getElementById('loadActivitiesText');
const loadActivitiesSpinner = document.getElementById('loadActivitiesSpinner');
const importFirstBtn = document.getElementById('importFirstBtn');
const importFirstText = document.getElementById('importFirstText');
const importFirstSpinner = document.getElementById('importFirstSpinner');
function setLoading(loading) {
loadActivitiesBtn.disabled = loading;
@ -111,6 +123,12 @@
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) {
errorAlert.textContent = message;
errorAlert.classList.remove('d-none');
@ -121,6 +139,18 @@
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) {
if (meters == null) {
return '-';
@ -193,17 +223,22 @@
resultsSection.classList.remove('d-none');
}
form.addEventListener('submit', async function(event) {
event.preventDefault();
clearError();
resultsSection.classList.add('d-none');
function buildPayload() {
const formData = new FormData(form);
const payload = {
return {
email: formData.get('email'),
password: formData.get('password'),
userId: formData.get('userId')
};
}
form.addEventListener('submit', async function(event) {
event.preventDefault();
clearError();
resetAlertToError();
resultsSection.classList.add('d-none');
const payload = buildPayload();
setLoading(true);
@ -239,6 +274,45 @@
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>
</th:block>

View file

@ -1,7 +1,11 @@
package net.javahippie.fitpub.service;
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.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
@ -13,8 +17,14 @@ import org.springframework.web.client.RestTemplate;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import java.util.UUID;
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.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
@ -26,12 +36,18 @@ class KomootImportServiceTest {
private RestTemplate restTemplate;
private MockRestServiceServer server;
private KomootImportService service;
private ActivityRepository activityRepository;
private ActivityFileService activityFileService;
private ActivityPostProcessingService activityPostProcessingService;
@BeforeEach
void setUp() {
restTemplate = new RestTemplate();
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");
}
@ -105,4 +121,167 @@ class KomootImportServiceTest {
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();
}
}