Skip to content

Commit 3578fef

Browse files
committed
feat: 작성자 검색 기능 추가
1 parent 840f9b6 commit 3578fef

File tree

5 files changed

+195
-2
lines changed

5 files changed

+195
-2
lines changed

src/main/java/com/example/log4u/domain/user/controller/UserController.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
import org.springframework.web.bind.annotation.PutMapping;
1616
import org.springframework.web.bind.annotation.RequestBody;
1717
import org.springframework.web.bind.annotation.RequestMapping;
18+
import org.springframework.web.bind.annotation.RequestParam;
1819
import org.springframework.web.bind.annotation.RestController;
1920

2021
import com.example.log4u.common.constants.UrlConstants;
22+
import com.example.log4u.common.dto.PageResponse;
2123
import com.example.log4u.common.oauth2.dto.CustomOAuth2User;
2224
import com.example.log4u.domain.user.dto.NicknameValidationResponseDto;
2325
import com.example.log4u.domain.user.dto.UserProfileMakeRequestDto;
@@ -134,4 +136,16 @@ public ResponseEntity<NicknameValidationResponseDto> validateNickname(
134136
) {
135137
return ResponseEntity.ok(userService.validateNickname(nickname));
136138
}
139+
140+
@GetMapping("/search")
141+
public ResponseEntity<PageResponse<UserProfileResponseDto>> searchUsers(
142+
@AuthenticationPrincipal CustomOAuth2User customOAuth2User,
143+
@RequestParam(required = false) String nickname,
144+
@RequestParam(required = false) Long cursorId,
145+
@RequestParam(defaultValue = "12") int size
146+
) {
147+
return ResponseEntity.ok(
148+
userService.searchUsersByCursor(nickname, cursorId, size)
149+
);
150+
}
137151
}

src/main/java/com/example/log4u/domain/user/repository/UserRepository.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22

33
import java.util.Optional;
44

5+
import org.springframework.data.domain.Pageable;
6+
import org.springframework.data.domain.Slice;
57
import org.springframework.data.jpa.repository.JpaRepository;
8+
import org.springframework.data.jpa.repository.Query;
9+
import org.springframework.data.repository.query.Param;
610
import org.springframework.stereotype.Repository;
711

812
import com.example.log4u.domain.user.entity.User;
@@ -14,4 +18,20 @@ public interface UserRepository extends JpaRepository<User, Long> {
1418
Boolean existsByNickname(String nickname);
1519

1620
Optional<User> findByProviderId(String providerId);
21+
22+
@Query(value = """
23+
SELECT u FROM User u
24+
LEFT JOIN (SELECT f.targetId, COUNT(f) as followerCount
25+
FROM Follow f
26+
GROUP BY f.targetId) fc
27+
ON u.userId = fc.targetId
28+
WHERE (:nickname IS NULL OR LOWER(u.nickname) LIKE LOWER(CONCAT('%', :nickname, '%')))
29+
AND u.userId < :cursorId
30+
ORDER BY fc.followerCount DESC NULLS LAST, u.userId DESC
31+
""")
32+
Slice<User> searchUsersByCursor(
33+
@Param("nickname") String nickname,
34+
@Param("cursorId") Long cursorId,
35+
Pageable pageable
36+
);
1737
}

src/main/java/com/example/log4u/domain/user/service/UserService.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
package com.example.log4u.domain.user.service;
22

3+
import java.util.List;
4+
5+
import org.springframework.data.domain.PageRequest;
6+
import org.springframework.data.domain.Slice;
7+
import org.springframework.data.domain.SliceImpl;
38
import org.springframework.stereotype.Service;
49
import org.springframework.transaction.annotation.Transactional;
510

11+
import com.example.log4u.common.dto.PageResponse;
612
import com.example.log4u.domain.follow.repository.FollowRepository;
713
import com.example.log4u.domain.user.dto.NicknameValidationResponseDto;
814
import com.example.log4u.domain.user.dto.UserProfileMakeRequestDto;
@@ -13,7 +19,9 @@
1319
import com.example.log4u.domain.user.repository.UserRepository;
1420

1521
import lombok.RequiredArgsConstructor;
22+
import lombok.extern.slf4j.Slf4j;
1623

