Tests
This commit is contained in:
parent
0e81a65d62
commit
d3dbf8e80a
7 changed files with 1599 additions and 16 deletions
32
CLAUDE.md
32
CLAUDE.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue