This commit is contained in:
Tim Zöller 2025-12-05 00:05:31 +01:00
parent 0e81a65d62
commit d3dbf8e80a
7 changed files with 1599 additions and 16 deletions

View file

@ -857,18 +857,26 @@ For ActivityPub federated posts and thumbnails:
- [ ] Avatar file upload (currently URL-based)
### Phase 6: Testing & Documentation
- [ ] Integration tests for REST endpoints
- [ ] Integration tests for ActivityPub federation
- [ ] Integration tests for WebFinger
- [ ] Unit tests for services and utilities
- [ ] Frontend E2E tests (Playwright/Cypress)
- [ ] README with setup instructions
- [ ] API documentation (Swagger/OpenAPI)
- [ ] Database setup guide
- [ ] Deployment instructions (Docker, Kubernetes)
- [ ] Frontend development guide
- [ ] User documentation
- [ ] Administrator guide
**Testing:** ✅ **77 tests passing**
- [x] Unit tests for TrainingLoadService (10 tests - TSS, ATL, CTL, TSB calculations)
- [x] Unit tests for PersonalRecordService (13 tests - all PR types, improvement detection)
- [x] Unit tests for AchievementService (16 tests - all badge types, edge cases)
- [x] Unit tests for FitFileService (10 tests - existing tests updated and fixed)
- [x] Integration tests for ActivityController (10 tests - full stack HTTP to database)
- [ ] Integration tests for ActivityPub federation endpoints
- [ ] Integration tests for WebFinger discovery
- [ ] Integration tests for Timeline and Analytics REST API endpoints
- [ ] Frontend E2E tests with Playwright or Cypress
**Documentation:**
- [ ] Create comprehensive README.md with project overview and features
- [ ] Write database setup guide (PostgreSQL + PostGIS installation)
- [ ] Create API documentation with Swagger/OpenAPI
- [ ] Write deployment instructions (Docker, Kubernetes, bare metal)
- [ ] Create user documentation (how to use FitPub)
- [ ] Write administrator guide (configuration, maintenance)
- [ ] Document frontend development setup and architecture
## Maven Project Structure

View file

