Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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",
Expand All @@ -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<Page<User>> 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."
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -40,6 +52,15 @@ public User getUserById(UUID userId) {
.orElseThrow(() -> new NotFoundException("No user found for user id: " + userId));
}

public Page<User> getFilteredAndPaginatedUsers(int pageNumber, int pageSize, String search,
String sortBy, String sortOrder) {
String searchFilter = StringUtils.defaultString(search).toLowerCase(Locale.ROOT);
Stream<User> users = userRepository.findAll().stream()
.filter(user -> matchesSearch(user, searchFilter));

return page(sortIfRequested(users, sortBy, sortOrder), pageNumber, pageSize);
}

/**
* Get user favourite courts by user id.
*
Expand Down Expand Up @@ -129,4 +150,60 @@ public void deleteInactiveUsers() {
List<User> 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<User> sortIfRequested(Stream<User> 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<User> 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<User> 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<User> page(List<User> 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()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -42,6 +44,18 @@ class UserControllerTest {
@InjectMocks
private UserController userController;

@Test
void getFilteredAndPaginatedUsersReturns200() {
Page<User> users = new PageImpl<>(List.of(new User()));
when(userService.getFilteredAndPaginatedUsers(0, 25, "admin", "lastLogin", "desc")).thenReturn(users);

ResponseEntity<Page<User>> 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<Court> courts = List.of(new Court());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<User> 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<User> emailResult = userService.getFilteredAndPaginatedUsers(0, 25, "MATCH", null, null);
Page<User> 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<User> ascendingResult =
userService.getFilteredAndPaginatedUsers(0, 25, null, "lastLogin", "asc");
Page<User> 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<User> inactiveUsers = List.of(new User());
Expand All @@ -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;
}
}
Loading