Batch Import

This commit is contained in:
Tim Zöller 2026-01-03 08:56:57 +01:00
parent 7ecb5456cc
commit a19d4870f7
30 changed files with 3387 additions and 48 deletions

View file

@ -35,6 +35,12 @@ class HeatmapGridServiceTest {
@Mock
private ActivityRepository activityRepository;
@Mock
private jakarta.persistence.EntityManager entityManager;
@Mock
private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
private HeatmapGridService heatmapGridService;
private ObjectMapper objectMapper;
private GeometryFactory geometryFactory;
@ -45,7 +51,9 @@ class HeatmapGridServiceTest {
heatmapGridService = new HeatmapGridService(
heatmapGridRepository,
activityRepository,
objectMapper
objectMapper,
entityManager,
jdbcTemplate
);
geometryFactory = new GeometryFactory(new PrecisionModel(), 4326);
}
@ -160,12 +168,14 @@ class HeatmapGridServiceTest {
.thenReturn(activities);
when(heatmapGridRepository.saveAll(anyList()))
.thenAnswer(invocation -> invocation.getArgument(0));
when(jdbcTemplate.update(anyString(), any(UUID.class)))
.thenReturn(10); // Simulate deleting 10 rows
// Execute
heatmapGridService.recalculateUserHeatmap(user);
// Verify
verify(heatmapGridRepository).deleteByUserId(userId);
verify(jdbcTemplate).update(anyString(), eq(userId));
verify(activityRepository).findByUserIdOrderByStartedAtDesc(userId);
verify(heatmapGridRepository, atLeastOnce()).saveAll(anyList());
}

View file

@ -0,0 +1,311 @@
package org.operaton.fitpub.util;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import org.operaton.fitpub.model.entity.Activity;
import org.operaton.fitpub.model.entity.User;
import org.operaton.fitpub.repository.ActivityRepository;
import org.operaton.fitpub.repository.UserRepository;
import org.operaton.fitpub.service.ActivityFileService;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/**
* Integration test to verify that activity dates are correctly persisted to
* and retrieved from the database.
*/
@SpringBootTest
@ActiveProfiles("test")
@Slf4j
@Transactional
class DatePersistenceTest {
@Autowired
private ActivityFileService activityFileService;
@Autowired
private ActivityRepository activityRepository;
@Autowired
private UserRepository userRepository;
@Autowired
private FitParser fitParser;
@Autowired
private GpxParser gpxParser;
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
@Test
@DisplayName("FIT file dates should persist correctly to database")
void testFitFileDatePersistence() throws IOException {
log.info("=== TESTING FIT FILE DATE PERSISTENCE ===");
// Create test user
User user = createTestUser();
// Load FIT file
String fitFileName = "/69287079d5e0a4532ba818ee.fit";
byte[] fileData = loadTestFile(fitFileName);
// Parse first to see what we expect
ParsedActivityData parsedData = fitParser.parse(fileData);
LocalDateTime expectedStartTime = parsedData.getStartTime();
LocalDateTime expectedEndTime = parsedData.getEndTime();
log.info("BEFORE DATABASE:");
log.info(" Parsed start time: {}", expectedStartTime);
log.info(" Parsed end time: {}", expectedEndTime);
// Upload via service (which saves to DB)
MockMultipartFile mockFile = new MockMultipartFile(
"file",
"test-activity.fit",
"application/octet-stream",
fileData
);
Activity savedActivity = activityFileService.processActivityFile(
mockFile,
user.getId(),
"Test Activity",
"Testing date persistence",
Activity.Visibility.PUBLIC
);
assertNotNull(savedActivity);
assertNotNull(savedActivity.getId());
log.info("");
log.info("AFTER SAVING TO DATABASE:");
log.info(" Saved ID: {}", savedActivity.getId());
log.info(" Saved start time: {}", savedActivity.getStartedAt());
log.info(" Saved end time: {}", savedActivity.getEndedAt());
log.info(" Saved timezone: {}", savedActivity.getTimezone());
// Compare
assertEquals(expectedStartTime, savedActivity.getStartedAt(),
"Start time should match parsed value");
assertEquals(expectedEndTime, savedActivity.getEndedAt(),
"End time should match parsed value");
// Flush to ensure write to DB
activityRepository.flush();
// Query back from database
Activity queriedActivity = activityRepository.findById(savedActivity.getId())
.orElseThrow(() -> new AssertionError("Activity should exist in database"));
log.info("");
log.info("AFTER QUERYING FROM DATABASE:");
log.info(" Queried start time: {}", queriedActivity.getStartedAt());
log.info(" Queried end time: {}", queriedActivity.getEndedAt());
log.info(" Queried timezone: {}", queriedActivity.getTimezone());
// Verify dates survived round-trip
assertEquals(expectedStartTime, queriedActivity.getStartedAt(),
"Queried start time should match original parsed value");
assertEquals(expectedEndTime, queriedActivity.getEndedAt(),
"Queried end time should match original parsed value");
// Also verify it's recent (within last 2 months)
LocalDateTime now = LocalDateTime.now();
LocalDateTime twoMonthsAgo = now.minusMonths(2);
assertTrue(queriedActivity.getStartedAt().isAfter(twoMonthsAgo),
String.format("Activity should be recent. Expected after %s, got %s",
twoMonthsAgo.format(FORMATTER),
queriedActivity.getStartedAt().format(FORMATTER)));
log.info("");
log.info("✅ FIT file date persistence: PASSED");
log.info(" Expected: {}", expectedStartTime);
log.info(" Got: {}", queriedActivity.getStartedAt());
}
@Test
@DisplayName("GPX file dates should persist correctly to database")
void testGpxFileDatePersistence() throws IOException {
log.info("=== TESTING GPX FILE DATE PERSISTENCE ===");
// Create test user
User user = createTestUser();
// Load GPX file
String gpxFileName = "/7410863774.gpx";
byte[] fileData = loadTestFile(gpxFileName);
// Parse first to see what we expect
ParsedActivityData parsedData = gpxParser.parse(fileData);
LocalDateTime expectedStartTime = parsedData.getStartTime();
LocalDateTime expectedEndTime = parsedData.getEndTime();
log.info("BEFORE DATABASE:");
log.info(" Parsed start time: {}", expectedStartTime);
log.info(" Parsed end time: {}", expectedEndTime);
log.info(" Parsed timezone: {}", parsedData.getTimezone());
// Upload via service
MockMultipartFile mockFile = new MockMultipartFile(
"file",
"test-activity.gpx",
"application/gpx+xml",
fileData
);
Activity savedActivity = activityFileService.processActivityFile(
mockFile,
user.getId(),
"Test GPX Activity",
"Testing GPX date persistence",
Activity.Visibility.PUBLIC
);
assertNotNull(savedActivity);
log.info("");
log.info("AFTER SAVING TO DATABASE:");
log.info(" Saved start time: {}", savedActivity.getStartedAt());
log.info(" Saved end time: {}", savedActivity.getEndedAt());
log.info(" Saved timezone: {}", savedActivity.getTimezone());
// Compare
assertEquals(expectedStartTime, savedActivity.getStartedAt(),
"Start time should match parsed value");
assertEquals(expectedEndTime, savedActivity.getEndedAt(),
"End time should match parsed value");
// Query back
activityRepository.flush();
Activity queriedActivity = activityRepository.findById(savedActivity.getId())
.orElseThrow(() -> new AssertionError("Activity should exist"));
log.info("");
log.info("AFTER QUERYING FROM DATABASE:");
log.info(" Queried start time: {}", queriedActivity.getStartedAt());
log.info(" Queried end time: {}", queriedActivity.getEndedAt());
// Verify round-trip
assertEquals(expectedStartTime, queriedActivity.getStartedAt(),
"Queried start time should match original");
assertEquals(expectedEndTime, queriedActivity.getEndedAt(),
"Queried end time should match original");
// This GPX file is from 2022, verify that
assertEquals(2022, queriedActivity.getStartedAt().getYear(),
"GPX file is from 2022");
log.info("");
log.info("✅ GPX file date persistence: PASSED");
log.info(" Expected: {}", expectedStartTime);
log.info(" Got: {}", queriedActivity.getStartedAt());
}
@Test
@DisplayName("Query activities ordered by date should show correct chronological order")
void testQueryActivitiesOrderedByDate() throws IOException {
log.info("=== TESTING ACTIVITY ORDERING BY DATE ===");
User user = createTestUser();
// Upload FIT file (Nov 2025 - recent)
byte[] fitData = loadTestFile("/69287079d5e0a4532ba818ee.fit");
MockMultipartFile fitFile = new MockMultipartFile(
"file", "recent.fit", "application/octet-stream", fitData
);
Activity fitActivity = activityFileService.processActivityFile(
fitFile, user.getId(), "Recent FIT", null, Activity.Visibility.PUBLIC
);
// Upload GPX file (July 2022 - old)
byte[] gpxData = loadTestFile("/7410863774.gpx");
MockMultipartFile gpxFile = new MockMultipartFile(
"file", "old.gpx", "application/gpx+xml", gpxData
);
Activity gpxActivity = activityFileService.processActivityFile(
gpxFile, user.getId(), "Old GPX", null, Activity.Visibility.PUBLIC
);
log.info("");
log.info("UPLOADED ACTIVITIES:");
log.info(" FIT (recent): {} - {}", fitActivity.getTitle(), fitActivity.getStartedAt());
log.info(" GPX (old): {} - {}", gpxActivity.getTitle(), gpxActivity.getStartedAt());
activityRepository.flush();
// Query ordered by date DESC (newest first)
List<Activity> activitiesNewestFirst = activityRepository
.findByUserIdOrderByStartedAtDesc(user.getId());
log.info("");
log.info("QUERY RESULT (newest first):");
for (int i = 0; i < activitiesNewestFirst.size(); i++) {
Activity a = activitiesNewestFirst.get(i);
log.info(" [{}] {} - {}", i, a.getTitle(), a.getStartedAt());
}
// Verify order
assertEquals(2, activitiesNewestFirst.size(), "Should have 2 activities");
Activity first = activitiesNewestFirst.get(0);
Activity second = activitiesNewestFirst.get(1);
// First should be the FIT file (Nov 2025)
assertEquals("Recent FIT", first.getTitle(),
"Newest activity should be the FIT file");
assertEquals(2025, first.getStartedAt().getYear(),
"Newest activity should be from 2025");
// Second should be the GPX file (July 2022)
assertEquals("Old GPX", second.getTitle(),
"Older activity should be the GPX file");
assertEquals(2022, second.getStartedAt().getYear(),
"Older activity should be from 2022");
// Verify chronological order
assertTrue(first.getStartedAt().isAfter(second.getStartedAt()),
String.format("First activity (%s) should be after second (%s)",
first.getStartedAt(), second.getStartedAt()));
log.info("");
log.info("✅ Activity ordering: PASSED");
log.info(" Newest: {} ({})", first.getTitle(), first.getStartedAt());
log.info(" Oldest: {} ({})", second.getTitle(), second.getStartedAt());
}
private User createTestUser() {
User user = User.builder()
.id(UUID.randomUUID())
.username("testuser_" + System.currentTimeMillis())
.email("test_" + System.currentTimeMillis() + "@example.com")
.passwordHash("dummy_hash")
.displayName("Test User")
.publicKey("-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtest\n-----END PUBLIC KEY-----")
.privateKey("-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtest\n-----END PRIVATE KEY-----")
.enabled(true)
.build();
return userRepository.save(user);
}
private byte[] loadTestFile(String resourcePath) throws IOException {
InputStream inputStream = getClass().getResourceAsStream(resourcePath);
assertNotNull(inputStream, "Test file should exist: " + resourcePath);
byte[] data = inputStream.readAllBytes();
inputStream.close();
return data;
}
}

View file

@ -64,10 +64,23 @@ class FitParserIntegrationTest {
if (parsedData.getStartTime() != null) {
log.info(" Start time: {}", parsedData.getStartTime());
// Verify timestamp is reasonable (within 5 years of current time)
long currentUnixTime = System.currentTimeMillis() / 1000;
long activityUnixTime = parsedData.getStartTime()
.atZone(java.time.ZoneId.systemDefault()).toEpochSecond();
long diffDays = Math.abs(currentUnixTime - activityUnixTime) / (24 * 60 * 60);
assertTrue(diffDays < 5 * 365,
String.format("Start time should be within 5 years of now. Got %s (diff: %d days)",
parsedData.getStartTime(), diffDays));
}
if (parsedData.getEndTime() != null) {
log.info(" End time: {}", parsedData.getEndTime());
// End time should be after start time
if (parsedData.getStartTime() != null) {
assertTrue(!parsedData.getEndTime().isBefore(parsedData.getStartTime()),
"End time should be after or equal to start time");
}
}
if (parsedData.getTotalDistance() != null) {

View file

@ -69,10 +69,23 @@ class GpxParserIntegrationTest {
if (parsedData.getStartTime() != null) {
log.info(" Start time: {}", parsedData.getStartTime());
// Verify timestamp is reasonable (within 10 years of current time for GPX files)
long currentUnixTime = System.currentTimeMillis() / 1000;
long activityUnixTime = parsedData.getStartTime()
.atZone(java.time.ZoneId.systemDefault()).toEpochSecond();
long diffDays = Math.abs(currentUnixTime - activityUnixTime) / (24 * 60 * 60);
assertTrue(diffDays < 10 * 365,
String.format("Start time should be within 10 years of now. Got %s (diff: %d days)",
parsedData.getStartTime(), diffDays));
}
if (parsedData.getEndTime() != null) {
log.info(" End time: {}", parsedData.getEndTime());
// End time should be after start time
if (parsedData.getStartTime() != null) {
assertTrue(!parsedData.getEndTime().isBefore(parsedData.getStartTime()),
"End time should be after or equal to start time");
}
}
if (parsedData.getTotalDistance() != null) {

View file

@ -0,0 +1,281 @@
package org.operaton.fitpub.util;
import com.garmin.fit.*;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import static org.junit.jupiter.api.Assertions.*;
/**
* Debugging test to investigate timestamp parsing issues in FIT and GPX files.
* This test logs detailed information about raw timestamps and their conversions.
*/
@Slf4j
class TimestampDebuggingTest {
private FitParser fitParser;
private GpxParser gpxParser;
private SpeedSmoother speedSmoother;
@BeforeEach
void setUp() {
speedSmoother = new SpeedSmoother();
fitParser = new FitParser(speedSmoother);
gpxParser = new GpxParser(speedSmoother);
}
@Test
@DisplayName("Debug FIT file timestamp parsing with detailed logging")
void debugFitTimestampParsing() throws IOException {
log.info("=== FIT FILE TIMESTAMP DEBUGGING ===");
log.info("Current system time: {}", LocalDateTime.now());
log.info("Current system timezone: {}", ZoneId.systemDefault());
log.info("Current Unix timestamp: {}", System.currentTimeMillis() / 1000);
log.info("");
// Load FIT file
String fitFileName = "/69287079d5e0a4532ba818ee.fit";
InputStream inputStream = getClass().getResourceAsStream(fitFileName);
assertNotNull(inputStream, "FIT file should exist");
byte[] fileData = inputStream.readAllBytes();
inputStream.close();
// Parse with FIT SDK directly to inspect raw values
Decode decode = new Decode();
MesgBroadcaster broadcaster = new MesgBroadcaster(decode);
final long FIT_EPOCH_OFFSET = 631065600L;
// Capture session message
broadcaster.addListener(new SessionMesgListener() {
@Override
public void onMesg(SessionMesg mesg) {
log.info("--- SESSION MESSAGE ---");
if (mesg.getStartTime() != null) {
DateTime startTime = mesg.getStartTime();
long rawTimestamp = startTime.getTimestamp();
long unixTimestamp = rawTimestamp + FIT_EPOCH_OFFSET;
Instant instant = Instant.ofEpochSecond(unixTimestamp);
LocalDateTime ldt = LocalDateTime.ofInstant(instant, ZoneId.systemDefault());
ZonedDateTime zdt = ZonedDateTime.ofInstant(instant, ZoneId.of("UTC"));
log.info("Raw FIT timestamp: {} seconds", rawTimestamp);
log.info("FIT epoch offset: {} seconds", FIT_EPOCH_OFFSET);
log.info("Unix timestamp (raw + offset): {} seconds", unixTimestamp);
log.info("Unix timestamp as instant: {}", instant);
log.info("As UTC ZonedDateTime: {}", zdt);
log.info("As LocalDateTime (system TZ): {}", ldt);
log.info("Expected if recent 2024: ~2024-11-27T15:49:09");
log.info("");
// Verify timestamp is reasonable (not in far future or past)
long currentUnixTime = System.currentTimeMillis() / 1000;
long diffSeconds = Math.abs(currentUnixTime - unixTimestamp);
long diffDays = diffSeconds / (24 * 60 * 60);
log.info("Difference from current time: {} days", diffDays);
// Reasonable range: within 5 years
long maxDiffDays = 5 * 365;
assertTrue(diffDays < maxDiffDays,
"Timestamp should be within 5 years of current time. Diff: " + diffDays + " days");
}
if (mesg.getTimestamp() != null) {
DateTime timestamp = mesg.getTimestamp();
long rawTimestamp = timestamp.getTimestamp();
long unixTimestamp = rawTimestamp + FIT_EPOCH_OFFSET;
Instant instant = Instant.ofEpochSecond(unixTimestamp);
log.info("Session timestamp (FIT): {} seconds", rawTimestamp);
log.info("Session timestamp (Unix): {} seconds", unixTimestamp);
log.info("Session timestamp (UTC): {}", ZonedDateTime.ofInstant(instant, ZoneId.of("UTC")));
}
}
});
// Capture first record message
final boolean[] firstRecordCaptured = {false};
broadcaster.addListener(new RecordMesgListener() {
@Override
public void onMesg(RecordMesg mesg) {
if (!firstRecordCaptured[0] && mesg.getTimestamp() != null) {
firstRecordCaptured[0] = true;
log.info("--- FIRST RECORD MESSAGE ---");
DateTime timestamp = mesg.getTimestamp();
long rawTimestamp = timestamp.getTimestamp();
long unixTimestamp = rawTimestamp + FIT_EPOCH_OFFSET;
Instant instant = Instant.ofEpochSecond(unixTimestamp);
log.info("First record raw timestamp: {} seconds", rawTimestamp);
log.info("First record Unix timestamp: {} seconds", unixTimestamp);
log.info("First record as UTC: {}", ZonedDateTime.ofInstant(instant, ZoneId.of("UTC")));
log.info("First record as LocalDateTime: {}",
LocalDateTime.ofInstant(instant, ZoneId.systemDefault()));
if (mesg.getPositionLat() != null && mesg.getPositionLong() != null) {
double lat = mesg.getPositionLat() * (180.0 / Math.pow(2, 31));
double lon = mesg.getPositionLong() * (180.0 / Math.pow(2, 31));
log.info("First record position: lat={}, lon={}", lat, lon);
}
}
}
});
// Decode the file
boolean success = decode.read(new ByteArrayInputStream(fileData), broadcaster);
assertTrue(success, "FIT file should decode successfully");
log.info("");
log.info("=== PARSING WITH FitParser ===");
ParsedActivityData parsedData = fitParser.parse(fileData);
log.info("Parsed start time: {}", parsedData.getStartTime());
log.info("Parsed end time: {}", parsedData.getEndTime());
log.info("Activity type: {}", parsedData.getActivityType());
log.info("Track points: {}", parsedData.getTrackPoints().size());
if (!parsedData.getTrackPoints().isEmpty()) {
ParsedActivityData.TrackPointData firstPoint = parsedData.getTrackPoints().get(0);
log.info("First track point timestamp: {}", firstPoint.getTimestamp());
}
}
@Test
@DisplayName("Debug GPX file timestamp parsing with detailed logging")
void debugGpxTimestampParsing() throws IOException {
log.info("=== GPX FILE TIMESTAMP DEBUGGING ===");
log.info("Current system time: {}", LocalDateTime.now());
log.info("Current system timezone: {}", ZoneId.systemDefault());
log.info("");
// Load GPX file
String gpxFileName = "/7410863774.gpx";
InputStream inputStream = getClass().getResourceAsStream(gpxFileName);
assertNotNull(inputStream, "GPX file should exist");
byte[] fileData = inputStream.readAllBytes();
inputStream.close();
// Parse GPX file
ParsedActivityData parsedData = gpxParser.parse(fileData);
log.info("--- PARSED GPX DATA ---");
log.info("Parsed start time: {}", parsedData.getStartTime());
log.info("Parsed end time: {}", parsedData.getEndTime());
log.info("Activity type: {}", parsedData.getActivityType());
log.info("Timezone: {}", parsedData.getTimezone());
log.info("Track points: {}", parsedData.getTrackPoints().size());
if (!parsedData.getTrackPoints().isEmpty()) {
ParsedActivityData.TrackPointData firstPoint = parsedData.getTrackPoints().get(0);
log.info("First track point timestamp: {}", firstPoint.getTimestamp());
log.info("First track point lat/lon: {}, {}", firstPoint.getLatitude(), firstPoint.getLongitude());
// Check if timestamp is reasonable
if (firstPoint.getTimestamp() != null) {
long currentUnixTime = System.currentTimeMillis() / 1000;
long pointUnixTime = firstPoint.getTimestamp().atZone(ZoneId.systemDefault()).toEpochSecond();
long diffSeconds = Math.abs(currentUnixTime - pointUnixTime);
long diffDays = diffSeconds / (24 * 60 * 60);
log.info("Difference from current time: {} days", diffDays);
// Verify within reasonable range
long maxDiffDays = 10 * 365; // 10 years
assertTrue(diffDays < maxDiffDays,
"Timestamp should be within 10 years of current time. Diff: " + diffDays + " days");
}
}
// Extract and log raw XML timestamp from file
String xmlContent = new String(fileData);
int timeIdx = xmlContent.indexOf("<time>");
if (timeIdx > 0) {
int endIdx = xmlContent.indexOf("</time>", timeIdx);
if (endIdx > 0) {
String rawTimeString = xmlContent.substring(timeIdx + 6, endIdx);
log.info("");
log.info("Raw XML timestamp string: {}", rawTimeString);
log.info("This should be in ISO-8601 format (YYYY-MM-DDTHH:MM:SSZ)");
}
}
}
@Test
@DisplayName("Verify FIT epoch offset calculation")
void verifyFitEpochOffset() {
log.info("=== FIT EPOCH OFFSET VERIFICATION ===");
// FIT epoch: 1989-12-31T00:00:00Z
// Unix epoch: 1970-01-01T00:00:00Z
LocalDateTime fitEpoch = LocalDateTime.of(1989, 12, 31, 0, 0, 0);
LocalDateTime unixEpoch = LocalDateTime.of(1970, 1, 1, 0, 0, 0);
ZonedDateTime fitEpochUtc = fitEpoch.atZone(ZoneId.of("UTC"));
ZonedDateTime unixEpochUtc = unixEpoch.atZone(ZoneId.of("UTC"));
long fitEpochSeconds = fitEpochUtc.toEpochSecond();
long unixEpochSeconds = unixEpochUtc.toEpochSecond();
long calculatedOffset = fitEpochSeconds - unixEpochSeconds;
final long EXPECTED_OFFSET = 631065600L;
log.info("Unix epoch: {}", unixEpochUtc);
log.info("Unix epoch seconds: {}", unixEpochSeconds);
log.info("FIT epoch: {}", fitEpochUtc);
log.info("FIT epoch seconds: {}", fitEpochSeconds);
log.info("Calculated offset: {} seconds", calculatedOffset);
log.info("Expected offset: {} seconds", EXPECTED_OFFSET);
log.info("Offset in days: {} days", calculatedOffset / (24 * 60 * 60));
log.info("Offset in years: {} years (approx)", calculatedOffset / (24 * 60 * 60 * 365.25));
assertEquals(EXPECTED_OFFSET, calculatedOffset,
"Calculated FIT epoch offset should match expected value");
}
@Test
@DisplayName("Test manual timestamp conversion examples")
void testManualTimestampConversions() {
log.info("=== MANUAL TIMESTAMP CONVERSION EXAMPLES ===");
final long FIT_EPOCH_OFFSET = 631065600L;
// Example: Convert a known date to see what FIT timestamp it should have
// Let's say we know the activity should be from 2024-11-27T15:49:09 UTC
LocalDateTime expectedDate = LocalDateTime.of(2024, 11, 27, 15, 49, 9);
ZonedDateTime expectedUtc = expectedDate.atZone(ZoneId.of("UTC"));
long expectedUnixTimestamp = expectedUtc.toEpochSecond();
long expectedFitTimestamp = expectedUnixTimestamp - FIT_EPOCH_OFFSET;
log.info("Expected date: {}", expectedDate);
log.info("Expected Unix timestamp: {}", expectedUnixTimestamp);
log.info("Expected FIT timestamp (Unix - offset): {}", expectedFitTimestamp);
log.info("");
// Now reverse: what date do we get if FIT timestamp is X?
// This simulates what the parser does
long simulatedFitTimestamp = expectedFitTimestamp;
long calculatedUnixTimestamp = simulatedFitTimestamp + FIT_EPOCH_OFFSET;
Instant instant = Instant.ofEpochSecond(calculatedUnixTimestamp);
LocalDateTime calculatedDate = LocalDateTime.ofInstant(instant, ZoneId.of("UTC"));
log.info("Simulated FIT timestamp: {}", simulatedFitTimestamp);
log.info("Calculated Unix timestamp (FIT + offset): {}", calculatedUnixTimestamp);
log.info("Calculated date: {}", calculatedDate);
log.info("");
// They should match
assertEquals(expectedDate, calculatedDate,
"Round-trip conversion should produce the same date");
log.info("Round-trip conversion: PASSED ✓");
}
}