Fit File Processing and Persistence
This commit is contained in:
commit
0bc4fb3118
24 changed files with 3533 additions and 0 deletions
|
|
@ -0,0 +1,15 @@
|
|||
package org.operaton.fitpub.exception;
|
||||
|
||||
/**
|
||||
* Exception thrown when FIT file processing fails.
|
||||
*/
|
||||
public class FitFileProcessingException extends RuntimeException {
|
||||
|
||||
public FitFileProcessingException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public FitFileProcessingException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
package org.operaton.fitpub.exception;
|
||||
|
||||
/**
|
||||
* Exception thrown when a FIT file is invalid or corrupted.
|
||||
*/
|
||||
public class InvalidFitFileException extends FitFileProcessingException {
|
||||
|
||||
public InvalidFitFileException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public InvalidFitFileException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
68
src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java
Normal file
68
src/main/java/org/operaton/fitpub/model/dto/ActivityDTO.java
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package org.operaton.fitpub.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* DTO for Activity data transfer.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ActivityDTO {
|
||||
|
||||
private UUID id;
|
||||
private UUID userId;
|
||||
private String activityType;
|
||||
private String title;
|
||||
private String description;
|
||||
private LocalDateTime startedAt;
|
||||
private LocalDateTime endedAt;
|
||||
private String visibility;
|
||||
private BigDecimal totalDistance;
|
||||
private Long totalDurationSeconds;
|
||||
private BigDecimal elevationGain;
|
||||
private BigDecimal elevationLoss;
|
||||
private ActivityMetricsDTO metrics;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* Creates a DTO from an Activity entity.
|
||||
*/
|
||||
public static ActivityDTO fromEntity(Activity activity) {
|
||||
ActivityDTOBuilder builder = ActivityDTO.builder()
|
||||
.id(activity.getId())
|
||||
.userId(activity.getUserId())
|
||||
.activityType(activity.getActivityType().name())
|
||||
.title(activity.getTitle())
|
||||
.description(activity.getDescription())
|
||||
.startedAt(activity.getStartedAt())
|
||||
.endedAt(activity.getEndedAt())
|
||||
.visibility(activity.getVisibility().name())
|
||||
.totalDistance(activity.getTotalDistance())
|
||||
.elevationGain(activity.getElevationGain())
|
||||
.elevationLoss(activity.getElevationLoss())
|
||||
.createdAt(activity.getCreatedAt())
|
||||
.updatedAt(activity.getUpdatedAt());
|
||||
|
||||
if (activity.getTotalDuration() != null) {
|
||||
builder.totalDurationSeconds(activity.getTotalDuration().getSeconds());
|
||||
}
|
||||
|
||||
if (activity.getMetrics() != null) {
|
||||
builder.metrics(ActivityMetricsDTO.fromEntity(activity.getMetrics()));
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
package org.operaton.fitpub.model.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.operaton.fitpub.model.entity.ActivityMetrics;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* DTO for ActivityMetrics data transfer.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ActivityMetricsDTO {
|
||||
|
||||
private BigDecimal averageSpeed;
|
||||
private BigDecimal maxSpeed;
|
||||
private Long averagePaceSeconds;
|
||||
private Integer averageHeartRate;
|
||||
private Integer maxHeartRate;
|
||||
private Integer averageCadence;
|
||||
private Integer maxCadence;
|
||||
private Integer averagePower;
|
||||
private Integer maxPower;
|
||||
private Integer normalizedPower;
|
||||
private Integer calories;
|
||||
private BigDecimal averageTemperature;
|
||||
private BigDecimal maxElevation;
|
||||
private BigDecimal minElevation;
|
||||
private BigDecimal totalAscent;
|
||||
private BigDecimal totalDescent;
|
||||
private Long movingTimeSeconds;
|
||||
private Long stoppedTimeSeconds;
|
||||
private Integer totalSteps;
|
||||
private BigDecimal trainingStressScore;
|
||||
|
||||
/**
|
||||
* Creates a DTO from an ActivityMetrics entity.
|
||||
*/
|
||||
public static ActivityMetricsDTO fromEntity(ActivityMetrics metrics) {
|
||||
ActivityMetricsDTOBuilder builder = ActivityMetricsDTO.builder()
|
||||
.averageSpeed(metrics.getAverageSpeed())
|
||||
.maxSpeed(metrics.getMaxSpeed())
|
||||
.averageHeartRate(metrics.getAverageHeartRate())
|
||||
.maxHeartRate(metrics.getMaxHeartRate())
|
||||
.averageCadence(metrics.getAverageCadence())
|
||||
.maxCadence(metrics.getMaxCadence())
|
||||
.averagePower(metrics.getAveragePower())
|
||||
.maxPower(metrics.getMaxPower())
|
||||
.normalizedPower(metrics.getNormalizedPower())
|
||||
.calories(metrics.getCalories())
|
||||
.averageTemperature(metrics.getAverageTemperature())
|
||||
.maxElevation(metrics.getMaxElevation())
|
||||
.minElevation(metrics.getMinElevation())
|
||||
.totalAscent(metrics.getTotalAscent())
|
||||
.totalDescent(metrics.getTotalDescent())
|
||||
.totalSteps(metrics.getTotalSteps())
|
||||
.trainingStressScore(metrics.getTrainingStressScore());
|
||||
|
||||
if (metrics.getAveragePace() != null) {
|
||||
builder.averagePaceSeconds(metrics.getAveragePace().getSeconds());
|
||||
}
|
||||
|
||||
if (metrics.getMovingTime() != null) {
|
||||
builder.movingTimeSeconds(metrics.getMovingTime().getSeconds());
|
||||
}
|
||||
|
||||
if (metrics.getStoppedTime() != null) {
|
||||
builder.stoppedTimeSeconds(metrics.getStoppedTime().getSeconds());
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
}
|
||||
150
src/main/java/org/operaton/fitpub/model/entity/Activity.java
Normal file
150
src/main/java/org/operaton/fitpub/model/entity/Activity.java
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package org.operaton.fitpub.model.entity;
|
||||
|
||||
import io.hypersistence.utils.hibernate.type.json.JsonBinaryType;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.hibernate.annotations.CreationTimestamp;
|
||||
import org.hibernate.annotations.Type;
|
||||
import org.hibernate.annotations.UpdateTimestamp;
|
||||
import org.locationtech.jts.geom.LineString;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Entity representing a fitness activity (workout).
|
||||
* Stores metadata, simplified track for map rendering, and full track data as JSONB.
|
||||
* This design optimizes for scalability by avoiding normalized track_points table.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "activities", indexes = {
|
||||
@Index(name = "idx_activity_user_id", columnList = "user_id"),
|
||||
@Index(name = "idx_activity_started_at", columnList = "started_at"),
|
||||
@Index(name = "idx_activity_type", columnList = "activity_type")
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Activity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private UUID userId;
|
||||
|
||||
@Column(name = "activity_type", nullable = false, length = 50)
|
||||
@Enumerated(EnumType.STRING)
|
||||
private ActivityType activityType;
|
||||
|
||||
@Column(nullable = false, length = 255)
|
||||
private String title;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String description;
|
||||
|
||||
@Column(name = "started_at", nullable = false)
|
||||
private LocalDateTime startedAt;
|
||||
|
||||
@Column(name = "ended_at", nullable = false)
|
||||
private LocalDateTime endedAt;
|
||||
|
||||
@Column(nullable = false, length = 20)
|
||||
@Enumerated(EnumType.STRING)
|
||||
private Visibility visibility;
|
||||
|
||||
/**
|
||||
* Simplified track for map rendering (50-200 points).
|
||||
* Uses Douglas-Peucker algorithm to reduce point count while maintaining shape.
|
||||
*/
|
||||
@Column(name = "simplified_track", columnDefinition = "geometry(LineString, 4326)")
|
||||
private LineString simplifiedTrack;
|
||||
|
||||
/**
|
||||
* Full track data stored as JSONB for detail view.
|
||||
* Contains all original track points with sensor data.
|
||||
* Much more efficient than normalized track_points table.
|
||||
*/
|
||||
@Type(JsonBinaryType.class)
|
||||
@Column(name = "track_points_json", columnDefinition = "jsonb")
|
||||
private String trackPointsJson;
|
||||
|
||||
@Column(name = "total_distance", precision = 10, scale = 2)
|
||||
private BigDecimal totalDistance;
|
||||
|
||||
@Column(name = "total_duration")
|
||||
private Duration totalDuration;
|
||||
|
||||
@Column(name = "elevation_gain", precision = 8, scale = 2)
|
||||
private BigDecimal elevationGain;
|
||||
|
||||
@Column(name = "elevation_loss", precision = 8, scale = 2)
|
||||
private BigDecimal elevationLoss;
|
||||
|
||||
/**
|
||||
* Original FIT file for re-processing if needed.
|
||||
* Allows us to re-parse with updated algorithms.
|
||||
*/
|
||||
@Column(name = "raw_fit_file")
|
||||
@Lob
|
||||
private byte[] rawFitFile;
|
||||
|
||||
@OneToOne(mappedBy = "activity", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private ActivityMetrics metrics;
|
||||
|
||||
@CreationTimestamp
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@UpdateTimestamp
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* Helper method to set metrics for this activity
|
||||
*/
|
||||
public void setMetrics(ActivityMetrics metrics) {
|
||||
this.metrics = metrics;
|
||||
if (metrics != null) {
|
||||
metrics.setActivity(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity types supported by the platform
|
||||
*/
|
||||
public enum ActivityType {
|
||||
RUN,
|
||||
RIDE,
|
||||
HIKE,
|
||||
WALK,
|
||||
SWIM,
|
||||
ALPINE_SKI,
|
||||
BACKCOUNTRY_SKI,
|
||||
NORDIC_SKI,
|
||||
SNOWBOARD,
|
||||
ROWING,
|
||||
KAYAKING,
|
||||
CANOEING,
|
||||
INLINE_SKATING,
|
||||
ROCK_CLIMBING,
|
||||
MOUNTAINEERING,
|
||||
YOGA,
|
||||
WORKOUT,
|
||||
OTHER
|
||||
}
|
||||
|
||||
/**
|
||||
* Visibility levels for activities
|
||||
*/
|
||||
public enum Visibility {
|
||||
PUBLIC,
|
||||
FOLLOWERS,
|
||||
PRIVATE
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
package org.operaton.fitpub.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Entity storing calculated metrics and statistics for an activity.
|
||||
* Includes average/max values, pace information, and other derived data.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "activity_metrics")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ActivityMetrics {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@OneToOne
|
||||
@JoinColumn(name = "activity_id", nullable = false)
|
||||
private Activity activity;
|
||||
|
||||
@Column(name = "average_speed", precision = 8, scale = 2)
|
||||
private BigDecimal averageSpeed;
|
||||
|
||||
@Column(name = "max_speed", precision = 8, scale = 2)
|
||||
private BigDecimal maxSpeed;
|
||||
|
||||
@Column(name = "average_pace")
|
||||
private Duration averagePace;
|
||||
|
||||
@Column(name = "average_heart_rate")
|
||||
private Integer averageHeartRate;
|
||||
|
||||
@Column(name = "max_heart_rate")
|
||||
private Integer maxHeartRate;
|
||||
|
||||
@Column(name = "average_cadence")
|
||||
private Integer averageCadence;
|
||||
|
||||
@Column(name = "max_cadence")
|
||||
private Integer maxCadence;
|
||||
|
||||
@Column(name = "average_power")
|
||||
private Integer averagePower;
|
||||
|
||||
@Column(name = "max_power")
|
||||
private Integer maxPower;
|
||||
|
||||
@Column(name = "normalized_power")
|
||||
private Integer normalizedPower;
|
||||
|
||||
@Column(name = "calories")
|
||||
private Integer calories;
|
||||
|
||||
@Column(name = "average_temperature", precision = 5, scale = 2)
|
||||
private BigDecimal averageTemperature;
|
||||
|
||||
@Column(name = "max_elevation", precision = 8, scale = 2)
|
||||
private BigDecimal maxElevation;
|
||||
|
||||
@Column(name = "min_elevation", precision = 8, scale = 2)
|
||||
private BigDecimal minElevation;
|
||||
|
||||
@Column(name = "total_ascent", precision = 8, scale = 2)
|
||||
private BigDecimal totalAscent;
|
||||
|
||||
@Column(name = "total_descent", precision = 8, scale = 2)
|
||||
private BigDecimal totalDescent;
|
||||
|
||||
@Column(name = "moving_time")
|
||||
private Duration movingTime;
|
||||
|
||||
@Column(name = "stopped_time")
|
||||
private Duration stoppedTime;
|
||||
|
||||
@Column(name = "total_steps")
|
||||
private Integer totalSteps;
|
||||
|
||||
@Column(name = "training_stress_score")
|
||||
private BigDecimal trainingStressScore;
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package org.operaton.fitpub.repository;
|
||||
|
||||
import org.operaton.fitpub.model.entity.ActivityMetrics;
|
||||
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.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Repository for ActivityMetrics entities.
|
||||
*/
|
||||
@Repository
|
||||
public interface ActivityMetricsRepository extends JpaRepository<ActivityMetrics, UUID> {
|
||||
|
||||
/**
|
||||
* Find metrics for a specific activity.
|
||||
*
|
||||
* @param activityId the activity ID
|
||||
* @return optional metrics
|
||||
*/
|
||||
@Query("SELECT am FROM ActivityMetrics am WHERE am.activity.id = :activityId")
|
||||
Optional<ActivityMetrics> findByActivityId(@Param("activityId") UUID activityId);
|
||||
|
||||
/**
|
||||
* Delete metrics for a specific activity.
|
||||
*
|
||||
* @param activityId the activity ID
|
||||
*/
|
||||
@Query("DELETE FROM ActivityMetrics am WHERE am.activity.id = :activityId")
|
||||
void deleteByActivityId(@Param("activityId") UUID activityId);
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
package org.operaton.fitpub.repository;
|
||||
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
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.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Repository for Activity entities.
|
||||
*/
|
||||
@Repository
|
||||
public interface ActivityRepository extends JpaRepository<Activity, UUID> {
|
||||
|
||||
/**
|
||||
* Find all activities for a specific user.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @return list of activities
|
||||
*/
|
||||
List<Activity> findByUserIdOrderByStartedAtDesc(UUID userId);
|
||||
|
||||
/**
|
||||
* Find all activities for a user within a date range.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @param startDate the start date
|
||||
* @param endDate the end date
|
||||
* @return list of activities
|
||||
*/
|
||||
List<Activity> findByUserIdAndStartedAtBetweenOrderByStartedAtDesc(
|
||||
UUID userId,
|
||||
LocalDateTime startDate,
|
||||
LocalDateTime endDate
|
||||
);
|
||||
|
||||
/**
|
||||
* Find all public activities for a user.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @return list of activities
|
||||
*/
|
||||
List<Activity> findByUserIdAndVisibilityOrderByStartedAtDesc(
|
||||
UUID userId,
|
||||
Activity.Visibility visibility
|
||||
);
|
||||
|
||||
/**
|
||||
* Find activities by type for a user.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @param activityType the activity type
|
||||
* @return list of activities
|
||||
*/
|
||||
List<Activity> findByUserIdAndActivityTypeOrderByStartedAtDesc(
|
||||
UUID userId,
|
||||
Activity.ActivityType activityType
|
||||
);
|
||||
|
||||
/**
|
||||
* Count activities for a user.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @return count of activities
|
||||
*/
|
||||
long countByUserId(UUID userId);
|
||||
|
||||
/**
|
||||
* Find an activity by ID and user ID.
|
||||
*
|
||||
* @param id the activity ID
|
||||
* @param userId the user ID
|
||||
* @return optional activity
|
||||
*/
|
||||
Optional<Activity> findByIdAndUserId(UUID id, UUID userId);
|
||||
|
||||
/**
|
||||
* Delete all activities for a user.
|
||||
*
|
||||
* @param userId the user ID
|
||||
*/
|
||||
void deleteByUserId(UUID userId);
|
||||
}
|
||||
349
src/main/java/org/operaton/fitpub/service/FitFileService.java
Normal file
349
src/main/java/org/operaton/fitpub/service/FitFileService.java
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
package org.operaton.fitpub.service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.locationtech.jts.geom.Coordinate;
|
||||
import org.locationtech.jts.geom.GeometryFactory;
|
||||
import org.locationtech.jts.geom.LineString;
|
||||
import org.locationtech.jts.geom.PrecisionModel;
|
||||
import org.operaton.fitpub.exception.FitFileProcessingException;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.model.entity.ActivityMetrics;
|
||||
import org.operaton.fitpub.repository.ActivityMetricsRepository;
|
||||
import org.operaton.fitpub.repository.ActivityRepository;
|
||||
import org.operaton.fitpub.util.FitFileValidator;
|
||||
import org.operaton.fitpub.util.FitParser;
|
||||
import org.operaton.fitpub.util.TrackSimplifier;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Service for processing FIT files and creating activities.
|
||||
* Uses JSONB for track points and simplified LineString for map rendering.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class FitFileService {
|
||||
|
||||
private static final int WGS84_SRID = 4326;
|
||||
private static final GeometryFactory GEOMETRY_FACTORY =
|
||||
new GeometryFactory(new PrecisionModel(), WGS84_SRID);
|
||||
|
||||
private final FitFileValidator validator;
|
||||
private final FitParser parser;
|
||||
private final TrackSimplifier trackSimplifier;
|
||||
private final ActivityRepository activityRepository;
|
||||
private final ActivityMetricsRepository metricsRepository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* Processes an uploaded FIT file and creates an activity.
|
||||
*
|
||||
* @param file the uploaded FIT file
|
||||
* @param userId the user ID
|
||||
* @param title optional custom title (will be auto-generated if null)
|
||||
* @param description optional description
|
||||
* @param visibility visibility level
|
||||
* @return the created activity
|
||||
* @throws FitFileProcessingException if processing fails
|
||||
*/
|
||||
@Transactional
|
||||
public Activity processFitFile(
|
||||
MultipartFile file,
|
||||
UUID userId,
|
||||
String title,
|
||||
String description,
|
||||
Activity.Visibility visibility
|
||||
) {
|
||||
try {
|
||||
// Validate file
|
||||
log.info("Processing FIT file: {}, size: {} bytes", file.getOriginalFilename(), file.getSize());
|
||||
validator.validate(file.getInputStream(), file.getSize());
|
||||
|
||||
// Parse FIT file
|
||||
byte[] fileData = file.getBytes();
|
||||
FitParser.ParsedFitData parsedData = parser.parse(fileData);
|
||||
|
||||
// Create activity entity
|
||||
Activity activity = createActivity(parsedData, userId, title, description, visibility, fileData);
|
||||
|
||||
// Convert track points to JSONB
|
||||
String trackPointsJson = convertTrackPointsToJson(parsedData.getTrackPoints());
|
||||
activity.setTrackPointsJson(trackPointsJson);
|
||||
|
||||
// Create full LineString from all points
|
||||
LineString fullTrack = createLineStringFromTrackPoints(parsedData.getTrackPoints());
|
||||
|
||||
// Simplify track for map rendering
|
||||
Coordinate[] coordinates = fullTrack.getCoordinates();
|
||||
LineString simplifiedTrack = trackSimplifier.simplify(coordinates);
|
||||
activity.setSimplifiedTrack(simplifiedTrack);
|
||||
|
||||
// Create metrics
|
||||
if (parsedData.getMetrics() != null) {
|
||||
ActivityMetrics metrics = parsedData.getMetrics().toEntity(activity);
|
||||
calculateAdditionalMetrics(metrics, parsedData.getTrackPoints());
|
||||
activity.setMetrics(metrics);
|
||||
}
|
||||
|
||||
// Save activity (single INSERT instead of 855!)
|
||||
Activity savedActivity = activityRepository.save(activity);
|
||||
|
||||
log.info("Successfully created activity {} with {} track points (simplified to {} for map)",
|
||||
savedActivity.getId(),
|
||||
parsedData.getTrackPoints().size(),
|
||||
simplifiedTrack.getNumPoints());
|
||||
|
||||
return savedActivity;
|
||||
} catch (IOException e) {
|
||||
throw new FitFileProcessingException("Failed to read FIT file", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes FIT file data directly (for testing or non-upload scenarios).
|
||||
*
|
||||
* @param fileData the FIT file bytes
|
||||
* @param userId the user ID
|
||||
* @param visibility visibility level
|
||||
* @return the created activity
|
||||
*/
|
||||
@Transactional
|
||||
public Activity processFitFile(byte[] fileData, UUID userId, Activity.Visibility visibility) {
|
||||
validator.validate(fileData);
|
||||
FitParser.ParsedFitData parsedData = parser.parse(fileData);
|
||||
return createActivityFromParsedData(parsedData, userId, null, null, visibility, fileData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an activity entity from parsed FIT data.
|
||||
*/
|
||||
private Activity createActivity(
|
||||
FitParser.ParsedFitData parsedData,
|
||||
UUID userId,
|
||||
String title,
|
||||
String description,
|
||||
Activity.Visibility visibility,
|
||||
byte[] rawFile
|
||||
) {
|
||||
String activityTitle = title != null && !title.isBlank()
|
||||
? title
|
||||
: generateTitle(parsedData);
|
||||
|
||||
return Activity.builder()
|
||||
.userId(userId)
|
||||
.activityType(parsedData.getActivityType())
|
||||
.title(activityTitle)
|
||||
.description(description)
|
||||
.startedAt(parsedData.getStartTime())
|
||||
.endedAt(parsedData.getEndTime())
|
||||
.visibility(visibility)
|
||||
.totalDistance(parsedData.getTotalDistance())
|
||||
.totalDuration(parsedData.getTotalDuration())
|
||||
.elevationGain(parsedData.getElevationGain())
|
||||
.elevationLoss(parsedData.getElevationLoss())
|
||||
.rawFitFile(rawFile)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an activity from parsed data (internal method).
|
||||
*/
|
||||
private Activity createActivityFromParsedData(
|
||||
FitParser.ParsedFitData parsedData,
|
||||
UUID userId,
|
||||
String title,
|
||||
String description,
|
||||
Activity.Visibility visibility,
|
||||
byte[] rawFile
|
||||
) {
|
||||
Activity activity = createActivity(parsedData, userId, title, description, visibility, rawFile);
|
||||
|
||||
String trackPointsJson = convertTrackPointsToJson(parsedData.getTrackPoints());
|
||||
activity.setTrackPointsJson(trackPointsJson);
|
||||
|
||||
LineString fullTrack = createLineStringFromTrackPoints(parsedData.getTrackPoints());
|
||||
LineString simplifiedTrack = trackSimplifier.simplify(fullTrack.getCoordinates());
|
||||
activity.setSimplifiedTrack(simplifiedTrack);
|
||||
|
||||
if (parsedData.getMetrics() != null) {
|
||||
ActivityMetrics metrics = parsedData.getMetrics().toEntity(activity);
|
||||
calculateAdditionalMetrics(metrics, parsedData.getTrackPoints());
|
||||
activity.setMetrics(metrics);
|
||||
}
|
||||
|
||||
return activityRepository.save(activity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts track points to JSON string for JSONB storage.
|
||||
*/
|
||||
private String convertTrackPointsToJson(List<FitParser.TrackPointData> trackPoints) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(trackPoints);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new FitFileProcessingException("Failed to serialize track points to JSON", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a PostGIS LineString from track points.
|
||||
*/
|
||||
private LineString createLineStringFromTrackPoints(List<FitParser.TrackPointData> trackPoints) {
|
||||
Coordinate[] coordinates = trackPoints.stream()
|
||||
.map(tp -> new Coordinate(tp.getLongitude(), tp.getLatitude()))
|
||||
.toArray(Coordinate[]::new);
|
||||
|
||||
return GEOMETRY_FACTORY.createLineString(coordinates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a default title for an activity.
|
||||
*/
|
||||
private String generateTitle(FitParser.ParsedFitData parsedData) {
|
||||
String activityType = formatActivityType(parsedData.getActivityType());
|
||||
String date = parsedData.getStartTime().toLocalDate().toString();
|
||||
return String.format("%s - %s", activityType, date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats activity type for display.
|
||||
*/
|
||||
private String formatActivityType(Activity.ActivityType type) {
|
||||
switch (type) {
|
||||
case RUN:
|
||||
return "Run";
|
||||
case RIDE:
|
||||
return "Ride";
|
||||
case HIKE:
|
||||
return "Hike";
|
||||
case WALK:
|
||||
return "Walk";
|
||||
case SWIM:
|
||||
return "Swim";
|
||||
case ALPINE_SKI:
|
||||
return "Alpine Ski";
|
||||
case BACKCOUNTRY_SKI:
|
||||
return "Backcountry Ski";
|
||||
case NORDIC_SKI:
|
||||
return "Nordic Ski";
|
||||
case SNOWBOARD:
|
||||
return "Snowboard";
|
||||
case ROWING:
|
||||
return "Rowing";
|
||||
case KAYAKING:
|
||||
return "Kayaking";
|
||||
case CANOEING:
|
||||
return "Canoeing";
|
||||
case INLINE_SKATING:
|
||||
return "Inline Skating";
|
||||
case ROCK_CLIMBING:
|
||||
return "Rock Climbing";
|
||||
case MOUNTAINEERING:
|
||||
return "Mountaineering";
|
||||
case YOGA:
|
||||
return "Yoga";
|
||||
case WORKOUT:
|
||||
return "Workout";
|
||||
default:
|
||||
return "Activity";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates additional metrics not provided by the FIT file.
|
||||
*/
|
||||
private void calculateAdditionalMetrics(
|
||||
ActivityMetrics metrics,
|
||||
List<FitParser.TrackPointData> trackPoints
|
||||
) {
|
||||
if (trackPoints.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate min/max elevation
|
||||
BigDecimal minElevation = null;
|
||||
BigDecimal maxElevation = null;
|
||||
|
||||
for (FitParser.TrackPointData tp : trackPoints) {
|
||||
if (tp.getElevation() != null) {
|
||||
if (minElevation == null || tp.getElevation().compareTo(minElevation) < 0) {
|
||||
minElevation = tp.getElevation();
|
||||
}
|
||||
if (maxElevation == null || tp.getElevation().compareTo(maxElevation) > 0) {
|
||||
maxElevation = tp.getElevation();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
metrics.setMinElevation(minElevation);
|
||||
metrics.setMaxElevation(maxElevation);
|
||||
|
||||
// Calculate average temperature
|
||||
BigDecimal tempSum = BigDecimal.ZERO;
|
||||
int tempCount = 0;
|
||||
|
||||
for (FitParser.TrackPointData tp : trackPoints) {
|
||||
if (tp.getTemperature() != null) {
|
||||
tempSum = tempSum.add(tp.getTemperature());
|
||||
tempCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (tempCount > 0) {
|
||||
metrics.setAverageTemperature(
|
||||
tempSum.divide(BigDecimal.valueOf(tempCount), 2, BigDecimal.ROUND_HALF_UP)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an activity and all associated data.
|
||||
*
|
||||
* @param activityId the activity ID
|
||||
* @param userId the user ID (for authorization)
|
||||
* @return true if deleted, false if not found or unauthorized
|
||||
*/
|
||||
@Transactional
|
||||
public boolean deleteActivity(UUID activityId, UUID userId) {
|
||||
return activityRepository.findByIdAndUserId(activityId, userId)
|
||||
.map(activity -> {
|
||||
activityRepository.delete(activity);
|
||||
log.info("Deleted activity {} for user {}", activityId, userId);
|
||||
return true;
|
||||
})
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an activity by ID.
|
||||
*
|
||||
* @param activityId the activity ID
|
||||
* @param userId the user ID (for authorization)
|
||||
* @return the activity or null if not found
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Activity getActivity(UUID activityId, UUID userId) {
|
||||
return activityRepository.findByIdAndUserId(activityId, userId).orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all activities for a user.
|
||||
*
|
||||
* @param userId the user ID
|
||||
* @return list of activities
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<Activity> getUserActivities(UUID userId) {
|
||||
return activityRepository.findByUserIdOrderByStartedAtDesc(userId);
|
||||
}
|
||||
}
|
||||
137
src/main/java/org/operaton/fitpub/util/FitFileValidator.java
Normal file
137
src/main/java/org/operaton/fitpub/util/FitFileValidator.java
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
package org.operaton.fitpub.util;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.operaton.fitpub.exception.InvalidFitFileException;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* Validates FIT files before processing.
|
||||
* Checks file size, header, and basic integrity.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class FitFileValidator {
|
||||
|
||||
private static final long MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||
private static final int MIN_FILE_SIZE = 14; // Minimum FIT file header size
|
||||
private static final byte[] FIT_HEADER_SIGNATURE = {'.', 'F', 'I', 'T'};
|
||||
private static final int HEADER_SIZE_OFFSET = 0;
|
||||
private static final int PROTOCOL_VERSION_OFFSET = 1;
|
||||
private static final int SIGNATURE_OFFSET = 8;
|
||||
|
||||
/**
|
||||
* Validates a FIT file from byte array.
|
||||
*
|
||||
* @param fileData the FIT file data
|
||||
* @throws InvalidFitFileException if the file is invalid
|
||||
*/
|
||||
public void validate(byte[] fileData) {
|
||||
if (fileData == null || fileData.length == 0) {
|
||||
throw new InvalidFitFileException("FIT file is empty");
|
||||
}
|
||||
|
||||
validateFileSize(fileData.length);
|
||||
validateFitHeader(fileData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a FIT file from input stream.
|
||||
*
|
||||
* @param inputStream the input stream
|
||||
* @param contentLength the content length
|
||||
* @throws InvalidFitFileException if the file is invalid
|
||||
*/
|
||||
public void validate(InputStream inputStream, long contentLength) throws IOException {
|
||||
validateFileSize(contentLength);
|
||||
|
||||
byte[] header = new byte[14];
|
||||
int bytesRead = inputStream.read(header);
|
||||
|
||||
if (bytesRead < MIN_FILE_SIZE) {
|
||||
throw new InvalidFitFileException("FIT file is too small. Minimum size is " + MIN_FILE_SIZE + " bytes");
|
||||
}
|
||||
|
||||
validateFitHeader(header);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the file size.
|
||||
*
|
||||
* @param size the file size in bytes
|
||||
* @throws InvalidFitFileException if the size is invalid
|
||||
*/
|
||||
private void validateFileSize(long size) {
|
||||
if (size < MIN_FILE_SIZE) {
|
||||
throw new InvalidFitFileException(
|
||||
String.format("FIT file is too small. Size: %d bytes, minimum: %d bytes", size, MIN_FILE_SIZE)
|
||||
);
|
||||
}
|
||||
|
||||
if (size > MAX_FILE_SIZE) {
|
||||
throw new InvalidFitFileException(
|
||||
String.format("FIT file is too large. Size: %d bytes, maximum: %d bytes", size, MAX_FILE_SIZE)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the FIT file header.
|
||||
*
|
||||
* @param data the file data (at least first 14 bytes)
|
||||
* @throws InvalidFitFileException if the header is invalid
|
||||
*/
|
||||
private void validateFitHeader(byte[] data) {
|
||||
if (data.length < MIN_FILE_SIZE) {
|
||||
throw new InvalidFitFileException("Insufficient data to validate FIT header");
|
||||
}
|
||||
|
||||
// Check header size
|
||||
int headerSize = data[HEADER_SIZE_OFFSET] & 0xFF;
|
||||
if (headerSize != 12 && headerSize != 14) {
|
||||
throw new InvalidFitFileException(
|
||||
String.format("Invalid FIT header size: %d. Expected 12 or 14", headerSize)
|
||||
);
|
||||
}
|
||||
|
||||
// Check protocol version
|
||||
int protocolVersion = data[PROTOCOL_VERSION_OFFSET] & 0xFF;
|
||||
int majorVersion = protocolVersion >> 4;
|
||||
if (majorVersion == 0 || majorVersion > 20) {
|
||||
log.warn("Unusual FIT protocol version: {}.{}", majorVersion, protocolVersion & 0x0F);
|
||||
}
|
||||
|
||||
// Check signature
|
||||
boolean signatureValid = true;
|
||||
for (int i = 0; i < FIT_HEADER_SIGNATURE.length; i++) {
|
||||
if (data[SIGNATURE_OFFSET + i] != FIT_HEADER_SIGNATURE[i]) {
|
||||
signatureValid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!signatureValid) {
|
||||
throw new InvalidFitFileException(
|
||||
"Invalid FIT file signature. Expected '.FIT' at offset " + SIGNATURE_OFFSET
|
||||
);
|
||||
}
|
||||
|
||||
log.debug("FIT file header validated successfully. Header size: {}, Protocol version: {}.{}",
|
||||
headerSize, majorVersion, protocolVersion & 0x0F);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a file appears to be a valid FIT file based on extension.
|
||||
*
|
||||
* @param filename the filename
|
||||
* @return true if the filename has a .fit extension
|
||||
*/
|
||||
public boolean hasValidExtension(String filename) {
|
||||
if (filename == null || filename.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return filename.toLowerCase().endsWith(".fit");
|
||||
}
|
||||
}
|
||||
401
src/main/java/org/operaton/fitpub/util/FitParser.java
Normal file
401
src/main/java/org/operaton/fitpub/util/FitParser.java
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
package org.operaton.fitpub.util;
|
||||
|
||||
import com.garmin.fit.*;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.locationtech.jts.geom.Coordinate;
|
||||
import org.locationtech.jts.geom.GeometryFactory;
|
||||
import org.locationtech.jts.geom.Point;
|
||||
import org.locationtech.jts.geom.PrecisionModel;
|
||||
import org.operaton.fitpub.exception.FitFileProcessingException;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.model.entity.ActivityMetrics;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Parser for Garmin FIT files.
|
||||
* Extracts GPS coordinates, activity metrics, and sensor data.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class FitParser {
|
||||
|
||||
private static final int WGS84_SRID = 4326;
|
||||
private static final GeometryFactory GEOMETRY_FACTORY =
|
||||
new GeometryFactory(new PrecisionModel(), WGS84_SRID);
|
||||
|
||||
private static final double SEMICIRCLES_TO_DEGREES = 180.0 / Math.pow(2, 31);
|
||||
private static final double MPS_TO_KPH = 3.6;
|
||||
|
||||
/**
|
||||
* Parses a FIT file and returns the extracted data.
|
||||
*
|
||||
* @param fileData the FIT file data
|
||||
* @return ParsedFitData containing activity information
|
||||
* @throws FitFileProcessingException if parsing fails
|
||||
*/
|
||||
public ParsedFitData parse(byte[] fileData) {
|
||||
try (InputStream inputStream = new ByteArrayInputStream(fileData)) {
|
||||
return parse(inputStream);
|
||||
} catch (Exception e) {
|
||||
throw new FitFileProcessingException("Failed to parse FIT file", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a FIT file from an input stream.
|
||||
*
|
||||
* @param inputStream the input stream
|
||||
* @return ParsedFitData containing activity information
|
||||
* @throws FitFileProcessingException if parsing fails
|
||||
*/
|
||||
public ParsedFitData parse(InputStream inputStream) {
|
||||
ParsedFitData parsedData = new ParsedFitData();
|
||||
Decode decode = new Decode();
|
||||
MesgBroadcaster broadcaster = new MesgBroadcaster(decode);
|
||||
|
||||
// Listen for record messages (GPS points)
|
||||
broadcaster.addListener((RecordMesgListener) record -> {
|
||||
TrackPointData trackPoint = extractTrackPoint(record);
|
||||
if (trackPoint != null) {
|
||||
parsedData.getTrackPoints().add(trackPoint);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for session messages (summary data)
|
||||
broadcaster.addListener((SessionMesgListener) session -> {
|
||||
extractSessionData(session, parsedData);
|
||||
});
|
||||
|
||||
// Listen for activity messages
|
||||
broadcaster.addListener((ActivityMesgListener) activity -> {
|
||||
extractActivityData(activity, parsedData);
|
||||
});
|
||||
|
||||
// Listen for lap messages
|
||||
broadcaster.addListener((LapMesgListener) lap -> {
|
||||
log.debug("Lap data: distance={}, time={}", lap.getTotalDistance(), lap.getTotalTimerTime());
|
||||
});
|
||||
|
||||
try {
|
||||
if (!decode.read(inputStream, broadcaster)) {
|
||||
throw new FitFileProcessingException("Failed to decode FIT file");
|
||||
}
|
||||
|
||||
if (parsedData.getTrackPoints().isEmpty()) {
|
||||
throw new FitFileProcessingException("No GPS track points found in FIT file");
|
||||
}
|
||||
|
||||
log.info("Successfully parsed FIT file: {} track points, activity type: {}",
|
||||
parsedData.getTrackPoints().size(), parsedData.getActivityType());
|
||||
|
||||
return parsedData;
|
||||
} catch (FitRuntimeException e) {
|
||||
throw new FitFileProcessingException("Error decoding FIT file", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a track point from a record message.
|
||||
*/
|
||||
private TrackPointData extractTrackPoint(RecordMesg record) {
|
||||
Integer positionLat = record.getPositionLat();
|
||||
Integer positionLong = record.getPositionLong();
|
||||
|
||||
if (positionLat == null || positionLong == null) {
|
||||
return null; // Skip points without GPS coordinates
|
||||
}
|
||||
|
||||
TrackPointData point = new TrackPointData();
|
||||
|
||||
// Convert semicircles to degrees
|
||||
double latitude = positionLat * SEMICIRCLES_TO_DEGREES;
|
||||
double longitude = positionLong * SEMICIRCLES_TO_DEGREES;
|
||||
|
||||
point.setLatitude(latitude);
|
||||
point.setLongitude(longitude);
|
||||
|
||||
// Extract timestamp
|
||||
if (record.getTimestamp() != null) {
|
||||
point.setTimestamp(convertDateTime(record.getTimestamp()));
|
||||
}
|
||||
|
||||
// Extract elevation
|
||||
if (record.getAltitude() != null) {
|
||||
point.setElevation(BigDecimal.valueOf(record.getAltitude()).setScale(2, RoundingMode.HALF_UP));
|
||||
}
|
||||
|
||||
// Extract heart rate
|
||||
if (record.getHeartRate() != null) {
|
||||
point.setHeartRate(record.getHeartRate().intValue());
|
||||
}
|
||||
|
||||
// Extract cadence
|
||||
if (record.getCadence() != null) {
|
||||
point.setCadence(record.getCadence().intValue());
|
||||
}
|
||||
|
||||
// Extract power
|
||||
if (record.getPower() != null) {
|
||||
point.setPower(record.getPower());
|
||||
}
|
||||
|
||||
// Extract speed (convert m/s to km/h)
|
||||
if (record.getSpeed() != null) {
|
||||
point.setSpeed(BigDecimal.valueOf(record.getSpeed() * MPS_TO_KPH)
|
||||
.setScale(2, RoundingMode.HALF_UP));
|
||||
}
|
||||
|
||||
// Extract distance
|
||||
if (record.getDistance() != null) {
|
||||
point.setDistance(BigDecimal.valueOf(record.getDistance()).setScale(2, RoundingMode.HALF_UP));
|
||||
}
|
||||
|
||||
// Extract temperature
|
||||
if (record.getTemperature() != null) {
|
||||
point.setTemperature(BigDecimal.valueOf(record.getTemperature()).setScale(2, RoundingMode.HALF_UP));
|
||||
}
|
||||
|
||||
return point;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts session data from a session message.
|
||||
*/
|
||||
private void extractSessionData(SessionMesg session, ParsedFitData parsedData) {
|
||||
if (session.getStartTime() != null) {
|
||||
parsedData.setStartTime(convertDateTime(session.getStartTime()));
|
||||
}
|
||||
|
||||
if (session.getTotalElapsedTime() != null) {
|
||||
long totalSeconds = session.getTotalElapsedTime().longValue();
|
||||
parsedData.setTotalDuration(Duration.ofSeconds(totalSeconds));
|
||||
|
||||
if (parsedData.getStartTime() != null) {
|
||||
parsedData.setEndTime(parsedData.getStartTime().plus(Duration.ofSeconds(totalSeconds)));
|
||||
}
|
||||
}
|
||||
|
||||
if (session.getTotalDistance() != null) {
|
||||
parsedData.setTotalDistance(
|
||||
BigDecimal.valueOf(session.getTotalDistance()).setScale(2, RoundingMode.HALF_UP)
|
||||
);
|
||||
}
|
||||
|
||||
if (session.getTotalAscent() != null) {
|
||||
parsedData.setElevationGain(
|
||||
BigDecimal.valueOf(session.getTotalAscent()).setScale(2, RoundingMode.HALF_UP)
|
||||
);
|
||||
}
|
||||
|
||||
if (session.getTotalDescent() != null) {
|
||||
parsedData.setElevationLoss(
|
||||
BigDecimal.valueOf(session.getTotalDescent()).setScale(2, RoundingMode.HALF_UP)
|
||||
);
|
||||
}
|
||||
|
||||
// Extract metrics
|
||||
ActivityMetricsData metrics = new ActivityMetricsData();
|
||||
|
||||
if (session.getAvgSpeed() != null) {
|
||||
metrics.setAverageSpeed(
|
||||
BigDecimal.valueOf(session.getAvgSpeed() * MPS_TO_KPH).setScale(2, RoundingMode.HALF_UP)
|
||||
);
|
||||
}
|
||||
|
||||
if (session.getMaxSpeed() != null) {
|
||||
metrics.setMaxSpeed(
|
||||
BigDecimal.valueOf(session.getMaxSpeed() * MPS_TO_KPH).setScale(2, RoundingMode.HALF_UP)
|
||||
);
|
||||
}
|
||||
|
||||
if (session.getAvgHeartRate() != null) {
|
||||
metrics.setAverageHeartRate(session.getAvgHeartRate().intValue());
|
||||
}
|
||||
|
||||
if (session.getMaxHeartRate() != null) {
|
||||
metrics.setMaxHeartRate(session.getMaxHeartRate().intValue());
|
||||
}
|
||||
|
||||
if (session.getAvgCadence() != null) {
|
||||
metrics.setAverageCadence(session.getAvgCadence().intValue());
|
||||
}
|
||||
|
||||
if (session.getMaxCadence() != null) {
|
||||
metrics.setMaxCadence(session.getMaxCadence().intValue());
|
||||
}
|
||||
|
||||
if (session.getAvgPower() != null) {
|
||||
metrics.setAveragePower(session.getAvgPower());
|
||||
}
|
||||
|
||||
if (session.getMaxPower() != null) {
|
||||
metrics.setMaxPower(session.getMaxPower());
|
||||
}
|
||||
|
||||
if (session.getNormalizedPower() != null) {
|
||||
metrics.setNormalizedPower(session.getNormalizedPower());
|
||||
}
|
||||
|
||||
if (session.getTotalCalories() != null) {
|
||||
metrics.setCalories(session.getTotalCalories());
|
||||
}
|
||||
|
||||
if (session.getTotalMovingTime() != null) {
|
||||
metrics.setMovingTime(Duration.ofSeconds(session.getTotalMovingTime().longValue()));
|
||||
}
|
||||
|
||||
if (session.getTotalStrides() != null) {
|
||||
metrics.setTotalSteps(session.getTotalStrides().intValue() * 2); // Strides to steps
|
||||
}
|
||||
|
||||
parsedData.setMetrics(metrics);
|
||||
|
||||
// Determine activity type
|
||||
if (session.getSport() != null) {
|
||||
parsedData.setActivityType(mapSportToActivityType(session.getSport()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts activity data from an activity message.
|
||||
*/
|
||||
private void extractActivityData(ActivityMesg activity, ParsedFitData parsedData) {
|
||||
if (activity.getTimestamp() != null) {
|
||||
parsedData.setActivityTimestamp(convertDateTime(activity.getTimestamp()));
|
||||
}
|
||||
|
||||
if (activity.getTotalTimerTime() != null) {
|
||||
log.debug("Activity total timer time: {}", activity.getTotalTimerTime());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts FIT DateTime to LocalDateTime.
|
||||
*/
|
||||
private LocalDateTime convertDateTime(DateTime dateTime) {
|
||||
long timestamp = dateTime.getTimestamp();
|
||||
Instant instant = Instant.ofEpochSecond(timestamp);
|
||||
return LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps FIT sport type to our activity type.
|
||||
*/
|
||||
private Activity.ActivityType mapSportToActivityType(Sport sport) {
|
||||
if (sport == Sport.RUNNING) {
|
||||
return Activity.ActivityType.RUN;
|
||||
} else if (sport == Sport.CYCLING) {
|
||||
return Activity.ActivityType.RIDE;
|
||||
} else if (sport == Sport.HIKING) {
|
||||
return Activity.ActivityType.HIKE;
|
||||
} else if (sport == Sport.WALKING) {
|
||||
return Activity.ActivityType.WALK;
|
||||
} else if (sport == Sport.SWIMMING) {
|
||||
return Activity.ActivityType.SWIM;
|
||||
} else if (sport == Sport.ROWING) {
|
||||
return Activity.ActivityType.ROWING;
|
||||
} else {
|
||||
return Activity.ActivityType.OTHER;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data class for track point information.
|
||||
*/
|
||||
@lombok.Data
|
||||
public static class TrackPointData {
|
||||
private LocalDateTime timestamp;
|
||||
private double latitude;
|
||||
private double longitude;
|
||||
private BigDecimal elevation;
|
||||
private Integer heartRate;
|
||||
private Integer cadence;
|
||||
private Integer power;
|
||||
private BigDecimal speed;
|
||||
private BigDecimal temperature;
|
||||
private BigDecimal distance;
|
||||
|
||||
public Point toGeometry() {
|
||||
return GEOMETRY_FACTORY.createPoint(new Coordinate(longitude, latitude));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data class for activity metrics.
|
||||
*/
|
||||
@lombok.Data
|
||||
public static class ActivityMetricsData {
|
||||
private BigDecimal averageSpeed;
|
||||
private BigDecimal maxSpeed;
|
||||
private Duration averagePace;
|
||||
private Integer averageHeartRate;
|
||||
private Integer maxHeartRate;
|
||||
private Integer averageCadence;
|
||||
private Integer maxCadence;
|
||||
private Integer averagePower;
|
||||
private Integer maxPower;
|
||||
private Integer normalizedPower;
|
||||
private Integer calories;
|
||||
private BigDecimal averageTemperature;
|
||||
private BigDecimal maxElevation;
|
||||
private BigDecimal minElevation;
|
||||
private BigDecimal totalAscent;
|
||||
private BigDecimal totalDescent;
|
||||
private Duration movingTime;
|
||||
private Duration stoppedTime;
|
||||
private Integer totalSteps;
|
||||
|
||||
public ActivityMetrics toEntity(Activity activity) {
|
||||
return ActivityMetrics.builder()
|
||||
.activity(activity)
|
||||
.averageSpeed(averageSpeed)
|
||||
.maxSpeed(maxSpeed)
|
||||
.averagePace(averagePace)
|
||||
.averageHeartRate(averageHeartRate)
|
||||
.maxHeartRate(maxHeartRate)
|
||||
.averageCadence(averageCadence)
|
||||
.maxCadence(maxCadence)
|
||||
.averagePower(averagePower)
|
||||
.maxPower(maxPower)
|
||||
.normalizedPower(normalizedPower)
|
||||
.calories(calories)
|
||||
.averageTemperature(averageTemperature)
|
||||
.maxElevation(maxElevation)
|
||||
.minElevation(minElevation)
|
||||
.totalAscent(totalAscent)
|
||||
.totalDescent(totalDescent)
|
||||
.movingTime(movingTime)
|
||||
.stoppedTime(stoppedTime)
|
||||
.totalSteps(totalSteps)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data class holding all parsed FIT file data.
|
||||
*/
|
||||
@lombok.Data
|
||||
public static class ParsedFitData {
|
||||
private List<TrackPointData> trackPoints = new ArrayList<>();
|
||||
private LocalDateTime startTime;
|
||||
private LocalDateTime endTime;
|
||||
private LocalDateTime activityTimestamp;
|
||||
private BigDecimal totalDistance;
|
||||
private Duration totalDuration;
|
||||
private BigDecimal elevationGain;
|
||||
private BigDecimal elevationLoss;
|
||||
private Activity.ActivityType activityType = Activity.ActivityType.OTHER;
|
||||
private ActivityMetricsData metrics;
|
||||
}
|
||||
}
|
||||
182
src/main/java/org/operaton/fitpub/util/TrackSimplifier.java
Normal file
182
src/main/java/org/operaton/fitpub/util/TrackSimplifier.java
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
package org.operaton.fitpub.util;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.locationtech.jts.geom.Coordinate;
|
||||
import org.locationtech.jts.geom.GeometryFactory;
|
||||
import org.locationtech.jts.geom.LineString;
|
||||
import org.locationtech.jts.geom.PrecisionModel;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Simplifies GPS tracks using the Douglas-Peucker algorithm.
|
||||
* Reduces the number of points while maintaining the overall shape of the track.
|
||||
* This is critical for scalability - we don't want to render 1000+ points on a map.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class TrackSimplifier {
|
||||
|
||||
private static final int WGS84_SRID = 4326;
|
||||
private static final GeometryFactory GEOMETRY_FACTORY =
|
||||
new GeometryFactory(new PrecisionModel(), WGS84_SRID);
|
||||
|
||||
// Default epsilon: ~10 meters tolerance (in degrees, roughly 0.0001 degrees)
|
||||
private static final double DEFAULT_EPSILON = 0.0001;
|
||||
|
||||
// Target: 50-200 points for map rendering
|
||||
private static final int TARGET_POINTS_MIN = 50;
|
||||
private static final int TARGET_POINTS_MAX = 200;
|
||||
|
||||
/**
|
||||
* Simplifies a track to a target number of points.
|
||||
* Automatically adjusts epsilon to achieve the desired point count.
|
||||
*
|
||||
* @param coordinates original track coordinates
|
||||
* @return simplified LineString
|
||||
*/
|
||||
public LineString simplify(Coordinate[] coordinates) {
|
||||
if (coordinates == null || coordinates.length == 0) {
|
||||
return GEOMETRY_FACTORY.createLineString(new Coordinate[0]);
|
||||
}
|
||||
|
||||
if (coordinates.length <= TARGET_POINTS_MAX) {
|
||||
// Already small enough, no simplification needed
|
||||
log.debug("Track has {} points, no simplification needed", coordinates.length);
|
||||
return GEOMETRY_FACTORY.createLineString(coordinates);
|
||||
}
|
||||
|
||||
// Try to find epsilon that gives us TARGET_POINTS_MIN to TARGET_POINTS_MAX points
|
||||
double epsilon = DEFAULT_EPSILON;
|
||||
List<Coordinate> simplified = douglasPeucker(coordinates, epsilon);
|
||||
|
||||
// Adjust epsilon if needed
|
||||
int iterations = 0;
|
||||
while (simplified.size() > TARGET_POINTS_MAX && iterations < 10) {
|
||||
epsilon *= 1.5; // Increase tolerance
|
||||
simplified = douglasPeucker(coordinates, epsilon);
|
||||
iterations++;
|
||||
}
|
||||
|
||||
while (simplified.size() < TARGET_POINTS_MIN && epsilon > 0.00001 && iterations < 10) {
|
||||
epsilon *= 0.7; // Decrease tolerance
|
||||
simplified = douglasPeucker(coordinates, epsilon);
|
||||
iterations++;
|
||||
}
|
||||
|
||||
log.info("Simplified track from {} to {} points (epsilon: {})",
|
||||
coordinates.length, simplified.size(), epsilon);
|
||||
|
||||
return GEOMETRY_FACTORY.createLineString(simplified.toArray(new Coordinate[0]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Douglas-Peucker algorithm implementation.
|
||||
* Recursively removes points that deviate less than epsilon from the line.
|
||||
*
|
||||
* @param coordinates input coordinates
|
||||
* @param epsilon tolerance (maximum distance from line)
|
||||
* @return simplified list of coordinates
|
||||
*/
|
||||
private List<Coordinate> douglasPeucker(Coordinate[] coordinates, double epsilon) {
|
||||
if (coordinates.length < 3) {
|
||||
List<Coordinate> result = new ArrayList<>();
|
||||
for (Coordinate coord : coordinates) {
|
||||
result.add(coord);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Find point with maximum distance from line between first and last point
|
||||
double maxDistance = 0;
|
||||
int maxIndex = 0;
|
||||
|
||||
Coordinate start = coordinates[0];
|
||||
Coordinate end = coordinates[coordinates.length - 1];
|
||||
|
||||
for (int i = 1; i < coordinates.length - 1; i++) {
|
||||
double distance = perpendicularDistance(coordinates[i], start, end);
|
||||
if (distance > maxDistance) {
|
||||
maxDistance = distance;
|
||||
maxIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
List<Coordinate> result = new ArrayList<>();
|
||||
|
||||
// If max distance is greater than epsilon, recursively simplify
|
||||
if (maxDistance > epsilon) {
|
||||
// Recursive call for first part
|
||||
Coordinate[] firstPart = new Coordinate[maxIndex + 1];
|
||||
System.arraycopy(coordinates, 0, firstPart, 0, maxIndex + 1);
|
||||
List<Coordinate> left = douglasPeucker(firstPart, epsilon);
|
||||
|
||||
// Recursive call for second part
|
||||
Coordinate[] secondPart = new Coordinate[coordinates.length - maxIndex];
|
||||
System.arraycopy(coordinates, maxIndex, secondPart, 0, coordinates.length - maxIndex);
|
||||
List<Coordinate> right = douglasPeucker(secondPart, epsilon);
|
||||
|
||||
// Combine results (remove duplicate point at junction)
|
||||
result.addAll(left.subList(0, left.size() - 1));
|
||||
result.addAll(right);
|
||||
} else {
|
||||
// All points can be removed except endpoints
|
||||
result.add(start);
|
||||
result.add(end);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates perpendicular distance from a point to a line segment.
|
||||
*
|
||||
* @param point the point
|
||||
* @param lineStart start of line segment
|
||||
* @param lineEnd end of line segment
|
||||
* @return perpendicular distance
|
||||
*/
|
||||
private double perpendicularDistance(Coordinate point, Coordinate lineStart, Coordinate lineEnd) {
|
||||
double x = point.x;
|
||||
double y = point.y;
|
||||
double x1 = lineStart.x;
|
||||
double y1 = lineStart.y;
|
||||
double x2 = lineEnd.x;
|
||||
double y2 = lineEnd.y;
|
||||
|
||||
double A = x - x1;
|
||||
double B = y - y1;
|
||||
double C = x2 - x1;
|
||||
double D = y2 - y1;
|
||||
|
||||
double dot = A * C + B * D;
|
||||
double lenSq = C * C + D * D;
|
||||
|
||||
if (lenSq == 0) {
|
||||
// Line segment is actually a point
|
||||
return Math.sqrt(A * A + B * B);
|
||||
}
|
||||
|
||||
double param = dot / lenSq;
|
||||
|
||||
double xx, yy;
|
||||
|
||||
if (param < 0) {
|
||||
xx = x1;
|
||||
yy = y1;
|
||||
} else if (param > 1) {
|
||||
xx = x2;
|
||||
yy = y2;
|
||||
} else {
|
||||
xx = x1 + param * C;
|
||||
yy = y1 + param * D;
|
||||
}
|
||||
|
||||
double dx = x - xx;
|
||||
double dy = y - yy;
|
||||
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,370 @@
|
|||
package org.operaton.fitpub.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.locationtech.jts.geom.Coordinate;
|
||||
import org.locationtech.jts.geom.GeometryFactory;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Spy;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.operaton.fitpub.exception.FitFileProcessingException;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
import org.operaton.fitpub.repository.ActivityMetricsRepository;
|
||||
import org.operaton.fitpub.repository.ActivityRepository;
|
||||
import org.operaton.fitpub.util.FitFileValidator;
|
||||
import org.operaton.fitpub.util.FitParser;
|
||||
import org.operaton.fitpub.util.TrackSimplifier;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Unit tests for FitFileService.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class FitFileServiceTest {
|
||||
|
||||
@Mock
|
||||
private FitFileValidator validator;
|
||||
|
||||
@Mock
|
||||
private FitParser parser;
|
||||
|
||||
@Mock
|
||||
private TrackSimplifier trackSimplifier;
|
||||
|
||||
@Mock
|
||||
private ActivityRepository activityRepository;
|
||||
|
||||
@Mock
|
||||
private ActivityMetricsRepository metricsRepository;
|
||||
|
||||
@Spy
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@InjectMocks
|
||||
private FitFileService fitFileService;
|
||||
|
||||
private UUID testUserId;
|
||||
private MockMultipartFile testFile;
|
||||
private FitParser.ParsedFitData testParsedData;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
testUserId = UUID.randomUUID();
|
||||
testFile = new MockMultipartFile(
|
||||
"file",
|
||||
"test-activity.fit",
|
||||
"application/octet-stream",
|
||||
new byte[100]
|
||||
);
|
||||
|
||||
// Configure ObjectMapper for Java 8 Time
|
||||
objectMapper.registerModule(new JavaTimeModule());
|
||||
|
||||
// Create test parsed data
|
||||
testParsedData = createTestParsedData();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should successfully process a valid FIT file")
|
||||
void testProcessFitFileSuccess() throws Exception {
|
||||
// Arrange
|
||||
when(parser.parse(any(byte[].class))).thenReturn(testParsedData);
|
||||
when(trackSimplifier.simplify(any())).thenAnswer(invocation -> {
|
||||
Coordinate[] coords = invocation.getArgument(0);
|
||||
return new GeometryFactory().createLineString(coords);
|
||||
});
|
||||
when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> {
|
||||
Activity activity = invocation.getArgument(0);
|
||||
activity.setId(UUID.randomUUID());
|
||||
activity.setCreatedAt(LocalDateTime.now());
|
||||
activity.setUpdatedAt(LocalDateTime.now());
|
||||
return activity;
|
||||
});
|
||||
|
||||
// Act
|
||||
Activity result = fitFileService.processFitFile(
|
||||
testFile,
|
||||
testUserId,
|
||||
"Test Run",
|
||||
"Morning run",
|
||||
Activity.Visibility.PUBLIC
|
||||
);
|
||||
|
||||
// Assert
|
||||
assertNotNull(result);
|
||||
assertEquals("Test Run", result.getTitle());
|
||||
assertEquals("Morning run", result.getDescription());
|
||||
assertEquals(testUserId, result.getUserId());
|
||||
assertEquals(Activity.Visibility.PUBLIC, result.getVisibility());
|
||||
assertEquals(Activity.ActivityType.RUN, result.getActivityType());
|
||||
|
||||
verify(validator).validate(any(), anyLong());
|
||||
verify(parser).parse(any(byte[].class));
|
||||
verify(activityRepository).save(any(Activity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should generate default title when title is null")
|
||||
void testProcessFitFileWithDefaultTitle() throws Exception {
|
||||
// Arrange
|
||||
when(parser.parse(any(byte[].class))).thenReturn(testParsedData);
|
||||
when(trackSimplifier.simplify(any())).thenAnswer(invocation -> {
|
||||
Coordinate[] coords = invocation.getArgument(0);
|
||||
return new GeometryFactory().createLineString(coords);
|
||||
});
|
||||
when(activityRepository.save(any(Activity.class))).thenAnswer(invocation -> {
|
||||
Activity activity = invocation.getArgument(0);
|
||||
activity.setId(UUID.randomUUID());
|
||||
return activity;
|
||||
});
|
||||
|
||||
// Act
|
||||
Activity result = fitFileService.processFitFile(
|
||||
testFile,
|
||||
testUserId,
|
||||
null,
|
||||
null,
|
||||
Activity.Visibility.PUBLIC
|
||||
);
|
||||
|
||||
// Assert
|
||||
assertNotNull(result);
|
||||
assertTrue(result.getTitle().contains("Run"));
|
||||
assertTrue(result.getTitle().contains(testParsedData.getStartTime().toLocalDate().toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception when validator fails")
|
||||
void testProcessFitFileValidationFailure() throws Exception {
|
||||
// Arrange
|
||||
doThrow(new FitFileProcessingException("Invalid file"))
|
||||
.when(validator).validate(any(), anyLong());
|
||||
|
||||
// Act & Assert
|
||||
assertThrows(FitFileProcessingException.class, () ->
|
||||
fitFileService.processFitFile(
|
||||
testFile,
|
||||
testUserId,
|
||||
"Test",
|
||||
null,
|
||||
Activity.Visibility.PUBLIC
|
||||
)
|
||||
);
|
||||
|
||||
verify(parser, never()).parse(any(byte[].class));
|
||||
verify(activityRepository, never()).save(any(Activity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception when parser fails")
|
||||
void testProcessFitFileParsingFailure() throws Exception {
|
||||
// Arrange
|
||||
when(parser.parse(any(byte[].class)))
|
||||
.thenThrow(new FitFileProcessingException("Parsing failed"));
|
||||
|
||||
// Act & Assert
|
||||
assertThrows(FitFileProcessingException.class, () ->
|
||||
fitFileService.processFitFile(
|
||||
testFile,
|
||||
testUserId,
|
||||
"Test",
|
||||
null,
|
||||
Activity.Visibility.PUBLIC
|
||||
)
|
||||
);
|
||||
|
||||
verify(activityRepository, never()).save(any(Activity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should successfully delete an activity")
|
||||
void testDeleteActivity() {
|
||||
// Arrange
|
||||
UUID activityId = UUID.randomUUID();
|
||||
Activity activity = Activity.builder()
|
||||
.id(activityId)
|
||||
.userId(testUserId)
|
||||
.build();
|
||||
|
||||
when(activityRepository.findByIdAndUserId(activityId, testUserId))
|
||||
.thenReturn(Optional.of(activity));
|
||||
|
||||
// Act
|
||||
boolean result = fitFileService.deleteActivity(activityId, testUserId);
|
||||
|
||||
// Assert
|
||||
assertTrue(result);
|
||||
verify(activityRepository).delete(activity);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should return false when deleting non-existent activity")
|
||||
void testDeleteNonExistentActivity() {
|
||||
// Arrange
|
||||
UUID activityId = UUID.randomUUID();
|
||||
when(activityRepository.findByIdAndUserId(activityId, testUserId))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
// Act
|
||||
boolean result = fitFileService.deleteActivity(activityId, testUserId);
|
||||
|
||||
// Assert
|
||||
assertFalse(result);
|
||||
verify(activityRepository, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should retrieve activity by ID and user ID")
|
||||
void testGetActivity() {
|
||||
// Arrange
|
||||
UUID activityId = UUID.randomUUID();
|
||||
Activity activity = Activity.builder()
|
||||
.id(activityId)
|
||||
.userId(testUserId)
|
||||
.title("Test Activity")
|
||||
.build();
|
||||
|
||||
when(activityRepository.findByIdAndUserId(activityId, testUserId))
|
||||
.thenReturn(Optional.of(activity));
|
||||
|
||||
// Act
|
||||
Activity result = fitFileService.getActivity(activityId, testUserId);
|
||||
|
||||
// Assert
|
||||
assertNotNull(result);
|
||||
assertEquals(activityId, result.getId());
|
||||
assertEquals("Test Activity", result.getTitle());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should return null for non-existent activity")
|
||||
void testGetNonExistentActivity() {
|
||||
// Arrange
|
||||
UUID activityId = UUID.randomUUID();
|
||||
when(activityRepository.findByIdAndUserId(activityId, testUserId))
|
||||
.thenReturn(Optional.empty());
|
||||
|
||||
// Act
|
||||
Activity result = fitFileService.getActivity(activityId, testUserId);
|
||||
|
||||
// Assert
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should retrieve all activities for a user")
|
||||
void testGetUserActivities() {
|
||||
// Arrange
|
||||
List<Activity> activities = new ArrayList<>();
|
||||
activities.add(Activity.builder().id(UUID.randomUUID()).userId(testUserId).build());
|
||||
activities.add(Activity.builder().id(UUID.randomUUID()).userId(testUserId).build());
|
||||
|
||||
when(activityRepository.findByUserIdOrderByStartedAtDesc(testUserId))
|
||||
.thenReturn(activities);
|
||||
|
||||
// Act
|
||||
List<Activity> result = fitFileService.getUserActivities(testUserId);
|
||||
|
||||
// Assert
|
||||
assertNotNull(result);
|
||||
assertEquals(2, result.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should process FIT file with metrics")
|
||||
void testProcessFitFileWithMetrics() throws Exception {
|
||||
// Arrange
|
||||
when(parser.parse(any(byte[].class))).thenReturn(testParsedData);
|
||||
when(trackSimplifier.simplify(any())).thenAnswer(invocation -> {
|
||||
Coordinate[] coords = invocation.getArgument(0);
|
||||
return new GeometryFactory().createLineString(coords);
|
||||
});
|
||||
|
||||
ArgumentCaptor<Activity> activityCaptor = ArgumentCaptor.forClass(Activity.class);
|
||||
when(activityRepository.save(activityCaptor.capture())).thenAnswer(invocation -> {
|
||||
Activity activity = invocation.getArgument(0);
|
||||
activity.setId(UUID.randomUUID());
|
||||
return activity;
|
||||
});
|
||||
|
||||
// Act
|
||||
Activity result = fitFileService.processFitFile(
|
||||
testFile,
|
||||
testUserId,
|
||||
"Complete Activity",
|
||||
null,
|
||||
Activity.Visibility.PUBLIC
|
||||
);
|
||||
|
||||
// Assert
|
||||
assertNotNull(result);
|
||||
Activity savedActivity = activityCaptor.getValue();
|
||||
|
||||
assertNotNull(savedActivity.getSimplifiedTrack());
|
||||
assertNotNull(savedActivity.getTrackPointsJson());
|
||||
assertNotNull(savedActivity.getMetrics());
|
||||
assertEquals(testParsedData.getTotalDistance(), savedActivity.getTotalDistance());
|
||||
assertEquals(testParsedData.getTotalDuration(), savedActivity.getTotalDuration());
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates test parsed FIT data with realistic values.
|
||||
*/
|
||||
private FitParser.ParsedFitData createTestParsedData() {
|
||||
FitParser.ParsedFitData data = new FitParser.ParsedFitData();
|
||||
|
||||
LocalDateTime startTime = LocalDateTime.of(2024, 1, 15, 8, 0, 0);
|
||||
data.setStartTime(startTime);
|
||||
data.setEndTime(startTime.plusMinutes(30));
|
||||
data.setActivityType(Activity.ActivityType.RUN);
|
||||
data.setTotalDistance(BigDecimal.valueOf(5000.0));
|
||||
data.setTotalDuration(Duration.ofMinutes(30));
|
||||
data.setElevationGain(BigDecimal.valueOf(100.0));
|
||||
data.setElevationLoss(BigDecimal.valueOf(95.0));
|
||||
|
||||
// Add test track points
|
||||
List<FitParser.TrackPointData> trackPoints = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < 10; i++) {
|
||||
FitParser.TrackPointData tp = new FitParser.TrackPointData();
|
||||
tp.setTimestamp(startTime.plusMinutes(i * 3));
|
||||
tp.setLatitude(47.0 + i * 0.001);
|
||||
tp.setLongitude(8.0 + i * 0.001);
|
||||
tp.setElevation(BigDecimal.valueOf(500 + i * 10));
|
||||
tp.setHeartRate(140 + i);
|
||||
tp.setSpeed(BigDecimal.valueOf(10.0));
|
||||
trackPoints.add(tp);
|
||||
}
|
||||
|
||||
data.setTrackPoints(trackPoints);
|
||||
|
||||
// Add test metrics
|
||||
FitParser.ActivityMetricsData metrics = new FitParser.ActivityMetricsData();
|
||||
metrics.setAverageSpeed(BigDecimal.valueOf(10.0));
|
||||
metrics.setMaxSpeed(BigDecimal.valueOf(15.0));
|
||||
metrics.setAverageHeartRate(150);
|
||||
metrics.setMaxHeartRate(170);
|
||||
metrics.setCalories(300);
|
||||
data.setMetrics(metrics);
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
152
src/test/java/org/operaton/fitpub/util/FitFileValidatorTest.java
Normal file
152
src/test/java/org/operaton/fitpub/util/FitFileValidatorTest.java
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package org.operaton.fitpub.util;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.operaton.fitpub.exception.InvalidFitFileException;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Unit tests for FitFileValidator.
|
||||
*/
|
||||
class FitFileValidatorTest {
|
||||
|
||||
private FitFileValidator validator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
validator = new FitFileValidator();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should validate a valid FIT file header")
|
||||
void testValidateValidHeader() {
|
||||
byte[] validHeader = TestFitFileGenerator.generateValidFitFileHeader();
|
||||
|
||||
assertDoesNotThrow(() -> validator.validate(validHeader));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception for empty file")
|
||||
void testValidateEmptyFile() {
|
||||
byte[] emptyFile = TestFitFileGenerator.generateEmptyFile();
|
||||
|
||||
InvalidFitFileException exception = assertThrows(
|
||||
InvalidFitFileException.class,
|
||||
() -> validator.validate(emptyFile)
|
||||
);
|
||||
|
||||
assertTrue(exception.getMessage().contains("empty"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception for null file")
|
||||
void testValidateNullFile() {
|
||||
InvalidFitFileException exception = assertThrows(
|
||||
InvalidFitFileException.class,
|
||||
() -> validator.validate((byte[]) null)
|
||||
);
|
||||
|
||||
assertTrue(exception.getMessage().contains("empty"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception for file that's too small")
|
||||
void testValidateTooSmallFile() {
|
||||
byte[] tooSmall = TestFitFileGenerator.generateTooSmallFile();
|
||||
|
||||
InvalidFitFileException exception = assertThrows(
|
||||
InvalidFitFileException.class,
|
||||
() -> validator.validate(tooSmall)
|
||||
);
|
||||
|
||||
assertTrue(exception.getMessage().contains("too small"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception for file that's too large")
|
||||
void testValidateTooLargeFile() throws IOException {
|
||||
long tooLarge = 60L * 1024 * 1024; // 60 MB
|
||||
byte[] validHeader = TestFitFileGenerator.generateValidFitFileHeader();
|
||||
ByteArrayInputStream inputStream = new ByteArrayInputStream(validHeader);
|
||||
|
||||
InvalidFitFileException exception = assertThrows(
|
||||
InvalidFitFileException.class,
|
||||
() -> validator.validate(inputStream, tooLarge)
|
||||
);
|
||||
|
||||
assertTrue(exception.getMessage().contains("too large"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception for invalid header size")
|
||||
void testValidateInvalidHeaderSize() {
|
||||
byte[] invalidHeader = TestFitFileGenerator.generateInvalidHeaderSize();
|
||||
|
||||
InvalidFitFileException exception = assertThrows(
|
||||
InvalidFitFileException.class,
|
||||
() -> validator.validate(invalidHeader)
|
||||
);
|
||||
|
||||
assertTrue(exception.getMessage().contains("Invalid FIT header size"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception for invalid signature")
|
||||
void testValidateInvalidSignature() {
|
||||
byte[] invalidSignature = TestFitFileGenerator.generateInvalidSignature();
|
||||
|
||||
InvalidFitFileException exception = assertThrows(
|
||||
InvalidFitFileException.class,
|
||||
() -> validator.validate(invalidSignature)
|
||||
);
|
||||
|
||||
assertTrue(exception.getMessage().contains("Invalid FIT file signature"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should validate file from input stream")
|
||||
void testValidateFromInputStream() throws IOException {
|
||||
byte[] validHeader = TestFitFileGenerator.generateValidFitFileHeader();
|
||||
ByteArrayInputStream inputStream = new ByteArrayInputStream(validHeader);
|
||||
|
||||
assertDoesNotThrow(() -> validator.validate(inputStream, validHeader.length));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should throw exception for input stream with insufficient data")
|
||||
void testValidateInsufficientDataFromStream() throws IOException {
|
||||
byte[] tooSmall = new byte[10];
|
||||
ByteArrayInputStream inputStream = new ByteArrayInputStream(tooSmall);
|
||||
|
||||
InvalidFitFileException exception = assertThrows(
|
||||
InvalidFitFileException.class,
|
||||
() -> validator.validate(inputStream, tooSmall.length)
|
||||
);
|
||||
|
||||
assertTrue(exception.getMessage().contains("too small"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should validate .fit file extension")
|
||||
void testHasValidExtension() {
|
||||
assertTrue(validator.hasValidExtension("activity.fit"));
|
||||
assertTrue(validator.hasValidExtension("ACTIVITY.FIT"));
|
||||
assertTrue(validator.hasValidExtension("path/to/file.fit"));
|
||||
assertTrue(validator.hasValidExtension("file.FIT"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should reject invalid file extensions")
|
||||
void testHasInvalidExtension() {
|
||||
assertFalse(validator.hasValidExtension("activity.gpx"));
|
||||
assertFalse(validator.hasValidExtension("activity.txt"));
|
||||
assertFalse(validator.hasValidExtension("activity"));
|
||||
assertFalse(validator.hasValidExtension(null));
|
||||
assertFalse(validator.hasValidExtension(""));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
package org.operaton.fitpub.util;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.operaton.fitpub.model.entity.Activity;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Integration test for FitParser using a real FIT file.
|
||||
*/
|
||||
@Slf4j
|
||||
class FitParserIntegrationTest {
|
||||
|
||||
private FitParser parser;
|
||||
private FitFileValidator validator;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
parser = new FitParser();
|
||||
validator = new FitFileValidator();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should successfully parse real FIT file from test resources")
|
||||
void testParseRealFitFile() throws IOException {
|
||||
// Load the real FIT file from test resources
|
||||
String fitFileName = "/69287079d5e0a4532ba818ee.fit";
|
||||
InputStream inputStream = getClass().getResourceAsStream(fitFileName);
|
||||
|
||||
assertNotNull(inputStream, "FIT file should exist in test resources: " + fitFileName);
|
||||
|
||||
// Read file into byte array
|
||||
byte[] fileData = inputStream.readAllBytes();
|
||||
inputStream.close();
|
||||
|
||||
// Validate the file
|
||||
assertDoesNotThrow(() -> validator.validate(fileData),
|
||||
"Real FIT file should pass validation");
|
||||
|
||||
// Parse the file
|
||||
FitParser.ParsedFitData parsedData = assertDoesNotThrow(
|
||||
() -> parser.parse(fileData),
|
||||
"Real FIT file should parse without errors"
|
||||
);
|
||||
|
||||
// Verify parsed data structure
|
||||
assertNotNull(parsedData, "Parsed data should not be null");
|
||||
|
||||
// Verify track points
|
||||
assertNotNull(parsedData.getTrackPoints(), "Track points should not be null");
|
||||
assertFalse(parsedData.getTrackPoints().isEmpty(), "Track points should not be empty");
|
||||
|
||||
log.info("Successfully parsed real FIT file:");
|
||||
log.info(" Track points: {}", parsedData.getTrackPoints().size());
|
||||
log.info(" Activity type: {}", parsedData.getActivityType());
|
||||
|
||||
if (parsedData.getStartTime() != null) {
|
||||
log.info(" Start time: {}", parsedData.getStartTime());
|
||||
}
|
||||
|
||||
if (parsedData.getEndTime() != null) {
|
||||
log.info(" End time: {}", parsedData.getEndTime());
|
||||
}
|
||||
|
||||
if (parsedData.getTotalDistance() != null) {
|
||||
log.info(" Total distance: {} meters", parsedData.getTotalDistance());
|
||||
}
|
||||
|
||||
if (parsedData.getTotalDuration() != null) {
|
||||
long minutes = parsedData.getTotalDuration().toMinutes();
|
||||
long seconds = parsedData.getTotalDuration().getSeconds() % 60;
|
||||
log.info(" Total duration: {}m {}s", minutes, seconds);
|
||||
}
|
||||
|
||||
if (parsedData.getElevationGain() != null) {
|
||||
log.info(" Elevation gain: {} meters", parsedData.getElevationGain());
|
||||
}
|
||||
|
||||
if (parsedData.getElevationLoss() != null) {
|
||||
log.info(" Elevation loss: {} meters", parsedData.getElevationLoss());
|
||||
}
|
||||
|
||||
// Verify at least some basic data
|
||||
assertNotNull(parsedData.getActivityType(), "Activity type should be determined");
|
||||
assertTrue(parsedData.getTrackPoints().size() > 0, "Should have at least one track point");
|
||||
|
||||
// Verify track point data quality
|
||||
FitParser.TrackPointData firstPoint = parsedData.getTrackPoints().get(0);
|
||||
assertNotNull(firstPoint, "First track point should not be null");
|
||||
assertNotEquals(0.0, firstPoint.getLatitude(), "Latitude should be set");
|
||||
assertNotEquals(0.0, firstPoint.getLongitude(), "Longitude should be set");
|
||||
|
||||
// Verify GPS coordinates are in valid range
|
||||
assertTrue(firstPoint.getLatitude() >= -90 && firstPoint.getLatitude() <= 90,
|
||||
"Latitude should be in valid range (-90 to 90)");
|
||||
assertTrue(firstPoint.getLongitude() >= -180 && firstPoint.getLongitude() <= 180,
|
||||
"Longitude should be in valid range (-180 to 180)");
|
||||
|
||||
log.info(" First point: lat={}, lon={}", firstPoint.getLatitude(), firstPoint.getLongitude());
|
||||
|
||||
if (firstPoint.getElevation() != null) {
|
||||
log.info(" First point elevation: {} meters", firstPoint.getElevation());
|
||||
}
|
||||
|
||||
if (firstPoint.getHeartRate() != null) {
|
||||
log.info(" First point heart rate: {} bpm", firstPoint.getHeartRate());
|
||||
}
|
||||
|
||||
// Verify metrics if present
|
||||
if (parsedData.getMetrics() != null) {
|
||||
FitParser.ActivityMetricsData metrics = parsedData.getMetrics();
|
||||
log.info("Metrics:");
|
||||
|
||||
if (metrics.getAverageSpeed() != null) {
|
||||
log.info(" Average speed: {} km/h", metrics.getAverageSpeed());
|
||||
}
|
||||
|
||||
if (metrics.getMaxSpeed() != null) {
|
||||
log.info(" Max speed: {} km/h", metrics.getMaxSpeed());
|
||||
}
|
||||
|
||||
if (metrics.getAverageHeartRate() != null) {
|
||||
log.info(" Average heart rate: {} bpm", metrics.getAverageHeartRate());
|
||||
}
|
||||
|
||||
if (metrics.getMaxHeartRate() != null) {
|
||||
log.info(" Max heart rate: {} bpm", metrics.getMaxHeartRate());
|
||||
}
|
||||
|
||||
if (metrics.getCalories() != null) {
|
||||
log.info(" Calories: {}", metrics.getCalories());
|
||||
}
|
||||
|
||||
if (metrics.getAverageCadence() != null) {
|
||||
log.info(" Average cadence: {}", metrics.getAverageCadence());
|
||||
}
|
||||
|
||||
if (metrics.getAveragePower() != null) {
|
||||
log.info(" Average power: {} watts", metrics.getAveragePower());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should extract complete activity data from real FIT file")
|
||||
void testExtractCompleteActivityData() throws IOException {
|
||||
// Load the real FIT file
|
||||
String fitFileName = "/69287079d5e0a4532ba818ee.fit";
|
||||
InputStream inputStream = getClass().getResourceAsStream(fitFileName);
|
||||
byte[] fileData = inputStream.readAllBytes();
|
||||
inputStream.close();
|
||||
|
||||
// Parse the file
|
||||
FitParser.ParsedFitData parsedData = parser.parse(fileData);
|
||||
|
||||
// Test converting to entity structures
|
||||
Activity.ActivityType activityType = parsedData.getActivityType();
|
||||
assertNotNull(activityType, "Activity type should be extracted");
|
||||
|
||||
// Verify we can convert track points to entities
|
||||
if (!parsedData.getTrackPoints().isEmpty()) {
|
||||
FitParser.TrackPointData trackPointData = parsedData.getTrackPoints().get(0);
|
||||
|
||||
// Test geometry creation
|
||||
assertDoesNotThrow(() -> trackPointData.toGeometry(),
|
||||
"Should be able to create Point geometry from track point");
|
||||
|
||||
var point = trackPointData.toGeometry();
|
||||
assertNotNull(point, "Point geometry should not be null");
|
||||
assertEquals(trackPointData.getLongitude(), point.getX(), 0.0001,
|
||||
"Point X coordinate should match longitude");
|
||||
assertEquals(trackPointData.getLatitude(), point.getY(), 0.0001,
|
||||
"Point Y coordinate should match latitude");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should validate real FIT file successfully")
|
||||
void testValidateRealFitFile() throws IOException {
|
||||
// Load the real FIT file
|
||||
String fitFileName = "/69287079d5e0a4532ba818ee.fit";
|
||||
InputStream inputStream = getClass().getResourceAsStream(fitFileName);
|
||||
byte[] fileData = inputStream.readAllBytes();
|
||||
inputStream.close();
|
||||
|
||||
// Should pass all validation checks
|
||||
assertDoesNotThrow(() -> validator.validate(fileData),
|
||||
"Real FIT file should pass validation");
|
||||
|
||||
// File should have valid extension
|
||||
assertTrue(validator.hasValidExtension(fitFileName),
|
||||
"File should have valid .fit extension");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should handle track points in chronological order")
|
||||
void testTrackPointsChronologicalOrder() throws IOException {
|
||||
// Load the real FIT file
|
||||
String fitFileName = "/69287079d5e0a4532ba818ee.fit";
|
||||
InputStream inputStream = getClass().getResourceAsStream(fitFileName);
|
||||
byte[] fileData = inputStream.readAllBytes();
|
||||
inputStream.close();
|
||||
|
||||
FitParser.ParsedFitData parsedData = parser.parse(fileData);
|
||||
|
||||
// Verify track points are in chronological order
|
||||
if (parsedData.getTrackPoints().size() > 1) {
|
||||
for (int i = 0; i < parsedData.getTrackPoints().size() - 1; i++) {
|
||||
FitParser.TrackPointData current = parsedData.getTrackPoints().get(i);
|
||||
FitParser.TrackPointData next = parsedData.getTrackPoints().get(i + 1);
|
||||
|
||||
if (current.getTimestamp() != null && next.getTimestamp() != null) {
|
||||
assertTrue(
|
||||
!current.getTimestamp().isAfter(next.getTimestamp()),
|
||||
"Track points should be in chronological order at index " + i
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Track points are in chronological order");
|
||||
log.info(" First timestamp: {}", parsedData.getTrackPoints().get(0).getTimestamp());
|
||||
log.info(" Last timestamp: {}",
|
||||
parsedData.getTrackPoints().get(parsedData.getTrackPoints().size() - 1).getTimestamp());
|
||||
}
|
||||
}
|
||||
}
|
||||
102
src/test/java/org/operaton/fitpub/util/TestFitFileGenerator.java
Normal file
102
src/test/java/org/operaton/fitpub/util/TestFitFileGenerator.java
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package org.operaton.fitpub.util;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
|
||||
/**
|
||||
* Utility class for generating test FIT files.
|
||||
* Creates minimal valid FIT file structures for testing.
|
||||
*/
|
||||
public class TestFitFileGenerator {
|
||||
|
||||
/**
|
||||
* Generates a minimal valid FIT file header.
|
||||
*/
|
||||
public static byte[] generateValidFitFileHeader() {
|
||||
ByteBuffer buffer = ByteBuffer.allocate(14);
|
||||
buffer.order(ByteOrder.LITTLE_ENDIAN);
|
||||
|
||||
buffer.put((byte) 14); // Header size
|
||||
buffer.put((byte) 0x10); // Protocol version 1.0
|
||||
buffer.putShort((short) 2048); // Profile version
|
||||
buffer.putInt(100); // Data size
|
||||
buffer.put(".FIT".getBytes()); // Signature
|
||||
|
||||
return buffer.array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a FIT file with invalid header size.
|
||||
*/
|
||||
public static byte[] generateInvalidHeaderSize() {
|
||||
byte[] header = generateValidFitFileHeader();
|
||||
header[0] = 20; // Invalid header size
|
||||
return header;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a FIT file with invalid signature.
|
||||
*/
|
||||
public static byte[] generateInvalidSignature() {
|
||||
byte[] header = generateValidFitFileHeader();
|
||||
header[8] = 'X'; // Invalid signature
|
||||
return header;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a minimal valid FIT file with a single data record.
|
||||
* This creates a very basic but valid FIT file structure.
|
||||
*/
|
||||
public static byte[] generateMinimalValidFitFile() throws IOException {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
|
||||
// Write header
|
||||
ByteBuffer header = ByteBuffer.allocate(14);
|
||||
header.order(ByteOrder.LITTLE_ENDIAN);
|
||||
header.put((byte) 14); // Header size
|
||||
header.put((byte) 0x20); // Protocol version 2.0
|
||||
header.putShort((short) 2113); // Profile version 21.13
|
||||
header.putInt(0); // Data size (will update later)
|
||||
header.put(".FIT".getBytes()); // Signature
|
||||
header.putShort((short) 0); // CRC (optional, set to 0)
|
||||
baos.write(header.array());
|
||||
|
||||
// For a real FIT file, we would write definition messages and data messages here
|
||||
// For testing purposes, this minimal header-only file should suffice for validation tests
|
||||
// More complex tests would require actual FIT SDK to generate proper files
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
|
||||
// Update data size in header
|
||||
ByteBuffer dataSize = ByteBuffer.allocate(4);
|
||||
dataSize.order(ByteOrder.LITTLE_ENDIAN);
|
||||
dataSize.putInt(result.length - 14 - 2); // Exclude header and CRC
|
||||
System.arraycopy(dataSize.array(), 0, result, 4, 4);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an empty byte array (invalid FIT file).
|
||||
*/
|
||||
public static byte[] generateEmptyFile() {
|
||||
return new byte[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a file that's too small.
|
||||
*/
|
||||
public static byte[] generateTooSmallFile() {
|
||||
return new byte[10];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a very large file (simulated, not actually allocating the memory).
|
||||
*/
|
||||
public static byte[] generateTooLargeFileHeader() {
|
||||
// Just return a header, tests will check the size parameter
|
||||
return generateValidFitFileHeader();
|
||||
}
|
||||
}
|
||||
BIN
src/test/resources/69287079d5e0a4532ba818ee.fit
Normal file
BIN
src/test/resources/69287079d5e0a4532ba818ee.fit
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue