feat(komoot): import listed activities sequentially with per-row status updates
Signed-off-by: Marcus Fihlon <marcus@fihlon.swiss>
This commit is contained in:
parent
6d89426584
commit
0387ca01e3
10 changed files with 336 additions and 240 deletions
|
|
@ -3,6 +3,7 @@ package net.javahippie.fitpub.controller;
|
||||||
import jakarta.validation.Valid;
|
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.KomootActivityImportRequest;
|
||||||
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.KomootImportExecutionResponse;
|
||||||
import net.javahippie.fitpub.model.dto.KomootImportRequest;
|
import net.javahippie.fitpub.model.dto.KomootImportRequest;
|
||||||
|
|
@ -47,19 +48,19 @@ public class KomootImportController {
|
||||||
return ResponseEntity.ok(response);
|
return ResponseEntity.ok(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/activities/import-first")
|
@PostMapping("/activities/import")
|
||||||
public ResponseEntity<KomootImportExecutionResponse> importFirstNewActivity(
|
public ResponseEntity<KomootImportExecutionResponse> importActivity(
|
||||||
@Valid @RequestBody KomootImportRequest request,
|
@Valid @RequestBody KomootActivityImportRequest request,
|
||||||
Authentication authentication
|
Authentication authentication
|
||||||
) {
|
) {
|
||||||
UUID fitPubUserId = userRepository.findByUsername(authentication.getName())
|
UUID fitPubUserId = userRepository.findByUsername(authentication.getName())
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Authenticated user not found"))
|
.orElseThrow(() -> new IllegalArgumentException("Authenticated user not found"))
|
||||||
.getId();
|
.getId();
|
||||||
|
|
||||||
log.info("User {} requested Komoot import for the first new activity",
|
log.info("User {} requested Komoot import for activity {}",
|
||||||
authentication.getName());
|
authentication.getName(), request.activityId());
|
||||||
|
|
||||||
KomootImportExecutionResponse response = komootImportService.importFirstNewActivity(
|
KomootImportExecutionResponse response = komootImportService.importActivity(
|
||||||
request,
|
request,
|
||||||
fitPubUserId
|
fitPubUserId
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package net.javahippie.fitpub.model.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request payload for importing one specific Komoot activity.
|
||||||
|
*
|
||||||
|
* <p>The password is only used for the current request and is never persisted.</p>
|
||||||
|
*/
|
||||||
|
public record KomootActivityImportRequest(
|
||||||
|
@NotBlank
|
||||||
|
@Email
|
||||||
|
String email,
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
String password,
|
||||||
|
|
||||||
|
@NotBlank
|
||||||
|
@Pattern(regexp = "\\d+", message = "Komoot user ID must contain digits only")
|
||||||
|
String userId,
|
||||||
|
|
||||||
|
@NotNull
|
||||||
|
Long activityId
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
package net.javahippie.fitpub.model.dto;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response for importing exactly one Komoot activity into FitPub.
|
||||||
|
*/
|
||||||
|
public record KomootImportExecutionResponse(
|
||||||
|
UUID importedActivityId,
|
||||||
|
Long importedKomootActivityId,
|
||||||
|
String status,
|
||||||
|
String message
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,11 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
|
||||||
"WHERE a.userId = :userId AND a.komootActivityId IS NOT NULL")
|
"WHERE a.userId = :userId AND a.komootActivityId IS NOT NULL")
|
||||||
List<Long> findImportedKomootActivityIdsByUserId(@Param("userId") UUID userId);
|
List<Long> findImportedKomootActivityIdsByUserId(@Param("userId") UUID userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a previously imported Komoot activity for the given user.
|
||||||
|
*/
|
||||||
|
Optional<Activity> findByUserIdAndKomootActivityId(UUID userId, Long komootActivityId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find all activities for a specific user.
|
* Find all activities for a specific user.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import net.javahippie.fitpub.repository.ActivityRepository;
|
||||||
import net.javahippie.fitpub.repository.ActivitySummaryRepository;
|
import net.javahippie.fitpub.repository.ActivitySummaryRepository;
|
||||||
import net.javahippie.fitpub.repository.AchievementRepository;
|
import net.javahippie.fitpub.repository.AchievementRepository;
|
||||||
import net.javahippie.fitpub.repository.PersonalRecordRepository;
|
import net.javahippie.fitpub.repository.PersonalRecordRepository;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
@ -67,17 +68,7 @@ public class ActivitySummaryService {
|
||||||
LocalDate weekStart = date.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY));
|
LocalDate weekStart = date.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY));
|
||||||
LocalDate weekEnd = weekStart.plusDays(6);
|
LocalDate weekEnd = weekStart.plusDays(6);
|
||||||
|
|
||||||
ActivitySummary summary = activitySummaryRepository
|
saveSummaryWithRetry(userId, ActivitySummary.PeriodType.WEEK, weekStart, weekEnd);
|
||||||
.findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.WEEK, weekStart)
|
|
||||||
.orElse(ActivitySummary.builder()
|
|
||||||
.userId(userId)
|
|
||||||
.periodType(ActivitySummary.PeriodType.WEEK)
|
|
||||||
.periodStart(weekStart)
|
|
||||||
.periodEnd(weekEnd)
|
|
||||||
.build());
|
|
||||||
|
|
||||||
calculateAndUpdateSummary(summary, userId, weekStart.atStartOfDay(), weekEnd.plusDays(1).atStartOfDay());
|
|
||||||
activitySummaryRepository.save(summary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -88,17 +79,7 @@ public class ActivitySummaryService {
|
||||||
LocalDate monthStart = date.with(TemporalAdjusters.firstDayOfMonth());
|
LocalDate monthStart = date.with(TemporalAdjusters.firstDayOfMonth());
|
||||||
LocalDate monthEnd = date.with(TemporalAdjusters.lastDayOfMonth());
|
LocalDate monthEnd = date.with(TemporalAdjusters.lastDayOfMonth());
|
||||||
|
|
||||||
ActivitySummary summary = activitySummaryRepository
|
saveSummaryWithRetry(userId, ActivitySummary.PeriodType.MONTH, monthStart, monthEnd);
|
||||||
.findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.MONTH, monthStart)
|
|
||||||
.orElse(ActivitySummary.builder()
|
|
||||||
.userId(userId)
|
|
||||||
.periodType(ActivitySummary.PeriodType.MONTH)
|
|
||||||
.periodStart(monthStart)
|
|
||||||
.periodEnd(monthEnd)
|
|
||||||
.build());
|
|
||||||
|
|
||||||
calculateAndUpdateSummary(summary, userId, monthStart.atStartOfDay(), monthEnd.plusDays(1).atStartOfDay());
|
|
||||||
activitySummaryRepository.save(summary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -109,17 +90,46 @@ public class ActivitySummaryService {
|
||||||
LocalDate yearStart = date.with(TemporalAdjusters.firstDayOfYear());
|
LocalDate yearStart = date.with(TemporalAdjusters.firstDayOfYear());
|
||||||
LocalDate yearEnd = date.with(TemporalAdjusters.lastDayOfYear());
|
LocalDate yearEnd = date.with(TemporalAdjusters.lastDayOfYear());
|
||||||
|
|
||||||
|
saveSummaryWithRetry(userId, ActivitySummary.PeriodType.YEAR, yearStart, yearEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveSummaryWithRetry(
|
||||||
|
UUID userId,
|
||||||
|
ActivitySummary.PeriodType periodType,
|
||||||
|
LocalDate periodStart,
|
||||||
|
LocalDate periodEnd
|
||||||
|
) {
|
||||||
ActivitySummary summary = activitySummaryRepository
|
ActivitySummary summary = activitySummaryRepository
|
||||||
.findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.YEAR, yearStart)
|
.findByUserIdAndPeriodTypeAndPeriodStart(userId, periodType, periodStart)
|
||||||
.orElse(ActivitySummary.builder()
|
.orElse(ActivitySummary.builder()
|
||||||
.userId(userId)
|
.userId(userId)
|
||||||
.periodType(ActivitySummary.PeriodType.YEAR)
|
.periodType(periodType)
|
||||||
.periodStart(yearStart)
|
.periodStart(periodStart)
|
||||||
.periodEnd(yearEnd)
|
.periodEnd(periodEnd)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
calculateAndUpdateSummary(summary, userId, yearStart.atStartOfDay(), yearEnd.plusDays(1).atStartOfDay());
|
LocalDateTime startDateTime = periodStart.atStartOfDay();
|
||||||
activitySummaryRepository.save(summary);
|
LocalDateTime endDateTime = periodEnd.plusDays(1).atStartOfDay();
|
||||||
|
|
||||||
|
calculateAndUpdateSummary(summary, userId, startDateTime, endDateTime);
|
||||||
|
|
||||||
|
try {
|
||||||
|
activitySummaryRepository.save(summary);
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
log.debug(
|
||||||
|
"Summary already created concurrently for user {}, period {} starting {}. Retrying as update.",
|
||||||
|
userId,
|
||||||
|
periodType,
|
||||||
|
periodStart
|
||||||
|
);
|
||||||
|
|
||||||
|
ActivitySummary existingSummary = activitySummaryRepository
|
||||||
|
.findByUserIdAndPeriodTypeAndPeriodStart(userId, periodType, periodStart)
|
||||||
|
.orElseThrow(() -> e);
|
||||||
|
|
||||||
|
calculateAndUpdateSummary(existingSummary, userId, startDateTime, endDateTime);
|
||||||
|
activitySummaryRepository.save(existingSummary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package net.javahippie.fitpub.service;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import net.javahippie.fitpub.model.dto.KomootActivityImportRequest;
|
||||||
import net.javahippie.fitpub.model.dto.KomootActivitiesResponse;
|
import net.javahippie.fitpub.model.dto.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.KomootImportExecutionResponse;
|
||||||
|
|
@ -32,7 +33,6 @@ import java.time.ZoneOffset;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
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.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
@ -95,31 +95,28 @@ 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) {
|
public KomootImportExecutionResponse importActivity(KomootActivityImportRequest request, UUID fitPubUserId) {
|
||||||
ImportCandidateContext context = buildImportCandidateContext(request, fitPubUserId);
|
if (activityRepository.findByUserIdAndKomootActivityId(fitPubUserId, request.activityId()).isPresent()) {
|
||||||
if (context.candidate() == null) {
|
|
||||||
return new KomootImportExecutionResponse(
|
return new KomootImportExecutionResponse(
|
||||||
null,
|
null,
|
||||||
null,
|
request.activityId(),
|
||||||
context.importedKomootActivityIds().size(),
|
"SKIPPED_ALREADY_IMPORTED",
|
||||||
context.activities().size(),
|
"Komoot activity " + request.activityId() + " was already imported."
|
||||||
"No new Komoot activities found to import."
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
KomootActivitySummaryDTO candidate = context.candidate();
|
JsonNode details = fetchActivityDetails(request.email(), request.password(), request.activityId());
|
||||||
JsonNode details = fetchActivityDetails(request, candidate.id());
|
byte[] gpxData = fetchActivityGpx(request.email(), request.password(), request.activityId());
|
||||||
byte[] gpxData = fetchActivityGpx(request, candidate.id());
|
|
||||||
|
|
||||||
ByteArrayMultipartFile gpxFile = new ByteArrayMultipartFile(
|
ByteArrayMultipartFile gpxFile = new ByteArrayMultipartFile(
|
||||||
"file",
|
"file",
|
||||||
"komoot-" + candidate.id() + ".gpx",
|
"komoot-" + request.activityId() + ".gpx",
|
||||||
"application/gpx+xml",
|
"application/gpx+xml",
|
||||||
gpxData
|
gpxData
|
||||||
);
|
);
|
||||||
|
|
||||||
Activity.Visibility mappedVisibility = mapVisibility(nullableText(details, "status"));
|
Activity.Visibility mappedVisibility = mapVisibility(nullableText(details, "status"));
|
||||||
String mappedTitle = firstNonBlank(nullableText(details, "name"), candidate.name(), "Komoot Activity " + candidate.id());
|
String mappedTitle = firstNonBlank(nullableText(details, "name"), null, "Komoot Activity " + request.activityId());
|
||||||
String mappedDescription = nullableText(details, "description");
|
String mappedDescription = nullableText(details, "description");
|
||||||
Activity.ActivityType mappedActivityType = mapKomootSportToActivityType(nullableText(details, "sport"));
|
Activity.ActivityType mappedActivityType = mapKomootSportToActivityType(nullableText(details, "sport"));
|
||||||
|
|
||||||
|
|
@ -131,7 +128,7 @@ public class KomootImportService {
|
||||||
mappedVisibility
|
mappedVisibility
|
||||||
);
|
);
|
||||||
|
|
||||||
importedActivity.setKomootActivityId(candidate.id());
|
importedActivity.setKomootActivityId(request.activityId());
|
||||||
importedActivity.setTitle(mappedTitle);
|
importedActivity.setTitle(mappedTitle);
|
||||||
importedActivity.setDescription(mappedDescription);
|
importedActivity.setDescription(mappedDescription);
|
||||||
importedActivity.setVisibility(mappedVisibility);
|
importedActivity.setVisibility(mappedVisibility);
|
||||||
|
|
@ -142,7 +139,7 @@ public class KomootImportService {
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
"Imported Komoot activity {} into FitPub activity {} with visibility {} and type {}",
|
"Imported Komoot activity {} into FitPub activity {} with visibility {} and type {}",
|
||||||
candidate.id(),
|
request.activityId(),
|
||||||
importedActivity.getId(),
|
importedActivity.getId(),
|
||||||
importedActivity.getVisibility(),
|
importedActivity.getVisibility(),
|
||||||
importedActivity.getActivityType()
|
importedActivity.getActivityType()
|
||||||
|
|
@ -150,10 +147,9 @@ public class KomootImportService {
|
||||||
|
|
||||||
return new KomootImportExecutionResponse(
|
return new KomootImportExecutionResponse(
|
||||||
importedActivity.getId(),
|
importedActivity.getId(),
|
||||||
candidate.id(),
|
request.activityId(),
|
||||||
context.importedKomootActivityIds().size() + 1,
|
"IMPORTED",
|
||||||
context.activities().size(),
|
"Imported Komoot activity " + request.activityId() + " into FitPub activity " + importedActivity.getId()
|
||||||
"Imported Komoot activity " + candidate.id() + " into FitPub activity " + importedActivity.getId()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,12 +246,12 @@ public class KomootImportService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private JsonNode fetchActivityDetails(KomootImportRequest request, long activityId) {
|
private JsonNode fetchActivityDetails(String email, String password, long activityId) {
|
||||||
try {
|
try {
|
||||||
ResponseEntity<JsonNode> response = restTemplate.exchange(
|
ResponseEntity<JsonNode> response = restTemplate.exchange(
|
||||||
buildDetailUri(activityId),
|
buildDetailUri(activityId),
|
||||||
HttpMethod.GET,
|
HttpMethod.GET,
|
||||||
new HttpEntity<>(buildHeaders(request.email(), request.password())),
|
new HttpEntity<>(buildHeaders(email, password)),
|
||||||
JsonNode.class
|
JsonNode.class
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -273,8 +269,8 @@ public class KomootImportService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private byte[] fetchActivityGpx(KomootImportRequest request, long activityId) {
|
private byte[] fetchActivityGpx(String email, String password, long activityId) {
|
||||||
HttpEntity<Void> httpEntity = new HttpEntity<>(buildGpxHeaders(request.email(), request.password()));
|
HttpEntity<Void> httpEntity = new HttpEntity<>(buildGpxHeaders(email, password));
|
||||||
List<URI> candidateUris = buildGpxCandidateUris(activityId);
|
List<URI> candidateUris = buildGpxCandidateUris(activityId);
|
||||||
Exception lastException = null;
|
Exception lastException = null;
|
||||||
|
|
||||||
|
|
@ -313,24 +309,6 @@ public class KomootImportService {
|
||||||
throw new IllegalStateException("Failed to download GPX from Komoot for activity " + activityId, lastException);
|
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, fitPubUserId).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 String formatKomootStartDate(LocalDate localDate) {
|
private String formatKomootStartDate(LocalDate localDate) {
|
||||||
return localDate.atStartOfDay(ZoneId.systemDefault())
|
return localDate.atStartOfDay(ZoneId.systemDefault())
|
||||||
.toInstant()
|
.toInstant()
|
||||||
|
|
@ -396,13 +374,6 @@ public class KomootImportService {
|
||||||
return fallback;
|
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()) {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="alert alert-secondary">
|
<div class="alert alert-secondary">
|
||||||
<div class="fw-semibold mb-1">Komoot Import</div>
|
<div class="fw-semibold mb-1">Important</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>
|
||||||
|
|
@ -63,15 +63,6 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 d-flex justify-content-end gap-2 flex-wrap">
|
<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 Komoot Activities
|
<i class="bi bi-arrow-repeat"></i> Load Komoot Activities
|
||||||
|
|
@ -81,6 +72,15 @@
|
||||||
Loading...
|
Loading...
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn btn-success" id="importFirstBtn" disabled>
|
||||||
|
<span id="importFirstText">
|
||||||
|
<i class="bi bi-download"></i> Import Listed Activities
|
||||||
|
</span>
|
||||||
|
<span id="importFirstSpinner" class="d-none">
|
||||||
|
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||||
|
Importing...
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -130,6 +130,11 @@
|
||||||
const importFirstBtn = document.getElementById('importFirstBtn');
|
const importFirstBtn = document.getElementById('importFirstBtn');
|
||||||
const importFirstText = document.getElementById('importFirstText');
|
const importFirstText = document.getElementById('importFirstText');
|
||||||
const importFirstSpinner = document.getElementById('importFirstSpinner');
|
const importFirstSpinner = document.getElementById('importFirstSpinner');
|
||||||
|
let currentActivities = [];
|
||||||
|
|
||||||
|
function updateImportButtonState() {
|
||||||
|
importFirstBtn.disabled = currentActivities.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
function setLoading(loading) {
|
function setLoading(loading) {
|
||||||
loadActivitiesBtn.disabled = loading;
|
loadActivitiesBtn.disabled = loading;
|
||||||
|
|
@ -216,6 +221,23 @@
|
||||||
return `<span class="activity-type-badge activity-type-${escapeHtml(normalizedType)}">${escapeHtml(activityType)}</span>`;
|
return `<span class="activity-type-badge activity-type-${escapeHtml(normalizedType)}">${escapeHtml(activityType)}</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderImportStatus(activity) {
|
||||||
|
if (activity.uiImportStatus === 'importing') {
|
||||||
|
return '<span class="spinner-border spinner-border-sm text-primary" role="status" aria-label="Importing"></span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity.uiImportStatus === 'error') {
|
||||||
|
const title = escapeHtml(activity.uiImportError || 'Import failed');
|
||||||
|
return `<i class="bi bi-exclamation-circle-fill text-danger" title="${title}" aria-label="${title}"></i>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity.imported) {
|
||||||
|
return '<i class="bi bi-check-circle-fill text-success" title="Already imported" aria-label="Already imported"></i>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<i class="bi bi-plus-circle text-muted" title="New activity" aria-label="New activity"></i>';
|
||||||
|
}
|
||||||
|
|
||||||
function renderActivities(activities) {
|
function renderActivities(activities) {
|
||||||
resultCount.textContent = activities.length;
|
resultCount.textContent = activities.length;
|
||||||
|
|
||||||
|
|
@ -233,11 +255,7 @@
|
||||||
<td>${formatDistance(activity.distanceMeters)}</td>
|
<td>${formatDistance(activity.distanceMeters)}</td>
|
||||||
<td>${formatDuration(activity.timeInMotionSeconds || activity.durationSeconds)}</td>
|
<td>${formatDuration(activity.timeInMotionSeconds || activity.durationSeconds)}</td>
|
||||||
<td>${formatElevation(activity.elevationUp)}</td>
|
<td>${formatElevation(activity.elevationUp)}</td>
|
||||||
<td class="text-center">
|
<td class="text-center">${renderImportStatus(activity)}</td>
|
||||||
${activity.imported
|
|
||||||
? '<i class="bi bi-check-circle-fill text-success" title="Already imported" aria-label="Already imported"></i>'
|
|
||||||
: '<i class="bi bi-plus-circle text-muted" title="New activity" aria-label="New activity"></i>'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
|
|
@ -257,6 +275,16 @@
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildImportPayload(activityId) {
|
||||||
|
const payload = buildPayload();
|
||||||
|
return {
|
||||||
|
email: payload.email,
|
||||||
|
password: payload.password,
|
||||||
|
userId: payload.userId,
|
||||||
|
activityId: activityId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function hasIncompleteDateRange(payload) {
|
function hasIncompleteDateRange(payload) {
|
||||||
return Boolean(payload.startDate) !== Boolean(payload.endDate);
|
return Boolean(payload.startDate) !== Boolean(payload.endDate);
|
||||||
}
|
}
|
||||||
|
|
@ -274,6 +302,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
currentActivities = [];
|
||||||
|
updateImportButtonState();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities', {
|
const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities', {
|
||||||
|
|
@ -293,8 +323,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
renderActivities(data.activities || []);
|
currentActivities = (data.activities || []).map(activity => ({
|
||||||
form.querySelector('#password').value = '';
|
...activity,
|
||||||
|
uiImportStatus: null,
|
||||||
|
uiImportError: null
|
||||||
|
}));
|
||||||
|
updateImportButtonState();
|
||||||
|
renderActivities(currentActivities);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
let message = error instanceof Error ? error.message : 'Failed to load Komoot activities.';
|
let message = error instanceof Error ? error.message : 'Failed to load Komoot activities.';
|
||||||
|
|
||||||
|
|
@ -317,29 +352,66 @@
|
||||||
showError('Start date and end date must either both be set or both be empty.');
|
showError('Start date and end date must either both be set or both be empty.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (currentActivities.length === 0) {
|
||||||
|
showError('Load Komoot activities before starting the import.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setImportLoading(true);
|
setImportLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities/import-first', {
|
const activitiesToImport = currentActivities
|
||||||
method: 'POST',
|
.filter(activity => !activity.imported)
|
||||||
body: payload
|
.sort((left, right) => new Date(left.date).getTime() - new Date(right.date).getTime());
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (activitiesToImport.length === 0) {
|
||||||
let message = 'Failed to import Komoot activity.';
|
showStatus('All listed Komoot activities are already imported.');
|
||||||
try {
|
return;
|
||||||
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();
|
let importedCount = 0;
|
||||||
showStatus(data.message || 'Komoot activity imported.');
|
let failedCount = 0;
|
||||||
|
|
||||||
|
for (const activity of activitiesToImport) {
|
||||||
|
activity.uiImportStatus = 'importing';
|
||||||
|
activity.uiImportError = null;
|
||||||
|
renderActivities(currentActivities);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await FitPubAuth.authenticatedFetch('/api/komoot-import/activities/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: buildImportPayload(activity.id)
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
activity.imported = data.status === 'IMPORTED' || data.status === 'SKIPPED_ALREADY_IMPORTED';
|
||||||
|
activity.uiImportStatus = activity.imported ? 'imported' : null;
|
||||||
|
activity.uiImportError = null;
|
||||||
|
if (data.status === 'IMPORTED') {
|
||||||
|
importedCount += 1;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failedCount += 1;
|
||||||
|
activity.uiImportStatus = 'error';
|
||||||
|
activity.uiImportError = error instanceof Error ? error.message : 'Failed to import Komoot activity.';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderActivities(currentActivities);
|
||||||
|
}
|
||||||
|
|
||||||
|
showStatus(`Imported ${importedCount} Komoot activit${importedCount === 1 ? 'y' : 'ies'}${failedCount > 0 ? `, ${failedCount} failed` : ''}.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
let message = error instanceof Error ? error.message : 'Failed to import Komoot activity.';
|
let message = error instanceof Error ? error.message : 'Failed to import Komoot activities.';
|
||||||
|
|
||||||
if (error instanceof Error && error.message === 'Authentication failed') {
|
if (error instanceof Error && error.message === 'Authentication failed') {
|
||||||
return;
|
return;
|
||||||
|
|
@ -350,6 +422,8 @@
|
||||||
setImportLoading(false);
|
setImportLoading(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateImportButtonState();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</th:block>
|
</th:block>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package net.javahippie.fitpub.service;
|
||||||
|
|
||||||
|
import net.javahippie.fitpub.model.entity.Activity;
|
||||||
|
import net.javahippie.fitpub.model.entity.ActivitySummary;
|
||||||
|
import net.javahippie.fitpub.repository.ActivityRepository;
|
||||||
|
import net.javahippie.fitpub.repository.ActivitySummaryRepository;
|
||||||
|
import net.javahippie.fitpub.repository.AchievementRepository;
|
||||||
|
import net.javahippie.fitpub.repository.PersonalRecordRepository;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class ActivitySummaryServiceTest {
|
||||||
|
|
||||||
|
private ActivitySummaryRepository activitySummaryRepository;
|
||||||
|
private ActivityRepository activityRepository;
|
||||||
|
private PersonalRecordRepository personalRecordRepository;
|
||||||
|
private AchievementRepository achievementRepository;
|
||||||
|
private ActivitySummaryService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
activitySummaryRepository = mock(ActivitySummaryRepository.class);
|
||||||
|
activityRepository = mock(ActivityRepository.class);
|
||||||
|
personalRecordRepository = mock(PersonalRecordRepository.class);
|
||||||
|
achievementRepository = mock(AchievementRepository.class);
|
||||||
|
service = new ActivitySummaryService(
|
||||||
|
activitySummaryRepository,
|
||||||
|
activityRepository,
|
||||||
|
personalRecordRepository,
|
||||||
|
achievementRepository
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should retry summary save as update when concurrent insert hits unique constraint")
|
||||||
|
void shouldRetrySummarySaveAfterConcurrentInsert() {
|
||||||
|
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||||
|
LocalDate date = LocalDate.of(2025, 10, 8);
|
||||||
|
LocalDate weekStart = LocalDate.of(2025, 10, 6);
|
||||||
|
LocalDate weekEnd = LocalDate.of(2025, 10, 12);
|
||||||
|
|
||||||
|
ActivitySummary existingSummary = ActivitySummary.builder()
|
||||||
|
.id(UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"))
|
||||||
|
.userId(userId)
|
||||||
|
.periodType(ActivitySummary.PeriodType.WEEK)
|
||||||
|
.periodStart(weekStart)
|
||||||
|
.periodEnd(weekEnd)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
when(activitySummaryRepository.findByUserIdAndPeriodTypeAndPeriodStart(
|
||||||
|
userId, ActivitySummary.PeriodType.WEEK, weekStart
|
||||||
|
)).thenReturn(Optional.empty(), Optional.of(existingSummary));
|
||||||
|
|
||||||
|
when(activityRepository.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc(
|
||||||
|
eq(userId),
|
||||||
|
eq(weekStart.atStartOfDay()),
|
||||||
|
eq(weekEnd.plusDays(1).atStartOfDay())
|
||||||
|
)).thenReturn(List.of(
|
||||||
|
Activity.builder()
|
||||||
|
.userId(userId)
|
||||||
|
.activityType(Activity.ActivityType.RIDE)
|
||||||
|
.startedAt(LocalDateTime.of(2025, 10, 8, 9, 0))
|
||||||
|
.totalDurationSeconds(3600L)
|
||||||
|
.build()
|
||||||
|
));
|
||||||
|
|
||||||
|
when(personalRecordRepository.countByUserIdAndDateRange(any(), any(), any())).thenReturn(0L);
|
||||||
|
when(achievementRepository.countByUserIdAndDateRange(any(), any(), any())).thenReturn(0L);
|
||||||
|
|
||||||
|
when(activitySummaryRepository.save(any(ActivitySummary.class)))
|
||||||
|
.thenThrow(new DataIntegrityViolationException("duplicate"))
|
||||||
|
.thenReturn(existingSummary);
|
||||||
|
|
||||||
|
service.updateWeeklySummary(userId, date);
|
||||||
|
|
||||||
|
verify(activitySummaryRepository, times(2))
|
||||||
|
.findByUserIdAndPeriodTypeAndPeriodStart(userId, ActivitySummary.PeriodType.WEEK, weekStart);
|
||||||
|
verify(activitySummaryRepository, times(2)).save(any(ActivitySummary.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package net.javahippie.fitpub.service;
|
package net.javahippie.fitpub.service;
|
||||||
|
|
||||||
|
import net.javahippie.fitpub.model.dto.KomootActivityImportRequest;
|
||||||
import net.javahippie.fitpub.model.dto.KomootActivitiesResponse;
|
import net.javahippie.fitpub.model.dto.KomootActivitiesResponse;
|
||||||
import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse;
|
import net.javahippie.fitpub.model.dto.KomootImportExecutionResponse;
|
||||||
import net.javahippie.fitpub.model.dto.KomootImportRequest;
|
import net.javahippie.fitpub.model.dto.KomootImportRequest;
|
||||||
|
|
@ -20,6 +21,7 @@ import java.nio.charset.StandardCharsets;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.TimeZone;
|
import java.util.TimeZone;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
|
@ -210,35 +212,14 @@ class KomootImportServiceTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Should import newest not-yet-imported Komoot activity via GPX and override metadata")
|
@DisplayName("Should import a specific Komoot activity via GPX and override metadata")
|
||||||
void shouldImportNewestNotYetImportedActivity() {
|
void shouldImportSpecificKomootActivity() {
|
||||||
String authHeader = "Basic " + Base64.getEncoder()
|
String authHeader = "Basic " + Base64.getEncoder()
|
||||||
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
|
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
|
||||||
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||||
UUID importedActivityId = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
UUID importedActivityId = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
||||||
|
|
||||||
when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of());
|
when(activityRepository.findByUserIdAndKomootActivityId(userId, 2880957035L)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&status=private&name=&hl=en&page=0"))
|
|
||||||
.andExpect(method(HttpMethod.GET))
|
|
||||||
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
|
||||||
.andRespond(withSuccess("""
|
|
||||||
{
|
|
||||||
"_embedded": {
|
|
||||||
"tours": [
|
|
||||||
{
|
|
||||||
"id": 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"))
|
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957035?hl=en"))
|
||||||
.andExpect(method(HttpMethod.GET))
|
.andExpect(method(HttpMethod.GET))
|
||||||
|
|
@ -276,13 +257,14 @@ class KomootImportServiceTest {
|
||||||
when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity);
|
when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity);
|
||||||
when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
KomootImportExecutionResponse response = service.importFirstNewActivity(
|
KomootImportExecutionResponse response = service.importActivity(
|
||||||
new KomootImportRequest("user@example.com", "secret", "123456", null, null),
|
new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957035L),
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
|
|
||||||
assertThat(response.importedActivityId()).isEqualTo(importedActivityId);
|
assertThat(response.importedActivityId()).isEqualTo(importedActivityId);
|
||||||
assertThat(response.importedKomootActivityId()).isEqualTo(2880957035L);
|
assertThat(response.importedKomootActivityId()).isEqualTo(2880957035L);
|
||||||
|
assertThat(response.status()).isEqualTo("IMPORTED");
|
||||||
assertThat(importedActivity.getKomootActivityId()).isEqualTo(2880957035L);
|
assertThat(importedActivity.getKomootActivityId()).isEqualTo(2880957035L);
|
||||||
assertThat(importedActivity.getTitle()).isEqualTo("Latest Ride");
|
assertThat(importedActivity.getTitle()).isEqualTo("Latest Ride");
|
||||||
assertThat(importedActivity.getDescription()).isEqualTo("Imported from Komoot");
|
assertThat(importedActivity.getDescription()).isEqualTo("Imported from Komoot");
|
||||||
|
|
@ -294,88 +276,23 @@ class KomootImportServiceTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("Should respect date range when choosing Komoot import candidate")
|
@DisplayName("Should skip already imported Komoot activity")
|
||||||
void shouldRespectDateRangeWhenImportingFirstNewActivity() {
|
void shouldSkipAlreadyImportedKomootActivity() {
|
||||||
String authHeader = "Basic " + Base64.getEncoder()
|
|
||||||
.encodeToString("user@example.com:secret".getBytes(StandardCharsets.UTF_8));
|
|
||||||
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||||
UUID importedActivityId = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd");
|
UUID existingActivityId = UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd");
|
||||||
|
|
||||||
when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of());
|
when(activityRepository.findByUserIdAndKomootActivityId(userId, 3002L)).thenReturn(
|
||||||
|
Optional.of(Activity.builder().id(existingActivityId).userId(userId).komootActivityId(3002L).build())
|
||||||
|
);
|
||||||
|
|
||||||
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"))
|
KomootImportExecutionResponse response = service.importActivity(
|
||||||
.andExpect(method(HttpMethod.GET))
|
new KomootActivityImportRequest("user@example.com", "secret", "123456", 3002L),
|
||||||
.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
|
userId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
assertThat(response.importedActivityId()).isNull();
|
||||||
assertThat(response.importedKomootActivityId()).isEqualTo(3002L);
|
assertThat(response.importedKomootActivityId()).isEqualTo(3002L);
|
||||||
assertThat(importedActivity.getKomootActivityId()).isEqualTo(3002L);
|
assertThat(response.status()).isEqualTo("SKIPPED_ALREADY_IMPORTED");
|
||||||
|
|
||||||
verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId);
|
|
||||||
server.verify();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -386,28 +303,7 @@ class KomootImportServiceTest {
|
||||||
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
UUID userId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
||||||
UUID importedActivityId = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc");
|
UUID importedActivityId = UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc");
|
||||||
|
|
||||||
when(activityRepository.findImportedKomootActivityIdsByUserId(userId)).thenReturn(List.of());
|
when(activityRepository.findByUserIdAndKomootActivityId(userId, 2880957036L)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
server.expect(once(), requestTo("https://www.komoot.com/api/v007/users/123456/tours/?type=tour_recorded&sort_field=date&sort_direction=desc&limit=100&status=private&name=&hl=en&page=0"))
|
|
||||||
.andExpect(method(HttpMethod.GET))
|
|
||||||
.andExpect(header(HttpHeaders.AUTHORIZATION, authHeader))
|
|
||||||
.andRespond(withSuccess("""
|
|
||||||
{
|
|
||||||
"_embedded": {
|
|
||||||
"tours": [
|
|
||||||
{
|
|
||||||
"id": 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"))
|
server.expect(once(), requestTo("https://www.komoot.com/api/v007/tours/2880957036?hl=en"))
|
||||||
.andExpect(method(HttpMethod.GET))
|
.andExpect(method(HttpMethod.GET))
|
||||||
|
|
@ -445,12 +341,13 @@ class KomootImportServiceTest {
|
||||||
when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity);
|
when(activityFileService.processActivityFile(any(), any(), any(), any(), any())).thenReturn(importedActivity);
|
||||||
when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
|
||||||
KomootImportExecutionResponse response = service.importFirstNewActivity(
|
KomootImportExecutionResponse response = service.importActivity(
|
||||||
new KomootImportRequest("user@example.com", "secret", "123456", null, null),
|
new KomootActivityImportRequest("user@example.com", "secret", "123456", 2880957036L),
|
||||||
userId
|
userId
|
||||||
);
|
);
|
||||||
|
|
||||||
assertThat(response.importedActivityId()).isEqualTo(importedActivityId);
|
assertThat(response.importedActivityId()).isEqualTo(importedActivityId);
|
||||||
|
assertThat(response.status()).isEqualTo("IMPORTED");
|
||||||
assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.OTHER);
|
assertThat(importedActivity.getActivityType()).isEqualTo(Activity.ActivityType.OTHER);
|
||||||
|
|
||||||
verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId);
|
verify(activityPostProcessingService).processActivityAsync(importedActivityId, userId);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
mock-maker-subclass
|
||||||
Loading…
Add table
Add a link
Reference in a new issue