diff --git a/src/main/java/com/example/log4u/domain/user/controller/UserController.java b/src/main/java/com/example/log4u/domain/user/controller/UserController.java index 5bb759a5..7dfe7d2c 100644 --- a/src/main/java/com/example/log4u/domain/user/controller/UserController.java +++ b/src/main/java/com/example/log4u/domain/user/controller/UserController.java @@ -15,9 +15,11 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.example.log4u.common.constants.UrlConstants; +import com.example.log4u.common.dto.PageResponse; import com.example.log4u.common.oauth2.dto.CustomOAuth2User; import com.example.log4u.domain.user.dto.NicknameValidationResponseDto; import com.example.log4u.domain.user.dto.UserProfileMakeRequestDto; @@ -134,4 +136,16 @@ public ResponseEntity validateNickname( ) { return ResponseEntity.ok(userService.validateNickname(nickname)); } + + @GetMapping("/search") + public ResponseEntity> searchUsers( + @AuthenticationPrincipal CustomOAuth2User customOAuth2User, + @RequestParam(required = false) String nickname, + @RequestParam(required = false) Long cursorId, + @RequestParam(defaultValue = "12") int size + ) { + return ResponseEntity.ok( + userService.searchUsersByCursor(nickname, cursorId, size) + ); + } } diff --git a/src/main/java/com/example/log4u/domain/user/repository/UserRepository.java b/src/main/java/com/example/log4u/domain/user/repository/UserRepository.java index 98f53c1c..fa6d8dcc 100644 --- a/src/main/java/com/example/log4u/domain/user/repository/UserRepository.java +++ b/src/main/java/com/example/log4u/domain/user/repository/UserRepository.java @@ -2,7 +2,11 @@ import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import com.example.log4u.domain.user.entity.User; @@ -14,4 +18,20 @@ public interface UserRepository extends JpaRepository { Boolean existsByNickname(String nickname); Optional findByProviderId(String providerId); + + @Query(value = """ + SELECT u FROM User u + LEFT JOIN (SELECT f.targetId, COUNT(f) as followerCount + FROM Follow f + GROUP BY f.targetId) fc + ON u.userId = fc.targetId + WHERE (:nickname IS NULL OR LOWER(u.nickname) LIKE LOWER(CONCAT('%', :nickname, '%'))) + AND u.userId < :cursorId + ORDER BY fc.followerCount DESC NULLS LAST, u.userId DESC + """) + Slice searchUsersByCursor( + @Param("nickname") String nickname, + @Param("cursorId") Long cursorId, + Pageable pageable + ); } diff --git a/src/main/java/com/example/log4u/domain/user/service/UserService.java b/src/main/java/com/example/log4u/domain/user/service/UserService.java index 5c069bf4..0cdd4dc5 100644 --- a/src/main/java/com/example/log4u/domain/user/service/UserService.java +++ b/src/main/java/com/example/log4u/domain/user/service/UserService.java @@ -1,8 +1,14 @@ package com.example.log4u.domain.user.service; +import java.util.List; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.example.log4u.common.dto.PageResponse; import com.example.log4u.domain.follow.repository.FollowRepository; import com.example.log4u.domain.user.dto.NicknameValidationResponseDto; import com.example.log4u.domain.user.dto.UserProfileMakeRequestDto; @@ -13,7 +19,9 @@ import com.example.log4u.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor public class UserService { @@ -87,4 +95,33 @@ public Long getUserIdByNickname(String nickname) { ).getUserId(); } + @Transactional(readOnly = true) + public PageResponse searchUsersByCursor( + String nickname, + Long cursorId, + int size + ) { + Slice userSlice = userRepository.searchUsersByCursor( + nickname, + cursorId != null ? cursorId : Long.MAX_VALUE, + PageRequest.of(0, size) + ); + + List content = userSlice.getContent().stream() + .map(user -> { + Long userId = user.getUserId(); + Long followers = followRepository.countByTargetId(userId); + Long followings = followRepository.countByInitiatorId(userId); + return UserProfileResponseDto.fromUser(user, followers, followings); + }) + .toList(); + + // 다음 커서 ID 계산 + Long nextCursor = content.isEmpty() ? null : content.get(content.size() - 1).userId(); + + return PageResponse.of( + new SliceImpl<>(content, userSlice.getPageable(), userSlice.hasNext()), + nextCursor + ); + } } diff --git a/src/test/java/com/example/log4u/domain/user/service/UserServiceTest.java b/src/test/java/com/example/log4u/domain/user/service/UserServiceTest.java index e64001c7..1a419437 100644 --- a/src/test/java/com/example/log4u/domain/user/service/UserServiceTest.java +++ b/src/test/java/com/example/log4u/domain/user/service/UserServiceTest.java @@ -3,17 +3,25 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.BDDMockito.*; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import com.example.log4u.common.dto.PageResponse; +import com.example.log4u.domain.follow.repository.FollowRepository; import com.example.log4u.domain.user.dto.NicknameValidationResponseDto; import com.example.log4u.domain.user.dto.UserProfileMakeRequestDto; +import com.example.log4u.domain.user.dto.UserProfileResponseDto; import com.example.log4u.domain.user.dto.UserProfileUpdateRequestDto; import com.example.log4u.domain.user.entity.User; import com.example.log4u.domain.user.exception.UserNotFoundException; @@ -29,7 +37,10 @@ class UserServiceTest { @Mock private UserRepository userRepository; - + + @Mock + private FollowRepository followRepository; + @Test @DisplayName("내 프로필을 생성하고 저장해야 한다.") void shouldCreateAndSaveMyUserProfile() { @@ -116,4 +127,103 @@ void shouldThrowExceptionWhenUserNotFound() { verify(userRepository).findById(userId); } + @Test + @DisplayName("닉네임으로 유저를 검색하고 팔로워 수 높은 순으로 정렬해야 한다") + void shouldSearchUsersByNicknameAndSortByFollowerCount() { + // given + String nickname = "test"; + Long cursorId = null; + int size = 10; + + // 테스트용 유저 목록 생성 + User user1 = UserFixture.createUserWithId(1L, "User1", "test1"); + User user2 = UserFixture.createUserWithId(2L, "User2", "test2"); + User user3 = UserFixture.createUserWithId(3L, "User3", "test3"); + + List users = List.of(user1, user2, user3); + Slice userSlice = new SliceImpl<>(users, PageRequest.of(0, size), false); + + // Mock 설정 + when(userRepository.searchUsersByCursor( + eq(nickname), + eq(Long.MAX_VALUE), + ArgumentMatchers.any(PageRequest.class))) + .thenReturn(userSlice); + + // 각 유저의 팔로워/팔로잉 수 설정 + when(followRepository.countByTargetId(1L)).thenReturn(10L); + when(followRepository.countByInitiatorId(1L)).thenReturn(5L); + + when(followRepository.countByTargetId(2L)).thenReturn(20L); + when(followRepository.countByInitiatorId(2L)).thenReturn(15L); + + when(followRepository.countByTargetId(3L)).thenReturn(30L); + when(followRepository.countByInitiatorId(3L)).thenReturn(25L); + + // when + PageResponse result = userService.searchUsersByCursor(nickname, cursorId, size); + + // then + assertNotNull(result); + assertEquals(3, result.list().size()); + + // 팔로워 수 확인 + assertEquals(10L, result.list().get(0).followers()); + assertEquals(20L, result.list().get(1).followers()); + assertEquals(30L, result.list().get(2).followers()); + + // 팔로잉 수 확인 + assertEquals(5L, result.list().get(0).followings()); + assertEquals(15L, result.list().get(1).followings()); + assertEquals(25L, result.list().get(2).followings()); + + // 다음 커서 확인 + assertEquals(3L, result.pageInfo().nextCursor()); + + // 메서드 호출 확인 + verify(userRepository).searchUsersByCursor( + eq(nickname), + eq(Long.MAX_VALUE), + ArgumentMatchers.any(PageRequest.class)); + + verify(followRepository, times(3)).countByTargetId(anyLong()); + verify(followRepository, times(3)).countByInitiatorId(anyLong()); + } + + @Test + @DisplayName("검색 결과가 없으면 빈 리스트를 반환해야 한다") + void shouldReturnEmptyListWhenNoSearchResults() { + // given + String nickname = "nonexistent"; + Long cursorId = null; + int size = 10; + + List emptyList = List.of(); + Slice emptySlice = new SliceImpl<>(emptyList, PageRequest.of(0, size), false); + + // Mock 설정 + when(userRepository.searchUsersByCursor( + eq(nickname), + eq(Long.MAX_VALUE), + ArgumentMatchers.any(PageRequest.class))) + .thenReturn(emptySlice); + + // when + PageResponse result = userService.searchUsersByCursor(nickname, cursorId, size); + + // then + assertNotNull(result); + assertTrue(result.list().isEmpty()); + assertNull(result.pageInfo().nextCursor()); + + // 메서드 호출 확인 + verify(userRepository).searchUsersByCursor( + eq(nickname), + eq(Long.MAX_VALUE), + ArgumentMatchers.any(PageRequest.class)); + + // 팔로워/팔로잉 수 조회 메서드가 호출되지 않아야 함 + verify(followRepository, never()).countByTargetId(anyLong()); + verify(followRepository, never()).countByInitiatorId(anyLong()); + } } \ No newline at end of file diff --git a/src/test/java/com/example/log4u/fixture/UserFixture.java b/src/test/java/com/example/log4u/fixture/UserFixture.java index fdc5c038..05225213 100644 --- a/src/test/java/com/example/log4u/fixture/UserFixture.java +++ b/src/test/java/com/example/log4u/fixture/UserFixture.java @@ -60,5 +60,17 @@ public static User createPremiumUserFixture(String nickname) { .isPremium(true) .build(); } -} + public static User createUserWithId(Long userId, String name, String nickname) { + return User.builder() + .userId(userId) + .name(name) + .nickname(nickname) + .statusMessage("Test status message") + .profileImage("test-profile-image.jpg") + .role("ROLE_USER") + .providerId("test-provider-id") + .socialType(SocialType.GOOGLE) // 또는 다른 소셜 타입 + .build(); + } +} \ No newline at end of file