From 67863433e53e7b05af5f26c4541c8b88c2f63fba Mon Sep 17 00:00:00 2001 From: josh-uk Date: Wed, 8 Jul 2026 12:51:29 +0100 Subject: [PATCH] WIP user changes --- .../api/controllers/UserControllerTest.java | 30 +++++++ .../data/api/controllers/UserController.java | 31 +++++++ .../fact/data/api/services/UserService.java | 77 +++++++++++++++++ .../api/controllers/UserControllerTest.java | 14 +++ .../data/api/services/UserServiceTest.java | 86 +++++++++++++++++++ 5 files changed, 238 insertions(+) diff --git a/src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/controllers/UserControllerTest.java b/src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/controllers/UserControllerTest.java index 4c5c98c0..43721f20 100644 --- a/src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/controllers/UserControllerTest.java +++ b/src/integrationTest/java/uk/gov/hmcts/reform/fact/data/api/controllers/UserControllerTest.java @@ -7,6 +7,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.data.domain.PageImpl; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @@ -17,11 +18,13 @@ import uk.gov.hmcts.reform.fact.data.api.services.CourtLockService; import uk.gov.hmcts.reform.fact.data.api.services.UserService; +import java.time.ZonedDateTime; import java.util.List; import java.util.UUID; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -51,6 +54,33 @@ class UserControllerTest { private final UUID nonExistentUserId = UUID.fromString("11111111-1111-1111-1111-111111111111"); private final UUID courtId = UUID.fromString("222e4567-e89b-12d3-a456-426614174000"); + @Test + @DisplayName("GET /user/v1 returns filtered and paginated users successfully") + void getFilteredAndPaginatedUsersReturnsSuccessfully() throws Exception { + UUID ssoId = UUID.fromString("333e4567-e89b-12d3-a456-426614174000"); + User user = User.builder() + .email("admin@justice.gov.uk") + .ssoId(ssoId) + .lastLogin(ZonedDateTime.parse("2026-07-08T10:15:30Z")) + .role(UserRole.ADMIN) + .build(); + when(userService.getFilteredAndPaginatedUsers(0, 25, "admin", "lastLogin", "desc")) + .thenReturn(new PageImpl<>(List.of(user))); + + mockMvc.perform(get("/user/v1") + .param("pageNumber", "0") + .param("pageSize", "25") + .param("search", "admin") + .param("sortBy", "lastLogin") + .param("sortOrder", "desc")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content[0].email").value("admin@justice.gov.uk")) + .andExpect(jsonPath("$.content[0].ssoId").value(ssoId.toString())) + .andExpect(jsonPath("$.content[0].role").value("Admin")); + + verify(userService).getFilteredAndPaginatedUsers(0, 25, "admin", "lastLogin", "desc"); + } + @Test @DisplayName("GET /user/v1/{userId}/favourites returns favourite courts successfully") void getUserFavoritesReturnsSuccessfully() throws Exception { diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/controllers/UserController.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/controllers/UserController.java index 5ae14c0c..6437e0de 100644 --- a/src/main/java/uk/gov/hmcts/reform/fact/data/api/controllers/UserController.java +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/controllers/UserController.java @@ -15,6 +15,10 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import org.springframework.data.domain.Page; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -24,6 +28,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; @SecuredFactRestController( name = "User", @@ -41,6 +46,32 @@ public UserController(UserService userService, CourtLockService courtLockService this.courtLockService = courtLockService; } + @GetMapping("/v1") + @Operation(summary = "Get filtered and paginated users") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Successfully retrieved users"), + @ApiResponse(responseCode = "400", description = "Invalid request parameters supplied") + }) + @PreAuthorize("@authService.isAdmin()") + public ResponseEntity> getFilteredAndPaginatedUsers( + @RequestParam(name = "pageNumber", defaultValue = "0") + @PositiveOrZero(message = "pageNumber must be greater than or equal to 0") int pageNumber, + @RequestParam(name = "pageSize", defaultValue = "25") + @Positive(message = "pageSize must be greater than 0") int pageSize, + @RequestParam(name = "search", required = false) + @Size(max = 250, message = "Search must be less than 250 characters") + String search, + @RequestParam(name = "sortBy", required = false) String sortBy, + @RequestParam(name = "sortOrder", required = false) String sortOrder) { + return ResponseEntity.ok(userService.getFilteredAndPaginatedUsers( + pageNumber, + pageSize, + search, + sortBy, + sortOrder + )); + } + @GetMapping("/v1/{userId}/favourites") @Operation(summary = "Get favourite courts by user ID", description = "Fetch favourite courts for a given user." diff --git a/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/UserService.java b/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/UserService.java index 71b6af96..16c79c71 100644 --- a/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/UserService.java +++ b/src/main/java/uk/gov/hmcts/reform/fact/data/api/services/UserService.java @@ -1,22 +1,34 @@ package uk.gov.hmcts.reform.fact.data.api.services; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import uk.gov.hmcts.reform.fact.data.api.entities.Court; import uk.gov.hmcts.reform.fact.data.api.entities.User; +import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.InvalidParameterCombinationException; import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.NotFoundException; import uk.gov.hmcts.reform.fact.data.api.repositories.UserRepository; import java.time.ZonedDateTime; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; +import java.util.Locale; import java.util.Optional; import java.util.UUID; +import java.util.stream.Stream; @Service public class UserService { + private static final String SORT_BY_LAST_LOGIN = "lastlogin"; + private static final String SORT_ORDER_ASC = "asc"; + private static final String SORT_ORDER_DESC = "desc"; + @Value("${user.retention-period}") private long retentionPeriod; @@ -40,6 +52,15 @@ public User getUserById(UUID userId) { .orElseThrow(() -> new NotFoundException("No user found for user id: " + userId)); } + public Page getFilteredAndPaginatedUsers(int pageNumber, int pageSize, String search, + String sortBy, String sortOrder) { + String searchFilter = StringUtils.defaultString(search).toLowerCase(Locale.ROOT); + Stream users = userRepository.findAll().stream() + .filter(user -> matchesSearch(user, searchFilter)); + + return page(sortIfRequested(users, sortBy, sortOrder), pageNumber, pageSize); + } + /** * Get user favourite courts by user id. * @@ -129,4 +150,60 @@ public void deleteInactiveUsers() { List inactiveUsers = userRepository.findAllByLastLoginBefore(cutoffDate); userRepository.deleteAll(inactiveUsers); } + + private boolean matchesSearch(User user, String searchFilter) { + return StringUtils.isBlank(searchFilter) + || matchesValue(user.getEmail(), searchFilter) + || matchesValue(user.getSsoId() == null ? null : user.getSsoId().toString(), searchFilter); + } + + private boolean matchesValue(String value, String searchFilter) { + return value != null && value.toLowerCase(Locale.ROOT).contains(searchFilter); + } + + private List sortIfRequested(Stream users, String sortBy, String sortOrder) { + if (StringUtils.isBlank(sortBy)) { + if (!StringUtils.isBlank(sortOrder)) { + throw new InvalidParameterCombinationException("sortOrder cannot be provided without sortBy"); + } + return users.toList(); + } + + return users.sorted(buildComparator(sortBy, sortOrder)).toList(); + } + + private Comparator buildComparator(String sortBy, String sortOrder) { + String normalizedSortBy = sortBy.trim().toLowerCase(Locale.ROOT); + String normalizedSortOrder = StringUtils.isBlank(sortOrder) ? SORT_ORDER_ASC + : sortOrder.trim().toLowerCase(Locale.ROOT); + + if (!SORT_ORDER_ASC.equals(normalizedSortOrder) && !SORT_ORDER_DESC.equals(normalizedSortOrder)) { + throw new InvalidParameterCombinationException("sortOrder must be one of: asc, desc"); + } + + if (!SORT_BY_LAST_LOGIN.equals(normalizedSortBy)) { + throw new InvalidParameterCombinationException("sortBy must be one of: lastLogin"); + } + + Comparator comparator = Comparator + .comparing( + User::getLastLogin, + Comparator.nullsLast(Comparator.naturalOrder()) + ) + .thenComparing(User::getEmail, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(User::getSsoId, Comparator.nullsLast(Comparator.naturalOrder())); + + return SORT_ORDER_DESC.equals(normalizedSortOrder) ? comparator.reversed() : comparator; + } + + private Page page(List users, int pageNumber, int pageSize) { + int fromIndex = Math.min(pageNumber * pageSize, users.size()); + int toIndex = Math.min(fromIndex + pageSize, users.size()); + + return new PageImpl<>( + users.subList(fromIndex, toIndex), + PageRequest.of(pageNumber, pageSize), + users.size() + ); + } } diff --git a/src/test/java/uk/gov/hmcts/reform/fact/data/api/controllers/UserControllerTest.java b/src/test/java/uk/gov/hmcts/reform/fact/data/api/controllers/UserControllerTest.java index 8861fe1f..8d477605 100644 --- a/src/test/java/uk/gov/hmcts/reform/fact/data/api/controllers/UserControllerTest.java +++ b/src/test/java/uk/gov/hmcts/reform/fact/data/api/controllers/UserControllerTest.java @@ -5,6 +5,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import uk.gov.hmcts.reform.fact.data.api.entities.Court; @@ -42,6 +44,18 @@ class UserControllerTest { @InjectMocks private UserController userController; + @Test + void getFilteredAndPaginatedUsersReturns200() { + Page users = new PageImpl<>(List.of(new User())); + when(userService.getFilteredAndPaginatedUsers(0, 25, "admin", "lastLogin", "desc")).thenReturn(users); + + ResponseEntity> response = + userController.getFilteredAndPaginatedUsers(0, 25, "admin", "lastLogin", "desc"); + + assertThat(response.getStatusCode()).as(RESPONSE_STATUS_MESSAGE).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).as(RESPONSE_BODY_MESSAGE).isEqualTo(users); + } + @Test void getUserFavoritesReturns200() { List courts = List.of(new Court()); diff --git a/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/UserServiceTest.java b/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/UserServiceTest.java index 825a81fc..320854c1 100644 --- a/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/UserServiceTest.java +++ b/src/test/java/uk/gov/hmcts/reform/fact/data/api/services/UserServiceTest.java @@ -5,9 +5,11 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; import uk.gov.hmcts.reform.fact.data.api.entities.Court; import uk.gov.hmcts.reform.fact.data.api.entities.User; import uk.gov.hmcts.reform.fact.data.api.entities.types.UserRole; +import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.InvalidParameterCombinationException; import uk.gov.hmcts.reform.fact.data.api.errorhandling.exceptions.NotFoundException; import uk.gov.hmcts.reform.fact.data.api.repositories.UserRepository; @@ -287,6 +289,81 @@ void updateExistingUserShouldPreserveSsoIdAndUpdateLastLoginWhenSsoIdExists() { userService.createOrUpdateLastLoginUser(updatedUser); } + @Test + void getFilteredAndPaginatedUsersShouldReturnPagedUsers() { + User firstUser = createUser("first@justice.gov.uk", UUID.randomUUID(), UserRole.ADMIN, + ZonedDateTime.now().minusDays(2)); + User secondUser = createUser("second@justice.gov.uk", UUID.randomUUID(), UserRole.SUPER_ADMIN, + ZonedDateTime.now().minusDays(1)); + when(userRepository.findAll()).thenReturn(List.of(firstUser, secondUser)); + + Page result = userService.getFilteredAndPaginatedUsers(0, 1, null, null, null); + + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().getFirst().getEmail()).isEqualTo("first@justice.gov.uk"); + assertThat(result.getContent().getFirst().getRole()).isEqualTo(UserRole.ADMIN); + assertThat(result.getTotalElements()).isEqualTo(2); + } + + @Test + void getFilteredAndPaginatedUsersShouldSearchByEmailOrSsoId() { + UUID matchingSsoId = UUID.fromString("123e4567-e89b-12d3-a456-426614174000"); + User emailMatch = createUser("match@justice.gov.uk", UUID.randomUUID(), UserRole.ADMIN, + ZonedDateTime.now().minusDays(2)); + User ssoMatch = createUser("other@justice.gov.uk", matchingSsoId, UserRole.SUPER_ADMIN, + ZonedDateTime.now().minusDays(1)); + User nonMatch = createUser("ignored@justice.gov.uk", UUID.randomUUID(), UserRole.ADMIN, + ZonedDateTime.now()); + when(userRepository.findAll()).thenReturn(List.of(emailMatch, ssoMatch, nonMatch)); + + Page emailResult = userService.getFilteredAndPaginatedUsers(0, 25, "MATCH", null, null); + Page ssoResult = userService.getFilteredAndPaginatedUsers(0, 25, "123e4567", null, null); + + assertThat(emailResult.getContent()).extracting(User::getEmail) + .containsExactly("match@justice.gov.uk"); + assertThat(ssoResult.getContent()).extracting(User::getEmail) + .containsExactly("other@justice.gov.uk"); + } + + @Test + void getFilteredAndPaginatedUsersShouldSortByLastLogin() { + User olderUser = createUser("older@justice.gov.uk", UUID.randomUUID(), UserRole.ADMIN, + ZonedDateTime.now().minusDays(2)); + User newerUser = createUser("newer@justice.gov.uk", UUID.randomUUID(), UserRole.SUPER_ADMIN, + ZonedDateTime.now().minusDays(1)); + when(userRepository.findAll()).thenReturn(List.of(olderUser, newerUser)); + + Page ascendingResult = + userService.getFilteredAndPaginatedUsers(0, 25, null, "lastLogin", "asc"); + Page descendingResult = + userService.getFilteredAndPaginatedUsers(0, 25, null, "lastLogin", "desc"); + + assertThat(ascendingResult.getContent()).extracting(User::getEmail) + .containsExactly("older@justice.gov.uk", "newer@justice.gov.uk"); + assertThat(descendingResult.getContent()).extracting(User::getEmail) + .containsExactly("newer@justice.gov.uk", "older@justice.gov.uk"); + } + + @Test + void getFilteredAndPaginatedUsersShouldRejectInvalidSortParameters() { + InvalidParameterCombinationException missingSortBy = assertThrows( + InvalidParameterCombinationException.class, + () -> userService.getFilteredAndPaginatedUsers(0, 25, null, null, "asc") + ); + InvalidParameterCombinationException invalidSortBy = assertThrows( + InvalidParameterCombinationException.class, + () -> userService.getFilteredAndPaginatedUsers(0, 25, null, "email", "asc") + ); + InvalidParameterCombinationException invalidSortOrder = assertThrows( + InvalidParameterCombinationException.class, + () -> userService.getFilteredAndPaginatedUsers(0, 25, null, "lastLogin", "sideways") + ); + + assertThat(missingSortBy).hasMessage("sortOrder cannot be provided without sortBy"); + assertThat(invalidSortBy).hasMessage("sortBy must be one of: lastLogin"); + assertThat(invalidSortOrder).hasMessage("sortOrder must be one of: asc, desc"); + } + @Test void deleteInactiveUsersShouldRemoveUsersNotLoggedInWithinRetentionPeriod() { List inactiveUsers = List.of(new User()); @@ -297,4 +374,13 @@ void deleteInactiveUsersShouldRemoveUsersNotLoggedInWithinRetentionPeriod() { verify(userRepository).deleteAll(inactiveUsers); } + + private User createUser(String email, UUID ssoId, UserRole role, ZonedDateTime lastLogin) { + User user = new User(); + user.setEmail(email); + user.setSsoId(ssoId); + user.setRole(role); + user.setLastLogin(lastLogin); + return user; + } }