From c34bef59a8886aa9777fd2ea1b32d6b5a1544d96 Mon Sep 17 00:00:00 2001 From: MichaelaMatiskova Date: Sun, 4 Oct 2026 12:34:44 +0200 Subject: [PATCH 1/4] feat: add mentorship cycle creation endpoint Allow authorized admins to create mentorship cycles in draft status with date validation, default mentee capacity, and duplicate-cycle conflict handling. Add request validation and unit/integration coverage for successful creation, invalid values, authorization, and conflicts. --- .../MentorshipAdminMatchesController.java | 21 +- .../MentorshipCycleCreateRequest.java | 20 ++ .../repository/MentorshipCycleRepository.java | 4 + .../PostgresMentorshipCycleRepository.java | 28 +- .../service/MentorshipCycleService.java | 49 ++++ .../MentorshipAdminMatchesControllerTest.java | 50 ++++ .../service/MentorshipCycleServiceTest.java | 117 +++++++- .../MentorshipCycleIntegrationTest.java | 268 +++++++++++++++++- 8 files changed, 552 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/wcc/platform/domain/platform/mentorship/MentorshipCycleCreateRequest.java diff --git a/src/main/java/com/wcc/platform/controller/MentorshipAdminMatchesController.java b/src/main/java/com/wcc/platform/controller/MentorshipAdminMatchesController.java index 68f85a1b..36220d8a 100644 --- a/src/main/java/com/wcc/platform/controller/MentorshipAdminMatchesController.java +++ b/src/main/java/com/wcc/platform/controller/MentorshipAdminMatchesController.java @@ -2,12 +2,13 @@ import com.wcc.platform.configuration.security.RequiresPermission; import com.wcc.platform.configuration.security.RequiresRole; -import com.wcc.platform.domain.exceptions.CycleNotFoundException; import com.wcc.platform.domain.auth.Permission; +import com.wcc.platform.domain.exceptions.CycleNotFoundException; import com.wcc.platform.domain.platform.mentorship.ApplicationStatus; import com.wcc.platform.domain.platform.mentorship.CycleStatus; import com.wcc.platform.domain.platform.mentorship.MatchCancelRequest; import com.wcc.platform.domain.platform.mentorship.MenteeApplicationAdminResponse; +import com.wcc.platform.domain.platform.mentorship.MentorshipCycleCreateRequest; import com.wcc.platform.domain.platform.mentorship.MentorshipCycleEntity; import com.wcc.platform.domain.platform.mentorship.MentorshipMatch; import com.wcc.platform.domain.platform.mentorship.recommendation.MentorshipRecommendationResponse; @@ -198,6 +199,24 @@ public ResponseEntity incrementSessionCount( // ==================== Cycle Management ==================== + /** + * API to create a mentorship cycle in draft status. + * + * @param request The cycle creation request + * @return Created mentorship cycle + */ + @PostMapping("/cycles") + @RequiresRole({RoleType.ADMIN, RoleType.MENTORSHIP_ADMIN}) + @Operation( + summary = "Create a mentorship cycle", + security = {@SecurityRequirement(name = BEARER_AUTH)}) + @ResponseStatus(HttpStatus.CREATED) + public ResponseEntity createCycle( + @Valid @RequestBody final MentorshipCycleCreateRequest request) { + final MentorshipCycleEntity createdCycle = cycleService.createCycle(request); + return ResponseEntity.status(HttpStatus.CREATED).body(createdCycle); + } + /** * API to get the currently open mentorship cycle. * diff --git a/src/main/java/com/wcc/platform/domain/platform/mentorship/MentorshipCycleCreateRequest.java b/src/main/java/com/wcc/platform/domain/platform/mentorship/MentorshipCycleCreateRequest.java new file mode 100644 index 00000000..7dc03c3f --- /dev/null +++ b/src/main/java/com/wcc/platform/domain/platform/mentorship/MentorshipCycleCreateRequest.java @@ -0,0 +1,20 @@ +package com.wcc.platform.domain.platform.mentorship; + +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import java.time.Month; +import java.time.Year; + +/** Request payload for creating a mentorship cycle. */ +public record MentorshipCycleCreateRequest( + @NotNull @FutureOrPresent Year cycleYear, + @NotNull Month cycleMonth, + @NotNull MentorshipType mentorshipType, + @NotNull @FutureOrPresent LocalDate registrationStartDate, + @NotNull @FutureOrPresent LocalDate registrationEndDate, + @NotNull @FutureOrPresent LocalDate cycleStartDate, + @FutureOrPresent LocalDate cycleEndDate, + @Min(1) Integer maxMenteesPerMentor, + String description) {} diff --git a/src/main/java/com/wcc/platform/repository/MentorshipCycleRepository.java b/src/main/java/com/wcc/platform/repository/MentorshipCycleRepository.java index 332d0fcf..b351d40c 100644 --- a/src/main/java/com/wcc/platform/repository/MentorshipCycleRepository.java +++ b/src/main/java/com/wcc/platform/repository/MentorshipCycleRepository.java @@ -3,6 +3,7 @@ import com.wcc.platform.domain.platform.mentorship.CycleStatus; import com.wcc.platform.domain.platform.mentorship.MentorshipCycleEntity; import com.wcc.platform.domain.platform.mentorship.MentorshipType; +import java.time.Month; import java.time.Year; import java.util.List; import java.util.Optional; @@ -67,4 +68,7 @@ public interface MentorshipCycleRepository extends CrudRepository findByYearAndTypeAndMonth( + Year year, MentorshipType type, Month month); } diff --git a/src/main/java/com/wcc/platform/repository/postgres/mentorship/PostgresMentorshipCycleRepository.java b/src/main/java/com/wcc/platform/repository/postgres/mentorship/PostgresMentorshipCycleRepository.java index 16413c9d..101e29dd 100644 --- a/src/main/java/com/wcc/platform/repository/postgres/mentorship/PostgresMentorshipCycleRepository.java +++ b/src/main/java/com/wcc/platform/repository/postgres/mentorship/PostgresMentorshipCycleRepository.java @@ -1,5 +1,6 @@ package com.wcc.platform.repository.postgres.mentorship; +import com.wcc.platform.domain.exceptions.DuplicatedException; import com.wcc.platform.domain.platform.mentorship.CycleStatus; import com.wcc.platform.domain.platform.mentorship.MentorshipCycleEntity; import com.wcc.platform.domain.platform.mentorship.MentorshipType; @@ -66,13 +67,25 @@ public class PostgresMentorshipCycleRepository implements MentorshipCycleReposit + "description = ?, updated_at = CURRENT_TIMESTAMP " + "WHERE cycle_id = ?"; + private static final String SELECT_CYCLE_BY_KEY = + "SELECT * FROM mentorship_cycles " + + "WHERE cycle_year = ? AND mentorship_type = ? AND cycle_month = ?"; + private final JdbcTemplate jdbc; @Override public MentorshipCycleEntity create(final MentorshipCycleEntity entity) { - final var existing = findByYearAndType(entity.getCycleYear(), entity.getMentorshipType()); + final var existing = + findByYearAndTypeAndMonth( + entity.getCycleYear(), entity.getMentorshipType(), entity.getCycleMonth()); + if (existing.isPresent()) { - return update(existing.get().getCycleId(), entity); + throw new DuplicatedException( + "Mentorship cycle already exists for year %d, type %s and month %s" + .formatted( + entity.getCycleYear().getValue(), + entity.getMentorshipType().name(), + entity.getCycleMonth().name())); } final Long generatedId = @@ -201,4 +214,15 @@ private MentorshipCycleEntity mapRow(final ResultSet rs) throws SQLException { .updatedAt(rs.getTimestamp("updated_at").toInstant().atZone(ZoneId.systemDefault())) .build(); } + + @Override + public Optional findByYearAndTypeAndMonth( + final Year year, final MentorshipType type, final Month month) { + return jdbc.query( + SELECT_CYCLE_BY_KEY, + rs -> rs.next() ? Optional.of(mapRow(rs)) : Optional.empty(), + year.getValue(), + type.getMentorshipTypeId(), + month.getValue()); + } } diff --git a/src/main/java/com/wcc/platform/service/MentorshipCycleService.java b/src/main/java/com/wcc/platform/service/MentorshipCycleService.java index 9446114f..cbf548cd 100644 --- a/src/main/java/com/wcc/platform/service/MentorshipCycleService.java +++ b/src/main/java/com/wcc/platform/service/MentorshipCycleService.java @@ -3,9 +3,11 @@ import com.wcc.platform.domain.exceptions.CycleNotFoundException; import com.wcc.platform.domain.exceptions.InvalidCycleStatusTransitionException; import com.wcc.platform.domain.platform.mentorship.CycleStatus; +import com.wcc.platform.domain.platform.mentorship.MentorshipCycleCreateRequest; import com.wcc.platform.domain.platform.mentorship.MentorshipCycleEntity; import com.wcc.platform.repository.MentorshipCycleRepository; import jakarta.validation.constraints.FutureOrPresent; +import java.time.Month; import java.time.Year; import java.util.List; import java.util.Map; @@ -61,4 +63,51 @@ public MentorshipCycleEntity updateStatus(final Long cycleId, final CycleStatus return cycleRepository.updateStatus(cycleId, newStatus); } + + /** + * Creates a new mentorship cycle in draft status. + * + * @param request cycle creation data + * @return the created mentorship cycle + * @throws IllegalArgumentException if the supplied dates are inconsistent + */ + public MentorshipCycleEntity createCycle(final MentorshipCycleCreateRequest request) { + + if (request.cycleMonth().getValue() < 2 || request.cycleMonth().getValue() > 11) { + throw new IllegalArgumentException("Cycle month must be between February and November"); + } + + if (request.registrationEndDate().isBefore(request.registrationStartDate())) { + throw new IllegalArgumentException( + "Registration end date must be greater than or equal to registration start date"); + } + + if (request.cycleStartDate().isBefore(request.registrationStartDate())) { + throw new IllegalArgumentException( + "Cycle start date must be greater than or equal to registration start date"); + } + + if (request.cycleEndDate() != null + && request.cycleEndDate().isBefore(request.cycleStartDate())) { + throw new IllegalArgumentException( + "Cycle end date must be greater than or equal to cycle start date"); + } + + final MentorshipCycleEntity cycle = + MentorshipCycleEntity.builder() + .cycleYear(request.cycleYear()) + .cycleMonth(request.cycleMonth()) + .mentorshipType(request.mentorshipType()) + .registrationStartDate(request.registrationStartDate()) + .registrationEndDate(request.registrationEndDate()) + .cycleStartDate(request.cycleStartDate()) + .cycleEndDate(request.cycleEndDate()) + .status(CycleStatus.DRAFT) + .maxMenteesPerMentor( + request.maxMenteesPerMentor() != null ? request.maxMenteesPerMentor() : 5) + .description(request.description()) + .build(); + + return cycleRepository.create(cycle); + } } diff --git a/src/test/java/com/wcc/platform/controller/MentorshipAdminMatchesControllerTest.java b/src/test/java/com/wcc/platform/controller/MentorshipAdminMatchesControllerTest.java index 3cea8a5b..342ab87a 100644 --- a/src/test/java/com/wcc/platform/controller/MentorshipAdminMatchesControllerTest.java +++ b/src/test/java/com/wcc/platform/controller/MentorshipAdminMatchesControllerTest.java @@ -1,20 +1,28 @@ package com.wcc.platform.controller; +import static org.assertj.core.api.Assertions.assertThat; import static com.wcc.platform.factories.MockMvcRequestFactory.getRequest; import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.wcc.platform.configuration.SecurityConfig; import com.wcc.platform.configuration.TestConfig; +import com.wcc.platform.configuration.security.RequiresRole; +import com.wcc.platform.domain.platform.mentorship.CycleStatus; +import com.wcc.platform.domain.platform.mentorship.MentorshipCycleCreateRequest; import com.wcc.platform.domain.platform.mentorship.MentorshipCycleEntity; +import com.wcc.platform.domain.platform.mentorship.MentorshipType; +import com.wcc.platform.domain.platform.type.RoleType; import com.wcc.platform.repository.MentorshipCycleRepository; import com.wcc.platform.service.AuthService; import com.wcc.platform.service.MenteeWorkflowService; import com.wcc.platform.service.MentorshipCycleService; import com.wcc.platform.service.MentorshipMatchingService; import com.wcc.platform.service.MentorshipRecommendationService; +import java.time.LocalDate; import java.time.Year; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -74,6 +82,48 @@ void shouldReturn400WhenYearIsNotANumber() throws Exception { .andExpect(status().isBadRequest()); } + @Test + @DisplayName("Given valid cycle request, when creating cycle, then return created cycle") + void shouldCreateCycleAndDelegateToService() throws NoSuchMethodException { + final MentorshipCycleCreateRequest request = request(); + final MentorshipCycleEntity created = + MentorshipCycleEntity.builder().cycleId(9L).status(CycleStatus.DRAFT).build(); + when(cycleService.createCycle(request)).thenReturn(created); + + final var controller = + new MentorshipAdminMatchesController( + matchingService, + cycleRepository, + recommendationService, + workflowService, + cycleService); + final var response = controller.createCycle(request); + + assertThat(response.getStatusCode().value()).isEqualTo(201); + assertThat(response.getBody()).isSameAs(created); + verify(cycleService).createCycle(request); + + final var method = + MentorshipAdminMatchesController.class.getDeclaredMethod( + "createCycle", MentorshipCycleCreateRequest.class); + final var annotation = method.getAnnotation(RequiresRole.class); + assertThat(annotation).isNotNull(); + assertThat(annotation.value()).containsExactly(RoleType.ADMIN, RoleType.MENTORSHIP_ADMIN); + } + + private MentorshipCycleCreateRequest request() { + return new MentorshipCycleCreateRequest( + Year.of(2026), + java.time.Month.OCTOBER, + MentorshipType.AD_HOC, + LocalDate.of(2026, 10, 1), + LocalDate.of(2026, 10, 15), + LocalDate.of(2026, 10, 20), + LocalDate.of(2026, 11, 20), + 5, + "October cycle"); + } + private MentorshipCycleEntity cycleInYear(final Long cycleId, final int year) { return MentorshipCycleEntity.builder().cycleId(cycleId).cycleYear(Year.of(year)).build(); } diff --git a/src/test/java/com/wcc/platform/service/MentorshipCycleServiceTest.java b/src/test/java/com/wcc/platform/service/MentorshipCycleServiceTest.java index c5df9142..77359948 100644 --- a/src/test/java/com/wcc/platform/service/MentorshipCycleServiceTest.java +++ b/src/test/java/com/wcc/platform/service/MentorshipCycleServiceTest.java @@ -7,11 +7,16 @@ import static org.mockito.Mockito.when; import com.wcc.platform.domain.exceptions.CycleNotFoundException; +import com.wcc.platform.domain.exceptions.DuplicatedException; import com.wcc.platform.domain.exceptions.InvalidCycleStatusTransitionException; import com.wcc.platform.domain.platform.mentorship.CycleStatus; +import com.wcc.platform.domain.platform.mentorship.MentorshipCycleCreateRequest; import com.wcc.platform.domain.platform.mentorship.MentorshipCycleEntity; -import com.wcc.platform.repository.MentorshipCycleRepository; +import com.wcc.platform.domain.platform.mentorship.MentorshipType; +import java.time.LocalDate; +import java.time.Month; import java.time.Year; +import com.wcc.platform.repository.MentorshipCycleRepository; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -209,6 +214,116 @@ void shouldReturnEmptyListForYearWithNoCycles() { assertThat(result).isEmpty(); } + @Test + @DisplayName("Given valid cycle request, when creating cycle, then create draft cycle") + void shouldCreateDraftCycleWithDefaultMaxMenteesPerMentor() { + final var request = validCreateRequest(null); + final var createdCycle = cycleWithStatus(9L, CycleStatus.DRAFT); + when(cycleRepository.create(org.mockito.ArgumentMatchers.any(MentorshipCycleEntity.class))) + .thenReturn(createdCycle); + + final MentorshipCycleEntity result = cycleService.createCycle(request); + + assertThat(result.getStatus()).isEqualTo(CycleStatus.DRAFT); + verify(cycleRepository) + .create( + org.mockito.ArgumentMatchers.argThat( + cycle -> + cycle.getCycleYear().equals(Year.of(2026)) + && cycle.getCycleMonth().equals(Month.OCTOBER) + && cycle.getMentorshipType() == MentorshipType.AD_HOC + && cycle.getMaxMenteesPerMentor() == 5)); + } + + @Test + @DisplayName("Given duplicate cycle, when creating cycle, then throw conflict exception") + void shouldPropagateDuplicateCycleException() { + when(cycleRepository.create(org.mockito.ArgumentMatchers.any(MentorshipCycleEntity.class))) + .thenThrow(new DuplicatedException("cycle already exists")); + + assertThatThrownBy(() -> cycleService.createCycle(validCreateRequest(5))) + .isInstanceOf(DuplicatedException.class) + .hasMessage("cycle already exists"); + } + + @Test + @DisplayName( + "Given registration end before registration start, when creating cycle, then reject request") + void shouldRejectInvalidRegistrationDateRange() { + final var request = + new MentorshipCycleCreateRequest( + Year.of(2026), + Month.OCTOBER, + MentorshipType.AD_HOC, + LocalDate.of(2026, 10, 15), + LocalDate.of(2026, 10, 1), + LocalDate.of(2026, 10, 20), + null, + 5, + "October cycle"); + + assertThatThrownBy(() -> cycleService.createCycle(request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Registration end date"); + verify(cycleRepository, never()).create(org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName( + "Given cycle start before registration start, when creating cycle, then reject request") + void shouldRejectCycleStartBeforeRegistrationStart() { + final var request = + new MentorshipCycleCreateRequest( + Year.of(2026), + Month.OCTOBER, + MentorshipType.AD_HOC, + LocalDate.of(2026, 10, 15), + LocalDate.of(2026, 10, 20), + LocalDate.of(2026, 10, 1), + null, + 5, + "October cycle"); + + assertThatThrownBy(() -> cycleService.createCycle(request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cycle start date"); + verify(cycleRepository, never()).create(org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("Given cycle end before cycle start, when creating cycle, then reject request") + void shouldRejectCycleEndBeforeCycleStart() { + final var request = + new MentorshipCycleCreateRequest( + Year.of(2026), + Month.OCTOBER, + MentorshipType.AD_HOC, + LocalDate.of(2026, 10, 1), + LocalDate.of(2026, 10, 15), + LocalDate.of(2026, 10, 20), + LocalDate.of(2026, 10, 19), + 5, + "October cycle"); + + assertThatThrownBy(() -> cycleService.createCycle(request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cycle end date"); + verify(cycleRepository, never()).create(org.mockito.ArgumentMatchers.any()); + } + + private MentorshipCycleCreateRequest validCreateRequest(final Integer maxMenteesPerMentor) { + return new MentorshipCycleCreateRequest( + Year.of(2026), + Month.OCTOBER, + MentorshipType.AD_HOC, + LocalDate.of(2026, 10, 1), + LocalDate.of(2026, 10, 15), + LocalDate.of(2026, 10, 20), + LocalDate.of(2026, 12, 20), + maxMenteesPerMentor, + "October cycle"); + } + private MentorshipCycleEntity cycleWithStatus(final Long cycleId, final CycleStatus status) { return MentorshipCycleEntity.builder().cycleId(cycleId).status(status).build(); } diff --git a/src/testInt/java/com/wcc/platform/service/MentorshipCycleIntegrationTest.java b/src/testInt/java/com/wcc/platform/service/MentorshipCycleIntegrationTest.java index 37fcdc7a..c0975249 100644 --- a/src/testInt/java/com/wcc/platform/service/MentorshipCycleIntegrationTest.java +++ b/src/testInt/java/com/wcc/platform/service/MentorshipCycleIntegrationTest.java @@ -3,10 +3,17 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.wcc.platform.domain.auth.UserAccount; import com.wcc.platform.domain.platform.mentorship.CycleStatus; import com.wcc.platform.domain.platform.mentorship.MentorshipCycleEntity; import com.wcc.platform.domain.platform.mentorship.MentorshipType; +import com.wcc.platform.domain.platform.member.Member; +import com.wcc.platform.domain.platform.type.RoleType; +import com.wcc.platform.repository.MemberRepository; import com.wcc.platform.repository.MentorshipCycleRepository; +import com.wcc.platform.repository.UserAccountRepository; import com.wcc.platform.repository.postgres.DefaultDatabaseSetup; import jakarta.validation.ConstraintViolationException; import java.time.LocalDate; @@ -14,22 +21,86 @@ import java.time.Year; import java.util.List; import java.util.Optional; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; /** * Integration tests for MentorshipCycleRepository with PostgreSQL. Tests cycle queries and * management operations. */ +@ActiveProfiles("test") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) class MentorshipCycleIntegrationTest extends DefaultDatabaseSetup { + private static final int TEST_YEAR = 2099; + private static final String ADMIN_EMAIL = "mentorship-cycle-admin@wcc.com"; + private static final String ADMIN_PASSWORD = "password"; + private static final String CYCLES_PATH = "/api/platform/v1/admin/mentorship/cycles"; + private static final String LOGIN_PATH = "/api/auth/login"; + + @LocalServerPort private int port; + + @Autowired private TestRestTemplate restTemplate; + @Autowired private ObjectMapper objectMapper; @Autowired private MentorshipCycleRepository cycleRepository; @Autowired private MentorshipCycleService cycleService; + @Autowired private MemberRepository memberRepository; + @Autowired private UserAccountRepository userAccountRepository; + @Autowired private PasswordEncoder passwordEncoder; + + private String adminToken; + + @BeforeAll + void setUpAdmin() { + final Member member = + memberRepository + .findByEmail(ADMIN_EMAIL) + .orElseGet( + () -> + memberRepository.create( + Member.builder() + .fullName("Mentorship Cycle Admin") + .position("Administrator") + .email(ADMIN_EMAIL) + .slackDisplayName("mentorship-cycle-admin") + .build())); + userAccountRepository + .findByEmail(ADMIN_EMAIL) + .orElseGet( + () -> + userAccountRepository.create( + new UserAccount( + null, + member.getId(), + ADMIN_EMAIL, + passwordEncoder.encode(ADMIN_PASSWORD), + List.of(RoleType.ADMIN), + true))); + adminToken = loginAsAdmin(); + } @BeforeEach void setUp() { + cycleRepository + .findByYearAndTypeAndMonth( + Year.of(TEST_YEAR), MentorshipType.AD_HOC, Month.NOVEMBER) + .ifPresent(cycle -> cycleRepository.deleteById(cycle.getCycleId())); + // Clean up before starting cycleRepository .findByYearAndType(Year.of(2026), MentorshipType.LONG_TERM) @@ -40,7 +111,7 @@ void setUp() { MentorshipCycleEntity.builder() .cycleYear(Year.of(2026)) .mentorshipType(MentorshipType.LONG_TERM) - .cycleMonth(Month.JANUARY) + .cycleMonth(Month.FEBRUARY) .registrationStartDate(LocalDate.now().minusDays(1)) .registrationEndDate(LocalDate.now().plusDays(10)) .cycleStartDate(LocalDate.now().plusDays(15)) @@ -50,6 +121,16 @@ void setUp() { .build()); } + @AfterAll + void tearDownAdmin() { + userAccountRepository + .findByEmail(ADMIN_EMAIL) + .ifPresent(user -> userAccountRepository.deleteById(user.getId())); + memberRepository + .findByEmail(ADMIN_EMAIL) + .ifPresent(member -> memberRepository.deleteById(member.getId())); + } + @Test @DisplayName( "Given database is seeded with cycles, when finding open cycle, then it should return the open cycle") @@ -159,4 +240,189 @@ void shouldRejectPastYear() { assertThatThrownBy(() -> cycleService.getCycles(Year.of(2025))) .isInstanceOf(ConstraintViolationException.class); } + + @Test + @DisplayName("Given valid cycle request, when posting it, then return 201 with draft cycle") + void shouldCreateCycleThroughPostEndpoint() throws Exception { + final ResponseEntity response = + restTemplate.exchange( + url(), + HttpMethod.POST, + requestEntity(validRequest()), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + final var body = objectMapper.readTree(response.getBody()); + assertThat(body.get("status").asText()).isEqualTo("DRAFT"); + assertThat(body.get("cycleYear").asInt()).isEqualTo(TEST_YEAR); + assertThat(body.get("mentorshipType").asText()).isEqualTo("Ad-Hoc"); + assertThat(body.get("cycleMonth").asText()).isEqualTo("NOVEMBER"); + } + + @Test + @DisplayName("Given existing cycle, when posting duplicate, then return 409") + void shouldReturnConflictForDuplicateCycle() { + final HttpEntity entity = requestEntity(validRequest()); + restTemplate.exchange(url(), HttpMethod.POST, entity, String.class); + + final ResponseEntity response = + restTemplate.exchange(url(), HttpMethod.POST, entity, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(response.getBody()).contains("already exists"); + } + + @Test + @DisplayName("Given missing required field, when posting cycle, then return 400") + void shouldReturnBadRequestForMissingRequiredField() { + final String body = + """ + { + "cycleMonth": 12, + "mentorshipType": "AD_HOC", + "registrationStartDate": "2099-12-01", + "registrationEndDate": "2099-12-15", + "cycleStartDate": "2099-12-20" + } + """; + + final ResponseEntity response = + restTemplate.exchange(url(), HttpMethod.POST, requestEntity(body), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).contains("cycleYear"); + } + + @Test + @DisplayName("Given invalid cycle month, when posting cycle, then return 400") + void shouldReturnBadRequestForInvalidCycleMonth() { + final CycleRequest validRequest = validRequest(); + final CycleRequest request = + new CycleRequest( + validRequest.cycleYear(), + 13, + validRequest.mentorshipType(), + validRequest.registrationStartDate(), + validRequest.registrationEndDate(), + validRequest.cycleStartDate(), + validRequest.cycleEndDate(), + validRequest.maxMenteesPerMentor(), + validRequest.description()); + + final ResponseEntity response = + restTemplate.exchange(url(), HttpMethod.POST, requestEntity(request), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).contains("cycleMonth"); + } + + @Test + @DisplayName("Given invalid mentee cap, when posting cycle, then return 400") + void shouldReturnBadRequestForInvalidMaxMenteesPerMentor() { + final CycleRequest validRequest = validRequest(); + final CycleRequest request = + new CycleRequest( + validRequest.cycleYear(), + validRequest.cycleMonth(), + validRequest.mentorshipType(), + validRequest.registrationStartDate(), + validRequest.registrationEndDate(), + validRequest.cycleStartDate(), + validRequest.cycleEndDate(), + 0, + validRequest.description()); + + final ResponseEntity response = + restTemplate.exchange(url(), HttpMethod.POST, requestEntity(request), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).contains("maxMenteesPerMentor"); + } + + @Test + @DisplayName("Given invalid date order, when posting cycle, then return 400") + void shouldReturnBadRequestForInvalidDateOrder() { + final CycleRequest validRequest = validRequest(); + final CycleRequest request = + new CycleRequest( + validRequest.cycleYear(), + validRequest.cycleMonth(), + validRequest.mentorshipType(), + validRequest.registrationStartDate(), + "2099-11-30", + validRequest.cycleStartDate(), + validRequest.cycleEndDate(), + validRequest.maxMenteesPerMentor(), + validRequest.description()); + + final ResponseEntity response = + restTemplate.exchange( + url(), HttpMethod.POST, requestEntity(request), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).contains("Registration end date"); + } + + private String url() { + return "http://localhost:" + port + CYCLES_PATH; + } + + private HttpEntity requestEntity(final CycleRequest request) { + return new HttpEntity<>(request, headers()); + } + + private HttpEntity requestEntity(final String body) { + return new HttpEntity<>(body, headers()); + } + + private HttpHeaders headers() { + final HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(adminToken); + headers.add("X-API-KEY", "test-api-key"); + return headers; + } + + private CycleRequest validRequest() { + return new CycleRequest( + TEST_YEAR, + 11, + "AD_HOC", + "2099-11-01", + "2099-11-15", + "2099-11-20", + "2100-02-20", + 5, + "December ad-hoc cycle"); + } + + private String loginAsAdmin() { + final HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + final ResponseEntity response = + restTemplate.postForEntity( + "http://localhost:" + port + LOGIN_PATH, + new HttpEntity<>(new LoginRequest(ADMIN_EMAIL, ADMIN_PASSWORD), headers), + LoginResponse.class); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + return response.getBody().token(); + } + + private record LoginRequest(String email, String password) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + private record LoginResponse(String token) {} + + private record CycleRequest( + int cycleYear, + int cycleMonth, + String mentorshipType, + String registrationStartDate, + String registrationEndDate, + String cycleStartDate, + String cycleEndDate, + int maxMenteesPerMentor, + String description) {} } From f84f368184c011e36cefd8c7f6606eb18699f4a8 Mon Sep 17 00:00:00 2001 From: MichaelaMatiskova Date: Mon, 5 Oct 2026 14:21:49 +0200 Subject: [PATCH 2/4] test: isolate request setup from exception assertion Create the mentorship cycle request before the assertion so the exception-testing lambda contains only the service invocation. --- .../com/wcc/platform/service/MentorshipCycleServiceTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/wcc/platform/service/MentorshipCycleServiceTest.java b/src/test/java/com/wcc/platform/service/MentorshipCycleServiceTest.java index 77359948..77f596ee 100644 --- a/src/test/java/com/wcc/platform/service/MentorshipCycleServiceTest.java +++ b/src/test/java/com/wcc/platform/service/MentorshipCycleServiceTest.java @@ -240,8 +240,9 @@ void shouldCreateDraftCycleWithDefaultMaxMenteesPerMentor() { void shouldPropagateDuplicateCycleException() { when(cycleRepository.create(org.mockito.ArgumentMatchers.any(MentorshipCycleEntity.class))) .thenThrow(new DuplicatedException("cycle already exists")); + final var request = validCreateRequest(5); - assertThatThrownBy(() -> cycleService.createCycle(validCreateRequest(5))) + assertThatThrownBy(() -> cycleService.createCycle(request)) .isInstanceOf(DuplicatedException.class) .hasMessage("cycle already exists"); } From 51ad21e3429965c08cf7204c286bb229083b52f3 Mon Sep 17 00:00:00 2001 From: MichaelaMatiskova Date: Sun, 11 Oct 2026 17:20:34 +0200 Subject: [PATCH 3/4] feat: validate mentorship cycle dates and allowed months Add Year and Month types with future-date validation for mentorship cycle creation, restrict new cycles to February through November, and return a readable error for invalid month values. Add matching entity validation, a Flyway database constraint, and update affected tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../configuration/GlobalExceptionHandler.java | 9 ++++++ .../MentorshipCycleCreateRequest.java | 3 +- .../mentorship/MentorshipCycleEntity.java | 12 ++++++++ .../service/MentorshipCycleService.java | 5 ---- .../MentorshipCycleMonthValidator.java | 15 ++++++++++ .../validation/ValidMentorshipCycleMonth.java | 30 +++++++++++++++++++ ...61011__restrict_mentorship_cycle_month.sql | 6 ++++ .../GlobalExceptionHandlerTest.java | 27 +++++++++++++++++ ...eApplicationRepositoryIntegrationTest.java | 2 +- .../service/MenteeServiceIntegrationTest.java | 4 +-- .../MentorshipCycleIntegrationTest.java | 29 +++++++++--------- 11 files changed, 118 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/wcc/platform/validation/MentorshipCycleMonthValidator.java create mode 100644 src/main/java/com/wcc/platform/validation/ValidMentorshipCycleMonth.java create mode 100644 src/main/resources/db/migration/V40__20261011__restrict_mentorship_cycle_month.sql diff --git a/src/main/java/com/wcc/platform/configuration/GlobalExceptionHandler.java b/src/main/java/com/wcc/platform/configuration/GlobalExceptionHandler.java index d841238f..9a56d58d 100644 --- a/src/main/java/com/wcc/platform/configuration/GlobalExceptionHandler.java +++ b/src/main/java/com/wcc/platform/configuration/GlobalExceptionHandler.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonMappingException.Reference; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException; import com.wcc.platform.domain.exceptions.ApplicationMenteeWorkflowException; import com.wcc.platform.domain.exceptions.ApplicationNotFoundException; @@ -32,6 +33,7 @@ import java.util.NoSuchElementException; import java.util.stream.Collectors; import java.util.stream.IntStream; +import java.time.Month; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; @@ -217,6 +219,13 @@ public ResponseEntity handleForbiddenException( private String extractReadableMessage(final HttpMessageNotReadableException ex) { final var cause = ex.getMostSpecificCause(); + if (cause instanceof InvalidFormatException invalidFormat + && Month.class.equals(invalidFormat.getTargetType()) + && invalidFormat.getPath().stream() + .anyMatch(reference -> "cycleMonth".equals(reference.getFieldName()))) { + return "Cycle month must be between February and November"; + } + if (cause instanceof UnrecognizedPropertyException unrecognizedProperty) { final var allowedFields = unrecognizedProperty.getKnownPropertyIds().stream() diff --git a/src/main/java/com/wcc/platform/domain/platform/mentorship/MentorshipCycleCreateRequest.java b/src/main/java/com/wcc/platform/domain/platform/mentorship/MentorshipCycleCreateRequest.java index 7dc03c3f..ce16158e 100644 --- a/src/main/java/com/wcc/platform/domain/platform/mentorship/MentorshipCycleCreateRequest.java +++ b/src/main/java/com/wcc/platform/domain/platform/mentorship/MentorshipCycleCreateRequest.java @@ -1,5 +1,6 @@ package com.wcc.platform.domain.platform.mentorship; +import com.wcc.platform.validation.ValidMentorshipCycleMonth; import jakarta.validation.constraints.FutureOrPresent; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; @@ -10,7 +11,7 @@ /** Request payload for creating a mentorship cycle. */ public record MentorshipCycleCreateRequest( @NotNull @FutureOrPresent Year cycleYear, - @NotNull Month cycleMonth, + @NotNull @ValidMentorshipCycleMonth Month cycleMonth, @NotNull MentorshipType mentorshipType, @NotNull @FutureOrPresent LocalDate registrationStartDate, @NotNull @FutureOrPresent LocalDate registrationEndDate, diff --git a/src/main/java/com/wcc/platform/domain/platform/mentorship/MentorshipCycleEntity.java b/src/main/java/com/wcc/platform/domain/platform/mentorship/MentorshipCycleEntity.java index 192d226f..2bc6bec4 100644 --- a/src/main/java/com/wcc/platform/domain/platform/mentorship/MentorshipCycleEntity.java +++ b/src/main/java/com/wcc/platform/domain/platform/mentorship/MentorshipCycleEntity.java @@ -1,6 +1,10 @@ package com.wcc.platform.domain.platform.mentorship; import com.wcc.platform.domain.cms.pages.mentorship.MentorsPage; +import com.wcc.platform.validation.ValidMentorshipCycleMonth; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; import java.time.LocalDate; import java.time.Month; import java.time.Year; @@ -16,14 +20,22 @@ @Builder public class MentorshipCycleEntity { private Long cycleId; + @NotNull @FutureOrPresent private Year cycleYear; + @NotNull private MentorshipType mentorshipType; + @NotNull @ValidMentorshipCycleMonth private Month cycleMonth; + @NotNull @FutureOrPresent private LocalDate registrationStartDate; + @NotNull @FutureOrPresent private LocalDate registrationEndDate; + @NotNull @FutureOrPresent private LocalDate cycleStartDate; + @FutureOrPresent private LocalDate cycleEndDate; private CycleStatus status; + @Min(1) private Integer maxMenteesPerMentor; private String description; private ZonedDateTime createdAt; diff --git a/src/main/java/com/wcc/platform/service/MentorshipCycleService.java b/src/main/java/com/wcc/platform/service/MentorshipCycleService.java index cbf548cd..fb87faa7 100644 --- a/src/main/java/com/wcc/platform/service/MentorshipCycleService.java +++ b/src/main/java/com/wcc/platform/service/MentorshipCycleService.java @@ -7,7 +7,6 @@ import com.wcc.platform.domain.platform.mentorship.MentorshipCycleEntity; import com.wcc.platform.repository.MentorshipCycleRepository; import jakarta.validation.constraints.FutureOrPresent; -import java.time.Month; import java.time.Year; import java.util.List; import java.util.Map; @@ -73,10 +72,6 @@ public MentorshipCycleEntity updateStatus(final Long cycleId, final CycleStatus */ public MentorshipCycleEntity createCycle(final MentorshipCycleCreateRequest request) { - if (request.cycleMonth().getValue() < 2 || request.cycleMonth().getValue() > 11) { - throw new IllegalArgumentException("Cycle month must be between February and November"); - } - if (request.registrationEndDate().isBefore(request.registrationStartDate())) { throw new IllegalArgumentException( "Registration end date must be greater than or equal to registration start date"); diff --git a/src/main/java/com/wcc/platform/validation/MentorshipCycleMonthValidator.java b/src/main/java/com/wcc/platform/validation/MentorshipCycleMonthValidator.java new file mode 100644 index 00000000..450d24f9 --- /dev/null +++ b/src/main/java/com/wcc/platform/validation/MentorshipCycleMonthValidator.java @@ -0,0 +1,15 @@ +package com.wcc.platform.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.time.Month; + +/** Validates the allowed month range for mentorship cycles. */ +public class MentorshipCycleMonthValidator + implements ConstraintValidator { + + @Override + public boolean isValid(final Month month, final ConstraintValidatorContext context) { + return month == null || (month.getValue() >= 2 && month.getValue() <= 11); + } +} diff --git a/src/main/java/com/wcc/platform/validation/ValidMentorshipCycleMonth.java b/src/main/java/com/wcc/platform/validation/ValidMentorshipCycleMonth.java new file mode 100644 index 00000000..40518295 --- /dev/null +++ b/src/main/java/com/wcc/platform/validation/ValidMentorshipCycleMonth.java @@ -0,0 +1,30 @@ +package com.wcc.platform.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; +import java.lang.annotation.RetentionPolicy; + +/** + * Validates that a mentorship cycle month is between February and November. + */ +@Documented +@Constraint(validatedBy = MentorshipCycleMonthValidator.class) +@Target({ + ElementType.FIELD, + ElementType.PARAMETER, + ElementType.ANNOTATION_TYPE, + ElementType.RECORD_COMPONENT +}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidMentorshipCycleMonth { + + String message() default "Cycle month must be between February and November"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/resources/db/migration/V40__20261011__restrict_mentorship_cycle_month.sql b/src/main/resources/db/migration/V40__20261011__restrict_mentorship_cycle_month.sql new file mode 100644 index 00000000..f22c65a8 --- /dev/null +++ b/src/main/resources/db/migration/V40__20261011__restrict_mentorship_cycle_month.sql @@ -0,0 +1,6 @@ +-- Restrict new mentorship cycles to February through November. +-- Existing January and December cycles remain valid historical data. +ALTER TABLE mentorship_cycles + ADD CONSTRAINT valid_cycle_month + CHECK (cycle_month >= 2 AND cycle_month <= 11) + NOT VALID; diff --git a/src/test/java/com/wcc/platform/configuration/GlobalExceptionHandlerTest.java b/src/test/java/com/wcc/platform/configuration/GlobalExceptionHandlerTest.java index f6755ee4..ed971e86 100644 --- a/src/test/java/com/wcc/platform/configuration/GlobalExceptionHandlerTest.java +++ b/src/test/java/com/wcc/platform/configuration/GlobalExceptionHandlerTest.java @@ -15,6 +15,7 @@ import com.wcc.platform.domain.exceptions.ForbiddenException; import com.wcc.platform.domain.platform.mentorship.TechnicalAreaProficiency; import java.util.List; +import java.time.Month; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -160,4 +161,30 @@ void shouldReturnBadRequestWithFallbackMessageForInvalidEnumValue() { assertEquals(BAD_REQUEST, response.getStatusCode()); assertEquals(expectation, response.getBody()); } + + @Test + @DisplayName( + "Given malformed JSON with invalid mentorship cycle month, " + + "when handling, then return the cycle month validation message") + void shouldReturnCycleMonthValidationMessageForInvalidMonth() { + record CycleRequest(Month cycleMonth) {} + + var invalidJson = "{\"cycleMonth\":\"JANUAR\"}"; + var cause = + assertThrows( + InvalidFormatException.class, + () -> new ObjectMapper().readValue(invalidJson, CycleRequest.class)); + var exception = + new HttpMessageNotReadableException( + "JSON parse error", cause, new MockHttpInputMessage(invalidJson.getBytes())); + + var response = + globalExceptionHandler.handleHttpMessageNotReadableException(exception, webRequest); + + var expectation = + new ErrorDetails( + BAD_REQUEST.value(), "Cycle month must be between February and November", DETAILS); + assertEquals(BAD_REQUEST, response.getStatusCode()); + assertEquals(expectation, response.getBody()); + } } diff --git a/src/testInt/java/com/wcc/platform/repository/postgres/PostgresMenteeApplicationRepositoryIntegrationTest.java b/src/testInt/java/com/wcc/platform/repository/postgres/PostgresMenteeApplicationRepositoryIntegrationTest.java index 23346346..d1b47a01 100644 --- a/src/testInt/java/com/wcc/platform/repository/postgres/PostgresMenteeApplicationRepositoryIntegrationTest.java +++ b/src/testInt/java/com/wcc/platform/repository/postgres/PostgresMenteeApplicationRepositoryIntegrationTest.java @@ -54,7 +54,7 @@ void setUp() { MentorshipCycleEntity.builder() .cycleYear(Year.of(2026)) .mentorshipType(MentorshipType.LONG_TERM) - .cycleMonth(Month.JANUARY) + .cycleMonth(Month.FEBRUARY) .registrationStartDate(LocalDate.now().minusDays(1)) .registrationEndDate(LocalDate.now().plusDays(10)) .cycleStartDate(LocalDate.now().plusDays(15)) diff --git a/src/testInt/java/com/wcc/platform/service/MenteeServiceIntegrationTest.java b/src/testInt/java/com/wcc/platform/service/MenteeServiceIntegrationTest.java index f8d08bb8..9928209c 100644 --- a/src/testInt/java/com/wcc/platform/service/MenteeServiceIntegrationTest.java +++ b/src/testInt/java/com/wcc/platform/service/MenteeServiceIntegrationTest.java @@ -349,14 +349,14 @@ private void createAdHocCycle() { MentorshipCycleEntity.builder() .cycleYear(TEST_YEAR_2028) .mentorshipType(MentorshipType.AD_HOC) - .cycleMonth(Month.DECEMBER) + .cycleMonth(Month.NOVEMBER) .registrationStartDate(LocalDate.now().minusDays(5)) .registrationEndDate(LocalDate.now().plusDays(5)) .cycleStartDate(LocalDate.now().plusDays(1)) .cycleEndDate(LocalDate.now().plusDays(30)) .status(CycleStatus.OPEN) .maxMenteesPerMentor(MAX_AD_HOC_MENTEES) - .description("Test AD_HOC cycle for December 2028") + .description("Test AD_HOC cycle for November 2028") .build(); final var savedCycle = cycleRepository.create(adHocCycle); diff --git a/src/testInt/java/com/wcc/platform/service/MentorshipCycleIntegrationTest.java b/src/testInt/java/com/wcc/platform/service/MentorshipCycleIntegrationTest.java index c0975249..6755021f 100644 --- a/src/testInt/java/com/wcc/platform/service/MentorshipCycleIntegrationTest.java +++ b/src/testInt/java/com/wcc/platform/service/MentorshipCycleIntegrationTest.java @@ -279,7 +279,7 @@ void shouldReturnBadRequestForMissingRequiredField() { final String body = """ { - "cycleMonth": 12, + "cycleMonth": "DECEMBER", "mentorshipType": "AD_HOC", "registrationStartDate": "2099-12-01", "registrationEndDate": "2099-12-15", @@ -297,18 +297,17 @@ void shouldReturnBadRequestForMissingRequiredField() { @Test @DisplayName("Given invalid cycle month, when posting cycle, then return 400") void shouldReturnBadRequestForInvalidCycleMonth() { - final CycleRequest validRequest = validRequest(); final CycleRequest request = new CycleRequest( - validRequest.cycleYear(), - 13, - validRequest.mentorshipType(), - validRequest.registrationStartDate(), - validRequest.registrationEndDate(), - validRequest.cycleStartDate(), - validRequest.cycleEndDate(), - validRequest.maxMenteesPerMentor(), - validRequest.description()); + TEST_YEAR, + Month.JANUARY, + "AD_HOC", + "2099-11-01", + "2099-11-15", + "2099-11-20", + "2100-02-20", + 5, + "January ad-hoc cycle"); final ResponseEntity response = restTemplate.exchange(url(), HttpMethod.POST, requestEntity(request), String.class); @@ -350,7 +349,7 @@ void shouldReturnBadRequestForInvalidDateOrder() { validRequest.cycleMonth(), validRequest.mentorshipType(), validRequest.registrationStartDate(), - "2099-11-30", + "2099-10-30", validRequest.cycleStartDate(), validRequest.cycleEndDate(), validRequest.maxMenteesPerMentor(), @@ -387,14 +386,14 @@ private HttpHeaders headers() { private CycleRequest validRequest() { return new CycleRequest( TEST_YEAR, - 11, + Month.NOVEMBER, "AD_HOC", "2099-11-01", "2099-11-15", "2099-11-20", "2100-02-20", 5, - "December ad-hoc cycle"); + "November ad-hoc cycle"); } private String loginAsAdmin() { @@ -417,7 +416,7 @@ private record LoginResponse(String token) {} private record CycleRequest( int cycleYear, - int cycleMonth, + Month cycleMonth, String mentorshipType, String registrationStartDate, String registrationEndDate, From 0407d85712d9b9b6d2171c91e796b2d046507240 Mon Sep 17 00:00:00 2001 From: MichaelaMatiskova Date: Sun, 18 Oct 2026 17:23:42 +0200 Subject: [PATCH 4/4] fix: align JaCoCo reports and split mentorship tests Restore the standard JaCoCo unit report location expected by CI and make coverage verification depend on both unit and integration reports. Split the mentorship cycle unit and integration tests into focused classes so they comply with the PMD TooManyMethods rule while preserving existing coverage and test behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build.gradle.kts | 3 +- .../service/MentorshipCycleCreationTest.java | 127 ++++++ .../service/MentorshipCycleQueryTest.java | 62 +++ ...st.java => MentorshipCycleStatusTest.java} | 182 +------- ...entorshipCycleEndpointIntegrationTest.java | 166 +++++++ .../MentorshipCycleIntegrationTest.java | 427 ------------------ ...MentorshipCycleIntegrationTestSupport.java | 129 ++++++ ...torshipCycleRepositoryIntegrationTest.java | 126 ++++++ 8 files changed, 627 insertions(+), 595 deletions(-) create mode 100644 src/test/java/com/wcc/platform/service/MentorshipCycleCreationTest.java create mode 100644 src/test/java/com/wcc/platform/service/MentorshipCycleQueryTest.java rename src/test/java/com/wcc/platform/service/{MentorshipCycleServiceTest.java => MentorshipCycleStatusTest.java} (50%) create mode 100644 src/testInt/java/com/wcc/platform/service/MentorshipCycleEndpointIntegrationTest.java delete mode 100644 src/testInt/java/com/wcc/platform/service/MentorshipCycleIntegrationTest.java create mode 100644 src/testInt/java/com/wcc/platform/service/MentorshipCycleIntegrationTestSupport.java create mode 100644 src/testInt/java/com/wcc/platform/service/MentorshipCycleRepositoryIntegrationTest.java diff --git a/build.gradle.kts b/build.gradle.kts index 515e4c23..3246a6b1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -99,7 +99,6 @@ tasks { xml.required.set(true) // Enable XML report for SonarQube html.required.set(true) // Keep HTML for local viewing csv.required.set(false) // Disable CSV to reduce clutter - html.outputLocation.set(layout.buildDirectory.dir("reports/jacoco/integration")) } } @@ -133,6 +132,7 @@ tasks { } jacocoTestCoverageVerification { + dependsOn("jacocoTestReport", "jacocoIntegrationReport") executionData( fileTree(layout.buildDirectory.asFile.get()).include( "jacoco/test.exec", @@ -270,4 +270,3 @@ tasks.register("pmdAll") { description = "Runs all PMD checks (main, test, testInt)." dependsOn("pmdMain", "pmdTest", "pmdTestInt") } - diff --git a/src/test/java/com/wcc/platform/service/MentorshipCycleCreationTest.java b/src/test/java/com/wcc/platform/service/MentorshipCycleCreationTest.java new file mode 100644 index 00000000..826921c3 --- /dev/null +++ b/src/test/java/com/wcc/platform/service/MentorshipCycleCreationTest.java @@ -0,0 +1,127 @@ +package com.wcc.platform.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; + +import com.wcc.platform.domain.exceptions.DuplicatedException; +import com.wcc.platform.domain.platform.mentorship.CycleStatus; +import com.wcc.platform.domain.platform.mentorship.MentorshipCycleCreateRequest; +import com.wcc.platform.domain.platform.mentorship.MentorshipCycleEntity; +import com.wcc.platform.domain.platform.mentorship.MentorshipType; +import com.wcc.platform.repository.MentorshipCycleRepository; +import java.time.LocalDate; +import java.time.Month; +import java.time.Year; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +class MentorshipCycleCreationTest { + + @Mock private MentorshipCycleRepository cycleRepository; + private MentorshipCycleService cycleService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + cycleService = new MentorshipCycleService(cycleRepository); + } + + @Test + @DisplayName("Given valid cycle request, when creating cycle, then create draft cycle") + void shouldCreateDraftCycleWithDefaultMaxMenteesPerMentor() { + final var request = validCreateRequest(null); + final var createdCycle = cycleWithStatus(9L, CycleStatus.DRAFT); + when(cycleRepository.create(org.mockito.ArgumentMatchers.any(MentorshipCycleEntity.class))) + .thenReturn(createdCycle); + + final var result = cycleService.createCycle(request); + + assertThat(result.getStatus()).isEqualTo(CycleStatus.DRAFT); + verify(cycleRepository) + .create( + org.mockito.ArgumentMatchers.argThat( + cycle -> + cycle.getCycleYear().equals(Year.of(2026)) + && cycle.getCycleMonth().equals(Month.OCTOBER) + && cycle.getMentorshipType() == MentorshipType.AD_HOC + && cycle.getMaxMenteesPerMentor() == 5)); + } + + @Test + @DisplayName("Given duplicate cycle, when creating cycle, then throw conflict exception") + void shouldPropagateDuplicateCycleException() { + when(cycleRepository.create(org.mockito.ArgumentMatchers.any(MentorshipCycleEntity.class))) + .thenThrow(new DuplicatedException("cycle already exists")); + final var request = validCreateRequest(5); + + assertThatThrownBy(() -> cycleService.createCycle(request)) + .isInstanceOf(DuplicatedException.class) + .hasMessage("cycle already exists"); + } + + @Test + @DisplayName( + "Given registration end before registration start, when creating cycle, then reject request") + void shouldRejectInvalidRegistrationDateRange() { + final var request = + new MentorshipCycleCreateRequest( + Year.of(2026), Month.OCTOBER, MentorshipType.AD_HOC, + LocalDate.of(2026, 10, 15), LocalDate.of(2026, 10, 1), + LocalDate.of(2026, 10, 20), null, 5, "October cycle"); + + assertThatThrownBy(() -> cycleService.createCycle(request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Registration end date"); + verify(cycleRepository, never()).create(org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName( + "Given cycle start before registration start, when creating cycle, then reject request") + void shouldRejectCycleStartBeforeRegistrationStart() { + final var request = + new MentorshipCycleCreateRequest( + Year.of(2026), Month.OCTOBER, MentorshipType.AD_HOC, + LocalDate.of(2026, 10, 15), LocalDate.of(2026, 10, 20), + LocalDate.of(2026, 10, 1), null, 5, "October cycle"); + + assertThatThrownBy(() -> cycleService.createCycle(request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cycle start date"); + verify(cycleRepository, never()).create(org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("Given cycle end before cycle start, when creating cycle, then reject request") + void shouldRejectCycleEndBeforeCycleStart() { + final var request = + new MentorshipCycleCreateRequest( + Year.of(2026), Month.OCTOBER, MentorshipType.AD_HOC, + LocalDate.of(2026, 10, 1), LocalDate.of(2026, 10, 15), + LocalDate.of(2026, 10, 20), LocalDate.of(2026, 10, 19), + 5, "October cycle"); + + assertThatThrownBy(() -> cycleService.createCycle(request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cycle end date"); + verify(cycleRepository, never()).create(org.mockito.ArgumentMatchers.any()); + } + + private MentorshipCycleCreateRequest validCreateRequest(final Integer maxMenteesPerMentor) { + return new MentorshipCycleCreateRequest( + Year.of(2026), Month.OCTOBER, MentorshipType.AD_HOC, + LocalDate.of(2026, 10, 1), LocalDate.of(2026, 10, 15), + LocalDate.of(2026, 10, 20), LocalDate.of(2026, 12, 20), + maxMenteesPerMentor, "October cycle"); + } + + private MentorshipCycleEntity cycleWithStatus(final Long cycleId, final CycleStatus status) { + return MentorshipCycleEntity.builder().cycleId(cycleId).status(status).build(); + } +} diff --git a/src/test/java/com/wcc/platform/service/MentorshipCycleQueryTest.java b/src/test/java/com/wcc/platform/service/MentorshipCycleQueryTest.java new file mode 100644 index 00000000..3cd9b21c --- /dev/null +++ b/src/test/java/com/wcc/platform/service/MentorshipCycleQueryTest.java @@ -0,0 +1,62 @@ +package com.wcc.platform.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import com.wcc.platform.domain.platform.mentorship.MentorshipCycleEntity; +import com.wcc.platform.repository.MentorshipCycleRepository; +import java.time.Year; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +class MentorshipCycleQueryTest { + + @Mock private MentorshipCycleRepository cycleRepository; + private MentorshipCycleService cycleService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + cycleService = new MentorshipCycleService(cycleRepository); + } + + @Test + @DisplayName("Given no year, when getting cycles, then return all cycles") + void shouldReturnAllCyclesWhenNoYear() { + final var cycles = List.of(cycleInYear(1L, 2025), cycleInYear(2L, 2026)); + when(cycleRepository.getAll()).thenReturn(cycles); + + final var result = cycleService.getCycles(null); + + assertThat(result).isEqualTo(cycles); + } + + @Test + @DisplayName("Given a year, when getting cycles, then return only cycles in that year") + void shouldReturnCyclesForYear() { + final var cycles = List.of(cycleInYear(2L, 2026)); + when(cycleRepository.findByYear(2026)).thenReturn(cycles); + + final var result = cycleService.getCycles(Year.of(2026)); + + assertThat(result).isEqualTo(cycles); + } + + @Test + @DisplayName("Given a year with no cycles, when getting cycles, then return an empty list") + void shouldReturnEmptyListForYearWithNoCycles() { + when(cycleRepository.findByYear(9999)).thenReturn(List.of()); + + final var result = cycleService.getCycles(Year.of(9999)); + + assertThat(result).isEmpty(); + } + + private MentorshipCycleEntity cycleInYear(final Long cycleId, final int year) { + return MentorshipCycleEntity.builder().cycleId(cycleId).cycleYear(Year.of(year)).build(); + } +} diff --git a/src/test/java/com/wcc/platform/service/MentorshipCycleServiceTest.java b/src/test/java/com/wcc/platform/service/MentorshipCycleStatusTest.java similarity index 50% rename from src/test/java/com/wcc/platform/service/MentorshipCycleServiceTest.java rename to src/test/java/com/wcc/platform/service/MentorshipCycleStatusTest.java index 77f596ee..090874f3 100644 --- a/src/test/java/com/wcc/platform/service/MentorshipCycleServiceTest.java +++ b/src/test/java/com/wcc/platform/service/MentorshipCycleStatusTest.java @@ -7,17 +7,10 @@ import static org.mockito.Mockito.when; import com.wcc.platform.domain.exceptions.CycleNotFoundException; -import com.wcc.platform.domain.exceptions.DuplicatedException; import com.wcc.platform.domain.exceptions.InvalidCycleStatusTransitionException; import com.wcc.platform.domain.platform.mentorship.CycleStatus; -import com.wcc.platform.domain.platform.mentorship.MentorshipCycleCreateRequest; import com.wcc.platform.domain.platform.mentorship.MentorshipCycleEntity; -import com.wcc.platform.domain.platform.mentorship.MentorshipType; -import java.time.LocalDate; -import java.time.Month; -import java.time.Year; import com.wcc.platform.repository.MentorshipCycleRepository; -import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -25,10 +18,9 @@ import org.mockito.Mock; import org.mockito.MockitoAnnotations; -class MentorshipCycleServiceTest { +class MentorshipCycleStatusTest { @Mock private MentorshipCycleRepository cycleRepository; - private MentorshipCycleService cycleService; @BeforeEach @@ -58,7 +50,7 @@ void shouldAllowDraftToOpen() { when(cycleRepository.findById(1L)).thenReturn(Optional.of(cycle)); when(cycleRepository.updateStatus(1L, CycleStatus.OPEN)).thenReturn(updated); - final MentorshipCycleEntity result = cycleService.updateStatus(1L, CycleStatus.OPEN); + final var result = cycleService.updateStatus(1L, CycleStatus.OPEN); assertThat(result.getStatus()).isEqualTo(CycleStatus.OPEN); verify(cycleRepository).updateStatus(1L, CycleStatus.OPEN); @@ -72,7 +64,7 @@ void shouldAllowDraftToCancelled() { when(cycleRepository.findById(1L)).thenReturn(Optional.of(cycle)); when(cycleRepository.updateStatus(1L, CycleStatus.CANCELLED)).thenReturn(updated); - final MentorshipCycleEntity result = cycleService.updateStatus(1L, CycleStatus.CANCELLED); + final var result = cycleService.updateStatus(1L, CycleStatus.CANCELLED); assertThat(result.getStatus()).isEqualTo(CycleStatus.CANCELLED); } @@ -85,7 +77,7 @@ void shouldAllowOpenToClosed() { when(cycleRepository.findById(1L)).thenReturn(Optional.of(cycle)); when(cycleRepository.updateStatus(1L, CycleStatus.CLOSED)).thenReturn(updated); - final MentorshipCycleEntity result = cycleService.updateStatus(1L, CycleStatus.CLOSED); + final var result = cycleService.updateStatus(1L, CycleStatus.CLOSED); assertThat(result.getStatus()).isEqualTo(CycleStatus.CLOSED); } @@ -99,7 +91,7 @@ void shouldAllowClosedToInProgress() { when(cycleRepository.findById(1L)).thenReturn(Optional.of(cycle)); when(cycleRepository.updateStatus(1L, CycleStatus.IN_PROGRESS)).thenReturn(updated); - final MentorshipCycleEntity result = cycleService.updateStatus(1L, CycleStatus.IN_PROGRESS); + final var result = cycleService.updateStatus(1L, CycleStatus.IN_PROGRESS); assertThat(result.getStatus()).isEqualTo(CycleStatus.IN_PROGRESS); } @@ -113,14 +105,15 @@ void shouldAllowInProgressToCompleted() { when(cycleRepository.findById(1L)).thenReturn(Optional.of(cycle)); when(cycleRepository.updateStatus(1L, CycleStatus.COMPLETED)).thenReturn(updated); - final MentorshipCycleEntity result = cycleService.updateStatus(1L, CycleStatus.COMPLETED); + final var result = cycleService.updateStatus(1L, CycleStatus.COMPLETED); assertThat(result.getStatus()).isEqualTo(CycleStatus.COMPLETED); } @Test @DisplayName( - "Given cycle in DRAFT status, when transitioning to IN_PROGRESS, then throw InvalidCycleStatusTransitionException") + "Given cycle in DRAFT status, when transitioning to IN_PROGRESS, then throw " + + "InvalidCycleStatusTransitionException") void shouldRejectDraftToInProgress() { final var cycle = cycleWithStatus(1L, CycleStatus.DRAFT); when(cycleRepository.findById(1L)).thenReturn(Optional.of(cycle)); @@ -135,7 +128,8 @@ void shouldRejectDraftToInProgress() { @Test @DisplayName( - "Given cycle in OPEN status, when transitioning to DRAFT, then throw InvalidCycleStatusTransitionException") + "Given cycle in OPEN status, when transitioning to DRAFT, then throw " + + "InvalidCycleStatusTransitionException") void shouldRejectOpenToDraft() { final var cycle = cycleWithStatus(1L, CycleStatus.OPEN); when(cycleRepository.findById(1L)).thenReturn(Optional.of(cycle)); @@ -148,7 +142,8 @@ void shouldRejectOpenToDraft() { @Test @DisplayName( - "Given cycle in COMPLETED status, when transitioning to any status, then throw InvalidCycleStatusTransitionException") + "Given cycle in COMPLETED status, when transitioning to any status, then throw " + + "InvalidCycleStatusTransitionException") void shouldRejectAnyTransitionFromCompleted() { final var cycle = cycleWithStatus(1L, CycleStatus.COMPLETED); when(cycleRepository.findById(1L)).thenReturn(Optional.of(cycle)); @@ -160,7 +155,8 @@ void shouldRejectAnyTransitionFromCompleted() { @Test @DisplayName( - "Given cycle in CANCELLED status, when transitioning to any status, then throw InvalidCycleStatusTransitionException") + "Given cycle in CANCELLED status, when transitioning to any status, then throw " + + "InvalidCycleStatusTransitionException") void shouldRejectAnyTransitionFromCancelled() { final var cycle = cycleWithStatus(1L, CycleStatus.CANCELLED); when(cycleRepository.findById(1L)).thenReturn(Optional.of(cycle)); @@ -172,7 +168,8 @@ void shouldRejectAnyTransitionFromCancelled() { @Test @DisplayName( - "Given cycle in OPEN status, when transitioning to same OPEN status, then throw InvalidCycleStatusTransitionException") + "Given cycle in OPEN status, when transitioning to same OPEN status, then throw " + + "InvalidCycleStatusTransitionException") void shouldRejectSameStatusTransition() { final var cycle = cycleWithStatus(1L, CycleStatus.OPEN); when(cycleRepository.findById(1L)).thenReturn(Optional.of(cycle)); @@ -182,154 +179,7 @@ void shouldRejectSameStatusTransition() { .hasMessageContaining("OPEN"); } - @Test - @DisplayName("Given no year, when getting cycles, then return all cycles") - void shouldReturnAllCyclesWhenNoYear() { - final var cycles = List.of(cycleInYear(1L, 2025), cycleInYear(2L, 2026)); - when(cycleRepository.getAll()).thenReturn(cycles); - - final List result = cycleService.getCycles(null); - - assertThat(result).isEqualTo(cycles); - } - - @Test - @DisplayName("Given a year, when getting cycles, then return only cycles in that year") - void shouldReturnCyclesForYear() { - final var cycles = List.of(cycleInYear(2L, 2026)); - when(cycleRepository.findByYear(2026)).thenReturn(cycles); - - final List result = cycleService.getCycles(Year.of(2026)); - - assertThat(result).isEqualTo(cycles); - } - - @Test - @DisplayName("Given a year with no cycles, when getting cycles, then return an empty list") - void shouldReturnEmptyListForYearWithNoCycles() { - when(cycleRepository.findByYear(9999)).thenReturn(List.of()); - - final List result = cycleService.getCycles(Year.of(9999)); - - assertThat(result).isEmpty(); - } - - @Test - @DisplayName("Given valid cycle request, when creating cycle, then create draft cycle") - void shouldCreateDraftCycleWithDefaultMaxMenteesPerMentor() { - final var request = validCreateRequest(null); - final var createdCycle = cycleWithStatus(9L, CycleStatus.DRAFT); - when(cycleRepository.create(org.mockito.ArgumentMatchers.any(MentorshipCycleEntity.class))) - .thenReturn(createdCycle); - - final MentorshipCycleEntity result = cycleService.createCycle(request); - - assertThat(result.getStatus()).isEqualTo(CycleStatus.DRAFT); - verify(cycleRepository) - .create( - org.mockito.ArgumentMatchers.argThat( - cycle -> - cycle.getCycleYear().equals(Year.of(2026)) - && cycle.getCycleMonth().equals(Month.OCTOBER) - && cycle.getMentorshipType() == MentorshipType.AD_HOC - && cycle.getMaxMenteesPerMentor() == 5)); - } - - @Test - @DisplayName("Given duplicate cycle, when creating cycle, then throw conflict exception") - void shouldPropagateDuplicateCycleException() { - when(cycleRepository.create(org.mockito.ArgumentMatchers.any(MentorshipCycleEntity.class))) - .thenThrow(new DuplicatedException("cycle already exists")); - final var request = validCreateRequest(5); - - assertThatThrownBy(() -> cycleService.createCycle(request)) - .isInstanceOf(DuplicatedException.class) - .hasMessage("cycle already exists"); - } - - @Test - @DisplayName( - "Given registration end before registration start, when creating cycle, then reject request") - void shouldRejectInvalidRegistrationDateRange() { - final var request = - new MentorshipCycleCreateRequest( - Year.of(2026), - Month.OCTOBER, - MentorshipType.AD_HOC, - LocalDate.of(2026, 10, 15), - LocalDate.of(2026, 10, 1), - LocalDate.of(2026, 10, 20), - null, - 5, - "October cycle"); - - assertThatThrownBy(() -> cycleService.createCycle(request)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Registration end date"); - verify(cycleRepository, never()).create(org.mockito.ArgumentMatchers.any()); - } - - @Test - @DisplayName( - "Given cycle start before registration start, when creating cycle, then reject request") - void shouldRejectCycleStartBeforeRegistrationStart() { - final var request = - new MentorshipCycleCreateRequest( - Year.of(2026), - Month.OCTOBER, - MentorshipType.AD_HOC, - LocalDate.of(2026, 10, 15), - LocalDate.of(2026, 10, 20), - LocalDate.of(2026, 10, 1), - null, - 5, - "October cycle"); - - assertThatThrownBy(() -> cycleService.createCycle(request)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Cycle start date"); - verify(cycleRepository, never()).create(org.mockito.ArgumentMatchers.any()); - } - - @Test - @DisplayName("Given cycle end before cycle start, when creating cycle, then reject request") - void shouldRejectCycleEndBeforeCycleStart() { - final var request = - new MentorshipCycleCreateRequest( - Year.of(2026), - Month.OCTOBER, - MentorshipType.AD_HOC, - LocalDate.of(2026, 10, 1), - LocalDate.of(2026, 10, 15), - LocalDate.of(2026, 10, 20), - LocalDate.of(2026, 10, 19), - 5, - "October cycle"); - - assertThatThrownBy(() -> cycleService.createCycle(request)) - .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("Cycle end date"); - verify(cycleRepository, never()).create(org.mockito.ArgumentMatchers.any()); - } - - private MentorshipCycleCreateRequest validCreateRequest(final Integer maxMenteesPerMentor) { - return new MentorshipCycleCreateRequest( - Year.of(2026), - Month.OCTOBER, - MentorshipType.AD_HOC, - LocalDate.of(2026, 10, 1), - LocalDate.of(2026, 10, 15), - LocalDate.of(2026, 10, 20), - LocalDate.of(2026, 12, 20), - maxMenteesPerMentor, - "October cycle"); - } - private MentorshipCycleEntity cycleWithStatus(final Long cycleId, final CycleStatus status) { return MentorshipCycleEntity.builder().cycleId(cycleId).status(status).build(); } - - private MentorshipCycleEntity cycleInYear(final Long cycleId, final int year) { - return MentorshipCycleEntity.builder().cycleId(cycleId).cycleYear(Year.of(year)).build(); - } } diff --git a/src/testInt/java/com/wcc/platform/service/MentorshipCycleEndpointIntegrationTest.java b/src/testInt/java/com/wcc/platform/service/MentorshipCycleEndpointIntegrationTest.java new file mode 100644 index 00000000..2f28eec0 --- /dev/null +++ b/src/testInt/java/com/wcc/platform/service/MentorshipCycleEndpointIntegrationTest.java @@ -0,0 +1,166 @@ +package com.wcc.platform.service; + +import static org.assertj.core.api.Assertions.assertThat; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Month; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; + +/** Integration tests for mentorship cycle REST endpoints. */ +@ActiveProfiles("test") +class MentorshipCycleEndpointIntegrationTest extends MentorshipCycleIntegrationTestSupport { + + @Autowired private ObjectMapper objectMapper; + + @Test + @DisplayName("Given a past year, when getting cycles, then it should throw ConstraintViolationException") + void shouldRejectPastYear() { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> cycleService.getCycles(java.time.Year.of(2025))) + .isInstanceOf(jakarta.validation.ConstraintViolationException.class); + } + + @Test + @DisplayName("Given valid cycle request, when posting it, then return 201 with draft cycle") + void shouldCreateCycleThroughPostEndpoint() throws Exception { + final ResponseEntity response = + restTemplate.exchange(url(), HttpMethod.POST, requestEntity(validRequest()), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + final var body = objectMapper.readTree(response.getBody()); + assertThat(body.get("status").asText()).isEqualTo("DRAFT"); + assertThat(body.get("cycleYear").asInt()).isEqualTo(TEST_YEAR); + assertThat(body.get("mentorshipType").asText()).isEqualTo("Ad-Hoc"); + assertThat(body.get("cycleMonth").asText()).isEqualTo("NOVEMBER"); + } + + @Test + @DisplayName("Given existing cycle, when posting duplicate, then return 409") + void shouldReturnConflictForDuplicateCycle() { + final HttpEntity entity = requestEntity(validRequest()); + restTemplate.exchange(url(), HttpMethod.POST, entity, String.class); + + final ResponseEntity response = + restTemplate.exchange(url(), HttpMethod.POST, entity, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(response.getBody()).contains("already exists"); + } + + @Test + @DisplayName("Given missing required field, when posting cycle, then return 400") + void shouldReturnBadRequestForMissingRequiredField() { + final String body = + """ + { + "cycleMonth": "DECEMBER", + "mentorshipType": "AD_HOC", + "registrationStartDate": "2099-12-01", + "registrationEndDate": "2099-12-15", + "cycleStartDate": "2099-12-20" + } + """; + + final ResponseEntity response = + restTemplate.exchange(url(), HttpMethod.POST, requestEntity(body), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).contains("cycleYear"); + } + + @Test + @DisplayName("Given invalid cycle month, when posting cycle, then return 400") + void shouldReturnBadRequestForInvalidCycleMonth() { + final CycleRequest request = + new CycleRequest( + TEST_YEAR, Month.JANUARY, "AD_HOC", "2099-11-01", "2099-11-15", + "2099-11-20", "2100-02-20", 5, "January ad-hoc cycle"); + + final ResponseEntity response = + restTemplate.exchange(url(), HttpMethod.POST, requestEntity(request), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).contains("cycleMonth"); + } + + @Test + @DisplayName("Given invalid mentee cap, when posting cycle, then return 400") + void shouldReturnBadRequestForInvalidMaxMenteesPerMentor() { + final CycleRequest validRequest = validRequest(); + final CycleRequest request = + new CycleRequest( + validRequest.cycleYear(), validRequest.cycleMonth(), validRequest.mentorshipType(), + validRequest.registrationStartDate(), validRequest.registrationEndDate(), + validRequest.cycleStartDate(), validRequest.cycleEndDate(), 0, + validRequest.description()); + + final ResponseEntity response = + restTemplate.exchange(url(), HttpMethod.POST, requestEntity(request), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).contains("maxMenteesPerMentor"); + } + + @Test + @DisplayName("Given invalid date order, when posting cycle, then return 400") + void shouldReturnBadRequestForInvalidDateOrder() { + final CycleRequest validRequest = validRequest(); + final CycleRequest request = + new CycleRequest( + validRequest.cycleYear(), validRequest.cycleMonth(), validRequest.mentorshipType(), + validRequest.registrationStartDate(), "2099-10-30", validRequest.cycleStartDate(), + validRequest.cycleEndDate(), validRequest.maxMenteesPerMentor(), + validRequest.description()); + + final ResponseEntity response = + restTemplate.exchange(url(), HttpMethod.POST, requestEntity(request), String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).contains("Registration end date"); + } + + private String url() { + return "http://localhost:" + port + CYCLES_PATH; + } + + private HttpEntity requestEntity(final CycleRequest request) { + return new HttpEntity<>(request, headers()); + } + + private HttpEntity requestEntity(final String body) { + return new HttpEntity<>(body, headers()); + } + + private HttpHeaders headers() { + final HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setBearerAuth(adminToken); + headers.add("X-API-KEY", "test-api-key"); + return headers; + } + + private CycleRequest validRequest() { + return new CycleRequest( + TEST_YEAR, Month.NOVEMBER, "AD_HOC", "2099-11-01", "2099-11-15", + "2099-11-20", "2100-02-20", 5, "November ad-hoc cycle"); + } + + private record CycleRequest( + int cycleYear, + Month cycleMonth, + String mentorshipType, + String registrationStartDate, + String registrationEndDate, + String cycleStartDate, + String cycleEndDate, + int maxMenteesPerMentor, + String description) {} +} diff --git a/src/testInt/java/com/wcc/platform/service/MentorshipCycleIntegrationTest.java b/src/testInt/java/com/wcc/platform/service/MentorshipCycleIntegrationTest.java deleted file mode 100644 index 6755021f..00000000 --- a/src/testInt/java/com/wcc/platform/service/MentorshipCycleIntegrationTest.java +++ /dev/null @@ -1,427 +0,0 @@ -package com.wcc.platform.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.wcc.platform.domain.auth.UserAccount; -import com.wcc.platform.domain.platform.mentorship.CycleStatus; -import com.wcc.platform.domain.platform.mentorship.MentorshipCycleEntity; -import com.wcc.platform.domain.platform.mentorship.MentorshipType; -import com.wcc.platform.domain.platform.member.Member; -import com.wcc.platform.domain.platform.type.RoleType; -import com.wcc.platform.repository.MemberRepository; -import com.wcc.platform.repository.MentorshipCycleRepository; -import com.wcc.platform.repository.UserAccountRepository; -import com.wcc.platform.repository.postgres.DefaultDatabaseSetup; -import jakarta.validation.ConstraintViolationException; -import java.time.LocalDate; -import java.time.Month; -import java.time.Year; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInstance; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.web.client.TestRestTemplate; -import org.springframework.boot.test.web.server.LocalServerPort; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.test.context.ActiveProfiles; - -/** - * Integration tests for MentorshipCycleRepository with PostgreSQL. Tests cycle queries and - * management operations. - */ -@ActiveProfiles("test") -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class MentorshipCycleIntegrationTest extends DefaultDatabaseSetup { - - private static final int TEST_YEAR = 2099; - private static final String ADMIN_EMAIL = "mentorship-cycle-admin@wcc.com"; - private static final String ADMIN_PASSWORD = "password"; - private static final String CYCLES_PATH = "/api/platform/v1/admin/mentorship/cycles"; - private static final String LOGIN_PATH = "/api/auth/login"; - - @LocalServerPort private int port; - - @Autowired private TestRestTemplate restTemplate; - @Autowired private ObjectMapper objectMapper; - @Autowired private MentorshipCycleRepository cycleRepository; - @Autowired private MentorshipCycleService cycleService; - @Autowired private MemberRepository memberRepository; - @Autowired private UserAccountRepository userAccountRepository; - @Autowired private PasswordEncoder passwordEncoder; - - private String adminToken; - - @BeforeAll - void setUpAdmin() { - final Member member = - memberRepository - .findByEmail(ADMIN_EMAIL) - .orElseGet( - () -> - memberRepository.create( - Member.builder() - .fullName("Mentorship Cycle Admin") - .position("Administrator") - .email(ADMIN_EMAIL) - .slackDisplayName("mentorship-cycle-admin") - .build())); - userAccountRepository - .findByEmail(ADMIN_EMAIL) - .orElseGet( - () -> - userAccountRepository.create( - new UserAccount( - null, - member.getId(), - ADMIN_EMAIL, - passwordEncoder.encode(ADMIN_PASSWORD), - List.of(RoleType.ADMIN), - true))); - adminToken = loginAsAdmin(); - } - - @BeforeEach - void setUp() { - cycleRepository - .findByYearAndTypeAndMonth( - Year.of(TEST_YEAR), MentorshipType.AD_HOC, Month.NOVEMBER) - .ifPresent(cycle -> cycleRepository.deleteById(cycle.getCycleId())); - - // Clean up before starting - cycleRepository - .findByYearAndType(Year.of(2026), MentorshipType.LONG_TERM) - .ifPresent(c -> cycleRepository.deleteById(c.getCycleId())); - - // Setup cycle - cycleRepository.create( - MentorshipCycleEntity.builder() - .cycleYear(Year.of(2026)) - .mentorshipType(MentorshipType.LONG_TERM) - .cycleMonth(Month.FEBRUARY) - .registrationStartDate(LocalDate.now().minusDays(1)) - .registrationEndDate(LocalDate.now().plusDays(10)) - .cycleStartDate(LocalDate.now().plusDays(15)) - .status(CycleStatus.OPEN) - .maxMenteesPerMentor(3) - .description("Test Cycle") - .build()); - } - - @AfterAll - void tearDownAdmin() { - userAccountRepository - .findByEmail(ADMIN_EMAIL) - .ifPresent(user -> userAccountRepository.deleteById(user.getId())); - memberRepository - .findByEmail(ADMIN_EMAIL) - .ifPresent(member -> memberRepository.deleteById(member.getId())); - } - - @Test - @DisplayName( - "Given database is seeded with cycles, when finding open cycle, then it should return the open cycle") - void shouldFindOpenCycle() { - final Optional openCycle = cycleRepository.findOpenCycle(); - - assertThat(openCycle).isPresent(); - assertThat(openCycle.get().getStatus()).isEqualTo(CycleStatus.OPEN); - } - - @Test - @DisplayName( - "Given database is seeded, when finding all cycles, then it should return all cycles") - void shouldFindAllCycles() { - final List allCycles = cycleRepository.getAll(); - - assertThat(allCycles).isNotEmpty(); - // V18 migration seeds 8 cycles for 2026 - assertThat(allCycles.size()).isGreaterThanOrEqualTo(8); - } - - @Test - @DisplayName( - "Given database is seeded, when finding cycles by status OPEN, then it should return open cycles") - void shouldFindCyclesByStatusOpen() { - final List openCycles = cycleRepository.findByStatus(CycleStatus.OPEN); - - assertThat(openCycles).isNotEmpty(); - assertThat(openCycles).allMatch(cycle -> cycle.getStatus() == CycleStatus.OPEN); - } - - @Test - @DisplayName( - "Given database is seeded, when finding cycles by status DRAFT, then it should return draft cycles") - void shouldFindCyclesByStatusDraft() { - final List draftCycles = cycleRepository.findByStatus(CycleStatus.DRAFT); - - assertThat(draftCycles).isNotEmpty(); - assertThat(draftCycles).allMatch(cycle -> cycle.getStatus() == CycleStatus.DRAFT); - } - - @Test - @DisplayName( - "Given database is seeded, when finding cycle by ID, then it should return the correct cycle") - void shouldFindCycleById() { - // First get all cycles to find a valid ID - final List allCycles = cycleRepository.getAll(); - assertThat(allCycles).isNotEmpty(); - - final Long validCycleId = allCycles.getFirst().getCycleId(); - final Optional found = cycleRepository.findById(validCycleId); - - assertThat(found).isPresent(); - assertThat(found.get().getCycleId()).isEqualTo(validCycleId); - } - - @Test - @DisplayName("Given non-existent cycle ID, when finding by ID, then it should return empty") - void shouldReturnEmptyForNonExistentCycleId() { - final Optional found = cycleRepository.findById(99L); - - assertThat(found).isEmpty(); - } - - @Test - @DisplayName( - "Given seeded cycles, when checking cycle properties, then they should have valid data") - void shouldHaveValidCycleData() { - final List allCycles = cycleRepository.getAll(); - assertThat(allCycles).isNotEmpty(); - - final MentorshipCycleEntity cycle = allCycles.getFirst(); - - assertThat(cycle.getCycleId()).isNotNull(); - assertThat(cycle.getCycleYear()).isNotNull(); - assertThat(cycle.getMentorshipType()).isNotNull(); - assertThat(cycle.getStatus()).isNotNull(); - assertThat(cycle.getRegistrationStartDate()).isNotNull(); - assertThat(cycle.getRegistrationEndDate()).isNotNull(); - assertThat(cycle.getCycleStartDate()).isNotNull(); - assertThat(cycle.getMaxMenteesPerMentor()).isGreaterThan(0); - } - - @Test - @DisplayName( - "Given database is seeded, when finding cycles by year 2026, then it should return only 2026 cycles") - void shouldFindCyclesByYear() { - final List cycles = cycleRepository.findByYear(2026); - - assertThat(cycles).isNotEmpty(); - assertThat(cycles).allMatch(cycle -> cycle.getCycleYear().equals(Year.of(2026))); - } - - @Test - @DisplayName( - "Given no cycles exist for year 9999, when finding cycles by year, then it should return empty") - void shouldReturnEmptyForYearWithNoCycles() { - final List cycles = cycleRepository.findByYear(9999); - - assertThat(cycles).isEmpty(); - } - - @Test - @DisplayName( - "Given a past year, when getting cycles, then it should throw ConstraintViolationException") - void shouldRejectPastYear() { - assertThatThrownBy(() -> cycleService.getCycles(Year.of(2025))) - .isInstanceOf(ConstraintViolationException.class); - } - - @Test - @DisplayName("Given valid cycle request, when posting it, then return 201 with draft cycle") - void shouldCreateCycleThroughPostEndpoint() throws Exception { - final ResponseEntity response = - restTemplate.exchange( - url(), - HttpMethod.POST, - requestEntity(validRequest()), - String.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); - assertThat(response.getBody()).isNotNull(); - final var body = objectMapper.readTree(response.getBody()); - assertThat(body.get("status").asText()).isEqualTo("DRAFT"); - assertThat(body.get("cycleYear").asInt()).isEqualTo(TEST_YEAR); - assertThat(body.get("mentorshipType").asText()).isEqualTo("Ad-Hoc"); - assertThat(body.get("cycleMonth").asText()).isEqualTo("NOVEMBER"); - } - - @Test - @DisplayName("Given existing cycle, when posting duplicate, then return 409") - void shouldReturnConflictForDuplicateCycle() { - final HttpEntity entity = requestEntity(validRequest()); - restTemplate.exchange(url(), HttpMethod.POST, entity, String.class); - - final ResponseEntity response = - restTemplate.exchange(url(), HttpMethod.POST, entity, String.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); - assertThat(response.getBody()).contains("already exists"); - } - - @Test - @DisplayName("Given missing required field, when posting cycle, then return 400") - void shouldReturnBadRequestForMissingRequiredField() { - final String body = - """ - { - "cycleMonth": "DECEMBER", - "mentorshipType": "AD_HOC", - "registrationStartDate": "2099-12-01", - "registrationEndDate": "2099-12-15", - "cycleStartDate": "2099-12-20" - } - """; - - final ResponseEntity response = - restTemplate.exchange(url(), HttpMethod.POST, requestEntity(body), String.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(response.getBody()).contains("cycleYear"); - } - - @Test - @DisplayName("Given invalid cycle month, when posting cycle, then return 400") - void shouldReturnBadRequestForInvalidCycleMonth() { - final CycleRequest request = - new CycleRequest( - TEST_YEAR, - Month.JANUARY, - "AD_HOC", - "2099-11-01", - "2099-11-15", - "2099-11-20", - "2100-02-20", - 5, - "January ad-hoc cycle"); - - final ResponseEntity response = - restTemplate.exchange(url(), HttpMethod.POST, requestEntity(request), String.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(response.getBody()).contains("cycleMonth"); - } - - @Test - @DisplayName("Given invalid mentee cap, when posting cycle, then return 400") - void shouldReturnBadRequestForInvalidMaxMenteesPerMentor() { - final CycleRequest validRequest = validRequest(); - final CycleRequest request = - new CycleRequest( - validRequest.cycleYear(), - validRequest.cycleMonth(), - validRequest.mentorshipType(), - validRequest.registrationStartDate(), - validRequest.registrationEndDate(), - validRequest.cycleStartDate(), - validRequest.cycleEndDate(), - 0, - validRequest.description()); - - final ResponseEntity response = - restTemplate.exchange(url(), HttpMethod.POST, requestEntity(request), String.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(response.getBody()).contains("maxMenteesPerMentor"); - } - - @Test - @DisplayName("Given invalid date order, when posting cycle, then return 400") - void shouldReturnBadRequestForInvalidDateOrder() { - final CycleRequest validRequest = validRequest(); - final CycleRequest request = - new CycleRequest( - validRequest.cycleYear(), - validRequest.cycleMonth(), - validRequest.mentorshipType(), - validRequest.registrationStartDate(), - "2099-10-30", - validRequest.cycleStartDate(), - validRequest.cycleEndDate(), - validRequest.maxMenteesPerMentor(), - validRequest.description()); - - final ResponseEntity response = - restTemplate.exchange( - url(), HttpMethod.POST, requestEntity(request), String.class); - - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(response.getBody()).contains("Registration end date"); - } - - private String url() { - return "http://localhost:" + port + CYCLES_PATH; - } - - private HttpEntity requestEntity(final CycleRequest request) { - return new HttpEntity<>(request, headers()); - } - - private HttpEntity requestEntity(final String body) { - return new HttpEntity<>(body, headers()); - } - - private HttpHeaders headers() { - final HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.setBearerAuth(adminToken); - headers.add("X-API-KEY", "test-api-key"); - return headers; - } - - private CycleRequest validRequest() { - return new CycleRequest( - TEST_YEAR, - Month.NOVEMBER, - "AD_HOC", - "2099-11-01", - "2099-11-15", - "2099-11-20", - "2100-02-20", - 5, - "November ad-hoc cycle"); - } - - private String loginAsAdmin() { - final HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - final ResponseEntity response = - restTemplate.postForEntity( - "http://localhost:" + port + LOGIN_PATH, - new HttpEntity<>(new LoginRequest(ADMIN_EMAIL, ADMIN_PASSWORD), headers), - LoginResponse.class); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isNotNull(); - return response.getBody().token(); - } - - private record LoginRequest(String email, String password) {} - - @JsonIgnoreProperties(ignoreUnknown = true) - private record LoginResponse(String token) {} - - private record CycleRequest( - int cycleYear, - Month cycleMonth, - String mentorshipType, - String registrationStartDate, - String registrationEndDate, - String cycleStartDate, - String cycleEndDate, - int maxMenteesPerMentor, - String description) {} -} diff --git a/src/testInt/java/com/wcc/platform/service/MentorshipCycleIntegrationTestSupport.java b/src/testInt/java/com/wcc/platform/service/MentorshipCycleIntegrationTestSupport.java new file mode 100644 index 00000000..47d46f83 --- /dev/null +++ b/src/testInt/java/com/wcc/platform/service/MentorshipCycleIntegrationTestSupport.java @@ -0,0 +1,129 @@ +package com.wcc.platform.service; + +import com.wcc.platform.domain.auth.UserAccount; +import com.wcc.platform.domain.platform.mentorship.CycleStatus; +import com.wcc.platform.domain.platform.mentorship.MentorshipCycleEntity; +import com.wcc.platform.domain.platform.mentorship.MentorshipType; +import com.wcc.platform.domain.platform.member.Member; +import com.wcc.platform.domain.platform.type.RoleType; +import com.wcc.platform.repository.MemberRepository; +import com.wcc.platform.repository.MentorshipCycleRepository; +import com.wcc.platform.repository.UserAccountRepository; +import com.wcc.platform.repository.postgres.DefaultDatabaseSetup; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.time.LocalDate; +import java.time.Month; +import java.time.Year; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** Shared PostgreSQL setup for mentorship cycle integration tests. */ +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +abstract class MentorshipCycleIntegrationTestSupport extends DefaultDatabaseSetup { + + protected static final int TEST_YEAR = 2099; + protected static final String ADMIN_EMAIL = "mentorship-cycle-admin@wcc.com"; + protected static final String ADMIN_PASSWORD = "password"; + protected static final String CYCLES_PATH = "/api/platform/v1/admin/mentorship/cycles"; + protected static final String LOGIN_PATH = "/api/auth/login"; + + @LocalServerPort protected int port; + + @Autowired protected TestRestTemplate restTemplate; + @Autowired protected MentorshipCycleRepository cycleRepository; + @Autowired protected MentorshipCycleService cycleService; + @Autowired protected MemberRepository memberRepository; + @Autowired protected UserAccountRepository userAccountRepository; + @Autowired protected PasswordEncoder passwordEncoder; + + protected String adminToken; + + @BeforeAll + void setUpAdmin() { + final Member member = + memberRepository + .findByEmail(ADMIN_EMAIL) + .orElseGet( + () -> + memberRepository.create( + Member.builder() + .fullName("Mentorship Cycle Admin") + .position("Administrator") + .email(ADMIN_EMAIL) + .slackDisplayName("mentorship-cycle-admin") + .build())); + userAccountRepository + .findByEmail(ADMIN_EMAIL) + .orElseGet( + () -> + userAccountRepository.create( + new UserAccount( + null, + member.getId(), + ADMIN_EMAIL, + passwordEncoder.encode(ADMIN_PASSWORD), + List.of(RoleType.ADMIN), + true))); + adminToken = loginAsAdmin(); + } + + @BeforeEach + void setUpCycle() { + cycleRepository + .findByYearAndTypeAndMonth( + Year.of(TEST_YEAR), MentorshipType.AD_HOC, Month.NOVEMBER) + .ifPresent(cycle -> cycleRepository.deleteById(cycle.getCycleId())); + cycleRepository + .findByYearAndType(Year.of(2026), MentorshipType.LONG_TERM) + .ifPresent(cycle -> cycleRepository.deleteById(cycle.getCycleId())); + cycleRepository.create( + MentorshipCycleEntity.builder() + .cycleYear(Year.of(2026)) + .mentorshipType(MentorshipType.LONG_TERM) + .cycleMonth(Month.FEBRUARY) + .registrationStartDate(LocalDate.now().minusDays(1)) + .registrationEndDate(LocalDate.now().plusDays(10)) + .cycleStartDate(LocalDate.now().plusDays(15)) + .status(CycleStatus.OPEN) + .maxMenteesPerMentor(3) + .description("Test Cycle") + .build()); + } + + @AfterAll + void tearDownAdmin() { + userAccountRepository + .findByEmail(ADMIN_EMAIL) + .ifPresent(user -> userAccountRepository.deleteById(user.getId())); + memberRepository + .findByEmail(ADMIN_EMAIL) + .ifPresent(member -> memberRepository.deleteById(member.getId())); + } + + protected String loginAsAdmin() { + final HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + final ResponseEntity response = + restTemplate.postForEntity( + "http://localhost:" + port + LOGIN_PATH, + new HttpEntity<>(new LoginRequest(ADMIN_EMAIL, ADMIN_PASSWORD), headers), + LoginResponse.class); + return response.getBody().token(); + } + + private record LoginRequest(String email, String password) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + private record LoginResponse(String token) {} +} diff --git a/src/testInt/java/com/wcc/platform/service/MentorshipCycleRepositoryIntegrationTest.java b/src/testInt/java/com/wcc/platform/service/MentorshipCycleRepositoryIntegrationTest.java new file mode 100644 index 00000000..8d62dc94 --- /dev/null +++ b/src/testInt/java/com/wcc/platform/service/MentorshipCycleRepositoryIntegrationTest.java @@ -0,0 +1,126 @@ +package com.wcc.platform.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.wcc.platform.domain.platform.mentorship.CycleStatus; +import com.wcc.platform.domain.platform.mentorship.MentorshipCycleEntity; +import com.wcc.platform.repository.MentorshipCycleRepository; +import java.time.Year; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ActiveProfiles; + +/** + * Integration tests for MentorshipCycleRepository with PostgreSQL. Tests cycle queries and + * management operations. + */ +@ActiveProfiles("test") +class MentorshipCycleRepositoryIntegrationTest extends MentorshipCycleIntegrationTestSupport { + + @Autowired private MentorshipCycleRepository cycleRepository; + + @Test + @DisplayName( + "Given database is seeded with cycles, when finding open cycle, then it should return the open cycle") + void shouldFindOpenCycle() { + final Optional openCycle = cycleRepository.findOpenCycle(); + + assertThat(openCycle).isPresent(); + assertThat(openCycle.get().getStatus()).isEqualTo(CycleStatus.OPEN); + } + + @Test + @DisplayName( + "Given database is seeded, when finding all cycles, then it should return all cycles") + void shouldFindAllCycles() { + final List allCycles = cycleRepository.getAll(); + + assertThat(allCycles).isNotEmpty(); + // V18 migration seeds 8 cycles for 2026 + assertThat(allCycles.size()).isGreaterThanOrEqualTo(8); + } + + @Test + @DisplayName( + "Given database is seeded, when finding cycles by status OPEN, then it should return open cycles") + void shouldFindCyclesByStatusOpen() { + final List openCycles = cycleRepository.findByStatus(CycleStatus.OPEN); + + assertThat(openCycles).isNotEmpty(); + assertThat(openCycles).allMatch(cycle -> cycle.getStatus() == CycleStatus.OPEN); + } + + @Test + @DisplayName( + "Given database is seeded, when finding cycles by status DRAFT, then it should return draft cycles") + void shouldFindCyclesByStatusDraft() { + final List draftCycles = cycleRepository.findByStatus(CycleStatus.DRAFT); + + assertThat(draftCycles).isNotEmpty(); + assertThat(draftCycles).allMatch(cycle -> cycle.getStatus() == CycleStatus.DRAFT); + } + + @Test + @DisplayName( + "Given database is seeded, when finding cycle by ID, then it should return the correct cycle") + void shouldFindCycleById() { + // First get all cycles to find a valid ID + final List allCycles = cycleRepository.getAll(); + assertThat(allCycles).isNotEmpty(); + + final Long validCycleId = allCycles.getFirst().getCycleId(); + final Optional found = cycleRepository.findById(validCycleId); + + assertThat(found).isPresent(); + assertThat(found.get().getCycleId()).isEqualTo(validCycleId); + } + + @Test + @DisplayName("Given non-existent cycle ID, when finding by ID, then it should return empty") + void shouldReturnEmptyForNonExistentCycleId() { + final Optional found = cycleRepository.findById(99L); + + assertThat(found).isEmpty(); + } + + @Test + @DisplayName( + "Given seeded cycles, when checking cycle properties, then they should have valid data") + void shouldHaveValidCycleData() { + final List allCycles = cycleRepository.getAll(); + assertThat(allCycles).isNotEmpty(); + + final MentorshipCycleEntity cycle = allCycles.getFirst(); + + assertThat(cycle.getCycleId()).isNotNull(); + assertThat(cycle.getCycleYear()).isNotNull(); + assertThat(cycle.getMentorshipType()).isNotNull(); + assertThat(cycle.getStatus()).isNotNull(); + assertThat(cycle.getRegistrationStartDate()).isNotNull(); + assertThat(cycle.getRegistrationEndDate()).isNotNull(); + assertThat(cycle.getCycleStartDate()).isNotNull(); + assertThat(cycle.getMaxMenteesPerMentor()).isGreaterThan(0); + } + + @Test + @DisplayName( + "Given database is seeded, when finding cycles by year 2026, then it should return only 2026 cycles") + void shouldFindCyclesByYear() { + final List cycles = cycleRepository.findByYear(2026); + + assertThat(cycles).isNotEmpty(); + assertThat(cycles).allMatch(cycle -> cycle.getCycleYear().equals(Year.of(2026))); + } + + @Test + @DisplayName( + "Given no cycles exist for year 9999, when finding cycles by year, then it should return empty") + void shouldReturnEmptyForYearWithNoCycles() { + final List cycles = cycleRepository.findByYear(9999); + + assertThat(cycles).isEmpty(); + } +}