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 @@ -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;
Expand Down Expand Up @@ -134,4 +136,16 @@ public ResponseEntity<NicknameValidationResponseDto> validateNickname(
) {
return ResponseEntity.ok(userService.validateNickname(nickname));
}

@GetMapping("/search")
public ResponseEntity<PageResponse<UserProfileResponseDto>> 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)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -14,4 +18,20 @@ public interface UserRepository extends JpaRepository<User, Long> {
Boolean existsByNickname(String nickname);

Optional<User> 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<User> searchUsersByCursor(
@Param("nickname") String nickname,
@Param("cursorId") Long cursorId,
Pageable pageable
);
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -87,4 +95,33 @@ public Long getUserIdByNickname(String nickname) {
).getUserId();
}

@Transactional(readOnly = true)
public PageResponse<UserProfileResponseDto> searchUsersByCursor(
String nickname,
Long cursorId,
int size
) {
Slice<User> userSlice = userRepository.searchUsersByCursor(
nickname,
cursorId != null ? cursorId : Long.MAX_VALUE,
PageRequest.of(0, size)
);

List<UserProfileResponseDto> 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
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,7 +37,10 @@ class UserServiceTest {

@Mock
private UserRepository userRepository;


@Mock
private FollowRepository followRepository;

@Test
@DisplayName("내 프로필을 생성하고 저장해야 한다.")
void shouldCreateAndSaveMyUserProfile() {
Expand Down Expand Up @@ -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<User> users = List.of(user1, user2, user3);
Slice<User> 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<UserProfileResponseDto> 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<User> emptyList = List.of();
Slice<User> 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<UserProfileResponseDto> 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());
}
}
14 changes: 13 additions & 1 deletion src/test/java/com/example/log4u/fixture/UserFixture.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}