24+
@Slf4j
1725
@Service
1826
@RequiredArgsConstructor
1927
public class UserService {
@@ -87,4 +95,33 @@ public Long getUserIdByNickname(String nickname) {
8795
).getUserId();
8896
}
8997

98+
@Transactional(readOnly = true)
99+
public PageResponse<UserProfileResponseDto> searchUsersByCursor(
100+
String nickname,
101+
Long cursorId,
102+
int size
103+
) {
104+
Slice<User> userSlice = userRepository.searchUsersByCursor(
105+
nickname,
106+
cursorId != null ? cursorId : Long.MAX_VALUE,
107+
PageRequest.of(0, size)
108+
);
109+
110+
List<UserProfileResponseDto> content = userSlice.getContent().stream()
111+
.map(user -> {
112+
Long userId = user.getUserId();
113+
Long followers = followRepository.countByTargetId(userId);
114+
Long followings = followRepository.countByInitiatorId(userId);
115+
return UserProfileResponseDto.fromUser(user, followers, followings);
116+
})
117+
.toList();
118+
119+
// 다음 커서 ID 계산
120+
Long nextCursor = content.isEmpty() ? null : content.get(content.size() - 1).userId();
121+
122+
return PageResponse.of(
123+
new SliceImpl<>(content, userSlice.getPageable(), userSlice.hasNext()),
124+
nextCursor
125+
);
126+
}
90127
}

src/test/java/com/example/log4u/domain/user/service/UserServiceTest.java

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,25 @@
33
import static org.junit.jupiter.api.Assertions.*;
44
import static org.mockito.BDDMockito.*;
55

6+
import java.util.List;
67
import java.util.Optional;
78

89
import org.junit.jupiter.api.DisplayName;
910
import org.junit.jupiter.api.Test;
1011
import org.junit.jupiter.api.extension.ExtendWith;
12+
import org.mockito.ArgumentMatchers;
1113
import org.mockito.InjectMocks;
1214
import org.mockito.Mock;
1315
import org.mockito.junit.jupiter.MockitoExtension;
16+
import org.springframework.data.domain.PageRequest;
17+
import org.springframework.data.domain.Slice;
18+
import org.springframework.data.domain.SliceImpl;
1419

