diff --git a/src/main/java/com/wcc/platform/controller/MenteeController.java b/src/main/java/com/wcc/platform/controller/MenteeController.java index 8f79c457..8243d48b 100644 --- a/src/main/java/com/wcc/platform/controller/MenteeController.java +++ b/src/main/java/com/wcc/platform/controller/MenteeController.java @@ -1,9 +1,14 @@ package com.wcc.platform.controller; +import com.wcc.platform.configuration.security.RequiresPermission; +import com.wcc.platform.domain.auth.Permission; +import com.wcc.platform.domain.platform.mentorship.ApplicationRejectRequest; import com.wcc.platform.domain.platform.mentorship.Mentee; import com.wcc.platform.domain.platform.mentorship.MenteeRegistration; +import com.wcc.platform.service.MenteeAdminService; import com.wcc.platform.service.MenteeService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -13,6 +18,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -31,6 +38,7 @@ public class MenteeController { private final MenteeService menteeService; + private final MenteeAdminService menteeAdminService; /** * API to create mentee. @@ -48,14 +56,66 @@ public ResponseEntity createMentee( } /** - * Retrieves a list of all registered mentees. + * Retrieves a list of all active mentees (status_id = 1). * - * @return a list of Mentee existent mentees + * @return a list of active mentees */ @GetMapping("/mentees") - @Operation(summary = "API to list all mentees") + @Operation(summary = "API to list all active mentees") @ResponseStatus(HttpStatus.OK) public ResponseEntity> listMentees() { return new ResponseEntity<>(menteeService.getAllMentees(), HttpStatus.OK); } + + /** + * Retrieves all mentees with PENDING status awaiting admin review. + * + * @return a list of pending mentees + */ + @GetMapping("/mentees/pending") + @RequiresPermission(Permission.MENTEE_APPROVE) + @Operation( + summary = "Get all pending mentees awaiting admin activation", + security = {@SecurityRequirement(name = "apiKey"), @SecurityRequirement(name = "bearerAuth")}) + @ResponseStatus(HttpStatus.OK) + public ResponseEntity> getPendingMentees() { + return ResponseEntity.ok(menteeAdminService.getPendingMentees()); + } + + /** + * Admin activates a mentee by setting their profile status to ACTIVE. + * + * @param menteeId The mentee ID + * @return the activated mentee + */ + @PatchMapping("/mentees/{menteeId}/activate") + @RequiresPermission(Permission.MENTEE_APPROVE) + @Operation( + summary = "Admin activates a mentee (sets status to ACTIVE)", + security = {@SecurityRequirement(name = "apiKey"), @SecurityRequirement(name = "bearerAuth")}) + @ResponseStatus(HttpStatus.OK) + public ResponseEntity activateMentee( + @Parameter(description = "Mentee ID") @PathVariable final Long menteeId) { + return ResponseEntity.ok(menteeAdminService.activateMentee(menteeId)); + } + + /** + * Admin rejects a mentee by setting their profile status to REJECTED and rejecting all pending + * applications. + * + * @param menteeId The mentee ID + * @param request Rejection request containing the reason + * @return the rejected mentee + */ + @PatchMapping("/mentees/{menteeId}/reject") + @RequiresPermission(Permission.MENTEE_APPROVE) + @Operation( + summary = "Admin rejects a mentee (sets status to REJECTED and rejects all applications)", + security = {@SecurityRequirement(name = "apiKey"), @SecurityRequirement(name = "bearerAuth")}) + @ResponseStatus(HttpStatus.OK) + public ResponseEntity rejectMentee( + @Parameter(description = "Mentee ID") @PathVariable final Long menteeId, + @Valid @RequestBody final ApplicationRejectRequest request) { + return ResponseEntity.ok(menteeAdminService.rejectMentee(menteeId, request.reason())); + } } diff --git a/src/main/java/com/wcc/platform/domain/platform/mentorship/ApplicationStatus.java b/src/main/java/com/wcc/platform/domain/platform/mentorship/ApplicationStatus.java index 874df6b5..a8dc5e1d 100644 --- a/src/main/java/com/wcc/platform/domain/platform/mentorship/ApplicationStatus.java +++ b/src/main/java/com/wcc/platform/domain/platform/mentorship/ApplicationStatus.java @@ -4,70 +4,71 @@ import lombok.RequiredArgsConstructor; /** - * Enum representing the status of a mentee application to a mentor. - * Corresponds to the application_status enum in the database. - * Tracks the complete workflow from application submission to matching. + * Enum representing the status of a mentee application to a mentor. Corresponds to the + * application_status enum in the database. Tracks the complete workflow from application submission + * to matching. */ @Getter @RequiredArgsConstructor public enum ApplicationStatus { - PENDING("pending", "Mentee submitted application, awaiting mentor response"), - MENTOR_REVIEWING("mentor_reviewing", "Mentor is actively reviewing the application"), - MENTOR_ACCEPTED("mentor_accepted", "Mentor accepted, awaiting team confirmation"), - MENTOR_DECLINED("mentor_declined", "Mentor declined this application"), - MATCHED("matched", "Successfully matched and confirmed"), - DROPPED("dropped", "Mentee withdrew application"), - REJECTED("rejected", "Rejected by Mentorship Team"), - EXPIRED("expired", "Application expired (no response within timeframe)"); + PENDING("pending", "Mentee submitted application, awaiting mentor response"), + MENTOR_REVIEWING("mentor_reviewing", "Mentor is actively reviewing the application"), + MENTOR_ACCEPTED("mentor_accepted", "Mentor accepted, awaiting team confirmation"), + MENTOR_DECLINED("mentor_declined", "Mentor declined this application"), + MATCHED("matched", "Successfully matched and confirmed"), + DROPPED("dropped", "Mentee withdrew application"), + REJECTED("rejected", "Rejected by Mentorship Team"), + EXPIRED("expired", "Application expired (no response within timeframe)"); - private final String value; - private final String description; + private final String value; - /** - * Get ApplicationStatus from database string value. - * - * @param value the database string value - * @return the corresponding ApplicationStatus - * @throws IllegalArgumentException if the value doesn't match any enum - */ - public static ApplicationStatus fromValue(final String value) { - for (final ApplicationStatus status : values()) { - if (status.value.equalsIgnoreCase(value)) { - return status; - } - } - throw new IllegalArgumentException("Unknown application status: " + value); - } + private final String description; - /** - * Check if the application is in a terminal state (no further changes expected). - * - * @return true if status is terminal - */ - public boolean isTerminal() { - return this == MATCHED || this == REJECTED || this == DROPPED || this == EXPIRED; + /** + * Get ApplicationStatus from database string value. + * + * @param value the database string value + * @return the corresponding ApplicationStatus + * @throws IllegalArgumentException if the value doesn't match any enum + */ + public static ApplicationStatus fromValue(final String value) { + for (final ApplicationStatus status : values()) { + if (status.value.equalsIgnoreCase(value)) { + return status; + } } + throw new IllegalArgumentException("Unknown application status: " + value); + } - /** - * Check if the application is pending mentor action. - * - * @return true if awaiting mentor response - */ - public boolean isPendingMentorAction() { - return this == PENDING || this == MENTOR_REVIEWING; - } + /** + * Check if the application is in a terminal state (no further changes expected). + * + * @return true if status is terminal + */ + public boolean isTerminal() { + return this == MATCHED || this == REJECTED || this == DROPPED || this == EXPIRED; + } - /** - * Check if the application has been accepted by mentor. - * - * @return true if mentor accepted - */ - public boolean isMentorAccepted() { - return this == MENTOR_ACCEPTED || this == MATCHED; - } + /** + * Check if the application is pending mentor action. + * + * @return true if awaiting mentor response + */ + public boolean isPendingMentorAction() { + return this == PENDING || this == MENTOR_REVIEWING; + } - @Override - public String toString() { - return value; - } + /** + * Check if the application has been accepted by mentor. + * + * @return true if mentor accepted + */ + public boolean isMentorAccepted() { + return this == MENTOR_ACCEPTED || this == MATCHED; + } + + @Override + public String toString() { + return value; + } } diff --git a/src/main/java/com/wcc/platform/repository/MenteeApplicationRepository.java b/src/main/java/com/wcc/platform/repository/MenteeApplicationRepository.java index be5f0200..18b88462 100644 --- a/src/main/java/com/wcc/platform/repository/MenteeApplicationRepository.java +++ b/src/main/java/com/wcc/platform/repository/MenteeApplicationRepository.java @@ -81,4 +81,22 @@ public interface MenteeApplicationRepository extends CrudRepository findByStatusAndPriorityOrder( + ApplicationStatus status, Integer priorityOrder); + + /** + * Find all PENDING applications for a specific mentee across all cycles, ordered by priority. + * + * @param menteeId the mentee ID + * @return list of PENDING applications for the mentee ordered by priority + */ + List findPendingByMenteeId(Long menteeId); } diff --git a/src/main/java/com/wcc/platform/repository/MenteeRepository.java b/src/main/java/com/wcc/platform/repository/MenteeRepository.java index fce0585a..e369e4fe 100644 --- a/src/main/java/com/wcc/platform/repository/MenteeRepository.java +++ b/src/main/java/com/wcc/platform/repository/MenteeRepository.java @@ -1,5 +1,6 @@ package com.wcc.platform.repository; +import com.wcc.platform.domain.platform.member.ProfileStatus; import com.wcc.platform.domain.platform.mentorship.Mentee; import java.util.List; @@ -15,4 +16,21 @@ public interface MenteeRepository extends CrudRepository { * @return list of mentees */ List getAll(); + + /** + * Return all mentees with the given profile status. + * + * @param status the profile status to filter by + * @return list of mentees with the given status + */ + List findByStatus(ProfileStatus status); + + /** + * Update the profile status of a mentee. + * + * @param menteeId the mentee ID + * @param status the new profile status + * @return the updated mentee + */ + Mentee updateProfileStatus(Long menteeId, ProfileStatus status); } diff --git a/src/main/java/com/wcc/platform/repository/postgres/mentorship/PostgresMenteeApplicationRepository.java b/src/main/java/com/wcc/platform/repository/postgres/mentorship/PostgresMenteeApplicationRepository.java index 3c934141..50d614e3 100644 --- a/src/main/java/com/wcc/platform/repository/postgres/mentorship/PostgresMenteeApplicationRepository.java +++ b/src/main/java/com/wcc/platform/repository/postgres/mentorship/PostgresMenteeApplicationRepository.java @@ -43,6 +43,14 @@ public class PostgresMenteeApplicationRepository implements MenteeApplicationRep "SELECT * FROM mentee_applications WHERE application_status = ?::application_status " + "ORDER BY applied_at DESC"; + private static final String SEL_BY_STATUS_PRIO = + "SELECT * FROM mentee_applications WHERE application_status = ?::application_status " + + "AND priority_order = ? ORDER BY applied_at DESC"; + + private static final String SEL_PENDING_MENTEE = + "SELECT * FROM mentee_applications WHERE mentee_id = ? " + + "AND application_status = 'pending' ORDER BY priority_order"; + private static final String SEL_BY_MENTOR = "SELECT * FROM mentee_applications " + "WHERE mentee_id = ? AND mentor_id = ? AND cycle_id = ?"; @@ -167,6 +175,21 @@ public Long countMenteeApplications(final Long menteeId, final Long cycleId) { return jdbc.queryForObject(COUNT_MENTEE_APPS, Long.class, menteeId, cycleId); } + @Override + public List findByStatusAndPriorityOrder( + final ApplicationStatus status, final Integer priorityOrder) { + return jdbc.query( + SEL_BY_STATUS_PRIO, + (rs, rowNum) -> mapRow(rs), + status.getValue(), + priorityOrder); + } + + @Override + public List findPendingByMenteeId(final Long menteeId) { + return jdbc.query(SEL_PENDING_MENTEE, (rs, rowNum) -> mapRow(rs), menteeId); + } + private MenteeApplication mapRow(final ResultSet rs) throws SQLException { return MenteeApplication.builder() .applicationId(rs.getLong("application_id")) diff --git a/src/main/java/com/wcc/platform/repository/postgres/mentorship/PostgresMenteeRepository.java b/src/main/java/com/wcc/platform/repository/postgres/mentorship/PostgresMenteeRepository.java index 12e9518f..0431a0f8 100644 --- a/src/main/java/com/wcc/platform/repository/postgres/mentorship/PostgresMenteeRepository.java +++ b/src/main/java/com/wcc/platform/repository/postgres/mentorship/PostgresMenteeRepository.java @@ -24,6 +24,10 @@ public class PostgresMenteeRepository implements MenteeRepository { private static final String SQL_GET_BY_ID = "SELECT * FROM mentees WHERE mentee_id = ?"; private static final String SQL_DELETE_BY_ID = "DELETE FROM mentees WHERE mentee_id = ?"; private static final String SELECT_ALL_MENTEES = "SELECT * FROM mentees"; + private static final String SELECT_BY_STATUS = + "SELECT * FROM mentees WHERE mentees_profile_status = ?"; + private static final String SQL_SET_STATUS = + "UPDATE mentees SET mentees_profile_status = ? WHERE mentee_id = ?"; private static final String SQL_INSERT_MENTEE = "INSERT INTO mentees (mentee_id, mentees_profile_status, bio, years_experience, " + "spoken_languages, available_hs_month) VALUES (?, ?, ?, ?, ?, ?)"; @@ -131,6 +135,20 @@ public void deleteById(final Long menteeId) { jdbc.update(SQL_DELETE_BY_ID, menteeId); } + @Override + public List findByStatus(final ProfileStatus status) { + return jdbc.query(SELECT_BY_STATUS, (rs, rowNum) -> menteeMapper.mapRowToMentee(rs), + status.getStatusId()); + } + + @Override + public Mentee updateProfileStatus(final Long menteeId, final ProfileStatus status) { + jdbc.update(SQL_SET_STATUS, status.getStatusId(), menteeId); + return findById(menteeId) + .orElseThrow( + () -> new MenteeNotSavedException("Mentee not found after status update: " + menteeId)); + } + private void updateMenteeDetails(final Mentee mentee, final Long memberId) { final var profileStatus = mentee.getProfileStatus(); final var skills = mentee.getSkills(); diff --git a/src/main/java/com/wcc/platform/service/MenteeAdminService.java b/src/main/java/com/wcc/platform/service/MenteeAdminService.java new file mode 100644 index 00000000..20bac975 --- /dev/null +++ b/src/main/java/com/wcc/platform/service/MenteeAdminService.java @@ -0,0 +1,78 @@ +package com.wcc.platform.service; + +import com.wcc.platform.domain.exceptions.ContentNotFoundException; +import com.wcc.platform.domain.platform.member.ProfileStatus; +import com.wcc.platform.domain.platform.mentorship.ApplicationStatus; +import com.wcc.platform.domain.platform.mentorship.Mentee; +import com.wcc.platform.domain.platform.mentorship.MenteeApplication; +import com.wcc.platform.repository.MenteeApplicationRepository; +import com.wcc.platform.repository.MenteeRepository; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** Service for admin operations on mentees (activate, reject, list pending). */ +@Slf4j +@Service +@AllArgsConstructor +public class MenteeAdminService { + + private final MenteeRepository menteeRepository; + private final MenteeApplicationRepository registrationsRepo; + + /** + * Return all mentees with PENDING profile status awaiting admin review. + * + * @return List of pending mentees. + */ + public List getPendingMentees() { + final var pendingMentees = menteeRepository.findByStatus(ProfileStatus.PENDING); + return pendingMentees == null ? List.of() : pendingMentees; + } + + /** + * Activate a mentee by setting their profile status to ACTIVE. + * + * @param menteeId the mentee ID + * @return the updated mentee + * @throws ContentNotFoundException if mentee not found + */ + @Transactional + public Mentee activateMentee(final Long menteeId) { + menteeRepository + .findById(menteeId) + .orElseThrow(() -> new ContentNotFoundException("Mentee not found: " + menteeId)); + + final Mentee updated = menteeRepository.updateProfileStatus(menteeId, ProfileStatus.ACTIVE); + log.info("Mentee {} activated by admin", menteeId); + return updated; + } + + /** + * Reject a mentee by setting their profile status to REJECTED and rejecting all pending + * applications. + * + * @param menteeId the mentee ID + * @param reason the reason for rejection + * @return the updated mentee + * @throws ContentNotFoundException if mentee not found + */ + @Transactional + public Mentee rejectMentee(final Long menteeId, final String reason) { + menteeRepository + .findById(menteeId) + .orElseThrow(() -> new ContentNotFoundException("Mentee not found: " + menteeId)); + + final List pending = registrationsRepo.findPendingByMenteeId(menteeId); + pending.forEach( + app -> + registrationsRepo.updateStatus( + app.getApplicationId(), ApplicationStatus.REJECTED, reason)); + + final Mentee updated = menteeRepository.updateProfileStatus(menteeId, ProfileStatus.REJECTED); + log.info("Mentee {} rejected by admin, {} applications rejected", menteeId, pending.size()); + return updated; + } +} diff --git a/src/main/java/com/wcc/platform/service/MenteeService.java b/src/main/java/com/wcc/platform/service/MenteeService.java index 4793e40a..f31e8288 100644 --- a/src/main/java/com/wcc/platform/service/MenteeService.java +++ b/src/main/java/com/wcc/platform/service/MenteeService.java @@ -2,6 +2,7 @@ import com.wcc.platform.domain.exceptions.*; import com.wcc.platform.domain.platform.member.Member; +import com.wcc.platform.domain.platform.member.ProfileStatus; import com.wcc.platform.domain.platform.mentorship.Mentee; import com.wcc.platform.domain.platform.mentorship.MenteeApplication; import com.wcc.platform.domain.platform.mentorship.MenteeRegistration; @@ -24,6 +25,7 @@ @AllArgsConstructor public class MenteeService { private static final int MAX_MENTORS = 5; + private final MentorshipCycleRepository cycleRepository; private final MenteeApplicationRepository registrationsRepo; private final MenteeRepository menteeRepository; @@ -32,16 +34,13 @@ public class MenteeService { private final UserProvisionService userProvisionService; /** - * Return all stored mentees. + * Return all active mentees (status_id = 1). * - * @return List of mentees. + * @return List of active mentees. */ public List getAllMentees() { - final var allMentees = menteeRepository.getAll(); - if (allMentees == null) { - return List.of(); - } - return allMentees; + final var activeMentees = menteeRepository.findByStatus(ProfileStatus.ACTIVE); + return activeMentees == null ? List.of() : activeMentees; } /** diff --git a/src/test/java/com/wcc/platform/controller/MenteeControllerTest.java b/src/test/java/com/wcc/platform/controller/MenteeControllerTest.java index b399e937..08f90922 100644 --- a/src/test/java/com/wcc/platform/controller/MenteeControllerTest.java +++ b/src/test/java/com/wcc/platform/controller/MenteeControllerTest.java @@ -3,14 +3,19 @@ import static com.wcc.platform.factories.SetupMenteeFactories.createMenteeTest; import static org.hamcrest.Matchers.is; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.fasterxml.jackson.databind.ObjectMapper; import com.wcc.platform.configuration.SecurityConfig; import com.wcc.platform.configuration.TestConfig; -import com.wcc.platform.domain.platform.mentorship.Mentee; +import com.wcc.platform.domain.exceptions.ContentNotFoundException; +import com.wcc.platform.domain.platform.mentorship.ApplicationRejectRequest; +import com.wcc.platform.service.MenteeAdminService; import com.wcc.platform.service.MenteeService; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -32,14 +37,18 @@ class MenteeControllerTest { private static final String API_MENTEES = "/api/platform/v1/mentees"; private static final String API_KEY_HEADER = "X-API-KEY"; private static final String API_KEY_VALUE = "test-api-key"; + private static final String REJECTION_REASON = + "Application does not meet the eligibility criteria for this mentorship cycle"; @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; @MockBean private MenteeService menteeService; + @MockBean private MenteeAdminService menteeAdminService; @Test @DisplayName("Given valid mentee registration, when creating mentee, then return 201 Created") void shouldCreateMenteeAndReturnCreated() throws Exception { - Mentee mockMentee = createMenteeTest(2L, "Mark", "mark@test.com"); + var mockMentee = createMenteeTest(2L, "Mark", "mark@test.com"); var currentYear = java.time.Year.now(); when(menteeService.saveRegistration(any())).thenReturn(mockMentee); @@ -59,9 +68,10 @@ void shouldCreateMenteeAndReturnCreated() throws Exception { } @Test - @DisplayName("Given mentees exist, when listing mentees, then return 200 OK") + @DisplayName( + "Given active mentees exist, when listing mentees, then return 200 OK with active only") void shouldListMenteesAndReturnOk() throws Exception { - Mentee mockMentee = createMenteeTest(2L, "Mark", "mark@test.com"); + var mockMentee = createMenteeTest(2L, "Mark", "mark@test.com"); when(menteeService.getAllMentees()).thenReturn(List.of(mockMentee)); mockMvc @@ -74,4 +84,115 @@ void shouldListMenteesAndReturnOk() throws Exception { .andExpect(jsonPath("$[0].id", is(2))) .andExpect(jsonPath("$[0].fullName", is("Mark"))); } + + @Test + @DisplayName("Given pending mentees exist, when getting pending mentees, then return 200 OK") + void shouldReturnPendingMenteesAndReturn200() throws Exception { + var mockMentee = createMenteeTest(2L, "Mark", "mark@test.com"); + when(menteeAdminService.getPendingMentees()).thenReturn(List.of(mockMentee)); + + mockMvc + .perform( + MockMvcRequestBuilders.get(API_MENTEES + "/pending") + .header(API_KEY_HEADER, API_KEY_VALUE) + .contentType(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()", is(1))) + .andExpect(jsonPath("$[0].id", is(2))); + } + + @Test + @DisplayName("Given no pending mentees, when getting pending mentees, then return empty list") + void shouldReturnEmptyListWhenNoPendingMentees() throws Exception { + when(menteeAdminService.getPendingMentees()).thenReturn(List.of()); + + mockMvc + .perform( + MockMvcRequestBuilders.get(API_MENTEES + "/pending") + .header(API_KEY_HEADER, API_KEY_VALUE) + .contentType(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()", is(0))); + } + + @Test + @DisplayName( + "Given a pending mentee, when admin activates, then return 200 OK with active mentee") + void shouldActivateMenteeAndReturn200() throws Exception { + var activeMentee = createMenteeTest(10L, "Jane", "jane@test.com"); + when(menteeAdminService.activateMentee(10L)).thenReturn(activeMentee); + + mockMvc + .perform( + MockMvcRequestBuilders.patch(API_MENTEES + "/10/activate") + .header(API_KEY_HEADER, API_KEY_VALUE) + .contentType(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(10))); + } + + @Test + @DisplayName("Given mentee not found, when admin activates, then return 404 NOT_FOUND") + void shouldReturn404WhenActivatingNonExistentMentee() throws Exception { + when(menteeAdminService.activateMentee(99L)) + .thenThrow(new ContentNotFoundException("Mentee not found: 99")); + + mockMvc + .perform( + MockMvcRequestBuilders.patch(API_MENTEES + "/99/activate") + .header(API_KEY_HEADER, API_KEY_VALUE) + .contentType(APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName( + "Given a pending mentee, when admin rejects with reason, then return 200 OK with rejected mentee") + void shouldRejectMenteeAndReturn200() throws Exception { + var rejectedMentee = createMenteeTest(10L, "Jane", "jane@test.com"); + when(menteeAdminService.rejectMentee(anyLong(), anyString())).thenReturn(rejectedMentee); + + final var request = new ApplicationRejectRequest(REJECTION_REASON); + + mockMvc + .perform( + MockMvcRequestBuilders.patch(API_MENTEES + "/10/reject") + .header(API_KEY_HEADER, API_KEY_VALUE) + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id", is(10))); + } + + @Test + @DisplayName( + "Given rejection reason is too short, when admin rejects, then return 400 BAD_REQUEST") + void shouldReturn400WhenRejectionReasonTooShort() throws Exception { + final var request = new ApplicationRejectRequest("Short"); + + mockMvc + .perform( + MockMvcRequestBuilders.patch(API_MENTEES + "/10/reject") + .header(API_KEY_HEADER, API_KEY_VALUE) + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("Given mentee not found, when admin rejects, then return 404 NOT_FOUND") + void shouldReturn404WhenRejectingNonExistentMentee() throws Exception { + when(menteeAdminService.rejectMentee(anyLong(), anyString())) + .thenThrow(new ContentNotFoundException("Mentee not found: 99")); + + final var request = new ApplicationRejectRequest(REJECTION_REASON); + + mockMvc + .perform( + MockMvcRequestBuilders.patch(API_MENTEES + "/99/reject") + .header(API_KEY_HEADER, API_KEY_VALUE) + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()); + } } diff --git a/src/test/java/com/wcc/platform/service/MenteeAdminServiceTest.java b/src/test/java/com/wcc/platform/service/MenteeAdminServiceTest.java new file mode 100644 index 00000000..55853b34 --- /dev/null +++ b/src/test/java/com/wcc/platform/service/MenteeAdminServiceTest.java @@ -0,0 +1,122 @@ +package com.wcc.platform.service; + +import static com.wcc.platform.factories.SetupMenteeFactories.createMenteeTest; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.wcc.platform.domain.exceptions.ContentNotFoundException; +import com.wcc.platform.domain.platform.member.ProfileStatus; +import com.wcc.platform.domain.platform.mentorship.ApplicationStatus; +import com.wcc.platform.domain.platform.mentorship.MenteeApplication; +import com.wcc.platform.repository.MenteeApplicationRepository; +import com.wcc.platform.repository.MenteeRepository; +import java.util.List; +import java.util.Optional; +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 MenteeAdminServiceTest { + + @Mock private MenteeRepository menteeRepository; + @Mock private MenteeApplicationRepository registrationsRepo; + + private MenteeAdminService service; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + service = new MenteeAdminService(menteeRepository, registrationsRepo); + } + + @Test + @DisplayName( + "Given pending mentees exist, when getting pending mentees, then should return pending mentees") + void shouldReturnPendingMentees() { + final var pendingMentee = createMenteeTest(5L, "Pending Mentee", "pending@wcc.com"); + when(menteeRepository.findByStatus(ProfileStatus.PENDING)).thenReturn(List.of(pendingMentee)); + + final var result = service.getPendingMentees(); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().getId()).isEqualTo(5L); + verify(menteeRepository).findByStatus(ProfileStatus.PENDING); + } + + @Test + @DisplayName( + "Given no pending mentees, when getting pending mentees, then should return empty list") + void shouldReturnEmptyListWhenNoPendingMentees() { + when(menteeRepository.findByStatus(ProfileStatus.PENDING)).thenReturn(List.of()); + + final var result = service.getPendingMentees(); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("Given existing mentee, when activating mentee, then should set status to ACTIVE") + void shouldActivateMenteeSuccessfully() { + final var activeMentee = createMenteeTest(10L, "Active Mentee", "active@wcc.com"); + when(menteeRepository.findById(10L)).thenReturn(Optional.of(activeMentee)); + when(menteeRepository.updateProfileStatus(10L, ProfileStatus.ACTIVE)).thenReturn(activeMentee); + + final var result = service.activateMentee(10L); + + assertThat(result).isEqualTo(activeMentee); + verify(menteeRepository).updateProfileStatus(10L, ProfileStatus.ACTIVE); + } + + @Test + @DisplayName( + "Given mentee not found, when activating mentee, then should throw ContentNotFoundException") + void shouldThrowContentNotFoundExceptionWhenActivatingNonExistentMentee() { + when(menteeRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThrows(ContentNotFoundException.class, () -> service.activateMentee(99L)); + } + + @Test + @DisplayName( + "Given existing mentee with pending applications, when rejecting mentee, " + + "then should set status to REJECTED and reject all pending applications") + void shouldRejectMenteeAndAllPendingApplications() { + final var rejectedMentee = createMenteeTest(10L, "Rejected Mentee", "rejected@wcc.com"); + final var pendingApp = + MenteeApplication.builder() + .applicationId(1L) + .menteeId(10L) + .mentorId(20L) + .cycleId(5L) + .priorityOrder(1) + .status(ApplicationStatus.PENDING) + .build(); + final String reason = "Does not meet criteria for this mentorship cycle at this time"; + + when(menteeRepository.findById(10L)).thenReturn(Optional.of(rejectedMentee)); + when(registrationsRepo.findPendingByMenteeId(10L)).thenReturn(List.of(pendingApp)); + when(menteeRepository.updateProfileStatus(10L, ProfileStatus.REJECTED)) + .thenReturn(rejectedMentee); + + final var result = service.rejectMentee(10L, reason); + + assertThat(result).isEqualTo(rejectedMentee); + verify(registrationsRepo).updateStatus(1L, ApplicationStatus.REJECTED, reason); + verify(menteeRepository).updateProfileStatus(10L, ProfileStatus.REJECTED); + } + + @Test + @DisplayName( + "Given mentee not found, when rejecting mentee, then should throw ContentNotFoundException") + void shouldThrowContentNotFoundExceptionWhenRejectingNonExistentMentee() { + when(menteeRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThrows( + ContentNotFoundException.class, + () -> service.rejectMentee(99L, "Some reason for rejection that is long enough")); + } +} diff --git a/src/test/java/com/wcc/platform/service/MenteeServiceTest.java b/src/test/java/com/wcc/platform/service/MenteeServiceTest.java index 7542824a..57f0cf44 100644 --- a/src/test/java/com/wcc/platform/service/MenteeServiceTest.java +++ b/src/test/java/com/wcc/platform/service/MenteeServiceTest.java @@ -269,15 +269,16 @@ void shouldThrowExceptionWhenPriorityDuplicatedInRequest() { } @Test - @DisplayName("Given has mentees, when getting all mentees, then should return all") - void testGetAllMentees() { - List mentees = List.of(mentee); - when(menteeRepository.getAll()).thenReturn(mentees); + @DisplayName( + "Given active mentees exist, when getting all mentees, then should return only active mentees") + void shouldReturnOnlyActiveMentees() { + var mentees = List.of(mentee); + when(menteeRepository.findByStatus(ProfileStatus.ACTIVE)).thenReturn(mentees); - List result = menteeService.getAllMentees(); + var result = menteeService.getAllMentees(); assertEquals(mentees, result); - verify(menteeRepository).getAll(); + verify(menteeRepository).findByStatus(ProfileStatus.ACTIVE); } @Test diff --git a/src/test/java/com/wcc/platform/service/MenteeWorkflowServiceTest.java b/src/test/java/com/wcc/platform/service/MenteeWorkflowServiceTest.java index 0e3fb683..aa289e7d 100644 --- a/src/test/java/com/wcc/platform/service/MenteeWorkflowServiceTest.java +++ b/src/test/java/com/wcc/platform/service/MenteeWorkflowServiceTest.java @@ -21,7 +21,7 @@ class MenteeWorkflowServiceTest { private static final String REJECTION_REASON = - "Application does not meet the eligibility criteria"; + "Application does not meet the eligibility criteria for this mentorship cycle"; @Mock private MenteeApplicationRepository applicationRepository; @Mock private MentorshipMatchRepository matchRepository; @@ -37,29 +37,10 @@ void setUp() { @Test @DisplayName( - "Given a PENDING application, when admin approves it, then status is updated to MENTOR_REVIEWING") + "Given a PENDING application, when admin approves, then status becomes MENTOR_REVIEWING") void shouldApprovePendingApplicationAndUpdateStatusToMentorReviewing() { - final MenteeApplication pending = - MenteeApplication.builder() - .applicationId(1L) - .menteeId(10L) - .mentorId(20L) - .cycleId(5L) - .priorityOrder(1) - .status(ApplicationStatus.PENDING) - .whyMentor("Great mentor") - .build(); - - final MenteeApplication approved = - MenteeApplication.builder() - .applicationId(1L) - .menteeId(10L) - .mentorId(20L) - .cycleId(5L) - .priorityOrder(1) - .status(ApplicationStatus.MENTOR_REVIEWING) - .whyMentor("Great mentor") - .build(); + final MenteeApplication pending = pendingApplication(1L, 10L, 1); + final MenteeApplication approved = reviewingApplication(1L, 10L); when(applicationRepository.findById(1L)).thenReturn(Optional.of(pending)); when(applicationRepository.updateStatus(1L, ApplicationStatus.MENTOR_REVIEWING, null)) @@ -72,7 +53,7 @@ void shouldApprovePendingApplicationAndUpdateStatusToMentorReviewing() { @Test @DisplayName( - "Given a non-PENDING application, when admin approves it, then ContentNotFoundException is thrown") + "Given non-PENDING application, when admin approves, then ContentNotFoundException is thrown") void shouldThrowContentNotFoundExceptionWhenApprovedApplicationIsNotPending() { final MenteeApplication reviewing = MenteeApplication.builder() @@ -94,7 +75,7 @@ void shouldThrowContentNotFoundExceptionWhenApprovedApplicationIsNotPending() { @Test @DisplayName( - "Given an application that does not exist, when admin approves it, then ApplicationNotFoundException is thrown") + "Given application not found, when admin approves, then ApplicationNotFoundException thrown") void shouldThrowApplicationNotFoundExceptionWhenApprovedApplicationDoesNotExist() { when(applicationRepository.findById(99L)).thenReturn(Optional.empty()); @@ -104,30 +85,10 @@ void shouldThrowApplicationNotFoundExceptionWhenApprovedApplicationDoesNotExist( } @Test - @DisplayName( - "Given a PENDING application, when admin rejects it, then status is updated to REJECTED") + @DisplayName("Given a PENDING application, when admin rejects, then status becomes REJECTED") void shouldRejectPendingApplicationAndUpdateStatusToRejected() { - final MenteeApplication pending = - MenteeApplication.builder() - .applicationId(1L) - .menteeId(10L) - .mentorId(20L) - .cycleId(5L) - .priorityOrder(1) - .status(ApplicationStatus.PENDING) - .whyMentor("Great mentor") - .build(); - - final MenteeApplication rejected = - MenteeApplication.builder() - .applicationId(1L) - .menteeId(10L) - .mentorId(20L) - .cycleId(5L) - .priorityOrder(1) - .status(ApplicationStatus.REJECTED) - .whyMentor("Great mentor") - .build(); + final MenteeApplication pending = pendingApplication(1L, 10L, 1); + final MenteeApplication rejected = rejectedApplication(1L, 10L); when(applicationRepository.findById(1L)).thenReturn(Optional.of(pending)); when(applicationRepository.updateStatus(1L, ApplicationStatus.REJECTED, REJECTION_REASON)) @@ -140,7 +101,7 @@ void shouldRejectPendingApplicationAndUpdateStatusToRejected() { @Test @DisplayName( - "Given a non-PENDING application, when admin rejects it, then ContentNotFoundException is thrown") + "Given non-PENDING application, when admin rejects, then ContentNotFoundException is thrown") void shouldThrowContentNotFoundExceptionWhenRejectedApplicationIsNotPending() { final MenteeApplication rejected = MenteeApplication.builder() @@ -162,7 +123,7 @@ void shouldThrowContentNotFoundExceptionWhenRejectedApplicationIsNotPending() { @Test @DisplayName( - "Given an application that does not exist, when admin rejects it, then ApplicationNotFoundException is thrown") + "Given application not found, when admin rejects, then ApplicationNotFoundException thrown") void shouldThrowApplicationNotFoundExceptionWhenApplicationDoesNotExist() { when(applicationRepository.findById(99L)).thenReturn(Optional.empty()); @@ -170,4 +131,43 @@ void shouldThrowApplicationNotFoundExceptionWhenApplicationDoesNotExist() { .isInstanceOf(ApplicationNotFoundException.class) .hasMessageContaining("Application not found with ID: 99"); } -} + + private MenteeApplication pendingApplication( + final Long applicationId, final Long menteeId, final int priority) { + return MenteeApplication.builder() + .applicationId(applicationId) + .menteeId(menteeId) + .mentorId(20L) + .cycleId(5L) + .priorityOrder(priority) + .status(ApplicationStatus.PENDING) + .whyMentor("Great mentor") + .build(); + } + + private MenteeApplication reviewingApplication( + final Long applicationId, final Long menteeId) { + return MenteeApplication.builder() + .applicationId(applicationId) + .menteeId(menteeId) + .mentorId(20L) + .cycleId(5L) + .priorityOrder(1) + .status(ApplicationStatus.MENTOR_REVIEWING) + .whyMentor("Great mentor") + .build(); + } + + private MenteeApplication rejectedApplication( + final Long applicationId, final Long menteeId) { + return MenteeApplication.builder() + .applicationId(applicationId) + .menteeId(menteeId) + .mentorId(20L) + .cycleId(5L) + .priorityOrder(1) + .status(ApplicationStatus.REJECTED) + .whyMentor("Great mentor") + .build(); + } +} \ No newline at end of file diff --git a/src/testInt/java/com/wcc/platform/repository/postgres/PostgresMenteeApplicationRepositoryNewMethodsIntegrationTest.java b/src/testInt/java/com/wcc/platform/repository/postgres/PostgresMenteeApplicationRepositoryNewMethodsIntegrationTest.java new file mode 100644 index 00000000..dfa15354 --- /dev/null +++ b/src/testInt/java/com/wcc/platform/repository/postgres/PostgresMenteeApplicationRepositoryNewMethodsIntegrationTest.java @@ -0,0 +1,220 @@ +package com.wcc.platform.repository.postgres; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.wcc.platform.domain.platform.mentorship.ApplicationStatus; +import com.wcc.platform.domain.platform.mentorship.CycleStatus; +import com.wcc.platform.domain.platform.mentorship.Mentee; +import com.wcc.platform.domain.platform.mentorship.MenteeApplication; +import com.wcc.platform.domain.platform.mentorship.Mentor; +import com.wcc.platform.domain.platform.mentorship.MentorshipCycleEntity; +import com.wcc.platform.domain.platform.mentorship.MentorshipType; +import com.wcc.platform.factories.SetupMenteeFactories; +import com.wcc.platform.factories.SetupMentorFactories; +import com.wcc.platform.repository.postgres.mentorship.PostgresMenteeApplicationRepository; +import com.wcc.platform.repository.postgres.mentorship.PostgresMenteeRepository; +import com.wcc.platform.repository.postgres.mentorship.PostgresMentorRepository; +import com.wcc.platform.repository.postgres.mentorship.PostgresMentorshipCycleRepository; +import java.time.LocalDate; +import java.time.Month; +import java.time.Year; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +/** + * Integration tests for the new query methods added to PostgresMenteeApplicationRepository: + * findByStatusAndPriorityOrder and findPendingByMenteeId. + */ +class PostgresMenteeApplicationRepositoryNewMethodsIntegrationTest extends DefaultDatabaseSetup { + + @Autowired private PostgresMenteeApplicationRepository applicationRepository; + @Autowired private PostgresMenteeRepository menteeRepository; + @Autowired private PostgresMentorRepository mentorRepository; + @Autowired private PostgresMentorshipCycleRepository cycleRepository; + @Autowired private PostgresMemberRepository memberRepository; + + private Mentee mentee; + private Mentor mentorA; + private Mentor mentorB; + private MentorshipCycleEntity cycle; + + @BeforeEach + void setUp() { + memberRepository.deleteByEmail("mentor_new_a@test.com"); + memberRepository.deleteByEmail("mentor_new_b@test.com"); + memberRepository.deleteByEmail("mentee_new@test.com"); + cycleRepository + .findByYearAndType(Year.of(2027), MentorshipType.LONG_TERM) + .ifPresent(c -> cycleRepository.deleteById(c.getCycleId())); + + cycle = + cycleRepository.create( + MentorshipCycleEntity.builder() + .cycleYear(Year.of(2027)) + .mentorshipType(MentorshipType.LONG_TERM) + .cycleMonth(Month.MARCH) + .registrationStartDate(LocalDate.now().minusDays(1)) + .registrationEndDate(LocalDate.now().plusDays(10)) + .cycleStartDate(LocalDate.now().plusDays(15)) + .status(CycleStatus.OPEN) + .maxMenteesPerMentor(3) + .description("New Methods Test Cycle") + .build()); + + mentorA = + mentorRepository.create( + SetupMentorFactories.createMentorTest(null, "Mentor A", "mentor_new_a@test.com")); + + mentorB = + mentorRepository.create( + SetupMentorFactories.createMentorTest(null, "Mentor B", "mentor_new_b@test.com")); + + mentee = + menteeRepository.create( + SetupMenteeFactories.createMenteeTest(null, "Mentee New", "mentee_new@test.com")); + } + + @AfterEach + void tearDown() { + if (mentee != null) { + menteeRepository.deleteById(mentee.getId()); + memberRepository.deleteById(mentee.getId()); + } + if (mentorA != null) { + mentorRepository.deleteById(mentorA.getId()); + memberRepository.deleteById(mentorA.getId()); + } + if (mentorB != null) { + mentorRepository.deleteById(mentorB.getId()); + memberRepository.deleteById(mentorB.getId()); + } + if (cycle != null) { + cycleRepository.deleteById(cycle.getCycleId()); + } + } + + @Test + @DisplayName( + "Given PENDING priority-1 application, when finding by status and priority, then returned") + void shouldFindByStatusAndPriorityOrder() { + final MenteeApplication created = createApplication(mentorA.getId(), 1, ApplicationStatus.PENDING); + + final List result = + applicationRepository.findByStatusAndPriorityOrder(ApplicationStatus.PENDING, 1); + + assertThat(result) + .anyMatch(app -> app.getApplicationId().equals(created.getApplicationId())); + } + + @Test + @DisplayName( + "Given priority-2 app, when finding by status and priority-1, then it is not returned") + void shouldNotReturnWrongPriorityWhenFindingByStatusAndPriority() { + final MenteeApplication created = createApplication(mentorA.getId(), 2, ApplicationStatus.PENDING); + + final List result = + applicationRepository.findByStatusAndPriorityOrder(ApplicationStatus.PENDING, 1); + + assertThat(result) + .noneMatch(app -> app.getApplicationId().equals(created.getApplicationId())); + } + + @Test + @DisplayName( + "Given MENTOR_REVIEWING app, when finding PENDING by priority-1, then it is not returned") + void shouldNotReturnWrongStatusWhenFindingByStatusAndPriority() { + final MenteeApplication created = createApplication(mentorA.getId(), 1, ApplicationStatus.PENDING); + applicationRepository.updateStatus( + created.getApplicationId(), ApplicationStatus.MENTOR_REVIEWING, null); + + final List result = + applicationRepository.findByStatusAndPriorityOrder(ApplicationStatus.PENDING, 1); + + assertThat(result) + .noneMatch(app -> app.getApplicationId().equals(created.getApplicationId())); + } + + @Test + @DisplayName( + "Given mentee has two PENDING apps to different mentors, when finding pending by menteeId, " + + "then both are returned") + void shouldFindPendingByMenteeId() { + final MenteeApplication app1 = createApplication(mentorA.getId(), 1, ApplicationStatus.PENDING); + final MenteeApplication app2 = createApplication(mentorB.getId(), 2, ApplicationStatus.PENDING); + + final List result = + applicationRepository.findPendingByMenteeId(mentee.getId()); + + assertThat(result).hasSize(2); + assertThat(result) + .extracting(MenteeApplication::getApplicationId) + .containsExactlyInAnyOrder(app1.getApplicationId(), app2.getApplicationId()); + } + + @Test + @DisplayName( + "Given mentee has PENDING and REJECTED apps, when finding pending by menteeId, " + + "then only PENDING is returned") + void shouldReturnOnlyPendingWhenFindingPendingByMenteeId() { + final MenteeApplication pending = createApplication(mentorA.getId(), 1, ApplicationStatus.PENDING); + final MenteeApplication toReject = createApplication(mentorB.getId(), 2, ApplicationStatus.PENDING); + applicationRepository.updateStatus( + toReject.getApplicationId(), ApplicationStatus.REJECTED, "Does not qualify"); + + final List result = + applicationRepository.findPendingByMenteeId(mentee.getId()); + + assertThat(result).hasSize(1); + assertThat(result.getFirst().getApplicationId()).isEqualTo(pending.getApplicationId()); + } + + @Test + @DisplayName( + "Given mentee has no applications, when finding pending by menteeId, " + + "then empty list is returned") + void shouldReturnEmptyListWhenNoAppsForMentee() { + final List result = + applicationRepository.findPendingByMenteeId(mentee.getId()); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName( + "Given two PENDING apps for mentee, when finding pending by menteeId, " + + "then results are ordered by priority") + void shouldReturnAppsOrderedByPriorityWhenFindingPendingByMenteeId() { + createApplication(mentorB.getId(), 2, ApplicationStatus.PENDING); + createApplication(mentorA.getId(), 1, ApplicationStatus.PENDING); + + final List result = + applicationRepository.findPendingByMenteeId(mentee.getId()); + + assertThat(result).hasSize(2); + assertThat(result.getFirst().getPriorityOrder()).isEqualTo(1); + assertThat(result.getLast().getPriorityOrder()).isEqualTo(2); + } + + private MenteeApplication createApplication( + final Long mentorId, final int priority, final ApplicationStatus status) { + final MenteeApplication app = + applicationRepository.create( + MenteeApplication.builder() + .menteeId(mentee.getId()) + .mentorId(mentorId) + .cycleId(cycle.getCycleId()) + .priorityOrder(priority) + .status(ApplicationStatus.PENDING) + .whyMentor("Because of their expertise") + .build()); + + if (status != ApplicationStatus.PENDING) { + return applicationRepository.updateStatus(app.getApplicationId(), status, "Status update"); + } + return app; + } +}