From 4e7dd4a536b3adf068124982235d6d9d44cfcf94 Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Sat, 28 Feb 2026 20:58:51 +0000 Subject: [PATCH] Add users pagination on /users endpoint (#2675) Add actual pagination support to the users listing endpoint. Previously, the endpoint accepted Pageable parameters but ignored them, always returning all users. Now the repository, service, and analytics layers properly paginate results using Spring Data Page. Also fix pre-existing compilation error in ContributorServiceImpl (Stream.toList() incompatible with Java 11). Co-Authored-By: Claude Opus 4.6 --- .../epam/brn/repo/UserAccountRepository.kt | 13 ++++- .../brn/service/ContributorServiceImpl.kt | 4 -- .../epam/brn/service/UserAccountService.kt | 3 +- .../epam/brn/service/UserAnalyticsService.kt | 3 +- .../service/impl/UserAccountServiceImpl.kt | 5 +- .../service/impl/UserAnalyticsServiceImpl.kt | 9 ++-- .../controller/UserDetailControllerTest.kt | 11 ++-- .../integration/UserDetailsControllerIT.kt | 52 ++++++++++++++++++- .../brn/service/UserAccountServiceTest.kt | 7 ++- .../brn/service/UserAnalyticsServiceTest.kt | 14 +++-- 10 files changed, 95 insertions(+), 26 deletions(-) diff --git a/src/main/kotlin/com/epam/brn/repo/UserAccountRepository.kt b/src/main/kotlin/com/epam/brn/repo/UserAccountRepository.kt index cde382fb8..28a637c22 100644 --- a/src/main/kotlin/com/epam/brn/repo/UserAccountRepository.kt +++ b/src/main/kotlin/com/epam/brn/repo/UserAccountRepository.kt @@ -48,11 +48,22 @@ interface UserAccountRepository : JpaRepository { fun findAllByUserIdIsNullAndIsFirebaseErrorIsFalse(pageable: Pageable): Page @Query( - """select DISTINCT u FROM UserAccount u left JOIN FETCH u.roleSet roles + """select DISTINCT u FROM UserAccount u left JOIN FETCH u.roleSet roles left JOIN FETCH u.headphones where roles.name = :roleName""", ) fun findUsersAccountsByRole(roleName: String): List + @Query( + value = """select DISTINCT u FROM UserAccount u left JOIN FETCH u.roleSet roles + left JOIN FETCH u.headphones where roles.name = :roleName""", + countQuery = """select count(DISTINCT u) FROM UserAccount u left JOIN u.roleSet roles + where roles.name = :roleName""", + ) + fun findUsersAccountsByRole( + roleName: String, + pageable: Pageable, + ): Page + @Transactional @Modifying @Query( diff --git a/src/main/kotlin/com/epam/brn/service/ContributorServiceImpl.kt b/src/main/kotlin/com/epam/brn/service/ContributorServiceImpl.kt index 8ad7faf92..eadd7452c 100644 --- a/src/main/kotlin/com/epam/brn/service/ContributorServiceImpl.kt +++ b/src/main/kotlin/com/epam/brn/service/ContributorServiceImpl.kt @@ -18,9 +18,7 @@ class ContributorServiceImpl( @Transactional(readOnly = true) override fun getAllContributors(): List = contributorRepository .findAll() - .stream() .map { e -> e.toContributorResponse() } - .toList() @Transactional(readOnly = true) override fun getContributors( @@ -28,9 +26,7 @@ class ContributorServiceImpl( type: ContributorType, ): List = contributorRepository .findAllByType(type) - .stream() .map { e -> e.toContributorResponse(locale) } - .toList() @Transactional override fun createContributor(request: ContributorRequest): ContributorResponse = diff --git a/src/main/kotlin/com/epam/brn/service/UserAccountService.kt b/src/main/kotlin/com/epam/brn/service/UserAccountService.kt index b2efc386a..4731303fe 100644 --- a/src/main/kotlin/com/epam/brn/service/UserAccountService.kt +++ b/src/main/kotlin/com/epam/brn/service/UserAccountService.kt @@ -5,6 +5,7 @@ import com.epam.brn.dto.UserAccountDto import com.epam.brn.dto.request.UserAccountChangeRequest import com.epam.brn.model.UserAccount import com.google.firebase.auth.UserRecord +import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable interface UserAccountService { @@ -22,7 +23,7 @@ interface UserAccountService { fun getUsers( pageable: Pageable, role: String, - ): List + ): Page fun updateAvatarForCurrentUser(avatarUrl: String): UserAccountDto fun updateCurrentUser(userChangeRequest: UserAccountChangeRequest): UserAccountDto diff --git a/src/main/kotlin/com/epam/brn/service/UserAnalyticsService.kt b/src/main/kotlin/com/epam/brn/service/UserAnalyticsService.kt index ec07fda1c..5218b929c 100644 --- a/src/main/kotlin/com/epam/brn/service/UserAnalyticsService.kt +++ b/src/main/kotlin/com/epam/brn/service/UserAnalyticsService.kt @@ -2,6 +2,7 @@ package com.epam.brn.service import com.epam.brn.dto.AudioFileMetaData import com.epam.brn.dto.response.UserWithAnalyticsResponse +import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import java.io.InputStream @@ -9,7 +10,7 @@ interface UserAnalyticsService { fun getUsersWithAnalytics( pageable: Pageable, role: String, - ): List + ): Page fun prepareAudioStreamForUser( exerciseId: Long, audioFileMetaData: AudioFileMetaData, diff --git a/src/main/kotlin/com/epam/brn/service/impl/UserAccountServiceImpl.kt b/src/main/kotlin/com/epam/brn/service/impl/UserAccountServiceImpl.kt index df1c3c2ae..759ec5f28 100644 --- a/src/main/kotlin/com/epam/brn/service/impl/UserAccountServiceImpl.kt +++ b/src/main/kotlin/com/epam/brn/service/impl/UserAccountServiceImpl.kt @@ -15,6 +15,7 @@ import com.epam.brn.service.TimeService import com.epam.brn.service.UserAccountService import com.google.firebase.auth.UserRecord import org.springframework.beans.factory.annotation.Value +import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.security.core.Authentication import org.springframework.security.core.context.SecurityContextHolder @@ -90,8 +91,8 @@ class UserAccountServiceImpl( override fun getUsers( pageable: Pageable, role: String, - ): List = userAccountRepository - .findUsersAccountsByRole(role) + ): Page = userAccountRepository + .findUsersAccountsByRole(role, pageable) .map { it.toDto() } override fun updateAvatarForCurrentUser(avatarUrl: String): UserAccountDto { diff --git a/src/main/kotlin/com/epam/brn/service/impl/UserAnalyticsServiceImpl.kt b/src/main/kotlin/com/epam/brn/service/impl/UserAnalyticsServiceImpl.kt index 441f60337..fe77fdf5d 100644 --- a/src/main/kotlin/com/epam/brn/service/impl/UserAnalyticsServiceImpl.kt +++ b/src/main/kotlin/com/epam/brn/service/impl/UserAnalyticsServiceImpl.kt @@ -17,6 +17,7 @@ import com.epam.brn.service.UserAccountService import com.epam.brn.service.UserAnalyticsService import com.epam.brn.service.WordsService import com.epam.brn.service.statistics.UserPeriodStatisticsService +import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import org.springframework.stereotype.Service import java.io.InputStream @@ -44,8 +45,8 @@ class UserAnalyticsServiceImpl( override fun getUsersWithAnalytics( pageable: Pageable, role: String, - ): List { - val users = userAccountRepository.findUsersAccountsByRole(role).map { it.toAnalyticsDto() } + ): Page { + val usersPage = userAccountRepository.findUsersAccountsByRole(role, pageable) val now = timeService.now() val firstWeekDay = WeekFields.of(Locale.getDefault()).dayOfWeek() @@ -54,7 +55,8 @@ class UserAnalyticsServiceImpl( val to = startDay.plusDays(7L).with(LocalTime.MAX) val startOfCurrentMonth = now.withDayOfMonth(1).with(LocalTime.MIN) - users.onEach { user -> + return usersPage.map { userAccount -> + val user = userAccount.toAnalyticsDto() user.lastWeek = userDayStatisticsService.getStatisticsForPeriod(from, to, user.id) user.studyDaysInCurrentMonth = countWorkDaysForMonth( @@ -69,7 +71,6 @@ class UserAnalyticsServiceImpl( this.doneExercises = userStatistic.doneExercises } } - return users } override fun prepareAudioStreamForUser( diff --git a/src/test/kotlin/com/epam/brn/controller/UserDetailControllerTest.kt b/src/test/kotlin/com/epam/brn/controller/UserDetailControllerTest.kt index 4f83556fc..6082bab0a 100644 --- a/src/test/kotlin/com/epam/brn/controller/UserDetailControllerTest.kt +++ b/src/test/kotlin/com/epam/brn/controller/UserDetailControllerTest.kt @@ -30,6 +30,7 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.data.domain.PageImpl import org.springframework.data.domain.Pageable import kotlin.test.assertEquals @@ -295,7 +296,8 @@ internal class UserDetailControllerTest { val role = BrnRole.USER val pageable = mockk() val userWithAnalyticsResponse = mockk() - every { userAnalyticsService.getUsersWithAnalytics(pageable, role) } returns listOf(userWithAnalyticsResponse) + val page = PageImpl(listOf(userWithAnalyticsResponse)) + every { userAnalyticsService.getUsersWithAnalytics(pageable, role) } returns page // WHEN val users = userDetailController.getUsers(withAnalytics, role, pageable) @@ -303,7 +305,7 @@ internal class UserDetailControllerTest { // THEN verify(exactly = 1) { userAnalyticsService.getUsersWithAnalytics(pageable, role) } users.statusCodeValue shouldBe HttpStatus.SC_OK - (users.body as BrnResponse<*>).data shouldBe listOf(userWithAnalyticsResponse) + (users.body as BrnResponse<*>).data shouldBe page } @Test @@ -312,7 +314,8 @@ internal class UserDetailControllerTest { val withAnalytics = false val role = BrnRole.USER val pageable = mockk() - every { userAccountService.getUsers(pageable, role) } returns listOf(userAccountDto) + val page = PageImpl(listOf(userAccountDto)) + every { userAccountService.getUsers(pageable, role) } returns page // WHEN val users = userDetailController.getUsers(withAnalytics, role, pageable) @@ -320,7 +323,7 @@ internal class UserDetailControllerTest { // THEN verify(exactly = 1) { userAccountService.getUsers(pageable, role) } users.statusCodeValue shouldBe HttpStatus.SC_OK - (users.body as BrnResponse<*>).data shouldBe listOf(userAccountDto) + (users.body as BrnResponse<*>).data shouldBe page } @Test diff --git a/src/test/kotlin/com/epam/brn/integration/UserDetailsControllerIT.kt b/src/test/kotlin/com/epam/brn/integration/UserDetailsControllerIT.kt index 5176f4480..5c195333d 100644 --- a/src/test/kotlin/com/epam/brn/integration/UserDetailsControllerIT.kt +++ b/src/test/kotlin/com/epam/brn/integration/UserDetailsControllerIT.kt @@ -341,12 +341,60 @@ class UserDetailsControllerIT : BaseIT() { .response .getContentAsString(StandardCharsets.UTF_8) - val data = gson.fromJson(response, BrnResponse::class.java).data + val responseMap: Map = objectMapper.readValue(response, object : TypeReference>() {}) + val dataMap = responseMap["data"] as Map<*, *> + val content = dataMap["content"] val users: List = - objectMapper.readValue(gson.toJson(data), object : TypeReference>() {}) + objectMapper.readValue(gson.toJson(content), object : TypeReference>() {}) // THEN users.size shouldBe 1 + dataMap["totalElements"] shouldBe 1 + } + + @Test + fun `should get paginated users by role`() { + // GIVEN + val roleUser = insertRole(BrnRole.USER) + + for (i in 1..5) { + val user = + UserAccount( + fullName = "testUser$i", + email = "testuser$i@test.test", + gender = BrnGender.MALE.toString(), + bornYear = 2000, + active = true, + ) + user.roleSet = mutableSetOf(roleUser) + userAccountRepository.save(user) + } + + // WHEN - request page 0 with size 2 + val response = + mockMvc + .perform( + MockMvcRequestBuilders + .get(baseUrl) + .param("role", BrnRole.USER) + .param("page", "0") + .param("size", "2"), + ).andExpect(status().isOk) + .andReturn() + .response + .getContentAsString(StandardCharsets.UTF_8) + + val responseMap: Map = objectMapper.readValue(response, object : TypeReference>() {}) + val dataMap = responseMap["data"] as Map<*, *> + val content = dataMap["content"] + val users: List = + objectMapper.readValue(gson.toJson(content), object : TypeReference>() {}) + + // THEN + users.size shouldBe 2 + dataMap["totalElements"] shouldBe 5 + dataMap["totalPages"] shouldBe 3 + dataMap["number"] shouldBe 0 } @Test diff --git a/src/test/kotlin/com/epam/brn/service/UserAccountServiceTest.kt b/src/test/kotlin/com/epam/brn/service/UserAccountServiceTest.kt index 791e3746d..06997684c 100644 --- a/src/test/kotlin/com/epam/brn/service/UserAccountServiceTest.kt +++ b/src/test/kotlin/com/epam/brn/service/UserAccountServiceTest.kt @@ -31,6 +31,7 @@ import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.data.domain.PageImpl import org.springframework.data.domain.Pageable import org.springframework.security.core.Authentication import org.springframework.security.core.context.SecurityContext @@ -499,11 +500,13 @@ internal class UserAccountServiceTest { fun `should return all users`() { // GIVEN val usersList = listOf(userAccount, userAccount, userAccount) - every { userAccountRepository.findUsersAccountsByRole(BrnRole.USER) } returns usersList + val usersPage = PageImpl(usersList) + every { userAccountRepository.findUsersAccountsByRole(BrnRole.USER, pageable) } returns usersPage // WHEN val userAccountDtos = userAccountService.getUsers(pageable = pageable, BrnRole.USER) // THEN - userAccountDtos.size shouldBe 3 + userAccountDtos.totalElements shouldBe 3 + userAccountDtos.content.size shouldBe 3 } } diff --git a/src/test/kotlin/com/epam/brn/service/UserAnalyticsServiceTest.kt b/src/test/kotlin/com/epam/brn/service/UserAnalyticsServiceTest.kt index 0341bf4d6..537b1dd73 100644 --- a/src/test/kotlin/com/epam/brn/service/UserAnalyticsServiceTest.kt +++ b/src/test/kotlin/com/epam/brn/service/UserAnalyticsServiceTest.kt @@ -24,6 +24,7 @@ import io.mockk.mockk import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.data.domain.PageImpl import org.springframework.data.domain.Pageable import java.io.InputStream import java.time.LocalDateTime @@ -76,40 +77,43 @@ internal class UserAnalyticsServiceTest { @Test fun `should return all users with analytics`() { val usersList = listOf(doctorAccount, doctorAccount) + val usersPage = PageImpl(usersList) val dayStatisticList = listOf(dayStudyStatistics, dayStudyStatistics) every { userStatisticView.firstStudy } returns LocalDateTime.now() every { userStatisticView.lastStudy } returns LocalDateTime.now() every { userStatisticView.spentTime } returns 10000L every { userStatisticView.doneExercises } returns 1 - every { userAccountRepository.findUsersAccountsByRole(BrnRole.ADMIN) } returns usersList + every { userAccountRepository.findUsersAccountsByRole(BrnRole.ADMIN, pageable) } returns usersPage every { userDayStatisticService.getStatisticsForPeriod(any(), any(), any()) } returns dayStatisticList every { timeService.now() } returns LocalDateTime.now() every { studyHistoryRepository.getStatisticsByUserAccountId(any()) } returns userStatisticView val userAnalyticsDtos = userAnalyticsService.getUsersWithAnalytics(pageable, BrnRole.ADMIN) - userAnalyticsDtos.size shouldBe 2 + userAnalyticsDtos.totalElements shouldBe 2 + userAnalyticsDtos.content.size shouldBe 2 } @Test fun `should not return user with analytics`() { val usersList = listOf(doctorAccount) + val usersPage = PageImpl(usersList) val dayStatisticList = emptyList() every { userStatisticView.firstStudy } returns LocalDateTime.now() every { userStatisticView.lastStudy } returns LocalDateTime.now() every { userStatisticView.spentTime } returns 10000L every { userStatisticView.doneExercises } returns 1 - every { userAccountRepository.findUsersAccountsByRole(BrnRole.ADMIN) } returns usersList + every { userAccountRepository.findUsersAccountsByRole(BrnRole.ADMIN, pageable) } returns usersPage every { userDayStatisticService.getStatisticsForPeriod(any(), any(), any()) } returns dayStatisticList every { timeService.now() } returns LocalDateTime.now() every { studyHistoryRepository.getStatisticsByUserAccountId(any()) } returns userStatisticView val userAnalyticsDtos = userAnalyticsService.getUsersWithAnalytics(pageable, BrnRole.ADMIN) - userAnalyticsDtos.size shouldBe 1 - userAnalyticsDtos[0].lastWeek.size shouldBe 0 + userAnalyticsDtos.totalElements shouldBe 1 + userAnalyticsDtos.content[0].lastWeek.size shouldBe 0 } private val currentUserId = 1L