Merge branch 'refs/heads/komoot-import' into sattelgeschichten

This commit is contained in:
Marcus Fihlon 2026-04-29 11:23:40 +02:00
commit 9824cca20d
Signed by: McPringle
GPG key ID: C6B7F469EE363E1F
14 changed files with 289 additions and 161 deletions

View file

@ -43,7 +43,7 @@ public class KomootImportController {
.getId();
log.info("User {} requested Komoot activity preview for Komoot ID {}",
authentication.getName(), request.userId());
authentication.getName(), request.getUserId());
KomootActivitiesResponse response = komootImportService.fetchCompletedActivities(request, fitPubUserId);
return ResponseEntity.ok(response);
}
@ -58,7 +58,7 @@ public class KomootImportController {
.getId();
log.info("User {} requested Komoot import for activity {}",
authentication.getName(), request.activityId());
authentication.getName(), request.getActivityId());
KomootImportExecutionResponse response = komootImportService.importActivity(
request,

View file

@ -1,13 +1,22 @@
package net.javahippie.fitpub.model.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* Response payload for the Komoot import preview.
*/
public record KomootActivitiesResponse(
String userId,
int totalCount,
List<KomootActivitySummaryDTO> activities
) {
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KomootActivitiesResponse {
private String userId;
private int totalCount;
private List<KomootActivitySummaryDTO> activities;
}

View file

@ -4,25 +4,33 @@ import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Request payload for importing one specific Komoot activity.
*
* <p>The password is only used for the current request and is never persisted.</p>
*/
public record KomootActivityImportRequest(
@NotBlank
@Email
String email,
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KomootActivityImportRequest {
@NotBlank
String password,
@NotBlank
@Email
private String email;
@NotBlank
@Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only")
String userId,
@NotBlank
private String password;
@NotNull
Long activityId
) {
@NotBlank
@Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only")
private String userId;
@NotNull
private Long activityId;
}

View file

@ -1,24 +1,33 @@
package net.javahippie.fitpub.model.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
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
) {
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KomootActivitySummaryDTO {
private long id;
private String name;
private String sport;
private String mappedActivityType;
private String status;
private String type;
private OffsetDateTime date;
private Double distanceMeters;
private Integer durationSeconds;
private Integer timeInMotionSeconds;
private Double elevationUp;
private boolean imported;
private UUID fitPubActivityId;
}

View file

@ -1,14 +1,23 @@
package net.javahippie.fitpub.model.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.UUID;
/**
* Response for importing exactly one Komoot activity into FitPub.
*/
public record KomootImportExecutionResponse(
UUID importedActivityId,
Long importedKomootActivityId,
String status,
String message
) {
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class KomootImportExecutionResponse {
private UUID importedActivityId;
private Long importedKomootActivityId;
private String status;
private String message;
}

View file

@ -1,8 +1,12 @@
package net.javahippie.fitpub.model.dto;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
@ -11,23 +15,46 @@ import java.time.LocalDate;
*
* <p>The password is only used for the current request and is never persisted.</p>
*/
public record KomootImportRequest(
@NotBlank
@Email
String email,
@Data
@Builder
@NoArgsConstructor
public class KomootImportRequest {
@NotBlank
String password,
@NotBlank
@Email
private String email;
@NotBlank
@Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only")
String userId,
@NotBlank
private String password;
LocalDate startDate,
@NotBlank
@Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only")
private String userId;
LocalDate endDate
) {
public KomootImportRequest {
private LocalDate startDate;
private LocalDate endDate;
public KomootImportRequest(String email, String password, String userId, LocalDate startDate, LocalDate endDate) {
this.email = email;
this.password = password;
this.userId = userId;
this.startDate = startDate;
this.endDate = endDate;
validateDateRange();
}
@AssertTrue(message = "Start date and end date must either both be set or both be empty, and start date must be before or equal to end date.")
public boolean isDateRangeConsistent() {
try {
validateDateRange();
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
private void validateDateRange() {
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.");

View file

@ -117,13 +117,6 @@ 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

@ -0,0 +1,46 @@
package net.javahippie.fitpub.model.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* Internal link between a FitPub activity and its originating Komoot activity.
*/
@Entity
@Table(name = "komoot_imports",
uniqueConstraints = {
@UniqueConstraint(name = "uk_komoot_imports_activity_id", columnNames = "activity_id"),
@UniqueConstraint(name = "uk_komoot_imports_user_komoot_activity_id", columnNames = {"user_id", "komoot_activity_id"})
},
indexes = {
@Index(name = "idx_komoot_imports_user_id", columnList = "user_id"),
@Index(name = "idx_komoot_imports_komoot_activity_id", columnList = "komoot_activity_id")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class KomootImport {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(name = "user_id", nullable = false)
private UUID userId;
@Column(name = "activity_id", nullable = false)
private UUID activityId;
@Column(name = "komoot_activity_id", nullable = false)
private Long komootActivityId;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
}

View file

@ -20,37 +20,9 @@ import java.util.UUID;
@Repository
public interface ActivityRepository extends JpaRepository<Activity, UUID> {
interface KomootImportLinkProjection {
UUID getId();
Long getKomootActivityId();
}
@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);
/**
* Finds a previously imported Komoot activity for the given user.
*/
Optional<Activity> 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<KomootImportLinkProjection> findKomootImportLinksByUserIdAndKomootActivityIdIn(
@Param("userId") UUID userId,
@Param("komootActivityIds") List<Long> komootActivityIds
);
/**
* Find all activities for a specific user.
*

View file

@ -0,0 +1,33 @@
package net.javahippie.fitpub.repository;
import net.javahippie.fitpub.model.entity.KomootImport;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface KomootImportRepository extends JpaRepository<KomootImport, UUID> {
interface KomootImportLinkProjection {
UUID getActivityId();
Long getKomootActivityId();
}
@Query("SELECT k.komootActivityId FROM KomootImport k WHERE k.userId = :userId")
List<Long> findImportedKomootActivityIdsByUserId(@Param("userId") UUID userId);
Optional<KomootImport> findByUserIdAndKomootActivityId(UUID userId, Long komootActivityId);
@Query("SELECT k.activityId AS activityId, k.komootActivityId AS komootActivityId " +
"FROM KomootImport k " +
"WHERE k.userId = :userId AND k.komootActivityId IN :komootActivityIds")
List<KomootImportLinkProjection> findKomootImportLinksByUserIdAndKomootActivityIdIn(
@Param("userId") UUID userId,
@Param("komootActivityIds") List<Long> komootActivityIds
);
}

View file

@ -9,7 +9,9 @@ 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.model.entity.KomootImport;
import net.javahippie.fitpub.repository.ActivityRepository;
import net.javahippie.fitpub.repository.KomootImportRepository;
import net.javahippie.fitpub.util.ByteArrayMultipartFile;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
@ -57,6 +59,7 @@ public class KomootImportService {
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 KomootImportRepository komootImportRepository;
private final ActivityFileService activityFileService;
private final ActivityPostProcessingService activityPostProcessingService;
@ -75,18 +78,18 @@ public class KomootImportService {
public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request, UUID fitPubUserId) {
List<KomootActivitySummaryDTO> activities = new ArrayList<>();
Set<Long> importedKomootActivityIds = new HashSet<>(
activityRepository.findImportedKomootActivityIdsByUserId(fitPubUserId));
komootImportRepository.findImportedKomootActivityIdsByUserId(fitPubUserId));
Map<Long, UUID> fitPubActivityIdsByKomootId = new HashMap<>();
if (!importedKomootActivityIds.isEmpty()) {
activityRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn(
komootImportRepository.findKomootImportLinksByUserIdAndKomootActivityIdIn(
fitPubUserId,
new ArrayList<>(importedKomootActivityIds)
)
.forEach(link -> fitPubActivityIdsByKomootId.put(link.getKomootActivityId(), link.getId()));
.forEach(link -> fitPubActivityIdsByKomootId.put(link.getKomootActivityId(), link.getActivityId()));
}
URI nextUri = buildInitialUri(request);
HttpEntity<Void> httpEntity = new HttpEntity<>(buildHeaders(request.email(), request.password()));
HttpEntity<Void> httpEntity = new HttpEntity<>(buildHeaders(request.getEmail(), request.getPassword()));
try {
while (nextUri != null) {
@ -113,8 +116,8 @@ public class KomootImportService {
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);
log.info("Fetched {} completed Komoot activities for user ID {}", activities.size(), request.getUserId());
return new KomootActivitiesResponse(request.getUserId(), activities.size(), activities);
}
void pauseBeforeNextPageRequest() {
@ -122,29 +125,29 @@ public class KomootImportService {
}
public KomootImportExecutionResponse importActivity(KomootActivityImportRequest request, UUID fitPubUserId) {
Activity existingActivity = activityRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.activityId()).orElse(null);
if (existingActivity != null) {
KomootImport existingImport = komootImportRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.getActivityId()).orElse(null);
if (existingImport != null) {
return new KomootImportExecutionResponse(
existingActivity.getId(),
request.activityId(),
existingImport.getActivityId(),
request.getActivityId(),
"SKIPPED_ALREADY_IMPORTED",
"Komoot activity " + request.activityId() + " was already imported."
"Komoot activity " + request.getActivityId() + " was already imported."
);
}
JsonNode details = fetchActivityDetails(request.email(), request.password(), request.activityId());
JsonNode details = fetchActivityDetails(request.getEmail(), request.getPassword(), request.getActivityId());
pauseBetweenDetailAndGpxRequest();
byte[] gpxData = fetchActivityGpx(request.email(), request.password(), request.activityId());
byte[] gpxData = fetchActivityGpx(request.getEmail(), request.getPassword(), request.getActivityId());
ByteArrayMultipartFile gpxFile = new ByteArrayMultipartFile(
"file",
"komoot-" + request.activityId() + ".gpx",
"komoot-" + request.getActivityId() + ".gpx",
"application/gpx+xml",
gpxData
);
Activity.Visibility mappedVisibility = mapVisibility(nullableText(details, "status"));
String mappedTitle = firstNonBlank(nullableText(details, "name"), null, "Komoot Activity " + request.activityId());
String mappedTitle = firstNonBlank(nullableText(details, "name"), null, "Komoot Activity " + request.getActivityId());
String mappedDescription = nullableText(details, "description");
Activity.ActivityType mappedActivityType = mapKomootSportToActivityType(nullableText(details, "sport"));
@ -156,18 +159,22 @@ public class KomootImportService {
mappedVisibility
);
importedActivity.setKomootActivityId(request.activityId());
importedActivity.setTitle(mappedTitle);
importedActivity.setDescription(mappedDescription);
importedActivity.setVisibility(mappedVisibility);
importedActivity.setActivityType(mappedActivityType);
importedActivity = activityRepository.save(importedActivity);
komootImportRepository.save(KomootImport.builder()
.userId(fitPubUserId)
.activityId(importedActivity.getId())
.komootActivityId(request.getActivityId())
.build());
activityPostProcessingService.processActivityAsync(importedActivity.getId(), fitPubUserId);
log.info(
"Imported Komoot activity {} into FitPub activity {} with visibility {} and type {}",
request.activityId(),
request.getActivityId(),
importedActivity.getId(),
importedActivity.getVisibility(),
importedActivity.getActivityType()
@ -177,9 +184,9 @@ public class KomootImportService {
return new KomootImportExecutionResponse(
importedActivity.getId(),
request.activityId(),
request.getActivityId(),
"IMPORTED",
"Imported Komoot activity " + request.activityId() + " into FitPub activity " + importedActivity.getId()
"Imported Komoot activity " + request.getActivityId() + " into FitPub activity " + importedActivity.getId()
);
}
@ -193,15 +200,15 @@ public class KomootImportService {
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/")
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/users/" + request.getUserId() + "/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()));
if (request.getStartDate() != null && request.getEndDate() != null) {
builder.queryParam("start_date", formatKomootStartDate(request.getStartDate()))
.queryParam("end_date", formatKomootEndDate(request.getEndDate()));
} else {
builder.queryParam("status", "private")
.queryParam("name", "")

View file

@ -1,15 +0,0 @@
-- 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';

View file

@ -0,0 +1,23 @@
-- Track imported Komoot activities separately from the core activities table.
--
-- This keeps the import-specific state isolated and allows all import-related
-- columns to be strictly non-nullable.
CREATE TABLE komoot_imports (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
activity_id UUID NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
komoot_activity_id BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_komoot_imports_activity_id UNIQUE (activity_id),
CONSTRAINT uk_komoot_imports_user_komoot_activity_id UNIQUE (user_id, komoot_activity_id)
);
CREATE INDEX idx_komoot_imports_user_id
ON komoot_imports(user_id);
CREATE INDEX idx_komoot_imports_komoot_activity_id
ON komoot_imports(komoot_activity_id);
COMMENT ON TABLE komoot_imports IS
'Internal mapping between FitPub activities and their originating Komoot activities';

View file

@ -5,7 +5,9 @@ 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.model.entity.KomootImport;
import net.javahippie.fitpub.repository.ActivityRepository;
import net.javahippie.fitpub.repository.KomootImportRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
@ -41,10 +43,10 @@ import static org.springframework.test.web.client.response.MockRestResponseCreat
class KomootImportServiceTest {
private static ActivityRepository.KomootImportLinkProjection importLink(UUID activityId, Long komootActivityId) {
return new ActivityRepository.KomootImportLinkProjection() {
private static KomootImportRepository.KomootImportLinkProjection importLink(UUID activityId, Long komootActivityId) {
return new KomootImportRepository.KomootImportLinkProjection() {
@Override
public UUID getId() {
public UUID getActivityId() {
return activityId;
}
@ -58,6 +60,7 @@ class KomootImportServiceTest {
private MockRestServiceServer server;
private KomootImportService service;
private ActivityRepository activityRepository;
private KomootImportRepository komootImportRepository;
private ActivityFileService activityFileService;
private ActivityPostProcessingService activityPostProcessingService;
private TimeZone originalTimeZone;
@ -69,9 +72,10 @@ class KomootImportServiceTest {
RestTemplate restTemplate = new RestTemplate();
server = MockRestServiceServer.bindTo(restTemplate).build();
activityRepository = mock(ActivityRepository.class);
komootImportRepository = mock(KomootImportRepository.class);
activityFileService = mock(ActivityFileService.class);
activityPostProcessingService = mock(ActivityPostProcessingService.class);
service = new KomootImportService(restTemplate, activityRepository, activityFileService, activityPostProcessingService);
service = new KomootImportService(restTemplate, activityRepository, komootImportRepository, activityFileService, activityPostProcessingService);
ReflectionTestUtils.setField(service, "komootBaseUrl", "https://www.komoot.com");
ReflectionTestUtils.setField(service, "paginatedRequestDelayMillis", 0L);
ReflectionTestUtils.setField(service, "detailToGpxDelayMillis", 0L);
@ -92,8 +96,8 @@ class KomootImportServiceTest {
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)))
when(komootImportRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1002L));
when(komootImportRepository.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"))
@ -154,15 +158,15 @@ class KomootImportServiceTest {
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);
assertThat(response.getTotalCount()).isEqualTo(2);
assertThat(response.getActivities()).hasSize(2);
assertThat(response.getActivities().get(0).getId()).isEqualTo(1001L);
assertThat(response.getActivities().get(0).isImported()).isFalse();
assertThat(response.getActivities().get(0).getFitPubActivityId()).isNull();
assertThat(response.getActivities().get(0).getTimeInMotionSeconds()).isEqualTo(7800);
assertThat(response.getActivities().get(1).getName()).isEqualTo("Lunch Walk");
assertThat(response.getActivities().get(1).isImported()).isTrue();
assertThat(response.getActivities().get(1).getFitPubActivityId()).isEqualTo(existingActivityId);
verify(throttledService).pauseBeforeNextPageRequest();
server.verify();
@ -176,8 +180,8 @@ class KomootImportServiceTest {
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)))
when(komootImportRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of(1003L));
when(komootImportRepository.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"))
@ -219,11 +223,11 @@ class KomootImportServiceTest {
),
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);
assertThat(response.getTotalCount()).isEqualTo(2);
assertThat(response.getActivities()).extracting("id").containsExactly(1002L, 1003L);
assertThat(response.getActivities().get(0).isImported()).isFalse();
assertThat(response.getActivities().get(1).isImported()).isTrue();
assertThat(response.getActivities().get(1).getFitPubActivityId()).isEqualTo(existingActivityId);
server.verify();
}
@ -252,7 +256,7 @@ class KomootImportServiceTest {
doNothing().when(throttledService).pauseBetweenDetailAndGpxRequest();
doNothing().when(throttledService).pauseAfterActivityImport();
when(activityRepository.findByUserIdAndKomootActivityId(userId, 2880957035L)).thenReturn(Optional.empty());
when(komootImportRepository.findByUserIdAndKomootActivityId(userId, 2880957035L)).thenReturn(Optional.empty());
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957035?hl=en"))
.andExpect(method(HttpMethod.GET))
@ -290,20 +294,21 @@ class KomootImportServiceTest {
when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity);
when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(komootImportRepository.save(any(KomootImport.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(response.getImportedActivityId()).isEqualTo(importedActivityId);
assertThat(response.getImportedKomootActivityId()).isEqualTo(2880957035L);
assertThat(response.getStatus()).isEqualTo("IMPORTED");
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(komootImportRepository).save(any(KomootImport.class));
verify(throttledService).pauseBetweenDetailAndGpxRequest();
verify(throttledService).pauseAfterActivityImport();
@ -317,8 +322,8 @@ class KomootImportServiceTest {
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())
when(komootImportRepository.findByUserIdAndKomootActivityId(userId, 3002L)).thenReturn(
Optional.of(KomootImport.builder().activityId(existingActivityId).userId(userId).komootActivityId(3002L).build())
);
KomootImportExecutionResponse response = service.importActivity(
@ -326,9 +331,9 @@ class KomootImportServiceTest {
userId
);
assertThat(response.importedActivityId()).isEqualTo(existingActivityId);
assertThat(response.importedKomootActivityId()).isEqualTo(3002L);
assertThat(response.status()).isEqualTo("SKIPPED_ALREADY_IMPORTED");
assertThat(response.getImportedActivityId()).isEqualTo(existingActivityId);
assertThat(response.getImportedKomootActivityId()).isEqualTo(3002L);
assertThat(response.getStatus()).isEqualTo("SKIPPED_ALREADY_IMPORTED");
}
@Test
@ -342,7 +347,7 @@ class KomootImportServiceTest {
doNothing().when(throttledService).pauseBetweenDetailAndGpxRequest();
doNothing().when(throttledService).pauseAfterActivityImport();
when(activityRepository.findByUserIdAndKomootActivityId(userId, 2880957036L)).thenReturn(Optional.empty());
when(komootImportRepository.findByUserIdAndKomootActivityId(userId, 2880957036L)).thenReturn(Optional.empty());
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957036?hl=en"))
.andExpect(method(HttpMethod.GET))
@ -380,15 +385,17 @@ class KomootImportServiceTest {
when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity);
when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0));
when(komootImportRepository.save(any(KomootImport.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(response.getImportedActivityId()).isEqualTo(importedActivityId);
assertThat(response.getStatus()).isEqualTo("IMPORTED");
assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.OTHER);
verify(komootImportRepository).save(any(KomootImport.class));
verify(throttledService).pauseBetweenDetailAndGpxRequest();
verify(throttledService).pauseAfterActivityImport();