20+
import com.example.log4u.common.dto.PageResponse;
21+
import com.example.log4u.domain.follow.repository.FollowRepository;
1522
import com.example.log4u.domain.user.dto.NicknameValidationResponseDto;
1623
import com.example.log4u.domain.user.dto.UserProfileMakeRequestDto;
24+
import com.example.log4u.domain.user.dto.UserProfileResponseDto;
1725
import com.example.log4u.domain.user.dto.UserProfileUpdateRequestDto;
1826
import com.example.log4u.domain.user.entity.User;
1927
import com.example.log4u.domain.user.exception.UserNotFoundException;
@@ -29,7 +37,10 @@ class UserServiceTest {
2937

3038
@Mock
3139
private UserRepository userRepository;
32-
40+
41+
@Mock
42+
private FollowRepository followRepository;
43+
3344
@Test
3445
@DisplayName("내 프로필을 생성하고 저장해야 한다.")
3546
void shouldCreateAndSaveMyUserProfile() {
@@ -116,4 +127,103 @@ void shouldThrowExceptionWhenUserNotFound() {
116127
verify(userRepository).findById(userId);
117128
}
118129

130+
@Test
131+
@DisplayName("닉네임으로 유저를 검색하고 팔로워 수 높은 순으로 정렬해야 한다")
132+
void shouldSearchUsersByNicknameAndSortByFollowerCount() {
133+
// given
134+
String nickname = "test";
135+
Long cursorId = null;
136+
int size = 10;
137+
138+
// 테스트용 유저 목록 생성
139+
User user1 = UserFixture.createUserWithId(1L, "User1", "test1");
140+
User user2 = UserFixture.createUserWithId(2L, "User2", "test2");
141+
User user3 = UserFixture.createUserWithId(3L, "User3", "test3");
142+
143+
List<User> users = List.of(user1, user2, user3);
144+
Slice<User> userSlice = new SliceImpl<>(users, PageRequest.of(0, size), false);
145+
146+
// Mock 설정
147+
when(userRepository.searchUsersByCursor(
148+
eq(nickname),
149+
eq(Long.MAX_VALUE),
150+
ArgumentMatchers.any(PageRequest.class)))
151+
.thenReturn(userSlice);
152+
153+
// 각 유저의 팔로워/팔로잉 수 설정
154+
when(followRepository.countByTargetId(1L)).thenReturn(10L);
155+
when(followRepository.countByInitiatorId(1L)).thenReturn(5L);
156+
157+
when(followRepository.countByTargetId(2L)).thenReturn(20L);
158+
when(followRepository.countByInitiatorId(2L)).thenReturn(15L);
159+
160+
when(followRepository.countByTargetId(3L)).thenReturn(30L);
161+
when(followRepository.countByInitiatorId(3L)).thenReturn(25L);
162+
163+
// when
164+
PageResponse<UserProfileResponseDto> result = userService.searchUsersByCursor(nickname, cursorId, size);
165+
166+
// then
167+
assertNotNull(result);
168+
assertEquals(3, result.list().size());
169+
170+
// 팔로워 수 확인
171+
assertEquals(10L, result.list().get(0).followers());
172+
assertEquals(20L, result.list().get(1).followers());
173+
assertEquals(30L, result.list().get(2).followers());
174+
175+
// 팔로잉 수 확인
176+
assertEquals(5L, result.list().get(0).followings());
177+
assertEquals(15L, result.list().get(1).followings());
178+
assertEquals(25L, result.list().get(2).followings());
179+
180+
// 다음 커서 확인
181+
assertEquals(3L, result.pageInfo().nextCursor());
182+
183+
// 메서드 호출 확인
184+
verify(userRepository).searchUsersByCursor(
185+
eq(nickname),
186+
eq(Long.MAX_VALUE),
187+
ArgumentMatchers.any(PageRequest.class));
188+
189+
verify(followRepository, times(3)).countByTargetId(anyLong());
190+
verify(followRepository, times(3)).countByInitiatorId(anyLong());
191+
}
192+
193+
@Test
194+
@DisplayName("검색 결과가 없으면 빈 리스트를 반환해야 한다")
195+
void shouldReturnEmptyListWhenNoSearchResults() {
196+
// given
197+
String nickname = "nonexistent";
198+
Long cursorId = null;
199+
int size = 10;
200+
201+
List<User> emptyList = List.of();
202+
Slice<User> emptySlice = new SliceImpl<>(emptyList, PageRequest.of(0, size), false);
203+
204+
// Mock 설정
205+
when(userRepository.searchUsersByCursor(
206+
eq(nickname),
207+
eq(Long.MAX_VALUE),
208+
ArgumentMatchers.any(PageRequest.class)))
209+
.thenReturn(emptySlice);
210+
211+
// when
212+
PageResponse<UserProfileResponseDto> result = userService.searchUsersByCursor(nickname, cursorId, size);
213+
214+
// then
215+
assertNotNull(result);
216+
assertTrue(result.list().isEmpty());
217+
assertNull(result.pageInfo().nextCursor());
218+
219+
// 메서드 호출 확인
220+
verify(userRepository).searchUsersByCursor(
221+
eq(nickname),
222+
eq(Long.MAX_VALUE),
223+
ArgumentMatchers.any(PageRequest.class));
224+
225+
// 팔로워/팔로잉 수 조회 메서드가 호출되지 않아야 함
226+
verify(followRepository, never()).countByTargetId(anyLong());
227+
verify(followRepository, never()).countByInitiatorId(anyLong());
228+
}
119229
}

src/test/java/com/example/log4u/fixture/UserFixture.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,5 +60,17 @@ public static User createPremiumUserFixture(String nickname) {
6060
.isPremium(true)
6161
.build();
6262
}
63-
}
6463

64+
public static User createUserWithId(Long userId, String name, String nickname) {
65+
return User.builder()
66+
.userId(userId)
67+
.name(name)
68+
.nickname(nickname)
69+
.statusMessage("Test status message")
70+
.profileImage("test-profile-image.jpg")
71+
.role("ROLE_USER")
72+
.providerId("test-provider-id")
73+
.socialType(SocialType.GOOGLE) // 또는 다른 소셜 타입
74+
.build();
75+
}
76+
}

0 commit comments

Comments
 (0)