Add Komoot activity import with guided browser-based batch flow #25
4 changed files with 249 additions and 18 deletions
|
|
@ -4,6 +4,8 @@ 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.
|
||||
*
|
||||
|
|
@ -19,6 +21,19 @@ public record KomootImportRequest(
|
|||
|
||||
@NotBlank
|
||||
@Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only")
|
||||
String userId
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,12 @@ 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.Comparator;
|
||||
|
|
@ -47,6 +52,7 @@ 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;
|
||||
|
|
@ -58,7 +64,7 @@ public class KomootImportService {
|
|||
public KomootActivitiesResponse fetchCompletedActivities(KomootImportRequest request) {
|
||||
List<KomootActivitySummaryDTO> activities = new ArrayList<>();
|
||||
|
||||
URI nextUri = buildInitialUri(request.userId());
|
||||
URI nextUri = buildInitialUri(request);
|
||||
HttpEntity<Void> httpEntity = new HttpEntity<>(buildHeaders(request.email(), request.password()));
|
||||
|
||||
try {
|
||||
|
|
@ -149,19 +155,25 @@ public class KomootImportService {
|
|||
);
|
||||
}
|
||||
|
||||
private URI buildInitialUri(String userId) {
|
||||
private URI buildInitialUri(KomootImportRequest request) {
|
||||
String normalizedBaseUrl = komootBaseUrl.endsWith("/") ? komootBaseUrl.substring(0, komootBaseUrl.length() - 1) : komootBaseUrl;
|
||||
return UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/users/" + userId + "/tours/")
|
||||
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(normalizedBaseUrl + "/api/v007/users/" + request.userId() + "/tours/")
|
||||
.queryParam("type", "tour_recorded")
|
||||
.queryParam("status", "private")
|
||||
.queryParam("name", "")
|
||||
.queryParam("hl", KOMOOT_LANGUAGE)
|
||||
.queryParam("sort_field", "date")
|
||||
.queryParam("sort_direction", "desc")
|
||||
.queryParam("page", 0)
|
||||
.queryParam("limit", PAGE_SIZE)
|
||||
.build()
|
||||
.toUri();
|
||||
.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) {
|
||||
|
|
@ -314,6 +326,21 @@ public class KomootImportService {
|
|||
return new ImportCandidateContext(importedKomootActivityIds, activities, candidate);
|
||||
}
|
||||
|
||||
private String formatKomootStartDate(LocalDate localDate) {
|
||||
return localDate.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.atOffset(ZoneOffset.UTC)
|
||||
.format(KOMOOT_DATE_TIME_FORMATTER);
|
||||
}
|
||||
|
||||
private String formatKomootEndDate(LocalDate localDate) {
|
||||
return localDate.atTime(LocalTime.of(23, 59, 59, 999_000_000))
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.atOffset(ZoneOffset.UTC)
|
||||
.format(KOMOOT_DATE_TIME_FORMATTER);
|
||||
}
|
||||
|
||||
private Activity.Visibility mapVisibility(String komootStatus) {
|
||||
if (komootStatus == null) {
|
||||
return Activity.Visibility.PRIVATE;
|
||||
|
|
|
|||
|
|
@ -47,6 +47,19 @@
|
|||
<input type="text" class="form-control" id="userId" name="userId" required inputmode="numeric" pattern="[0-9]+">
|
||||
<div class="form-text">You can find the Komoot ID in your Komoot account settings.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="startDate" class="form-label">Start Date</label>
|
||||
<input type="date" class="form-control" id="startDate" name="startDate">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label for="endDate" class="form-label">End Date</label>
|
||||
<input type="date" class="form-control" id="endDate" name="endDate">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">Both dates must be set together. Inclusive, day-based filter.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 d-flex justify-content-end gap-2 flex-wrap">
|
||||
|
|
@ -225,13 +238,21 @@
|
|||
|
||||
function buildPayload() {
|
||||
const formData = new FormData(form);
|
||||
const startDate = formData.get('startDate');
|
||||
const endDate = formData.get('endDate');
|
||||
return {
|
||||
email: formData.get('email'),
|
||||
password: formData.get('password'),
|
||||
userId: formData.get('userId')
|
||||
userId: formData.get('userId'),
|
||||
startDate: startDate || null,
|
||||
endDate: endDate || null
|
||||
};
|
||||
}
|
||||
|
||||
function hasIncompleteDateRange(payload) {
|
||||
return Boolean(payload.startDate) !== Boolean(payload.endDate);
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async function(event) {
|
||||
event.preventDefault();
|
||||
clearError();
|
||||
|
|
@ -239,6 +260,10 @@
|
|||
resultsSection.classList.add('d-none');
|
||||
|
||||
const payload = buildPayload();
|
||||
if (hasIncompleteDateRange(payload)) {
|
||||
showError('Start date and end date must either both be set or both be empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
|
|
@ -280,6 +305,10 @@
|
|||
resetAlertToError();
|
||||
|
||||
const payload = buildPayload();
|
||||
if (hasIncompleteDateRange(payload)) {
|
||||
showError('Start date and end date must either both be set or both be empty.');
|
||||
return;
|
||||
}
|
||||
setImportLoading(true);
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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;
|
||||
|
|
@ -16,11 +17,14 @@ 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.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.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
|
@ -39,9 +43,12 @@ class KomootImportServiceTest {
|
|||
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 = new RestTemplate();
|
||||
server = MockRestServiceServer.bindTo(restTemplate).build();
|
||||
activityRepository = mock(ActivityRepository.class);
|
||||
|
|
@ -51,12 +58,17 @@ class KomootImportServiceTest {
|
|||
ReflectionTestUtils.setField(service, "komootBaseUrl", "https://www.komoot.com");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
TimeZone.setDefault(originalTimeZone);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFetchAndMergePagedCompletedActivities() {
|
||||
String authHeader = "Basic " + Base64.getEncoder()
|
||||
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
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"))
|
||||
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("""
|
||||
|
|
@ -111,7 +123,7 @@ class KomootImportServiceTest {
|
|||
""", MediaType.APPLICATION_JSON));
|
||||
|
||||
KomootActivitiesResponse response = service.fetchCompletedActivities(
|
||||
new KomootImportRequest("user@example.com", "secret", "123456"));
|
||||
new KomootImportRequest("user@example.com", "secret", "123456", null, null));
|
||||
|
||||
assertThat(response.totalCount()).isEqualTo(2);
|
||||
assertThat(response.activities()).hasSize(2);
|
||||
|
|
@ -122,6 +134,69 @@ class KomootImportServiceTest {
|
|||
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));
|
||||
|
||||
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)
|
||||
));
|
||||
|
||||
assertThat(response.totalCount()).isEqualTo(2);
|
||||
assertThat(response.activities()).extracting("id").containsExactly(1002L, 1003L);
|
||||
|
||||
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 newest not-yet-imported Komoot activity via GPX and override metadata")
|
||||
void shouldImportNewestNotYetImportedActivity() {
|
||||
|
|
@ -132,7 +207,7 @@ class KomootImportServiceTest {
|
|||
|
||||
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"))
|
||||
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("""
|
||||
|
|
@ -190,7 +265,7 @@ class KomootImportServiceTest {
|
|||
when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
KomootImportExecutionResponse response = service.importFirstNewActivity(
|
||||
new KomootImportRequest("user@example.com", "secret", "123456"),
|
||||
new KomootImportRequest("user@example.com", "secret", "123456", null, null),
|
||||
userId
|
||||
);
|
||||
|
||||
|
|
@ -206,6 +281,91 @@ class KomootImportServiceTest {
|
|||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should respect date range when choosing Komoot import candidate")
|
||||
void shouldRespectDateRangeWhenImportingFirstNewActivity() {
|
||||
String authHeader = "Basic " + Base64.getEncoder()
|
||||
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
|
||||
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||
UUID importedActivityId = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd");
|
||||
|
||||
when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of());
|
||||
|
||||
server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&start_date=2026-04-26T22:00:00.000Z&end_date=2026-04-27T21:59:59.999Z"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||
.andRespond(withSuccess("""
|
||||
{
|
||||
"_embedded": {
|
||||
"tours": [
|
||||
{
|
||||
"id": 3002,
|
||||
"name": "Inside Range Candidate",
|
||||
"sport": "mtb_easy",
|
||||
"status": "public",
|
||||
"type": "tour_recorded",
|
||||
"date": "2026-04-27T18:15:00+02:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
"_links": {}
|
||||
}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
|
||||
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/3002?hl=en"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||
.andRespond(withSuccess("""
|
||||
{
|
||||
"id": "3002",
|
||||
"name": "Inside Range Candidate",
|
||||
"description": "Imported from Komoot",
|
||||
"status": "public",
|
||||
"sport": "mtb_easy"
|
||||
}
|
||||
""", MediaType.APPLICATION_JSON));
|
||||
|
||||
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/3002.gpx"))
|
||||
.andExpect(method(HttpMethod.GET))
|
||||
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
||||
.andRespond(withSuccess("""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gpx version="1.1" creator="komoot">
|
||||
<trk><name>Inside Range Candidate</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",
|
||||
LocalDate.of(2026, 4, 27),
|
||||
LocalDate.of(2026, 4, 27)
|
||||
),
|
||||
userId
|
||||
);
|
||||
|
||||
assertThat(response.importedKomootActivityId()).isEqualTo(3002L);
|
||||
assertThat(importedActivity.getKomootActivityId()).isEqualTo(3002L);
|
||||
|
||||
verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId);
|
||||
server.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should fall back to OTHER when Komoot sport cannot be mapped")
|
||||
void shouldFallbackToOtherForUnknownKomootSport() {
|
||||
|
|
@ -216,7 +376,7 @@ class KomootImportServiceTest {
|
|||
|
||||
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"))
|
||||
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("""
|
||||
|
|
@ -274,7 +434,7 @@ class KomootImportServiceTest {
|
|||
when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
KomootImportExecutionResponse response = service.importFirstNewActivity(
|
||||
new KomootImportRequest("user@example.com", "secret", "123456"),
|
||||
new KomootImportRequest("user@example.com", "secret", "123456", null, null),
|
||||
userId
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue