diff --git a/src/main/java/konkuk/thip/book/application/service/BookSearchService.java b/src/main/java/konkuk/thip/book/application/service/BookSearchService.java index fe1c7d31d..8e5b28b2a 100644 --- a/src/main/java/konkuk/thip/book/application/service/BookSearchService.java +++ b/src/main/java/konkuk/thip/book/application/service/BookSearchService.java @@ -72,7 +72,7 @@ public NaverBookParseResult searchBooks(String keyword, int page, Long userId) { .type(BOOK_SEARCH.getSearchType()) .userId(userId) .build(); - recentSearchCommandPort.save(userId,recentSearch); + recentSearchCommandPort.save(recentSearch); return result; } diff --git a/src/main/java/konkuk/thip/common/entity/BaseDomainEntity.java b/src/main/java/konkuk/thip/common/entity/BaseDomainEntity.java index fd0f1257b..fcad1c86c 100644 --- a/src/main/java/konkuk/thip/common/entity/BaseDomainEntity.java +++ b/src/main/java/konkuk/thip/common/entity/BaseDomainEntity.java @@ -1,6 +1,7 @@ package konkuk.thip.common.entity; import lombok.Getter; +import lombok.Setter; import lombok.experimental.SuperBuilder; import java.time.LocalDateTime; @@ -9,6 +10,7 @@ @SuperBuilder public class BaseDomainEntity { + @Setter private LocalDateTime createdAt; private LocalDateTime modifiedAt; diff --git a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java index d6b449b83..647673959 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -72,6 +72,7 @@ public enum ErrorCode implements ResponseCode { * 90000 : recentSearch error */ INVALID_SEARCH_TYPE(HttpStatus.BAD_REQUEST, 90000,"알맞은 검색어 타입을 찾을 수 없습니다."), + RECENT_SEARCH_NOT_FOUND(HttpStatus.NOT_FOUND, 90001, "존재하지 않는 RECENT SEARCH 입니다."), /** * 100000 : room error diff --git a/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java b/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java index 2dc99468b..ded60e9b6 100644 --- a/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java +++ b/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java @@ -23,6 +23,9 @@ public enum SwaggerResponseDescription { USER_SIGNUP(new LinkedHashSet<>(Set.of( ALIAS_NAME_NOT_MATCH ))), + USER_SEARCH(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND + ))), // Follow CHANGE_FOLLOW_STATE(new LinkedHashSet<>(Set.of( diff --git a/src/main/java/konkuk/thip/recentSearch/adapter/out/jpa/RecentSearchJpaEntity.java b/src/main/java/konkuk/thip/recentSearch/adapter/out/jpa/RecentSearchJpaEntity.java index 12fc1652f..5919d66bb 100644 --- a/src/main/java/konkuk/thip/recentSearch/adapter/out/jpa/RecentSearchJpaEntity.java +++ b/src/main/java/konkuk/thip/recentSearch/adapter/out/jpa/RecentSearchJpaEntity.java @@ -3,6 +3,7 @@ import jakarta.persistence.*; import konkuk.thip.common.entity.BaseJpaEntity; +import konkuk.thip.recentSearch.domain.RecentSearch; import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import lombok.*; @@ -29,4 +30,10 @@ public class RecentSearchJpaEntity extends BaseJpaEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") private UserJpaEntity userJpaEntity; + + public void updateFrom(RecentSearch recentSearch) { + this.searchTerm = recentSearch.getSearchTerm(); + this.type = SearchType.from(recentSearch.getType()); + this.setCreatedAt(recentSearch.getCreatedAt()); + } } \ No newline at end of file diff --git a/src/main/java/konkuk/thip/recentSearch/adapter/out/jpa/SearchType.java b/src/main/java/konkuk/thip/recentSearch/adapter/out/jpa/SearchType.java index d019edb8a..4f01d8105 100644 --- a/src/main/java/konkuk/thip/recentSearch/adapter/out/jpa/SearchType.java +++ b/src/main/java/konkuk/thip/recentSearch/adapter/out/jpa/SearchType.java @@ -10,7 +10,7 @@ public enum SearchType { USER_SEARCH("사용자 검색"), - BOOK_SEARCH("책_검색"); + BOOK_SEARCH("책 검색"); private final String searchType; diff --git a/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/RecentSearchCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/RecentSearchCommandPersistenceAdapter.java index baad78256..2fb2664cc 100644 --- a/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/RecentSearchCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/RecentSearchCommandPersistenceAdapter.java @@ -11,6 +11,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import static konkuk.thip.common.exception.code.ErrorCode.RECENT_SEARCH_NOT_FOUND; import static konkuk.thip.common.exception.code.ErrorCode.USER_NOT_FOUND; @Repository @@ -23,9 +24,9 @@ public class RecentSearchCommandPersistenceAdapter implements RecentSearchComman private final RecentSearchMapper recentSearchMapper; @Override - public void save(Long userId,RecentSearch recentSearch) { + public void save(RecentSearch recentSearch) { - UserJpaEntity userJpaEntity = userJpaRepository.findById(userId) + UserJpaEntity userJpaEntity = userJpaRepository.findById(recentSearch.getUserId()) .orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND)); RecentSearchJpaEntity recentSearchJpaEntity = @@ -33,4 +34,21 @@ public void save(Long userId,RecentSearch recentSearch) { recentSearchJpaRepository.save(recentSearchJpaEntity); } + + @Override + public void delete(Long id) { + recentSearchJpaRepository.delete( + recentSearchJpaRepository.findById(id) + .orElseThrow(() -> new EntityNotFoundException(RECENT_SEARCH_NOT_FOUND)) + ); + } + + @Override + public void update(RecentSearch recentSearch) { + RecentSearchJpaEntity recentSearchJpaEntity = recentSearchJpaRepository.findById(recentSearch.getId()) + .orElseThrow(() -> new EntityNotFoundException(RECENT_SEARCH_NOT_FOUND)); + + recentSearchJpaEntity.updateFrom(recentSearch); + recentSearchJpaRepository.save(recentSearchJpaEntity); + } } diff --git a/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/RecentSearchQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/RecentSearchQueryPersistenceAdapter.java index b1315c552..2f9426342 100644 --- a/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/RecentSearchQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/RecentSearchQueryPersistenceAdapter.java @@ -3,9 +3,14 @@ import konkuk.thip.recentSearch.adapter.out.mapper.RecentSearchMapper; import konkuk.thip.recentSearch.adapter.out.persistence.repository.RecentSearchJpaRepository; import konkuk.thip.recentSearch.application.port.out.RecentSearchQueryPort; +import konkuk.thip.recentSearch.domain.RecentSearch; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.Optional; + +import static konkuk.thip.recentSearch.adapter.out.jpa.SearchType.USER_SEARCH; + @Repository @RequiredArgsConstructor public class RecentSearchQueryPersistenceAdapter implements RecentSearchQueryPort { @@ -13,4 +18,9 @@ public class RecentSearchQueryPersistenceAdapter implements RecentSearchQueryPor private final RecentSearchJpaRepository recentSearchJpaRepository; private final RecentSearchMapper recentSearchMapper; + @Override + public Optional findRecentSearchByKeywordAndUserId(String keyword, Long userId) { + return recentSearchJpaRepository.findBySearchTermAndTypeAndUserId(keyword, USER_SEARCH, userId) + .map(recentSearchMapper::toDomainEntity); + } } diff --git a/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/repository/RecentSearchJpaRepository.java b/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/repository/RecentSearchJpaRepository.java index 8c897df77..3e464b006 100644 --- a/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/repository/RecentSearchJpaRepository.java +++ b/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/repository/RecentSearchJpaRepository.java @@ -2,6 +2,8 @@ import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; -public interface RecentSearchJpaRepository extends JpaRepository { +@Repository +public interface RecentSearchJpaRepository extends JpaRepository, RecentSearchQueryRepository { } diff --git a/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/repository/RecentSearchQueryRepository.java b/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/repository/RecentSearchQueryRepository.java new file mode 100644 index 000000000..a764ef81d --- /dev/null +++ b/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/repository/RecentSearchQueryRepository.java @@ -0,0 +1,10 @@ +package konkuk.thip.recentSearch.adapter.out.persistence.repository; + +import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchJpaEntity; +import konkuk.thip.recentSearch.adapter.out.jpa.SearchType; + +import java.util.Optional; + +public interface RecentSearchQueryRepository { + Optional findBySearchTermAndTypeAndUserId(String searchTerm, SearchType type, Long userId); +} diff --git a/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/repository/RecentSearchQueryRepositoryImpl.java b/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/repository/RecentSearchQueryRepositoryImpl.java new file mode 100644 index 000000000..f5c0ed5ea --- /dev/null +++ b/src/main/java/konkuk/thip/recentSearch/adapter/out/persistence/repository/RecentSearchQueryRepositoryImpl.java @@ -0,0 +1,32 @@ +package konkuk.thip.recentSearch.adapter.out.persistence.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchJpaEntity; +import konkuk.thip.recentSearch.adapter.out.jpa.SearchType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +import static konkuk.thip.recentSearch.adapter.out.jpa.QRecentSearchJpaEntity.recentSearchJpaEntity; + +@Repository +@RequiredArgsConstructor +public class RecentSearchQueryRepositoryImpl implements RecentSearchQueryRepository { + + private final JPAQueryFactory queryFactory; + + @Override + public Optional findBySearchTermAndTypeAndUserId(String searchTerm, SearchType type, Long userId) { + RecentSearchJpaEntity result = queryFactory + .selectFrom(recentSearchJpaEntity) + .where( + recentSearchJpaEntity.searchTerm.eq(searchTerm), + recentSearchJpaEntity.type.eq(type), + recentSearchJpaEntity.userJpaEntity.userId.eq(userId) + ) + .fetchOne(); + + return Optional.ofNullable(result); + } +} diff --git a/src/main/java/konkuk/thip/recentSearch/application/port/out/RecentSearchCommandPort.java b/src/main/java/konkuk/thip/recentSearch/application/port/out/RecentSearchCommandPort.java index 3046ce967..ddb7a19fb 100644 --- a/src/main/java/konkuk/thip/recentSearch/application/port/out/RecentSearchCommandPort.java +++ b/src/main/java/konkuk/thip/recentSearch/application/port/out/RecentSearchCommandPort.java @@ -4,5 +4,8 @@ import konkuk.thip.recentSearch.domain.RecentSearch; public interface RecentSearchCommandPort { - void save(Long userId, RecentSearch recentSearch); + void save(RecentSearch recentSearch); + void delete(Long id); + + void update(RecentSearch recentSearch); } diff --git a/src/main/java/konkuk/thip/recentSearch/application/port/out/RecentSearchQueryPort.java b/src/main/java/konkuk/thip/recentSearch/application/port/out/RecentSearchQueryPort.java index f445f2f42..1a15b29f9 100644 --- a/src/main/java/konkuk/thip/recentSearch/application/port/out/RecentSearchQueryPort.java +++ b/src/main/java/konkuk/thip/recentSearch/application/port/out/RecentSearchQueryPort.java @@ -1,5 +1,11 @@ package konkuk.thip.recentSearch.application.port.out; +import konkuk.thip.recentSearch.domain.RecentSearch; + +import java.util.Optional; + public interface RecentSearchQueryPort { + Optional findRecentSearchByKeywordAndUserId(String keyword, Long userId); + } diff --git a/src/main/java/konkuk/thip/recentSearch/application/service/manager/RecentSearchCreateManager.java b/src/main/java/konkuk/thip/recentSearch/application/service/manager/RecentSearchCreateManager.java new file mode 100644 index 000000000..1a0ad1994 --- /dev/null +++ b/src/main/java/konkuk/thip/recentSearch/application/service/manager/RecentSearchCreateManager.java @@ -0,0 +1,37 @@ +package konkuk.thip.recentSearch.application.service.manager; + +import konkuk.thip.recentSearch.application.port.out.RecentSearchCommandPort; +import konkuk.thip.recentSearch.application.port.out.RecentSearchQueryPort; +import konkuk.thip.recentSearch.domain.RecentSearch; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class RecentSearchCreateManager { + + private static final String USER_SEARCH_TERM = "사용자 검색"; + + private final RecentSearchCommandPort recentSearchCommandPort; + private final RecentSearchQueryPort recentSearchQueryPort; + + public void saveRecentSearchByUser(Long userId, String keyword) { + + // 동일 조건 (userId + keyword + type) 검색 기록이 이미 있는지 확인 + recentSearchQueryPort.findRecentSearchByKeywordAndUserId(keyword, userId) + .ifPresentOrElse( + existingRecentSearch -> { + // 이미 존재하면 createdAt만 갱신 + existingRecentSearch.updateCreatedAt(LocalDateTime.now()); + recentSearchCommandPort.update(existingRecentSearch); + }, + () -> { + // 없으면 새로 저장 + RecentSearch userRecentSearch = RecentSearch.withoutId(keyword, USER_SEARCH_TERM, userId); + recentSearchCommandPort.save(userRecentSearch); + } + ); + } +} diff --git a/src/main/java/konkuk/thip/recentSearch/domain/RecentSearch.java b/src/main/java/konkuk/thip/recentSearch/domain/RecentSearch.java index 42ecfc5d3..068718a78 100644 --- a/src/main/java/konkuk/thip/recentSearch/domain/RecentSearch.java +++ b/src/main/java/konkuk/thip/recentSearch/domain/RecentSearch.java @@ -4,6 +4,8 @@ import lombok.Getter; import lombok.experimental.SuperBuilder; +import java.time.LocalDateTime; + @Getter @SuperBuilder public class RecentSearch extends BaseDomainEntity { @@ -15,4 +17,16 @@ public class RecentSearch extends BaseDomainEntity { private String type; private Long userId; + + public static RecentSearch withoutId(String searchTerm, String type, Long userId) { + return RecentSearch.builder() + .searchTerm(searchTerm) + .type(type) + .userId(userId) + .build(); + } + + public void updateCreatedAt(LocalDateTime localDateTime) { + this.setCreatedAt(localDateTime); + } } diff --git a/src/main/java/konkuk/thip/user/adapter/in/web/UserQueryController.java b/src/main/java/konkuk/thip/user/adapter/in/web/UserQueryController.java index 490538119..1827cbff9 100644 --- a/src/main/java/konkuk/thip/user/adapter/in/web/UserQueryController.java +++ b/src/main/java/konkuk/thip/user/adapter/in/web/UserQueryController.java @@ -6,8 +6,10 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; +import konkuk.thip.user.adapter.in.web.response.*; import konkuk.thip.common.swagger.annotation.ExceptionDescription; import konkuk.thip.user.adapter.in.web.request.UserVerifyNicknameRequest; import konkuk.thip.user.adapter.in.web.response.UserFollowersResponse; @@ -18,13 +20,22 @@ import konkuk.thip.user.application.port.in.UserGetFollowUsecase; import konkuk.thip.user.application.port.in.UserVerifyNicknameUseCase; import konkuk.thip.user.application.port.in.UserIsFollowingUsecase; +import konkuk.thip.user.application.port.in.UserSearchUsecase; import konkuk.thip.user.application.port.in.UserViewAliasChoiceUseCase; +import konkuk.thip.user.application.port.in.dto.UserSearchQuery; import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.*; import static konkuk.thip.common.swagger.SwaggerResponseDescription.GET_USER_FOLLOW; +import static konkuk.thip.common.swagger.SwaggerResponseDescription.USER_SEARCH; @Tag(name = "User Query API", description = "사용자가 주체가 되는 조회") +@Validated @RestController @RequiredArgsConstructor public class UserQueryController { @@ -33,6 +44,7 @@ public class UserQueryController { private final UserGetFollowUsecase userGetFollowUsecase; private final UserIsFollowingUsecase userIsFollowingUsecase; private final UserVerifyNicknameUseCase userVerifyNicknameUseCase; + private final UserSearchUsecase userSearchUsecase; @Operation( summary = "닉네임 중복 확인", @@ -94,4 +106,18 @@ public BaseResponse checkIsFollowing( @Parameter(description = "팔로우 여부를 확인할 대상 사용자 ID") @PathVariable final Long targetUserId) { return BaseResponse.ok(UserIsFollowingResponse.of(userIsFollowingUsecase.isFollowing(userId, targetUserId))); } + + + @Operation( + summary = "사용자 검색", + description = "닉네임을 기준으로 사용자를 검색합니다. 정확도순 정렬을 지원합니다." + ) + @ExceptionDescription(USER_SEARCH) + @GetMapping("/users") + public BaseResponse showSearchUsers( + @Parameter(description = "검색어", example = "thip") @RequestParam @NotBlank(message = "검색어는 필수입니다.") final String keyword, + @Parameter(description = "단일 검색 결과 페이지 크기 (1~30) / default : 30", example = "30") @RequestParam(required = false, defaultValue = "30") @Min(1) @Max(30) final Integer size, + @Parameter(hidden = true) @UserId final Long userId) { + return BaseResponse.ok(userSearchUsecase.searchUsers(UserSearchQuery.of(keyword, userId, size))); + } } diff --git a/src/main/java/konkuk/thip/user/adapter/in/web/response/UserFollowersResponse.java b/src/main/java/konkuk/thip/user/adapter/in/web/response/UserFollowersResponse.java index 32d5c907a..9326a3a56 100644 --- a/src/main/java/konkuk/thip/user/adapter/in/web/response/UserFollowersResponse.java +++ b/src/main/java/konkuk/thip/user/adapter/in/web/response/UserFollowersResponse.java @@ -6,16 +6,17 @@ @Builder public record UserFollowersResponse( - List followers, + List followers, String nextCursor, boolean isLast ) { @Builder - public record Follower( + public record FollowerDto( Long userId, String nickname, String profileImageUrl, String aliasName, + String aliasColor, Integer followerCount ){ diff --git a/src/main/java/konkuk/thip/user/adapter/in/web/response/UserFollowingResponse.java b/src/main/java/konkuk/thip/user/adapter/in/web/response/UserFollowingResponse.java index a099bb173..c558984b4 100644 --- a/src/main/java/konkuk/thip/user/adapter/in/web/response/UserFollowingResponse.java +++ b/src/main/java/konkuk/thip/user/adapter/in/web/response/UserFollowingResponse.java @@ -6,16 +6,17 @@ @Builder public record UserFollowingResponse( - List followings, + List followings, String nextCursor, boolean isLast ) { @Builder - public record Following( + public record FollowingDto( Long userId, String nickname, String profileImageUrl, - String aliasName + String aliasName, + String aliasColor ){ } diff --git a/src/main/java/konkuk/thip/user/adapter/in/web/response/UserSearchResponse.java b/src/main/java/konkuk/thip/user/adapter/in/web/response/UserSearchResponse.java new file mode 100644 index 000000000..f43958bf5 --- /dev/null +++ b/src/main/java/konkuk/thip/user/adapter/in/web/response/UserSearchResponse.java @@ -0,0 +1,21 @@ +package konkuk.thip.user.adapter.in.web.response; + +import java.util.List; + +public record UserSearchResponse( + List userList +) { + public record UserDto( + Long userId, + String nickname, + String profileImageUrl, + String aliasName, + String aliasColor, + Integer followerCount + ) { + } + + public static UserSearchResponse of(List userList) { + return new UserSearchResponse(userList); + } +} diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryPersistenceAdapter.java index 271528411..e7e9bfe1b 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryPersistenceAdapter.java @@ -2,7 +2,7 @@ import konkuk.thip.common.util.CursorBasedList; import konkuk.thip.common.util.DateUtil; -import konkuk.thip.user.application.port.out.dto.FollowQueryDto; +import konkuk.thip.user.application.port.out.dto.UserQueryDto; import konkuk.thip.user.adapter.out.persistence.repository.following.FollowingJpaRepository; import konkuk.thip.user.application.port.out.FollowingQueryPort; import lombok.RequiredArgsConstructor; @@ -18,9 +18,9 @@ public class FollowingQueryPersistenceAdapter implements FollowingQueryPort { private final FollowingJpaRepository followingJpaRepository; @Override - public CursorBasedList getFollowersByUserId(Long userId, String cursor, int size) { + public CursorBasedList getFollowersByUserId(Long userId, String cursor, int size) { LocalDateTime cursorVal = cursor != null && !cursor.isBlank() ? DateUtil.parseDateTime(cursor) : null; - List followerDtos = followingJpaRepository.findFollowerDtosByUserIdBeforeCreatedAt( + List followerDtos = followingJpaRepository.findFollowerDtosByUserIdBeforeCreatedAt( userId, cursorVal, size @@ -30,9 +30,9 @@ public CursorBasedList getFollowersByUserId(Long userId, String } @Override - public CursorBasedList getFollowingByUserId(Long userId, String cursor, int size) { + public CursorBasedList getFollowingByUserId(Long userId, String cursor, int size) { LocalDateTime cursorVal = cursor != null && !cursor.isBlank() ? DateUtil.parseDateTime(cursor) : null; - List followingDtos = followingJpaRepository.findFollowingDtosByUserIdBeforeCreatedAt( + List followingDtos = followingJpaRepository.findFollowingDtosByUserIdBeforeCreatedAt( userId, cursorVal, size diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/UserQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/UserQueryPersistenceAdapter.java index d05f06384..7697a9e06 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/UserQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/UserQueryPersistenceAdapter.java @@ -1,13 +1,14 @@ package konkuk.thip.user.adapter.out.persistence; -import konkuk.thip.user.adapter.out.mapper.UserMapper; -import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; import konkuk.thip.user.application.port.in.dto.UserViewAliasChoiceResult; import konkuk.thip.user.application.port.out.UserQueryPort; +import konkuk.thip.user.application.port.out.dto.UserQueryDto; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Set; @Repository @@ -16,7 +17,6 @@ public class UserQueryPersistenceAdapter implements UserQueryPort { private final UserJpaRepository userJpaRepository; private final AliasJpaRepository aliasJpaRepository; - private final UserMapper userMapper; @Override public boolean existsByNickname(String nickname) { @@ -32,4 +32,9 @@ public Set findUserIdsParticipatedInRoomsByBookId(Long bookId) { public UserViewAliasChoiceResult getAllAliasesAndCategories() { return aliasJpaRepository.getAllAliasesAndCategories(); } + + @Override + public List findUsersByNicknameOrderByAccuracy(String keyword, Long userId, Integer size) { + return userJpaRepository.findUsersByNicknameOrderByAccuracy(keyword, userId, size); + } } \ No newline at end of file diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/UserQueryRepository.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/UserQueryRepository.java index 9786a6bf8..4d0ad5ca9 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/UserQueryRepository.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/UserQueryRepository.java @@ -1,7 +1,13 @@ package konkuk.thip.user.adapter.out.persistence.repository; +import konkuk.thip.user.application.port.out.dto.UserQueryDto; + +import java.util.List; import java.util.Set; public interface UserQueryRepository { Set findUserIdsByBookId(Long bookId); + + List findUsersByNicknameOrderByAccuracy(String keyword, Long userId, Integer size); + } \ No newline at end of file diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/UserQueryRepositoryImpl.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/UserQueryRepositoryImpl.java index 916743cca..4afd08233 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/UserQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/UserQueryRepositoryImpl.java @@ -1,19 +1,27 @@ package konkuk.thip.user.adapter.out.persistence.repository; +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.NumberExpression; import com.querydsl.jpa.impl.JPAQueryFactory; +import konkuk.thip.common.entity.StatusType; import konkuk.thip.room.adapter.out.jpa.QRoomJpaEntity; import konkuk.thip.room.adapter.out.jpa.QRoomParticipantJpaEntity; +import konkuk.thip.user.adapter.out.jpa.QAliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.QUserJpaEntity; +import konkuk.thip.user.application.port.out.dto.QUserQueryDto; +import konkuk.thip.user.application.port.out.dto.UserQueryDto; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.util.HashSet; +import java.util.List; import java.util.Set; @Repository @RequiredArgsConstructor public class UserQueryRepositoryImpl implements UserQueryRepository { - private final JPAQueryFactory jpaQueryFactory; + private final JPAQueryFactory queryFactory; @Override public Set findUserIdsByBookId(Long bookId) { @@ -21,7 +29,7 @@ public Set findUserIdsByBookId(Long bookId) { QRoomJpaEntity room = QRoomJpaEntity.roomJpaEntity; return new HashSet<>( - jpaQueryFactory + queryFactory .select(userRoom.userJpaEntity.userId) .distinct() .from(userRoom) @@ -30,4 +38,39 @@ public Set findUserIdsByBookId(Long bookId) { .fetch() ); } + + @Override + public List findUsersByNicknameOrderByAccuracy(String keyword, Long userId, Integer size) { + QUserJpaEntity user = QUserJpaEntity.userJpaEntity; + QAliasJpaEntity alias = QAliasJpaEntity.aliasJpaEntity; + + String pattern = "%" + keyword + "%"; + + NumberExpression priority = new CaseBuilder() + .when(user.nickname.eq(keyword)).then(3) + .when(user.nickname.like(keyword + "%")).then(2) + .when(user.nickname.like(pattern)).then(1) + .otherwise(0); + + return queryFactory + .select(new QUserQueryDto( + user.userId, + user.nickname, + alias.imageUrl, + alias.value, + alias.color, + user.followerCount, + user.createdAt + )) + .from(user) + .leftJoin(user.aliasForUserJpaEntity, alias) + .where(user.nickname.like(pattern) + .and(user.userId.ne(userId)) + .and(user.status.eq(StatusType.ACTIVE))) + .orderBy(priority.desc(), user.nickname.asc()) + .limit(size) + .fetch(); + } + + } diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepository.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepository.java index fac66996c..5bdc3d477 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepository.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepository.java @@ -1,7 +1,7 @@ package konkuk.thip.user.adapter.out.persistence.repository.following; import konkuk.thip.user.adapter.out.jpa.FollowingJpaEntity; -import konkuk.thip.user.application.port.out.dto.FollowQueryDto; +import konkuk.thip.user.application.port.out.dto.UserQueryDto; import java.time.LocalDateTime; import java.util.List; @@ -10,6 +10,6 @@ public interface FollowingQueryRepository { Optional findByUserAndTargetUser(Long userId, Long targetUserId); - List findFollowerDtosByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size); - List findFollowingDtosByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size); + List findFollowerDtosByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size); + List findFollowingDtosByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size); } diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepositoryImpl.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepositoryImpl.java index dae546e69..ea79cf601 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepositoryImpl.java @@ -7,8 +7,8 @@ import konkuk.thip.user.adapter.out.jpa.QAliasJpaEntity; import konkuk.thip.user.adapter.out.jpa.QFollowingJpaEntity; import konkuk.thip.user.adapter.out.jpa.QUserJpaEntity; -import konkuk.thip.user.application.port.out.dto.FollowQueryDto; -import konkuk.thip.user.application.port.out.dto.QFollowQueryDto; +import konkuk.thip.user.application.port.out.dto.QUserQueryDto; +import konkuk.thip.user.application.port.out.dto.UserQueryDto; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -37,7 +37,7 @@ public Optional findByUserAndTargetUser(Long userId, Long ta } @Override - public List findFollowerDtosByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size) { + public List findFollowerDtosByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size) { return findFollowDtos( userId, cursor, @@ -47,7 +47,7 @@ public List findFollowerDtosByUserIdBeforeCreatedAt(Long userId, } @Override - public List findFollowingDtosByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size) { + public List findFollowingDtosByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size) { return findFollowDtos( userId, cursor, @@ -56,7 +56,7 @@ public List findFollowingDtosByUserIdBeforeCreatedAt(Long userId ); } - private List findFollowDtos(Long userId, LocalDateTime cursor, int size, boolean isFollowerQuery) { + private List findFollowDtos(Long userId, LocalDateTime cursor, int size, boolean isFollowerQuery) { QFollowingJpaEntity following = QFollowingJpaEntity.followingJpaEntity; QUserJpaEntity user = QUserJpaEntity.userJpaEntity; QAliasJpaEntity alias = QAliasJpaEntity.aliasJpaEntity; @@ -72,11 +72,12 @@ private List findFollowDtos(Long userId, LocalDateTime cursor, i QUserJpaEntity targetUser = isFollowerQuery ? following.userJpaEntity : following.followingUserJpaEntity; return jpaQueryFactory - .select(new QFollowQueryDto( + .select(new QUserQueryDto( targetUser.userId, targetUser.nickname, alias.imageUrl, alias.value, + alias.color, targetUser.followerCount, following.createdAt )) diff --git a/src/main/java/konkuk/thip/user/application/mapper/FollowQueryMapper.java b/src/main/java/konkuk/thip/user/application/mapper/FollowQueryMapper.java index ce01eea4d..71f011d2a 100644 --- a/src/main/java/konkuk/thip/user/application/mapper/FollowQueryMapper.java +++ b/src/main/java/konkuk/thip/user/application/mapper/FollowQueryMapper.java @@ -2,13 +2,13 @@ import konkuk.thip.user.adapter.in.web.response.UserFollowersResponse; import konkuk.thip.user.adapter.in.web.response.UserFollowingResponse; -import konkuk.thip.user.application.port.out.dto.FollowQueryDto; +import konkuk.thip.user.application.port.out.dto.UserQueryDto; import org.mapstruct.Mapper; @Mapper(componentModel = "spring") public interface FollowQueryMapper { - UserFollowersResponse.Follower toFollowerList(FollowQueryDto dto); + UserFollowersResponse.FollowerDto toFollowerDto(UserQueryDto dto); - UserFollowingResponse.Following toFollowingList(FollowQueryDto dto); + UserFollowingResponse.FollowingDto toFollowingDto(UserQueryDto dto); } \ No newline at end of file diff --git a/src/main/java/konkuk/thip/user/application/mapper/UserQueryMapper.java b/src/main/java/konkuk/thip/user/application/mapper/UserQueryMapper.java new file mode 100644 index 000000000..24085b78f --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/mapper/UserQueryMapper.java @@ -0,0 +1,15 @@ +package konkuk.thip.user.application.mapper; + +import konkuk.thip.user.adapter.in.web.response.UserSearchResponse; +import konkuk.thip.user.application.port.out.dto.UserQueryDto; +import org.mapstruct.Mapper; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface UserQueryMapper { + + // List -> List + List toUserDtoList(List userQueryDtos); + +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/user/application/port/in/UserSearchUsecase.java b/src/main/java/konkuk/thip/user/application/port/in/UserSearchUsecase.java new file mode 100644 index 000000000..c55145d84 --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/port/in/UserSearchUsecase.java @@ -0,0 +1,8 @@ +package konkuk.thip.user.application.port.in; + +import konkuk.thip.user.adapter.in.web.response.UserSearchResponse; +import konkuk.thip.user.application.port.in.dto.UserSearchQuery; + +public interface UserSearchUsecase { + UserSearchResponse searchUsers(UserSearchQuery userSearchQuery); +} diff --git a/src/main/java/konkuk/thip/user/application/port/in/dto/UserSearchQuery.java b/src/main/java/konkuk/thip/user/application/port/in/dto/UserSearchQuery.java new file mode 100644 index 000000000..17806ef8c --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/port/in/dto/UserSearchQuery.java @@ -0,0 +1,11 @@ +package konkuk.thip.user.application.port.in.dto; + +public record UserSearchQuery( + String keyword, + Long userId, + Integer size +) { + public static UserSearchQuery of(String keyword, Long userId, Integer size) { + return new UserSearchQuery(keyword, userId, size); + } +} diff --git a/src/main/java/konkuk/thip/user/application/port/out/FollowingQueryPort.java b/src/main/java/konkuk/thip/user/application/port/out/FollowingQueryPort.java index 4b3651b98..817b552ad 100644 --- a/src/main/java/konkuk/thip/user/application/port/out/FollowingQueryPort.java +++ b/src/main/java/konkuk/thip/user/application/port/out/FollowingQueryPort.java @@ -1,10 +1,10 @@ package konkuk.thip.user.application.port.out; import konkuk.thip.common.util.CursorBasedList; -import konkuk.thip.user.application.port.out.dto.FollowQueryDto; +import konkuk.thip.user.application.port.out.dto.UserQueryDto; public interface FollowingQueryPort { - CursorBasedList getFollowersByUserId(Long userId, String cursor, int size); - CursorBasedList getFollowingByUserId(Long userId, String cursor, int size); + CursorBasedList getFollowersByUserId(Long userId, String cursor, int size); + CursorBasedList getFollowingByUserId(Long userId, String cursor, int size); } diff --git a/src/main/java/konkuk/thip/user/application/port/out/UserQueryPort.java b/src/main/java/konkuk/thip/user/application/port/out/UserQueryPort.java index 5b71774eb..2026b288f 100644 --- a/src/main/java/konkuk/thip/user/application/port/out/UserQueryPort.java +++ b/src/main/java/konkuk/thip/user/application/port/out/UserQueryPort.java @@ -1,7 +1,9 @@ package konkuk.thip.user.application.port.out; import konkuk.thip.user.application.port.in.dto.UserViewAliasChoiceResult; +import konkuk.thip.user.application.port.out.dto.UserQueryDto; +import java.util.List; import java.util.Set; public interface UserQueryPort { @@ -9,4 +11,6 @@ public interface UserQueryPort { Set findUserIdsParticipatedInRoomsByBookId(Long bookId); UserViewAliasChoiceResult getAllAliasesAndCategories(); -} + + List findUsersByNicknameOrderByAccuracy(String keyword, Long userId, Integer size); +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/user/application/port/out/dto/FollowQueryDto.java b/src/main/java/konkuk/thip/user/application/port/out/dto/UserQueryDto.java similarity index 61% rename from src/main/java/konkuk/thip/user/application/port/out/dto/FollowQueryDto.java rename to src/main/java/konkuk/thip/user/application/port/out/dto/UserQueryDto.java index 0b88f7d54..9bf3c82ac 100644 --- a/src/main/java/konkuk/thip/user/application/port/out/dto/FollowQueryDto.java +++ b/src/main/java/konkuk/thip/user/application/port/out/dto/UserQueryDto.java @@ -5,19 +5,21 @@ import java.time.LocalDateTime; -public record FollowQueryDto(Long userId, - String nickname, - String profileImageUrl, - String aliasName, - Integer followerCount, - LocalDateTime createdAt) { +public record UserQueryDto(Long userId, + String nickname, + String profileImageUrl, + String aliasName, + String aliasColor, + Integer followerCount, + LocalDateTime createdAt) { @QueryProjection - public FollowQueryDto { + public UserQueryDto { Assert.notNull(userId, "userId must not be null"); Assert.notNull(nickname, "nickname must not be null"); Assert.notNull(profileImageUrl, "profileImageUrl must not be null"); Assert.notNull(aliasName, "aliasName must not be null"); + Assert.notNull(aliasColor, "aliasColor must not be null"); // Assert.notNull(followerCount, "followerCount must not be null"); // 내 팔로잉 목록 조회에서는 필요 x Assert.notNull(createdAt, "createdAt must not be null"); } diff --git a/src/main/java/konkuk/thip/user/application/service/UserSearchService.java b/src/main/java/konkuk/thip/user/application/service/UserSearchService.java new file mode 100644 index 000000000..f93335e7f --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/service/UserSearchService.java @@ -0,0 +1,36 @@ +package konkuk.thip.user.application.service; + +import konkuk.thip.recentSearch.application.service.manager.RecentSearchCreateManager; +import konkuk.thip.user.adapter.in.web.response.UserSearchResponse; +import konkuk.thip.user.application.mapper.UserQueryMapper; +import konkuk.thip.user.application.port.in.UserSearchUsecase; +import konkuk.thip.user.application.port.in.dto.UserSearchQuery; +import konkuk.thip.user.application.port.out.UserQueryPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UserSearchService implements UserSearchUsecase { + + private final UserQueryPort userQueryPort; + private final UserQueryMapper userQueryMapper; + + private final RecentSearchCreateManager recentSearchCreateManager; + + @Override + @Transactional // <- 최근 검색 저장으로 인한 트랜잭션 + public UserSearchResponse searchUsers(UserSearchQuery userSearchQuery) { + var userDtoList = userQueryMapper.toUserDtoList(userQueryPort.findUsersByNicknameOrderByAccuracy( + userSearchQuery.keyword().toLowerCase(), + userSearchQuery.userId(), + userSearchQuery.size() + )); + + // 최근 검색어 저장 + recentSearchCreateManager.saveRecentSearchByUser(userSearchQuery.userId(), userSearchQuery.keyword()); + + return UserSearchResponse.of(userDtoList); + } +} diff --git a/src/main/java/konkuk/thip/user/application/service/following/UserGetFollowService.java b/src/main/java/konkuk/thip/user/application/service/following/UserGetFollowService.java index 3991df5d4..29789f126 100644 --- a/src/main/java/konkuk/thip/user/application/service/following/UserGetFollowService.java +++ b/src/main/java/konkuk/thip/user/application/service/following/UserGetFollowService.java @@ -4,7 +4,7 @@ import konkuk.thip.user.adapter.in.web.response.UserFollowersResponse; import konkuk.thip.user.adapter.in.web.response.UserFollowingResponse; import konkuk.thip.user.application.mapper.FollowQueryMapper; -import konkuk.thip.user.application.port.out.dto.FollowQueryDto; +import konkuk.thip.user.application.port.out.dto.UserQueryDto; import konkuk.thip.user.application.port.in.UserGetFollowUsecase; import konkuk.thip.user.application.port.out.FollowingQueryPort; import konkuk.thip.user.application.port.out.UserCommandPort; @@ -29,12 +29,12 @@ public class UserGetFollowService implements UserGetFollowUsecase { public UserFollowersResponse getUserFollowers(Long userId, String cursor, int size) { User user = userCommandPort.findById(userId); - CursorBasedList result = followingQueryPort.getFollowersByUserId( + CursorBasedList result = followingQueryPort.getFollowersByUserId( user.getId(), cursor, Math.min(size, MAX_PAGE_SIZE) ); var followers = result.contents().stream() - .map(followQueryMapper::toFollowerList) + .map(followQueryMapper::toFollowerDto) .toList(); return UserFollowersResponse.builder() @@ -49,12 +49,12 @@ public UserFollowersResponse getUserFollowers(Long userId, String cursor, int si public UserFollowingResponse getMyFollowing(Long userId, String cursor, int size) { User user = userCommandPort.findById(userId); - CursorBasedList result = followingQueryPort.getFollowingByUserId( + CursorBasedList result = followingQueryPort.getFollowingByUserId( user.getId(), cursor, Math.min(size, MAX_PAGE_SIZE) ); var following = result.contents().stream() - .map(followQueryMapper::toFollowingList) + .map(followQueryMapper::toFollowingDto) .toList(); return UserFollowingResponse.builder() diff --git a/src/test/java/konkuk/thip/user/adapter/in/web/UserSearchApiTest.java b/src/test/java/konkuk/thip/user/adapter/in/web/UserSearchApiTest.java new file mode 100644 index 000000000..4e068fff1 --- /dev/null +++ b/src/test/java/konkuk/thip/user/adapter/in/web/UserSearchApiTest.java @@ -0,0 +1,94 @@ +package konkuk.thip.user.adapter.in.web; + +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchJpaEntity; +import konkuk.thip.recentSearch.adapter.out.jpa.SearchType; +import konkuk.thip.recentSearch.adapter.out.persistence.repository.RecentSearchJpaRepository; +import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@Transactional +@DisplayName("[통합] 사용자 검색 API + 최근 검색어 저장 테스트") +class UserSearchApiTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private RecentSearchJpaRepository recentSearchJpaRepository; + + @Autowired + private AliasJpaRepository aliasJpaRepository; + + private Long currentUserId; + + @BeforeEach + void setUp() { + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createLiteratureAlias()); + + // 검색 요청을 하는 사용자 + UserJpaEntity currentUser = userJpaRepository.save(TestEntityFactory.createUser(alias, "검색자")); + currentUserId = currentUser.getUserId(); + + // 검색 대상 사용자들 + List.of("thipalpha", "thipbeta", "123thip", "thipgamma", "otheruser") + .forEach(nickname -> userJpaRepository.save(TestEntityFactory.createUser(alias, nickname))); + } + + @Test + @DisplayName("사용자 검색 시 검색 결과 반환 + 최근 검색어 저장") + void searchUsersAndSaveRecentSearch() throws Exception { + String keyword = "thip"; + + // when: 사용자 검색 API 호출 + ResultActions result = mockMvc.perform( + get("/users") + .param("keyword", keyword) + .requestAttr("userId", currentUserId) + .param("size", "10") + + ); + + // then: 검색 결과 검증 + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userList", hasSize(4))) + .andExpect(jsonPath("$.data.userList[0].nickname").value("thipalpha")) + .andExpect(jsonPath("$.data.userList[1].nickname").value("thipbeta")) + .andExpect(jsonPath("$.data.userList[2].nickname").value("thipgamma")) + .andExpect(jsonPath("$.data.userList[3].nickname").value("123thip")); + + // 최근 검색어 DB 저장 여부 검증 + List recentSearches = recentSearchJpaRepository.findAll(); + assertEquals(1, recentSearches.size()); + RecentSearchJpaEntity saved = recentSearches.get(0); + assertEquals(keyword, saved.getSearchTerm()); + assertEquals(SearchType.USER_SEARCH, saved.getType()); + assertEquals(currentUserId, saved.getUserJpaEntity().getUserId()); + } +} \ No newline at end of file