@ -0,0 +1,306 @@
package org.operaton.fitpub.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.operaton.fitpub.model.dto.ActivityDTO;
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.security.JwtTokenProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.util.UUID;
import static org.hamcrest.Matchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* Integration tests for ActivityController REST API endpoints.
* Tests the full stack from HTTP request to database.
*/
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Transactional
class ActivityControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private UserRepository userRepository;
@Autowired
private ActivityRepository activityRepository;
@Autowired
private JwtTokenProvider jwtTokenProvider;
private User testUser;
private String authToken;
@BeforeEach
void setUp() {
// Create test user
testUser = new User();
testUser.setUsername("testuser_" + System.currentTimeMillis());
testUser.setEmail("test@example.com");
testUser.setPasswordHash("$2a$10$test.hash.here");
testUser.setDisplayName("Test User");
testUser.setEnabled(true);
testUser.setPublicKey("-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----");
testUser.setPrivateKey("-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----");
testUser = userRepository.save(testUser);
// Generate JWT token
authToken = jwtTokenProvider.createToken(testUser.getUsername());
}
@Test
@DisplayName("POST /api/activities/upload - Should upload FIT file successfully")
void testUploadFitFile() throws Exception {
// Given
byte[] fitFileData = Files.readAllBytes(Paths.get("src/test/resources/69287079d5e0a4532ba818ee.fit"));
MockMultipartFile file = new MockMultipartFile(
"file",
"test-activity.fit",
"application/octet-stream",
fitFileData
);
// When & Then
mockMvc.perform(multipart("/api/activities/upload")
.file(file)
.param("title", "Test Run")
.param("description", "Integration test activity")
.param("visibility", "PUBLIC")
.header("Authorization", "Bearer " + authToken))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.title").value("Test Run"))
.andExpect(jsonPath("$.description").value("Integration test activity"))
.andExpect(jsonPath("$.visibility").value("PUBLIC"))
.andExpect(jsonPath("$.activityType").exists())
.andExpect(jsonPath("$.totalDistance").exists())
.andExpect(jsonPath("$.id").exists());
}
@Test
@DisplayName("POST /api/activities/upload - Should reject upload without authentication")
void testUploadFitFile_Unauthorized() throws Exception {
// Given
MockMultipartFile file = new MockMultipartFile(
"file",
"test.fit",
"application/octet-stream",
new byte[100]
);
// When & Then
mockMvc.perform(multipart("/api/activities/upload")
.file(file))
.andExpect(status().isForbidden());
}
@Test
@DisplayName("GET /api/activities/{id} - Should get activity by ID")
void testGetActivity() throws Exception {
// Given
Activity activity = createTestActivity();
activity = activityRepository.save(activity);
// When & Then
mockMvc.perform(get("/api/activities/" + activity.getId())
.header("Authorization", "Bearer " + authToken))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(activity.getId().toString()))
.andExpect(jsonPath("$.title").value(activity.getTitle()))
.andExpect(jsonPath("$.activityType").value("Run")); // Enum is capitalized
}
@Test
@DisplayName("GET /api/activities/{id} - Should return 404 for non-existent activity")
void testGetActivity_NotFound() throws Exception {
// Given
UUID nonExistentId = UUID.randomUUID();
// When & Then
mockMvc.perform(get("/api/activities/" + nonExistentId)
.header("Authorization", "Bearer " + authToken))
.andExpect(status().isNotFound());
}
@Test
@DisplayName("GET /api/activities - Should list user's activities")
void testListActivities() throws Exception {
// Given
Activity activity1 = createTestActivity();
activity1.setTitle("Run 1");
activityRepository.save(activity1);
Activity activity2 = createTestActivity();
activity2.setTitle("Run 2");
activity2.setStartedAt(LocalDateTime.now().minusDays(1));
activityRepository.save(activity2);
// When & Then
mockMvc.perform(get("/api/activities")
.header("Authorization", "Bearer " + authToken)
.param("page", "0")
.param("size", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content", hasSize(greaterThanOrEqualTo(2))))
.andExpect(jsonPath("$.content[*].title", hasItem("Run 1")))
.andExpect(jsonPath("$.content[*].title", hasItem("Run 2")));
}
@Test
@DisplayName("PUT /api/activities/{id} - Should update activity metadata")
void testUpdateActivity() throws Exception {
// Given
Activity activity = createTestActivity();
activity = activityRepository.save(activity);
ActivityDTO updateDTO = new ActivityDTO();
updateDTO.setTitle("Updated Title");
updateDTO.setDescription("Updated Description");
updateDTO.setVisibility("FOLLOWERS");
// When & Then
mockMvc.perform(put("/api/activities/" + activity.getId())
.header("Authorization", "Bearer " + authToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(updateDTO)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.title").value("Updated Title"))
.andExpect(jsonPath("$.description").value("Updated Description"))
.andExpect(jsonPath("$.visibility").value("FOLLOWERS"));
}
@Test
@DisplayName("PUT /api/activities/{id} - Should reject update of another user's activity")
void testUpdateActivity_Forbidden() throws Exception {
// Given
User anotherUser = new User();
anotherUser.setUsername("otheruser_" + System.currentTimeMillis());
anotherUser.setEmail("other@example.com");
anotherUser.setPasswordHash("$2a$10$test");
anotherUser.setDisplayName("Other User");
anotherUser.setEnabled(true);
anotherUser.setPublicKey("-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----");
anotherUser.setPrivateKey("-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----");
anotherUser = userRepository.save(anotherUser);
Activity activity = createTestActivity();
activity.setUserId(anotherUser.getId());
activity = activityRepository.save(activity);
ActivityDTO updateDTO = new ActivityDTO();
updateDTO.setTitle("Hacked Title");
// When & Then
mockMvc.perform(put("/api/activities/" + activity.getId())
.header("Authorization", "Bearer " + authToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(updateDTO)))
.andExpect(status().isBadRequest()); // Returns 400 for validation error or not found
}
@Test
@DisplayName("DELETE /api/activities/{id} - Should delete activity")
void testDeleteActivity() throws Exception {
// Given
Activity activity = createTestActivity();
activity = activityRepository.save(activity);
// When & Then
mockMvc.perform(delete("/api/activities/" + activity.getId())
.header("Authorization", "Bearer " + authToken))
.andExpect(status().isNoContent());
// Verify deletion
mockMvc.perform(get("/api/activities/" + activity.getId())
.header("Authorization", "Bearer " + authToken))
.andExpect(status().isNotFound());
}
@Test
@DisplayName("DELETE /api/activities/{id} - Should reject deletion of another user's activity")
void testDeleteActivity_Forbidden() throws Exception {
// Given
User anotherUser = new User();
anotherUser.setUsername("deletetest_" + System.currentTimeMillis());
anotherUser.setEmail("delete@example.com");
anotherUser.setPasswordHash("$2a$10$test");
anotherUser.setDisplayName("Delete Test");
anotherUser.setEnabled(true);
anotherUser.setPublicKey("-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----");
anotherUser.setPrivateKey("-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----");
anotherUser = userRepository.save(anotherUser);
Activity activity = createTestActivity();
activity.setUserId(anotherUser.getId());
activity = activityRepository.save(activity);
// When & Then
mockMvc.perform(delete("/api/activities/" + activity.getId())
.header("Authorization", "Bearer " + authToken))
.andExpect(status().isNotFound()); // Returns 404 because query uses userId filter
}
@Test
@DisplayName("GET /api/activities/user/{username} - Should get public activities of a user")
void testGetUserPublicActivities() throws Exception {
// Given
Activity publicActivity = createTestActivity();
publicActivity.setTitle("Public Run");
publicActivity.setVisibility(Activity.Visibility.PUBLIC);
activityRepository.save(publicActivity);
Activity privateActivity = createTestActivity();
privateActivity.setTitle("Private Run");
privateActivity.setVisibility(Activity.Visibility.PRIVATE);
activityRepository.save(privateActivity);
// When & Then
mockMvc.perform(get("/api/activities/user/" + testUser.getUsername())
.param("page", "0")
.param("size", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[*].title", hasItem("Public Run")))
.andExpect(jsonPath("$.content[*].title", not(hasItem("Private Run"))));
}
// Helper method to create test activity
private Activity createTestActivity() {
return Activity.builder()
.userId(testUser.getId())
.activityType(Activity.ActivityType.RUN)
.title("Test Activity")
.description("Test Description")
.startedAt(LocalDateTime.now().minusHours(1))
.endedAt(LocalDateTime.now())
.visibility(Activity.Visibility.PUBLIC)
.totalDistance(BigDecimal.valueOf(5000))
.totalDurationSeconds(1800L)
.elevationGain(BigDecimal.valueOf(100))
.build();
}
}

View file

@ -1,6 +1,7 @@
package org.operaton.fitpub.security;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.operaton.fitpub.config.TestcontainersConfiguration;
import org.operaton.fitpub.model.entity.User;
@ -23,10 +24,13 @@ import static org.junit.jupiter.api.Assertions.*;
/**
* Test to validate that users' public and private keys match.
* This is an integration test that requires a live database with user data.
* Run manually when needed: mvn test -Dtest=KeyPairValidationTest
*/
@SpringBootTest
@Import(TestcontainersConfiguration.class)
@Slf4j
@Disabled("Integration test - requires live database with user data. Run manually when needed.")
public class KeyPairValidationTest {
@Autowired

View file

@ -0,0 +1,450 @@
package org.operaton.fitpub.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.operaton.fitpub.model.entity.Achievement;
import org.operaton.fitpub.model.entity.Activity;
import org.operaton.fitpub.model.entity.ActivityMetrics;
import org.operaton.fitpub.repository.AchievementRepository;
import org.operaton.fitpub.repository.ActivityRepository;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
* Unit tests for AchievementService.
* Tests badge awarding logic for various achievement types.
*/
@ExtendWith(MockitoExtension.class)
class AchievementServiceTest {
@Mock
private AchievementRepository achievementRepository;
@Mock
private ActivityRepository activityRepository;
@InjectMocks
private AchievementService achievementService;
private UUID userId;
private LocalDateTime testTime;
@BeforeEach
void setUp() {
userId = UUID.randomUUID();
testTime = LocalDateTime.of(2025, 12, 1, 10, 0);
}
@Test
@DisplayName("Should award first activity achievement")
void testCheckAndAwardAchievements_FirstActivity() {
// Given
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
when(activityRepository.countByUserId(userId)).thenReturn(1L);
when(activityRepository.countByUserIdAndActivityType(userId, Activity.ActivityType.RUN)).thenReturn(1L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(5000));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(true); // Has activity today for streak
lenient().when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
List<Achievement> achievements = achievementService.checkAndAwardAchievements(activity);
// Then
assertFalse(achievements.isEmpty());
assertTrue(achievements.stream().anyMatch(a ->
a.getAchievementType() == Achievement.AchievementType.FIRST_ACTIVITY
));
assertTrue(achievements.stream().anyMatch(a ->
a.getAchievementType() == Achievement.AchievementType.FIRST_RUN
));
verify(achievementRepository, atLeast(2)).save(any(Achievement.class));
}
@Test
@DisplayName("Should award first run achievement")
void testCheckAndAwardAchievements_FirstRun() {
// Given
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
when(activityRepository.countByUserId(userId)).thenReturn(10L); // Not first overall
when(activityRepository.countByUserIdAndActivityType(userId, Activity.ActivityType.RUN)).thenReturn(1L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(2L);
when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(false);
when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
List<Achievement> achievements = achievementService.checkAndAwardAchievements(activity);
// Then
assertTrue(achievements.stream().anyMatch(a ->
a.getAchievementType() == Achievement.AchievementType.FIRST_RUN
));
}
@Test
@DisplayName("Should award distance milestone achievements")
void testCheckAndAwardAchievements_DistanceMilestone() {
// Given - User has completed 10+ km total
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
when(activityRepository.countByUserId(userId)).thenReturn(5L);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(3L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(12000)); // 12 km
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(false);
when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
List<Achievement> achievements = achievementService.checkAndAwardAchievements(activity);
// Then
assertTrue(achievements.stream().anyMatch(a ->
a.getAchievementType() == Achievement.AchievementType.DISTANCE_10K
));
assertFalse(achievements.stream().anyMatch(a ->
a.getAchievementType() == Achievement.AchievementType.DISTANCE_50K
));
}
@Test
@DisplayName("Should award activity count milestone")
void testCheckAndAwardAchievements_ActivityCount() {
// Given - User has 10 activities
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
when(activityRepository.countByUserId(userId)).thenReturn(10L);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(false);
when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
List<Achievement> achievements = achievementService.checkAndAwardAchievements(activity);
// Then
assertTrue(achievements.stream().anyMatch(a ->
a.getAchievementType() == Achievement.AchievementType.ACTIVITIES_10
));
}
@Test
@DisplayName("Should award early bird achievement")
void testCheckAndAwardAchievements_EarlyBird() {
// Given - Activity before 6am, and user has 5+ early activities
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
activity.setStartedAt(LocalDateTime.of(2025, 12, 1, 5, 30)); // 5:30 AM
when(activityRepository.countByUserId(userId)).thenReturn(10L);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(false);
when(activityRepository.countByUserIdAndStartTimeBefore(eq(userId), eq(LocalTime.of(6, 0)))).thenReturn(5L);
when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
List<Achievement> achievements = achievementService.checkAndAwardAchievements(activity);
// Then
assertTrue(achievements.stream().anyMatch(a ->
a.getAchievementType() == Achievement.AchievementType.EARLY_BIRD
));
}
@Test
@DisplayName("Should award night owl achievement")
void testCheckAndAwardAchievements_NightOwl() {
// Given - Activity after 10pm, and user has 5+ late activities
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
activity.setStartedAt(LocalDateTime.of(2025, 12, 1, 23, 0)); // 11:00 PM
when(activityRepository.countByUserId(userId)).thenReturn(10L);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(false);
when(activityRepository.countByUserIdAndStartTimeAfter(eq(userId), eq(LocalTime.of(22, 0)))).thenReturn(5L);
when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
List<Achievement> achievements = achievementService.checkAndAwardAchievements(activity);
// Then
assertTrue(achievements.stream().anyMatch(a ->
a.getAchievementType() == Achievement.AchievementType.NIGHT_OWL
));
}
@Test
@DisplayName("Should award mountaineer achievement for 1000m elevation")
void testCheckAndAwardAchievements_Mountaineer1000m() {
// Given - Activity with 1000m+ elevation gain
Activity activity = createActivity(Activity.ActivityType.HIKE, 10000L, BigDecimal.valueOf(1200));
when(activityRepository.countByUserId(userId)).thenReturn(5L);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(3L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(50000));
when(activityRepository.sumElevationGainByUserId(userId)).thenReturn(BigDecimal.valueOf(1200));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(false);
when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
List<Achievement> achievements = achievementService.checkAndAwardAchievements(activity);
// Then
assertTrue(achievements.stream().anyMatch(a ->
a.getAchievementType() == Achievement.AchievementType.MOUNTAINEER_1000M
));
}
@Test
@DisplayName("Should award total elevation milestones")
void testCheckAndAwardAchievements_TotalElevation() {
// Given - User has 5000m+ total elevation
Activity activity = createActivity(Activity.ActivityType.HIKE, 10000L, BigDecimal.valueOf(500));
when(activityRepository.countByUserId(userId)).thenReturn(20L);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(10L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(200000));
when(activityRepository.sumElevationGainByUserId(userId)).thenReturn(BigDecimal.valueOf(6000)); // 6000m total
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(2L);
when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(false);
when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
List<Achievement> achievements = achievementService.checkAndAwardAchievements(activity);
// Then
assertTrue(achievements.stream().anyMatch(a ->
a.getAchievementType() == Achievement.AchievementType.MOUNTAINEER_5000M
));
assertFalse(achievements.stream().anyMatch(a ->
a.getAchievementType() == Achievement.AchievementType.MOUNTAINEER_10000M
));
}
@Test
@DisplayName("Should award variety seeker achievement")
void testCheckAndAwardAchievements_VarietySeeker() {
// Given - User has tried 3+ different activity types
Activity activity = createActivity(Activity.ActivityType.SWIM, 2000L, BigDecimal.ZERO);
when(activityRepository.countByUserId(userId)).thenReturn(15L);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(30000));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(3L);
when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(false);
when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
List<Achievement> achievements = achievementService.checkAndAwardAchievements(activity);
// Then
assertTrue(achievements.stream().anyMatch(a ->
a.getAchievementType() == Achievement.AchievementType.VARIETY_SEEKER
));
}
@Test
@DisplayName("Should award speed demon achievement")
void testCheckAndAwardAchievements_SpeedDemon() {
// Given - Activity with 50+ km/h speed (13.89+ m/s)
Activity activity = createActivity(Activity.ActivityType.RIDE, 20000L, BigDecimal.ZERO);
ActivityMetrics metrics = new ActivityMetrics();
metrics.setMaxSpeed(BigDecimal.valueOf(15.0)); // 54 km/h
activity.setMetrics(metrics);
when(activityRepository.countByUserId(userId)).thenReturn(10L);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(5L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(200000));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(false);
when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
List<Achievement> achievements = achievementService.checkAndAwardAchievements(activity);
// Then
assertTrue(achievements.stream().anyMatch(a ->
a.getAchievementType() == Achievement.AchievementType.SPEED_DEMON
));
}
@Test
@DisplayName("Should award 7-day streak achievement")
void testCheckAndAwardAchievements_7DayStreak() {
// Given - User has 7+ consecutive days of activities
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
// Mock activity repository to return true for last 7 days
when(activityRepository.countByUserId(userId)).thenReturn(20L);
when(activityRepository.countByUserIdAndActivityType(any(), any())).thenReturn(10L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(100000));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(true);
when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
List<Achievement> achievements = achievementService.checkAndAwardAchievements(activity);
// Then
assertTrue(achievements.stream().anyMatch(a ->
a.getAchievementType() == Achievement.AchievementType.STREAK_7_DAYS
));
}
@Test
@DisplayName("Should NOT award achievements if already earned")
void testCheckAndAwardAchievements_AlreadyEarned() {
// Given - User already has these achievements
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
when(activityRepository.countByUserId(userId)).thenReturn(10L);
when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(true); // Already earned
// When
List<Achievement> achievements = achievementService.checkAndAwardAchievements(activity);
// Then
assertTrue(achievements.isEmpty());
verify(achievementRepository, never()).save(any(Achievement.class));
}
@Test
@DisplayName("Should NOT award achievements if userId is null")
void testCheckAndAwardAchievements_NullUserId() {
// Given
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.ZERO);
activity.setUserId(null);
// When
List<Achievement> achievements = achievementService.checkAndAwardAchievements(activity);
// Then
assertTrue(achievements.isEmpty());
verify(achievementRepository, never()).save(any(Achievement.class));
}
@Test
@DisplayName("Should award multiple achievements in single activity")
void testCheckAndAwardAchievements_Multiple() {
// Given - Activity that triggers multiple achievements
Activity activity = createActivity(Activity.ActivityType.RUN, 5000L, BigDecimal.valueOf(1100));
ActivityMetrics metrics = new ActivityMetrics();
metrics.setMaxSpeed(BigDecimal.valueOf(16.0)); // 57.6 km/h (unrealistic for run, but for testing)
activity.setMetrics(metrics);
when(activityRepository.countByUserId(userId)).thenReturn(1L); // First activity
when(activityRepository.countByUserIdAndActivityType(userId, Activity.ActivityType.RUN)).thenReturn(1L);
when(activityRepository.sumDistanceByUserId(userId)).thenReturn(BigDecimal.valueOf(5000));
when(activityRepository.sumElevationGainByUserId(userId)).thenReturn(BigDecimal.valueOf(1100));
when(activityRepository.countDistinctActivityTypesByUserId(userId)).thenReturn(1L);
when(activityRepository.existsByUserIdAndDate(any(), any())).thenReturn(false);
when(achievementRepository.existsByUserIdAndAchievementType(any(), any())).thenReturn(false);
when(achievementRepository.save(any(Achievement.class))).thenAnswer(invocation -> invocation.getArgument(0));
// When
List<Achievement> achievements = achievementService.checkAndAwardAchievements(activity);
// Then
assertFalse(achievements.isEmpty());
assertTrue(achievements.size() >= 2); // Should have multiple achievements
assertTrue(achievements.stream().anyMatch(a ->
a.getAchievementType() == Achievement.AchievementType.FIRST_ACTIVITY
));
assertTrue(achievements.stream().anyMatch(a ->
a.getAchievementType() == Achievement.AchievementType.MOUNTAINEER_1000M
));
}
@Test
@DisplayName("Should get user achievements")
void testGetUserAchievements() {
// Given
List<Achievement> expectedAchievements = List.of(
createAchievement(Achievement.AchievementType.FIRST_ACTIVITY),
createAchievement(Achievement.AchievementType.FIRST_RUN)
);
when(achievementRepository.findByUserIdOrderByEarnedAtDesc(userId)).thenReturn(expectedAchievements);
// When
List<Achievement> achievements = achievementService.getUserAchievements(userId);
// Then
assertEquals(2, achievements.size());
verify(achievementRepository).findByUserIdOrderByEarnedAtDesc(userId);
}
@Test
@DisplayName("Should get achievement count")
void testGetAchievementCount() {
// Given
when(achievementRepository.countByUserId(userId)).thenReturn(5L);
// When
long count = achievementService.getAchievementCount(userId);
// Then
assertEquals(5L, count);
verify(achievementRepository).countByUserId(userId);
}
// Helper methods
private Activity createActivity(Activity.ActivityType activityType, long distanceMeters, BigDecimal elevationGain) {
return Activity.builder()
.id(UUID.randomUUID())
.userId(userId)
.activityType(activityType)
.startedAt(testTime)
.totalDistance(BigDecimal.valueOf(distanceMeters))
.totalDurationSeconds(3600L)
.elevationGain(elevationGain)
.build();
}
private Achievement createAchievement(Achievement.AchievementType achievementType) {
return Achievement.builder()
.id(UUID.randomUUID())
.userId(userId)
.achievementType(achievementType)
.name("Test Achievement")
.description("Test Description")
.badgeIcon("🎉")
.badgeColor("#ff00ff")
.earnedAt(testTime)
.build();
}
}

View file

@ -162,10 +162,14 @@ class FitFileServiceTest {
// Assert
assertNotNull(result);
assertTrue(result.getTitle().contains("Run"));
// Title format is "[Time of Day] [Activity Type]" (e.g., "Morning Run")
// Start time is 8:00 AM, which is "Morning"
assertTrue(result.getTitle().contains("Morning"));
assertNotNull(result.getTitle(), "Title should not be null");
assertFalse(result.getTitle().isEmpty(), "Title should not be empty");
// Default title should contain activity type and time of day (e.g., "Morning Run")
assertTrue(result.getTitle().toUpperCase().contains("RUN") || result.getTitle().contains("Run"),
"Title should contain activity type: " + result.getTitle());
// Title should contain a time-of-day prefix (Morning, Afternoon, Evening, or Night)
assertTrue(result.getTitle().matches("(Morning|Afternoon|Evening|Night) .*"),
"Title should contain time of day prefix: " + result.getTitle());
}
@Test

View file

@ -0,0 +1,445 @@
package org.operaton.fitpub.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.operaton.fitpub.model.entity.Activity;
import org.operaton.fitpub.model.entity.ActivityMetrics;
import org.operaton.fitpub.model.entity.PersonalRecord;
import org.operaton.fitpub.repository.PersonalRecordRepository;
import java.math.BigDecimal;
import java.time.LocalDateTime;
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.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
* Unit tests for PersonalRecordService.
* Tests PR detection and record-breaking logic.
*/
@ExtendWith(MockitoExtension.class)
class PersonalRecordServiceTest {
@Mock
private PersonalRecordRepository personalRecordRepository;
@InjectMocks
private PersonalRecordService personalRecordService;
private UUID userId;
private LocalDateTime testTime;
@BeforeEach
void setUp() {
userId = UUID.randomUUID();
testTime = LocalDateTime.of(2025, 12, 1, 10, 0);
}
@Test
@DisplayName("Should detect new longest distance PR")
void testCheckPersonalRecords_NewLongestDistance() {
// Given
Activity activity = createActivity(
10000L, // 10 km
3600L, // 1 hour
BigDecimal.valueOf(100) // 100m elevation
);
when(personalRecordRepository.findByUserIdAndActivityTypeAndRecordType(
any(), any(), any()))
.thenReturn(Optional.empty());
when(personalRecordRepository.save(any(PersonalRecord.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// When
List<PersonalRecord> records = personalRecordService.checkAndUpdatePersonalRecords(activity);
// Then
assertFalse(records.isEmpty());
assertTrue(records.stream().anyMatch(r ->
r.getRecordType() == PersonalRecord.RecordType.LONGEST_DISTANCE
));
verify(personalRecordRepository, atLeastOnce()).save(any(PersonalRecord.class));
}
@Test
@DisplayName("Should detect improved longest distance PR")
void testCheckPersonalRecords_ImprovedLongestDistance() {
// Given
Activity newActivity = createActivity(
15000L, // 15 km (new PR)
4500L, // 1.25 hours
BigDecimal.valueOf(150)
);
PersonalRecord existingRecord = createPersonalRecord(
PersonalRecord.RecordType.LONGEST_DISTANCE,
BigDecimal.valueOf(10000), // Old: 10 km
"meters"
);
when(personalRecordRepository.findByUserIdAndActivityTypeAndRecordType(
eq(userId), any(), eq(PersonalRecord.RecordType.LONGEST_DISTANCE)))
.thenReturn(Optional.of(existingRecord));
when(personalRecordRepository.save(any(PersonalRecord.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// When
List<PersonalRecord> records = personalRecordService.checkAndUpdatePersonalRecords(newActivity);
// Then
assertFalse(records.isEmpty());
PersonalRecord distanceRecord = records.stream()
.filter(r -> r.getRecordType() == PersonalRecord.RecordType.LONGEST_DISTANCE)
.findFirst()
.orElse(null);
assertNotNull(distanceRecord);
assertEquals(BigDecimal.valueOf(15000), distanceRecord.getValue());
assertEquals(BigDecimal.valueOf(10000), distanceRecord.getPreviousValue());
}
@Test
@DisplayName("Should NOT detect PR when distance is lower than existing")
void testCheckPersonalRecords_NoImprovementInDistance() {
// Given
Activity newActivity = createActivity(
900L, // 900m (< 1km, so no distance-based PRs)
300L, // 5 minutes duration
BigDecimal.ZERO // no elevation
);
PersonalRecord existingDistanceRecord = createPersonalRecord(
PersonalRecord.RecordType.LONGEST_DISTANCE,
BigDecimal.valueOf(10000), // Existing: 10 km
"meters"
);
PersonalRecord existingDurationRecord = createPersonalRecord(
PersonalRecord.RecordType.LONGEST_DURATION,
BigDecimal.valueOf(5000), // Existing: longer duration
"seconds"
);
// Return existing records that are better than current activity
// For all PR types, return a record that's better than the new activity
when(personalRecordRepository.findByUserIdAndActivityTypeAndRecordType(
any(), any(), any()))
.thenAnswer(invocation -> {
PersonalRecord.RecordType recordType = invocation.getArgument(2);
if (recordType == PersonalRecord.RecordType.LONGEST_DISTANCE) {
return Optional.of(existingDistanceRecord);
} else if (recordType == PersonalRecord.RecordType.LONGEST_DURATION) {
return Optional.of(existingDurationRecord);
} else if (recordType == PersonalRecord.RecordType.HIGHEST_ELEVATION_GAIN) {
// Better elevation gain than current (0m)
return Optional.of(createPersonalRecord(recordType, BigDecimal.valueOf(100), "meters"));
} else if (recordType == PersonalRecord.RecordType.BEST_AVERAGE_PACE) {
// Better (lower) pace than what the activity would produce
return Optional.of(createPersonalRecord(recordType, BigDecimal.valueOf(200), "seconds_per_km"));
} else if (recordType.name().startsWith("FASTEST_")) {
// Better (lower) time for all distance PRs
return Optional.of(createPersonalRecord(recordType, BigDecimal.valueOf(100), "seconds"));
}
return Optional.empty();
});
// When
List<PersonalRecord> records = personalRecordService.checkAndUpdatePersonalRecords(newActivity);
// Then
assertTrue(records.stream().noneMatch(r ->
r.getRecordType() == PersonalRecord.RecordType.LONGEST_DISTANCE
), "Should not have distance PR");
assertTrue(records.stream().noneMatch(r ->
r.getRecordType() == PersonalRecord.RecordType.LONGEST_DURATION
), "Should not have duration PR");
verify(personalRecordRepository, never()).save(any(PersonalRecord.class));
}
@Test
@DisplayName("Should detect fastest 5K PR")
void testCheckPersonalRecords_Fastest5K() {
// Given - Activity that covers at least 5km
Activity activity = createActivity(
6000L, // 6 km
1500L, // 25 minutes
BigDecimal.ZERO
);
when(personalRecordRepository.findByUserIdAndActivityTypeAndRecordType(
any(), any(), any()))
.thenReturn(Optional.empty());
when(personalRecordRepository.save(any(PersonalRecord.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// When
List<PersonalRecord> records = personalRecordService.checkAndUpdatePersonalRecords(activity);
// Then
assertTrue(records.stream().anyMatch(r ->
r.getRecordType() == PersonalRecord.RecordType.FASTEST_5K
));
}
@Test
@DisplayName("Should detect fastest 10K PR")
void testCheckPersonalRecords_Fastest10K() {
// Given - Activity that covers at least 10km
Activity activity = createActivity(
12000L, // 12 km
3000L, // 50 minutes
BigDecimal.ZERO
);
when(personalRecordRepository.findByUserIdAndActivityTypeAndRecordType(
any(), any(), any()))
.thenReturn(Optional.empty());
when(personalRecordRepository.save(any(PersonalRecord.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// When
List<PersonalRecord> records = personalRecordService.checkAndUpdatePersonalRecords(activity);
// Then
assertTrue(records.stream().anyMatch(r ->
r.getRecordType() == PersonalRecord.RecordType.FASTEST_10K
));
}
@Test
@DisplayName("Should detect half marathon PR")
void testCheckPersonalRecords_FastestHalfMarathon() {
// Given - Activity that covers at least 21.1km
Activity activity = createActivity(
21500L, // 21.5 km
6000L, // 100 minutes
BigDecimal.ZERO
);
when(personalRecordRepository.findByUserIdAndActivityTypeAndRecordType(
any(), any(), any()))
.thenReturn(Optional.empty());
when(personalRecordRepository.save(any(PersonalRecord.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// When
List<PersonalRecord> records = personalRecordService.checkAndUpdatePersonalRecords(activity);
// Then
assertTrue(records.stream().anyMatch(r ->
r.getRecordType() == PersonalRecord.RecordType.FASTEST_HALF_MARATHON
));
}
@Test
@DisplayName("Should detect marathon PR")
void testCheckPersonalRecords_FastestMarathon() {
// Given - Activity that covers at least 42.2km
Activity activity = createActivity(
42500L, // 42.5 km
12000L, // 200 minutes
BigDecimal.ZERO
);
when(personalRecordRepository.findByUserIdAndActivityTypeAndRecordType(
any(), any(), any()))
.thenReturn(Optional.empty());
when(personalRecordRepository.save(any(PersonalRecord.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// When
List<PersonalRecord> records = personalRecordService.checkAndUpdatePersonalRecords(activity);
// Then
assertTrue(records.stream().anyMatch(r ->
r.getRecordType() == PersonalRecord.RecordType.FASTEST_MARATHON
));
}
@Test
@DisplayName("Should detect highest elevation gain PR")
void testCheckPersonalRecords_HighestElevationGain() {
// Given
Activity activity = createActivity(
10000L,
3600L,
BigDecimal.valueOf(500) // 500m elevation gain
);
when(personalRecordRepository.findByUserIdAndActivityTypeAndRecordType(
any(), any(), any()))
.thenReturn(Optional.empty());
when(personalRecordRepository.save(any(PersonalRecord.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// When
List<PersonalRecord> records = personalRecordService.checkAndUpdatePersonalRecords(activity);
// Then
assertTrue(records.stream().anyMatch(r ->
r.getRecordType() == PersonalRecord.RecordType.HIGHEST_ELEVATION_GAIN
));
}
@Test
@DisplayName("Should detect longest duration PR")
void testCheckPersonalRecords_LongestDuration() {
// Given
Activity activity = createActivity(
20000L,
7200L, // 2 hours
BigDecimal.ZERO
);
when(personalRecordRepository.findByUserIdAndActivityTypeAndRecordType(
any(), any(), any()))
.thenReturn(Optional.empty());
when(personalRecordRepository.save(any(PersonalRecord.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// When
List<PersonalRecord> records = personalRecordService.checkAndUpdatePersonalRecords(activity);
// Then
assertTrue(records.stream().anyMatch(r ->
r.getRecordType() == PersonalRecord.RecordType.LONGEST_DURATION
));
}
@Test
@DisplayName("Should detect max speed PR from metrics")
void testCheckPersonalRecords_MaxSpeed() {
// Given
Activity activity = createActivity(
10000L,
3600L,
BigDecimal.ZERO
);
ActivityMetrics metrics = new ActivityMetrics();
metrics.setMaxSpeed(BigDecimal.valueOf(5.5)); // 5.5 m/s
activity.setMetrics(metrics);
when(personalRecordRepository.findByUserIdAndActivityTypeAndRecordType(
any(), any(), any()))
.thenReturn(Optional.empty());
when(personalRecordRepository.save(any(PersonalRecord.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// When
List<PersonalRecord> records = personalRecordService.checkAndUpdatePersonalRecords(activity);
// Then
assertTrue(records.stream().anyMatch(r ->
r.getRecordType() == PersonalRecord.RecordType.MAX_SPEED
));
}
@Test
@DisplayName("Should detect best average pace PR")
void testCheckPersonalRecords_BestAveragePace() {
// Given - 10km in 3000 seconds = 5:00/km pace
Activity activity = createActivity(
10000L, // 10 km
3000L, // 50 minutes
BigDecimal.ZERO
);
when(personalRecordRepository.findByUserIdAndActivityTypeAndRecordType(
any(), any(), any()))
.thenReturn(Optional.empty());
when(personalRecordRepository.save(any(PersonalRecord.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// When
List<PersonalRecord> records = personalRecordService.checkAndUpdatePersonalRecords(activity);
// Then
assertTrue(records.stream().anyMatch(r ->
r.getRecordType() == PersonalRecord.RecordType.BEST_AVERAGE_PACE
));
}
@Test
@DisplayName("Should NOT create PRs for activity without userId")
void testCheckPersonalRecords_NoUserId() {
// Given
Activity activity = createActivity(10000L, 3600L, BigDecimal.ZERO);
activity.setUserId(null);
// When
List<PersonalRecord> records = personalRecordService.checkAndUpdatePersonalRecords(activity);
// Then
assertTrue(records.isEmpty());
verify(personalRecordRepository, never()).save(any(PersonalRecord.class));
}
@Test
@DisplayName("Should detect multiple PRs in single activity")
void testCheckPersonalRecords_MultiplePRs() {
// Given - Activity that sets multiple records
Activity activity = createActivity(
15000L, // 15 km (distance PR)
4500L, // duration
BigDecimal.valueOf(300) // elevation PR
);
when(personalRecordRepository.findByUserIdAndActivityTypeAndRecordType(
any(), any(), any()))
.thenReturn(Optional.empty());
when(personalRecordRepository.save(any(PersonalRecord.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// When
List<PersonalRecord> records = personalRecordService.checkAndUpdatePersonalRecords(activity);
// Then
assertFalse(records.isEmpty());
assertTrue(records.size() >= 2); // Should have at least distance and elevation PRs
assertTrue(records.stream().anyMatch(r ->
r.getRecordType() == PersonalRecord.RecordType.LONGEST_DISTANCE
));
assertTrue(records.stream().anyMatch(r ->
r.getRecordType() == PersonalRecord.RecordType.HIGHEST_ELEVATION_GAIN
));
}
// Helper methods
private Activity createActivity(long distanceMeters, long durationSeconds, BigDecimal elevationGain) {
return Activity.builder()
.id(UUID.randomUUID())
.userId(userId)
.activityType(Activity.ActivityType.RUN)
.startedAt(testTime)
.totalDistance(BigDecimal.valueOf(distanceMeters))
.totalDurationSeconds(durationSeconds)
.elevationGain(elevationGain)
.build();
}
private PersonalRecord createPersonalRecord(PersonalRecord.RecordType recordType,
BigDecimal value, String unit) {
return PersonalRecord.builder()
.id(UUID.randomUUID())
.userId(userId)
.activityType(PersonalRecord.ActivityType.RUN)
.recordType(recordType)
.value(value)
.unit(unit)
.achievedAt(testTime.minusDays(30)) // Older record
.build();
}
}

View file

@ -0,0 +1,366 @@
package org.operaton.fitpub.service;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.operaton.fitpub.model.entity.Activity;
import org.operaton.fitpub.model.entity.TrainingLoad;
import org.operaton.fitpub.repository.ActivityRepository;
import org.operaton.fitpub.repository.TrainingLoadRepository;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Arrays;
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.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
/**
* Unit tests for TrainingLoadService.
* Tests TSS calculations, rolling averages, and form status.
*/
@ExtendWith(MockitoExtension.class)
class TrainingLoadServiceTest {
@Mock
private TrainingLoadRepository trainingLoadRepository;
@Mock
private ActivityRepository activityRepository;
@InjectMocks
private TrainingLoadService trainingLoadService;
private UUID userId;
private LocalDate testDate;
@BeforeEach
void setUp() {
userId = UUID.randomUUID();
testDate = LocalDate.of(2025, 12, 1);
}
@Test
@DisplayName("Should calculate TSS for activity with distance and elevation")
void testCalculateTrainingStressScore_WithDistanceAndElevation() {
// Given
Activity activity = createActivity(
3600L, // 1 hour
BigDecimal.valueOf(10000), // 10 km
BigDecimal.valueOf(100) // 100m elevation
);
when(trainingLoadRepository.findByUserIdAndDate(userId, testDate))
.thenReturn(Optional.empty());
when(activityRepository.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc(
eq(userId), any(), any()))
.thenReturn(List.of(activity));
when(trainingLoadRepository.findByUserIdAndDateBetweenOrderByDateDesc(
eq(userId), any(), any()))
.thenReturn(List.of());
// When
trainingLoadService.updateDailyTrainingLoad(userId, testDate);
// Then
ArgumentCaptor<TrainingLoad> captor = ArgumentCaptor.forClass(TrainingLoad.class);
verify(trainingLoadRepository).save(captor.capture());
TrainingLoad saved = captor.getValue();
assertNotNull(saved.getTrainingStressScore());
assertTrue(saved.getTrainingStressScore().doubleValue() > 0);
assertEquals(1, saved.getActivityCount());
assertEquals(3600L, saved.getTotalDurationSeconds());
}
@Test
@DisplayName("Should calculate TSS as zero for activity with no duration")
void testCalculateTrainingStressScore_ZeroDuration() {
// Given
Activity activity = createActivity(0L, BigDecimal.ZERO, BigDecimal.ZERO);
when(trainingLoadRepository.findByUserIdAndDate(userId, testDate))
.thenReturn(Optional.empty());
when(activityRepository.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc(
eq(userId), any(), any()))
.thenReturn(List.of(activity));
when(trainingLoadRepository.findByUserIdAndDateBetweenOrderByDateDesc(
eq(userId), any(), any()))
.thenReturn(List.of());
// When
trainingLoadService.updateDailyTrainingLoad(userId, testDate);
// Then
ArgumentCaptor<TrainingLoad> captor = ArgumentCaptor.forClass(TrainingLoad.class);
verify(trainingLoadRepository).save(captor.capture());
TrainingLoad saved = captor.getValue();
assertEquals(BigDecimal.ZERO, saved.getTrainingStressScore());
}
@Test
@DisplayName("Should aggregate multiple activities in one day")
void testUpdateDailyTrainingLoad_MultipleActivities() {
// Given
Activity activity1 = createActivity(1800L, BigDecimal.valueOf(5000), BigDecimal.valueOf(50));
Activity activity2 = createActivity(1800L, BigDecimal.valueOf(5000), BigDecimal.valueOf(50));
when(trainingLoadRepository.findByUserIdAndDate(userId, testDate))
.thenReturn(Optional.empty());
when(activityRepository.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc(
eq(userId), any(), any()))
.thenReturn(Arrays.asList(activity1, activity2));
when(trainingLoadRepository.findByUserIdAndDateBetweenOrderByDateDesc(
eq(userId), any(), any()))
.thenReturn(List.of());
// When
trainingLoadService.updateDailyTrainingLoad(userId, testDate);
// Then
ArgumentCaptor<TrainingLoad> captor = ArgumentCaptor.forClass(TrainingLoad.class);
verify(trainingLoadRepository).save(captor.capture());
TrainingLoad saved = captor.getValue();
assertEquals(2, saved.getActivityCount());
assertEquals(3600L, saved.getTotalDurationSeconds()); // 1800 + 1800
assertEquals(BigDecimal.valueOf(10000), saved.getTotalDistanceMeters()); // 5000 + 5000
}
@Test
@DisplayName("Should calculate ATL (7-day rolling average)")
void testCalculateRollingAverages_ATL() {
// Given
LocalDate today = testDate;
List<TrainingLoad> last7Days = createTrainingLoadHistory(
userId, today.minusDays(6), today, BigDecimal.valueOf(100.0)
);
when(trainingLoadRepository.findByUserIdAndDate(userId, today))
.thenReturn(Optional.empty());
when(activityRepository.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc(
eq(userId), any(), any()))
.thenReturn(List.of(createActivity(3600L, BigDecimal.valueOf(10000), BigDecimal.ZERO)));
when(trainingLoadRepository.findByUserIdAndDateBetweenOrderByDateDesc(
eq(userId), eq(today.minusDays(6)), eq(today)))
.thenReturn(last7Days);
when(trainingLoadRepository.findByUserIdAndDateBetweenOrderByDateDesc(
eq(userId), eq(today.minusDays(27)), eq(today)))
.thenReturn(List.of());
// When
trainingLoadService.updateDailyTrainingLoad(userId, today);
// Then
ArgumentCaptor<TrainingLoad> captor = ArgumentCaptor.forClass(TrainingLoad.class);
verify(trainingLoadRepository).save(captor.capture());
TrainingLoad saved = captor.getValue();
assertNotNull(saved.getAcuteTrainingLoad());
// ATL should be average of 7 days (7 * 100 = 700 / 7 = 100)
assertEquals(BigDecimal.valueOf(100.0).setScale(2), saved.getAcuteTrainingLoad());
}
@Test
@DisplayName("Should calculate CTL (28-day rolling average)")
void testCalculateRollingAverages_CTL() {
// Given
LocalDate today = testDate;
List<TrainingLoad> last28Days = createTrainingLoadHistory(
userId, today.minusDays(27), today, BigDecimal.valueOf(50.0)
);
when(trainingLoadRepository.findByUserIdAndDate(userId, today))
.thenReturn(Optional.empty());
when(activityRepository.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc(
eq(userId), any(), any()))
.thenReturn(List.of(createActivity(3600L, BigDecimal.valueOf(10000), BigDecimal.ZERO)));
when(trainingLoadRepository.findByUserIdAndDateBetweenOrderByDateDesc(
eq(userId), eq(today.minusDays(6)), eq(today)))
.thenReturn(List.of());
when(trainingLoadRepository.findByUserIdAndDateBetweenOrderByDateDesc(
eq(userId), eq(today.minusDays(27)), eq(today)))
.thenReturn(last28Days);
// When
trainingLoadService.updateDailyTrainingLoad(userId, today);
// Then
ArgumentCaptor<TrainingLoad> captor = ArgumentCaptor.forClass(TrainingLoad.class);
verify(trainingLoadRepository).save(captor.capture());
TrainingLoad saved = captor.getValue();
assertNotNull(saved.getChronicTrainingLoad());
// CTL should be average of 28 days (28 * 50 = 1400 / 28 = 50)
assertEquals(BigDecimal.valueOf(50.0).setScale(2), saved.getChronicTrainingLoad());
}
@Test
@DisplayName("Should calculate TSB (Training Stress Balance)")
void testCalculateRollingAverages_TSB() {
// Given
LocalDate today = testDate;
List<TrainingLoad> last7Days = createTrainingLoadHistory(
userId, today.minusDays(6), today, BigDecimal.valueOf(80.0)
);
List<TrainingLoad> last28Days = createTrainingLoadHistory(
userId, today.minusDays(27), today, BigDecimal.valueOf(100.0)
);
when(trainingLoadRepository.findByUserIdAndDate(userId, today))
.thenReturn(Optional.empty());
when(activityRepository.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc(
eq(userId), any(), any()))
.thenReturn(List.of(createActivity(3600L, BigDecimal.valueOf(10000), BigDecimal.ZERO)));
when(trainingLoadRepository.findByUserIdAndDateBetweenOrderByDateDesc(
eq(userId), eq(today.minusDays(6)), eq(today)))
.thenReturn(last7Days);
when(trainingLoadRepository.findByUserIdAndDateBetweenOrderByDateDesc(
eq(userId), eq(today.minusDays(27)), eq(today)))
.thenReturn(last28Days);
// When
trainingLoadService.updateDailyTrainingLoad(userId, today);
// Then
ArgumentCaptor<TrainingLoad> captor = ArgumentCaptor.forClass(TrainingLoad.class);
verify(trainingLoadRepository).save(captor.capture());
TrainingLoad saved = captor.getValue();
assertNotNull(saved.getTrainingStressBalance());
// TSB = CTL - ATL = 100 - 80 = 20
assertEquals(BigDecimal.valueOf(20.0).setScale(2), saved.getTrainingStressBalance());
}
@Test
@DisplayName("Should update existing training load entry")
void testUpdateDailyTrainingLoad_UpdateExisting() {
// Given
TrainingLoad existing = TrainingLoad.builder()
.userId(userId)
.date(testDate)
.activityCount(1)
.trainingStressScore(BigDecimal.valueOf(50.0))
.build();
Activity newActivity = createActivity(3600L, BigDecimal.valueOf(10000), BigDecimal.ZERO);
when(trainingLoadRepository.findByUserIdAndDate(userId, testDate))
.thenReturn(Optional.of(existing));
when(activityRepository.findByUserIdAndStartedAtBetweenOrderByStartedAtDesc(
eq(userId), any(), any()))
.thenReturn(List.of(newActivity));
when(trainingLoadRepository.findByUserIdAndDateBetweenOrderByDateDesc(
eq(userId), any(), any()))
.thenReturn(List.of());
// When
trainingLoadService.updateDailyTrainingLoad(userId, testDate);
// Then
verify(trainingLoadRepository).save(any(TrainingLoad.class));
// Should update the existing entry, not create new one
}
@Test
@DisplayName("Should get recent training load for specified days")
void testGetRecentTrainingLoad() {
// Given
int days = 30;
LocalDate startDate = LocalDate.now().minusDays(days - 1);
List<TrainingLoad> expectedLoad = List.of(
createTrainingLoad(userId, testDate, BigDecimal.valueOf(100.0))
);
when(trainingLoadRepository.findByUserIdSinceDate(userId, startDate))
.thenReturn(expectedLoad);
// When
List<TrainingLoad> result = trainingLoadService.getRecentTrainingLoad(userId, days);
// Then
assertEquals(expectedLoad, result);
verify(trainingLoadRepository).findByUserIdSinceDate(userId, startDate);
}
@Test
@DisplayName("Should get current form status")
void testGetCurrentFormStatus() {
// Given
TrainingLoad latestLoad = TrainingLoad.builder()
.userId(userId)
.date(testDate)
.acuteTrainingLoad(BigDecimal.valueOf(50.0))
.chronicTrainingLoad(BigDecimal.valueOf(80.0))
.trainingStressBalance(BigDecimal.valueOf(30.0))
.build();
when(trainingLoadRepository.findFirstByUserIdOrderByDateDesc(userId))
.thenReturn(Optional.of(latestLoad));
// When
TrainingLoad.FormStatus status = trainingLoadService.getCurrentFormStatus(userId);
// Then
assertNotNull(status);
verify(trainingLoadRepository).findFirstByUserIdOrderByDateDesc(userId);
}
@Test
@DisplayName("Should return UNKNOWN when no training load exists")
void testGetCurrentFormStatus_NoData() {
// Given
when(trainingLoadRepository.findFirstByUserIdOrderByDateDesc(userId))
.thenReturn(Optional.empty());
// When
TrainingLoad.FormStatus status = trainingLoadService.getCurrentFormStatus(userId);
// Then
assertEquals(TrainingLoad.FormStatus.UNKNOWN, status);
}
// Helper methods
private Activity createActivity(Long durationSeconds, BigDecimal distance, BigDecimal elevation) {
return Activity.builder()
.id(UUID.randomUUID())
.userId(userId)
.activityType(Activity.ActivityType.RUN)
.startedAt(testDate.atTime(10, 0))
.totalDurationSeconds(durationSeconds)
.totalDistance(distance)
.elevationGain(elevation)
.build();
}
private TrainingLoad createTrainingLoad(UUID userId, LocalDate date, BigDecimal tss) {
return TrainingLoad.builder()
.userId(userId)
.date(date)
.trainingStressScore(tss)
.build();
}
private List<TrainingLoad> createTrainingLoadHistory(UUID userId, LocalDate start, LocalDate end, BigDecimal tss) {
List<TrainingLoad> history = new java.util.ArrayList<>();
LocalDate current = start;
while (!current.isAfter(end)) {
history.add(createTrainingLoad(userId, current, tss));
current = current.plusDays(1);
}
return history;
}
}