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 8e5b28b2a..cb1848a5e 100644 --- a/src/main/java/konkuk/thip/book/application/service/BookSearchService.java +++ b/src/main/java/konkuk/thip/book/application/service/BookSearchService.java @@ -28,7 +28,7 @@ import static konkuk.thip.book.adapter.out.api.naver.NaverApiUtil.PAGE_SIZE; import static konkuk.thip.common.exception.code.ErrorCode.*; -import static konkuk.thip.recentSearch.adapter.out.jpa.SearchType.BOOK_SEARCH; +import static konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchType.BOOK_SEARCH; @Service @RequiredArgsConstructor @@ -69,7 +69,7 @@ public NaverBookParseResult searchBooks(String keyword, int page, Long userId) { //최근검색어 추가 RecentSearch recentSearch = RecentSearch.builder() .searchTerm(keyword) - .type(BOOK_SEARCH.getSearchType()) + .type(BOOK_SEARCH) .userId(userId) .build(); recentSearchCommandPort.save(recentSearch); diff --git a/src/main/java/konkuk/thip/common/entity/BaseDomainEntity.java b/src/main/java/konkuk/thip/common/entity/BaseDomainEntity.java index fcad1c86c..613f2f07c 100644 --- a/src/main/java/konkuk/thip/common/entity/BaseDomainEntity.java +++ b/src/main/java/konkuk/thip/common/entity/BaseDomainEntity.java @@ -1,7 +1,6 @@ package konkuk.thip.common.entity; import lombok.Getter; -import lombok.Setter; import lombok.experimental.SuperBuilder; import java.time.LocalDateTime; @@ -10,18 +9,10 @@ @SuperBuilder public class BaseDomainEntity { - @Setter private LocalDateTime createdAt; private LocalDateTime modifiedAt; private StatusType status; - protected void changeStatus() { - if (this.status == StatusType.ACTIVE) { - this.status = StatusType.INACTIVE; - } else { - this.status = StatusType.ACTIVE; - } - } } diff --git a/src/main/java/konkuk/thip/common/entity/BaseJpaEntity.java b/src/main/java/konkuk/thip/common/entity/BaseJpaEntity.java index 4d8b06bdb..b1f619ef1 100644 --- a/src/main/java/konkuk/thip/common/entity/BaseJpaEntity.java +++ b/src/main/java/konkuk/thip/common/entity/BaseJpaEntity.java @@ -5,10 +5,8 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer; import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; -import com.google.common.annotations.VisibleForTesting; import jakarta.persistence.*; import lombok.Getter; -import lombok.Setter; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @@ -27,7 +25,6 @@ public abstract class BaseJpaEntity { @Column(name = "created_at",nullable = false, updatable = false) private LocalDateTime createdAt; - @Setter @LastModifiedDate @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonSerialize(using = LocalDateSerializer.class) @@ -35,13 +32,7 @@ public abstract class BaseJpaEntity { @Column(name = "modified_at",nullable = false) private LocalDateTime modifiedAt; - @Setter @Enumerated(EnumType.STRING) @Column(nullable = false) private StatusType status = StatusType.ACTIVE; - - @VisibleForTesting - protected void setCreatedAt(LocalDateTime createdAt) { - this.createdAt = createdAt; - } } 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 647673959..554eb236f 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -73,6 +73,7 @@ public enum ErrorCode implements ResponseCode { */ INVALID_SEARCH_TYPE(HttpStatus.BAD_REQUEST, 90000,"알맞은 검색어 타입을 찾을 수 없습니다."), RECENT_SEARCH_NOT_FOUND(HttpStatus.NOT_FOUND, 90001, "존재하지 않는 RECENT SEARCH 입니다."), + RECENT_SEARCH_NOT_ADDED_BY_USER(HttpStatus.BAD_REQUEST, 90002, "사용자가 추가하지 않은 검색어는 삭제할 수 없습니다."), /** * 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 ded60e9b6..11189f22a 100644 --- a/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java +++ b/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java @@ -194,6 +194,12 @@ public enum SwaggerResponseDescription { JSON_PROCESSING_ERROR ))), + // Recent Search + RECENT_SEARCH_DELETE(new LinkedHashSet<>(Set.of( + RECENT_SEARCH_NOT_FOUND, + RECENT_SEARCH_NOT_ADDED_BY_USER + ))), + ; private final Set errorCodeList; SwaggerResponseDescription(Set errorCodeList) { diff --git a/src/main/java/konkuk/thip/feed/adapter/out/jpa/FeedJpaEntity.java b/src/main/java/konkuk/thip/feed/adapter/out/jpa/FeedJpaEntity.java index 7580d422f..cd184408e 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/jpa/FeedJpaEntity.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/jpa/FeedJpaEntity.java @@ -1,7 +1,6 @@ package konkuk.thip.feed.adapter.out.jpa; -import com.google.common.annotations.VisibleForTesting; import jakarta.persistence.*; import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; import konkuk.thip.feed.domain.Feed; @@ -12,7 +11,6 @@ import lombok.Getter; import lombok.NoArgsConstructor; -import java.time.LocalDateTime; import java.util.List; @Entity @@ -51,9 +49,4 @@ public void updateFrom(Feed feed) { this.likeCount = feed.getLikeCount(); this.commentCount = feed.getCommentCount(); } - - @VisibleForTesting - public void setCreatedAt(LocalDateTime newCreatedAt) { - super.setCreatedAt(newCreatedAt); - } } \ No newline at end of file diff --git a/src/main/java/konkuk/thip/recentSearch/adapter/in/web/RecentSearchCommandController.java b/src/main/java/konkuk/thip/recentSearch/adapter/in/web/RecentSearchCommandController.java index 72a4180a4..d92097cfe 100644 --- a/src/main/java/konkuk/thip/recentSearch/adapter/in/web/RecentSearchCommandController.java +++ b/src/main/java/konkuk/thip/recentSearch/adapter/in/web/RecentSearchCommandController.java @@ -1,10 +1,33 @@ package konkuk.thip.recentSearch.adapter.in.web; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import konkuk.thip.common.dto.BaseResponse; +import konkuk.thip.common.security.annotation.UserId; +import konkuk.thip.common.swagger.annotation.ExceptionDescription; +import konkuk.thip.recentSearch.application.port.in.RecentSearchDeleteUseCase; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; +import static konkuk.thip.common.swagger.SwaggerResponseDescription.RECENT_SEARCH_DELETE; + +@Tag(name = "Recent Search Command API", description = "최근 검색어 상태 변경 관련 API") @RestController @RequiredArgsConstructor public class RecentSearchCommandController { + private final RecentSearchDeleteUseCase recentSearchDeleteUseCase; + + @Operation(summary = "최근 검색어 삭제", description = "최근 검색어를 삭제합니다.") + @ExceptionDescription(RECENT_SEARCH_DELETE) + @DeleteMapping("/recent-searches/{recentSearchId}") + public BaseResponse deleteRecentSearch( + @PathVariable(value = "recentSearchId") final Long recentSearchId, + @UserId final Long userId + ) { + return BaseResponse.ok(recentSearchDeleteUseCase.deleteRecentSearch(recentSearchId, userId)); + } + } diff --git a/src/main/java/konkuk/thip/recentSearch/adapter/in/web/RecentSearchQueryController.java b/src/main/java/konkuk/thip/recentSearch/adapter/in/web/RecentSearchQueryController.java index cb921ec47..9b4714433 100644 --- a/src/main/java/konkuk/thip/recentSearch/adapter/in/web/RecentSearchQueryController.java +++ b/src/main/java/konkuk/thip/recentSearch/adapter/in/web/RecentSearchQueryController.java @@ -1,10 +1,32 @@ package konkuk.thip.recentSearch.adapter.in.web; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import konkuk.thip.common.dto.BaseResponse; +import konkuk.thip.common.security.annotation.UserId; +import konkuk.thip.recentSearch.adapter.in.web.response.RecentSearchGetResponse; +import konkuk.thip.recentSearch.application.port.in.RecentSearchGetUseCase; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "Recent Search Query API", description = "최근 검색어 조회 관련 API") @RestController @RequiredArgsConstructor public class RecentSearchQueryController { + private final RecentSearchGetUseCase recentSearchGetUseCase; + + @Operation(summary = "최근 검색어 조회", description = "사용자의 최근 검색어를 조회합니다. 최신순으로 최대 5개까지 조회됩니다.") + @GetMapping("/recent-searches") + public BaseResponse showRecentSearches( + @Parameter(description = "최근 검색어 유형 (사용자 검색 : USER / 방 검색 : ROOM / 책 검색 : BOOK)", example = "USER") + @RequestParam(value = "type") String type, + @Parameter(hidden = true) @UserId final Long userId + ) { + return BaseResponse.ok(recentSearchGetUseCase.getRecentSearches(type, userId)); + } + } diff --git a/src/main/java/konkuk/thip/recentSearch/adapter/in/web/response/DummyResponse.java b/src/main/java/konkuk/thip/recentSearch/adapter/in/web/response/DummyResponse.java deleted file mode 100644 index f03394880..000000000 --- a/src/main/java/konkuk/thip/recentSearch/adapter/in/web/response/DummyResponse.java +++ /dev/null @@ -1,7 +0,0 @@ -package konkuk.thip.recentSearch.adapter.in.web.response; - -import lombok.Getter; - -@Getter -public class DummyResponse { -} diff --git a/src/main/java/konkuk/thip/recentSearch/adapter/in/web/response/RecentSearchGetResponse.java b/src/main/java/konkuk/thip/recentSearch/adapter/in/web/response/RecentSearchGetResponse.java new file mode 100644 index 000000000..99cc1133c --- /dev/null +++ b/src/main/java/konkuk/thip/recentSearch/adapter/in/web/response/RecentSearchGetResponse.java @@ -0,0 +1,17 @@ +package konkuk.thip.recentSearch.adapter.in.web.response; + +import java.util.List; + +public record RecentSearchGetResponse( + List recentSearchList +) { + + public record RecentSearchDto( + Long recentSearchId, + String searchTerm + ) { + } + public static RecentSearchGetResponse of(List recentSearchList) { + return new RecentSearchGetResponse(recentSearchList); + } +} 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 5919d66bb..adc701bcf 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,7 +3,6 @@ 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.*; @@ -25,15 +24,9 @@ public class RecentSearchJpaEntity extends BaseJpaEntity { @Enumerated(EnumType.STRING) @Column(nullable = false) - private SearchType type; + private RecentSearchType type; @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/RecentSearchType.java b/src/main/java/konkuk/thip/recentSearch/adapter/out/jpa/RecentSearchType.java new file mode 100644 index 000000000..2a301c3ae --- /dev/null +++ b/src/main/java/konkuk/thip/recentSearch/adapter/out/jpa/RecentSearchType.java @@ -0,0 +1,31 @@ +package konkuk.thip.recentSearch.adapter.out.jpa; + + +import konkuk.thip.common.exception.InvalidStateException; +import lombok.Getter; + +import static konkuk.thip.common.exception.code.ErrorCode.INVALID_SEARCH_TYPE; + +@Getter +public enum RecentSearchType { + + USER_SEARCH("USER"), + BOOK_SEARCH("BOOK"), + ROOM_SEARCH("ROOM"), + ; + + private final String searchType; + + RecentSearchType(String searchType) { + this.searchType = searchType; + } + + public static RecentSearchType from(String searchType) { + for (RecentSearchType type : RecentSearchType.values()) { + if (type.getSearchType().equals(searchType)) { + return type; + } + } + throw new InvalidStateException(INVALID_SEARCH_TYPE); + } +} \ 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 deleted file mode 100644 index 4f01d8105..000000000 --- a/src/main/java/konkuk/thip/recentSearch/adapter/out/jpa/SearchType.java +++ /dev/null @@ -1,29 +0,0 @@ -package konkuk.thip.recentSearch.adapter.out.jpa; - - -import konkuk.thip.common.exception.BusinessException; -import lombok.Getter; - -import static konkuk.thip.common.exception.code.ErrorCode.INVALID_SEARCH_TYPE; - -@Getter -public enum SearchType { - - USER_SEARCH("사용자 검색"), - BOOK_SEARCH("책 검색"); - - private final String searchType; - - SearchType(String searchType) { - this.searchType = searchType; - } - - public static SearchType from(String searchType) { - for (SearchType type : SearchType.values()) { - if (type.getSearchType().equals(searchType)) { - return type; - } - } - throw new BusinessException(INVALID_SEARCH_TYPE); - } -} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/recentSearch/adapter/out/mapper/RecentSearchMapper.java b/src/main/java/konkuk/thip/recentSearch/adapter/out/mapper/RecentSearchMapper.java index 8085d7856..e21f75cf1 100644 --- a/src/main/java/konkuk/thip/recentSearch/adapter/out/mapper/RecentSearchMapper.java +++ b/src/main/java/konkuk/thip/recentSearch/adapter/out/mapper/RecentSearchMapper.java @@ -1,7 +1,6 @@ package konkuk.thip.recentSearch.adapter.out.mapper; import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchJpaEntity; -import konkuk.thip.recentSearch.adapter.out.jpa.SearchType; import konkuk.thip.recentSearch.domain.RecentSearch; import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import org.springframework.stereotype.Component; @@ -12,7 +11,7 @@ public class RecentSearchMapper { public RecentSearchJpaEntity toJpaEntity(RecentSearch recentSearch, UserJpaEntity userJpaEntity) { return RecentSearchJpaEntity.builder() .searchTerm(recentSearch.getSearchTerm()) - .type(SearchType.from(recentSearch.getType())) + .type(recentSearch.getType()) .userJpaEntity(userJpaEntity) .build(); } @@ -21,7 +20,7 @@ public RecentSearch toDomainEntity(RecentSearchJpaEntity recentSearchJpaEntity) return RecentSearch.builder() .id(recentSearchJpaEntity.getRecentSearchId()) .searchTerm(recentSearchJpaEntity.getSearchTerm()) - .type(recentSearchJpaEntity.getType().getSearchType()) + .type(recentSearchJpaEntity.getType()) .userId(recentSearchJpaEntity.getUserJpaEntity().getUserId()) .createdAt(recentSearchJpaEntity.getCreatedAt()) .modifiedAt(recentSearchJpaEntity.getModifiedAt()) 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 2fb2664cc..d57ca22a4 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,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.Optional; + import static konkuk.thip.common.exception.code.ErrorCode.RECENT_SEARCH_NOT_FOUND; import static konkuk.thip.common.exception.code.ErrorCode.USER_NOT_FOUND; @@ -23,6 +25,12 @@ public class RecentSearchCommandPersistenceAdapter implements RecentSearchComman private final RecentSearchMapper recentSearchMapper; + @Override + public Optional findById(Long id) { + return recentSearchJpaRepository.findById(id) + .map(recentSearchMapper::toDomainEntity); + } + @Override public void save(RecentSearch recentSearch) { @@ -44,11 +52,9 @@ public void delete(Long id) { } @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); + public void touch(RecentSearch recentSearch) { + recentSearchJpaRepository.updateModifiedAt(recentSearch.getId()); } + + } 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 2f9426342..3f049e8ac 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 @@ -1,5 +1,6 @@ package konkuk.thip.recentSearch.adapter.out.persistence; +import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchType; 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; @@ -7,10 +8,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; -import static konkuk.thip.recentSearch.adapter.out.jpa.SearchType.USER_SEARCH; - @Repository @RequiredArgsConstructor public class RecentSearchQueryPersistenceAdapter implements RecentSearchQueryPort { @@ -19,8 +19,16 @@ public class RecentSearchQueryPersistenceAdapter implements RecentSearchQueryPor private final RecentSearchMapper recentSearchMapper; @Override - public Optional findRecentSearchByKeywordAndUserId(String keyword, Long userId) { - return recentSearchJpaRepository.findBySearchTermAndTypeAndUserId(keyword, USER_SEARCH, userId) + public Optional findRecentSearchByKeywordAndUserId(String keyword, Long userId, RecentSearchType type) { + return recentSearchJpaRepository.findBySearchTermAndTypeAndUserId(keyword, type, userId) .map(recentSearchMapper::toDomainEntity); } + + @Override + public List findRecentSearchesByTypeAndUserId(RecentSearchType type, Long userId, int limit) { + return recentSearchJpaRepository.findByTypeAndUserId(type, userId, limit) + .stream() + .map(recentSearchMapper::toDomainEntity) + .toList(); + } } 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 3e464b006..71a707798 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,8 +2,14 @@ import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository public interface RecentSearchJpaRepository extends JpaRepository, RecentSearchQueryRepository { + @Modifying + @Query("UPDATE RecentSearchJpaEntity r SET r.modifiedAt = CURRENT_TIMESTAMP WHERE r.recentSearchId = :recentSearchId") + void updateModifiedAt(@Param("recentSearchId") Long recentSearchId); } 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 index a764ef81d..309713668 100644 --- 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 @@ -1,10 +1,13 @@ 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 konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchType; +import java.util.List; import java.util.Optional; public interface RecentSearchQueryRepository { - Optional findBySearchTermAndTypeAndUserId(String searchTerm, SearchType type, Long userId); + Optional findBySearchTermAndTypeAndUserId(String searchTerm, RecentSearchType type, Long userId); + + List findByTypeAndUserId(RecentSearchType type, Long userId, int limit); } 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 index f5c0ed5ea..7bd65aef4 100644 --- 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 @@ -2,12 +2,14 @@ import com.querydsl.jpa.impl.JPAQueryFactory; import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchJpaEntity; -import konkuk.thip.recentSearch.adapter.out.jpa.SearchType; +import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; +import static konkuk.thip.common.entity.StatusType.ACTIVE; import static konkuk.thip.recentSearch.adapter.out.jpa.QRecentSearchJpaEntity.recentSearchJpaEntity; @Repository @@ -17,16 +19,31 @@ public class RecentSearchQueryRepositoryImpl implements RecentSearchQueryReposit private final JPAQueryFactory queryFactory; @Override - public Optional findBySearchTermAndTypeAndUserId(String searchTerm, SearchType type, Long userId) { + public Optional findBySearchTermAndTypeAndUserId(String searchTerm, RecentSearchType type, Long userId) { RecentSearchJpaEntity result = queryFactory .selectFrom(recentSearchJpaEntity) .where( recentSearchJpaEntity.searchTerm.eq(searchTerm), recentSearchJpaEntity.type.eq(type), - recentSearchJpaEntity.userJpaEntity.userId.eq(userId) + recentSearchJpaEntity.userJpaEntity.userId.eq(userId), + recentSearchJpaEntity.status.eq(ACTIVE) ) .fetchOne(); return Optional.ofNullable(result); } + + @Override + public List findByTypeAndUserId(RecentSearchType type, Long userId, int limit) { + return queryFactory + .selectFrom(recentSearchJpaEntity) + .where( + recentSearchJpaEntity.type.eq(type), + recentSearchJpaEntity.userJpaEntity.userId.eq(userId), + recentSearchJpaEntity.status.eq(ACTIVE) + ) + .orderBy(recentSearchJpaEntity.modifiedAt.desc()) + .limit(limit) + .fetch(); + } } diff --git a/src/main/java/konkuk/thip/recentSearch/application/RecentSearchQueryMapper.java b/src/main/java/konkuk/thip/recentSearch/application/RecentSearchQueryMapper.java new file mode 100644 index 000000000..203a13a4d --- /dev/null +++ b/src/main/java/konkuk/thip/recentSearch/application/RecentSearchQueryMapper.java @@ -0,0 +1,17 @@ +package konkuk.thip.recentSearch.application; + +import konkuk.thip.recentSearch.adapter.in.web.response.RecentSearchGetResponse; +import konkuk.thip.recentSearch.domain.RecentSearch; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface RecentSearchQueryMapper { + + @Mapping(source = "id", target = "recentSearchId") + RecentSearchGetResponse.RecentSearchDto toDto(RecentSearch recentSearch); + + List toResponseList(List recentSearchQueryDtos); +} diff --git a/src/main/java/konkuk/thip/recentSearch/application/port/in/DummyUseCase.java b/src/main/java/konkuk/thip/recentSearch/application/port/in/DummyUseCase.java deleted file mode 100644 index 40602a14e..000000000 --- a/src/main/java/konkuk/thip/recentSearch/application/port/in/DummyUseCase.java +++ /dev/null @@ -1,5 +0,0 @@ -package konkuk.thip.recentSearch.application.port.in; - -public interface DummyUseCase { - -} diff --git a/src/main/java/konkuk/thip/recentSearch/application/port/in/RecentSearchDeleteUseCase.java b/src/main/java/konkuk/thip/recentSearch/application/port/in/RecentSearchDeleteUseCase.java new file mode 100644 index 000000000..3a708f6e0 --- /dev/null +++ b/src/main/java/konkuk/thip/recentSearch/application/port/in/RecentSearchDeleteUseCase.java @@ -0,0 +1,5 @@ +package konkuk.thip.recentSearch.application.port.in; + +public interface RecentSearchDeleteUseCase { + Void deleteRecentSearch(Long recentSearchId, Long userId); +} diff --git a/src/main/java/konkuk/thip/recentSearch/application/port/in/RecentSearchGetUseCase.java b/src/main/java/konkuk/thip/recentSearch/application/port/in/RecentSearchGetUseCase.java new file mode 100644 index 000000000..4bcabdb67 --- /dev/null +++ b/src/main/java/konkuk/thip/recentSearch/application/port/in/RecentSearchGetUseCase.java @@ -0,0 +1,9 @@ +package konkuk.thip.recentSearch.application.port.in; + +import konkuk.thip.recentSearch.adapter.in.web.response.RecentSearchGetResponse; + +public interface RecentSearchGetUseCase { + + RecentSearchGetResponse getRecentSearches(String typeParam, Long userId); + +} 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 ddb7a19fb..8c2d5e09d 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 @@ -1,11 +1,24 @@ package konkuk.thip.recentSearch.application.port.out; +import konkuk.thip.common.exception.EntityNotFoundException; import konkuk.thip.recentSearch.domain.RecentSearch; +import java.util.Optional; + +import static konkuk.thip.common.exception.code.ErrorCode.RECENT_SEARCH_NOT_FOUND; + public interface RecentSearchCommandPort { + + Optional findById(Long id); + + default RecentSearch getByIdOrThrow(Long id) { + return findById(id) + .orElseThrow(() -> new EntityNotFoundException(RECENT_SEARCH_NOT_FOUND)); + } + void save(RecentSearch recentSearch); void delete(Long id); - void update(RecentSearch recentSearch); + void touch(RecentSearch recentSearch); // modifiedAt 갱신용 메서드 } 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 1a15b29f9..04c09d662 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,11 +1,15 @@ package konkuk.thip.recentSearch.application.port.out; +import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchType; import konkuk.thip.recentSearch.domain.RecentSearch; +import java.util.List; import java.util.Optional; public interface RecentSearchQueryPort { - Optional findRecentSearchByKeywordAndUserId(String keyword, Long userId); + Optional findRecentSearchByKeywordAndUserId(String keyword, Long userId, RecentSearchType type); + List findRecentSearchesByTypeAndUserId(RecentSearchType type, Long userId, int limit); } + diff --git a/src/main/java/konkuk/thip/recentSearch/application/service/RecentSearchDeleteService.java b/src/main/java/konkuk/thip/recentSearch/application/service/RecentSearchDeleteService.java new file mode 100644 index 000000000..f703134c3 --- /dev/null +++ b/src/main/java/konkuk/thip/recentSearch/application/service/RecentSearchDeleteService.java @@ -0,0 +1,28 @@ +package konkuk.thip.recentSearch.application.service; + +import konkuk.thip.common.exception.BusinessException; +import konkuk.thip.common.exception.code.ErrorCode; +import konkuk.thip.recentSearch.application.port.in.RecentSearchDeleteUseCase; +import konkuk.thip.recentSearch.application.port.out.RecentSearchCommandPort; +import konkuk.thip.recentSearch.domain.RecentSearch; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class RecentSearchDeleteService implements RecentSearchDeleteUseCase { + + private final RecentSearchCommandPort recentSearchCommandPort; + + @Transactional + public Void deleteRecentSearch(Long recentSearchId, Long userId) { + RecentSearch recentSearch = recentSearchCommandPort.getByIdOrThrow(recentSearchId); + if (!recentSearch.getUserId().equals(userId)) { + throw new BusinessException(ErrorCode.RECENT_SEARCH_NOT_ADDED_BY_USER); + } + + recentSearchCommandPort.delete(recentSearch.getId()); + return null; + } +} diff --git a/src/main/java/konkuk/thip/recentSearch/application/service/RecentSearchGetService.java b/src/main/java/konkuk/thip/recentSearch/application/service/RecentSearchGetService.java new file mode 100644 index 000000000..33e4b8962 --- /dev/null +++ b/src/main/java/konkuk/thip/recentSearch/application/service/RecentSearchGetService.java @@ -0,0 +1,34 @@ +package konkuk.thip.recentSearch.application.service; + +import konkuk.thip.recentSearch.adapter.in.web.response.RecentSearchGetResponse; +import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchType; +import konkuk.thip.recentSearch.application.RecentSearchQueryMapper; +import konkuk.thip.recentSearch.application.port.in.RecentSearchGetUseCase; +import konkuk.thip.recentSearch.application.port.out.RecentSearchQueryPort; +import konkuk.thip.recentSearch.domain.RecentSearch; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class RecentSearchGetService implements RecentSearchGetUseCase { + + private final RecentSearchQueryPort recentSearchQueryPort; + private final RecentSearchQueryMapper recentSearchQueryMapper; + + private static final int MAX_RECENT_SEARCHES = 5; + + @Transactional(readOnly = true) + public RecentSearchGetResponse getRecentSearches(String typeParam, Long userId) { + RecentSearchType type = RecentSearchType.from(typeParam); + List recentSearchList = recentSearchQueryPort.findRecentSearchesByTypeAndUserId(type, userId, MAX_RECENT_SEARCHES); + + return RecentSearchGetResponse.of( + recentSearchQueryMapper.toResponseList(recentSearchList) + ); + } + +} diff --git a/src/main/java/konkuk/thip/recentSearch/application/service/RecentSearchService.java b/src/main/java/konkuk/thip/recentSearch/application/service/RecentSearchService.java deleted file mode 100644 index f5933f406..000000000 --- a/src/main/java/konkuk/thip/recentSearch/application/service/RecentSearchService.java +++ /dev/null @@ -1,11 +0,0 @@ -package konkuk.thip.recentSearch.application.service; - -import konkuk.thip.recentSearch.application.port.in.DummyUseCase; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class RecentSearchService implements DummyUseCase { - -} 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 index 1a0ad1994..ee0c51b23 100644 --- a/src/main/java/konkuk/thip/recentSearch/application/service/manager/RecentSearchCreateManager.java +++ b/src/main/java/konkuk/thip/recentSearch/application/service/manager/RecentSearchCreateManager.java @@ -1,37 +1,26 @@ package konkuk.thip.recentSearch.application.service.manager; +import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchType; 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) { + public void saveRecentSearchByUser(Long userId, String keyword, RecentSearchType type) { // 동일 조건 (userId + keyword + type) 검색 기록이 이미 있는지 확인 - recentSearchQueryPort.findRecentSearchByKeywordAndUserId(keyword, userId) + recentSearchQueryPort.findRecentSearchByKeywordAndUserId(keyword, userId, type) .ifPresentOrElse( - existingRecentSearch -> { - // 이미 존재하면 createdAt만 갱신 - existingRecentSearch.updateCreatedAt(LocalDateTime.now()); - recentSearchCommandPort.update(existingRecentSearch); - }, - () -> { - // 없으면 새로 저장 - RecentSearch userRecentSearch = RecentSearch.withoutId(keyword, USER_SEARCH_TERM, userId); - recentSearchCommandPort.save(userRecentSearch); - } + recentSearchCommandPort::touch, // 있으면 modifiedAt 갱신 + () -> recentSearchCommandPort.save(RecentSearch.withoutId(keyword, type, userId)) // 없으면 새로 저장 ); } } diff --git a/src/main/java/konkuk/thip/recentSearch/domain/RecentSearch.java b/src/main/java/konkuk/thip/recentSearch/domain/RecentSearch.java index 068718a78..35d9a8ffc 100644 --- a/src/main/java/konkuk/thip/recentSearch/domain/RecentSearch.java +++ b/src/main/java/konkuk/thip/recentSearch/domain/RecentSearch.java @@ -1,11 +1,10 @@ package konkuk.thip.recentSearch.domain; import konkuk.thip.common.entity.BaseDomainEntity; +import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchType; import lombok.Getter; import lombok.experimental.SuperBuilder; -import java.time.LocalDateTime; - @Getter @SuperBuilder public class RecentSearch extends BaseDomainEntity { @@ -14,19 +13,15 @@ public class RecentSearch extends BaseDomainEntity { private String searchTerm; - private String type; + private RecentSearchType type; private Long userId; - public static RecentSearch withoutId(String searchTerm, String type, Long userId) { + public static RecentSearch withoutId(String searchTerm, RecentSearchType 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/application/service/UserSearchService.java b/src/main/java/konkuk/thip/user/application/service/UserSearchService.java index f93335e7f..e2aed557e 100644 --- a/src/main/java/konkuk/thip/user/application/service/UserSearchService.java +++ b/src/main/java/konkuk/thip/user/application/service/UserSearchService.java @@ -1,5 +1,6 @@ package konkuk.thip.user.application.service; +import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchType; import konkuk.thip.recentSearch.application.service.manager.RecentSearchCreateManager; import konkuk.thip.user.adapter.in.web.response.UserSearchResponse; import konkuk.thip.user.application.mapper.UserQueryMapper; @@ -29,7 +30,7 @@ public UserSearchResponse searchUsers(UserSearchQuery userSearchQuery) { )); // 최근 검색어 저장 - recentSearchCreateManager.saveRecentSearchByUser(userSearchQuery.userId(), userSearchQuery.keyword()); + recentSearchCreateManager.saveRecentSearchByUser(userSearchQuery.userId(), userSearchQuery.keyword(), RecentSearchType.USER_SEARCH); return UserSearchResponse.of(userDtoList); } diff --git a/src/main/java/konkuk/thip/user/application/service/following/UserFollowService.java b/src/main/java/konkuk/thip/user/application/service/following/UserFollowService.java index c3be511e5..d47406f49 100644 --- a/src/main/java/konkuk/thip/user/application/service/following/UserFollowService.java +++ b/src/main/java/konkuk/thip/user/application/service/following/UserFollowService.java @@ -13,8 +13,7 @@ import java.util.Optional; -import static konkuk.thip.common.exception.code.ErrorCode.USER_ALREADY_UNFOLLOWED; -import static konkuk.thip.common.exception.code.ErrorCode.USER_CANNOT_FOLLOW_SELF; +import static konkuk.thip.common.exception.code.ErrorCode.*; @Service @RequiredArgsConstructor @@ -35,19 +34,16 @@ public Boolean changeFollowingState(UserFollowCommand followCommand) { Optional optionalFollowing = followingCommandPort.findByUserIdAndTargetUserId(userId, targetUserId); User targetUser = userCommandPort.findById(targetUserId); - if (optionalFollowing.isPresent()) { // 이미 팔로우 관계가 존재하는 경우 - Following following = optionalFollowing.get(); - boolean isFollowing = following.changeFollowingState(type); - targetUser.updateFollowerCount(isFollowing); - followingCommandPort.deleteFollowing(following, targetUser); - return isFollowing; - } else { // 팔로우 관계가 존재하지 않는 경우 - if (!type) { - throw new BusinessException(USER_ALREADY_UNFOLLOWED); // 언팔로우 요청인데 팔로우 관계가 존재하지 않으므로 이미 언팔로우 상태 - } + boolean isFollowRequest = Following.validateFollowingState(optionalFollowing.isPresent(), type); + + if (isFollowRequest) { // 팔로우 요청인 경우 targetUser.increaseFollowerCount(); followingCommandPort.save(Following.withoutId(userId, targetUserId), targetUser); - return true; // 새로 팔로우한 경우 + return true; + } else { // 언팔로우 요청인 경우 + targetUser.decreaseFollowerCount(); + followingCommandPort.deleteFollowing(optionalFollowing.get(), targetUser); + return false; } } diff --git a/src/main/java/konkuk/thip/user/domain/Following.java b/src/main/java/konkuk/thip/user/domain/Following.java index 6ffa7efb2..c96385a2d 100644 --- a/src/main/java/konkuk/thip/user/domain/Following.java +++ b/src/main/java/konkuk/thip/user/domain/Following.java @@ -27,21 +27,12 @@ public static Following withoutId(Long userId, Long followingUserId) { .build(); } - public boolean changeFollowingState(boolean isFollowRequest) { - StatusType currentStatus = getStatus(); - validateFollowingState(isFollowRequest, currentStatus); - - super.changeStatus(); - return isFollowRequest; - } - - private void validateFollowingState(boolean isFollowRequest, StatusType currentStatus) { - if (isFollowRequest && currentStatus == StatusType.ACTIVE) { // 팔로우 요청일 때 이미 팔로우 중인 경우 + public static boolean validateFollowingState(boolean isExistingFollowing, boolean isFollowRequest) { + if (isExistingFollowing && isFollowRequest) { // 이미 팔로우 관계가 존재하는 상태에서 팔로우 요청을 하는 경우 throw new InvalidStateException(USER_ALREADY_FOLLOWED); - } - - if (!isFollowRequest && currentStatus == StatusType.INACTIVE) { // 언팔로우 요청일 때 이미 언팔로우 중인 경우 + } else if (!isExistingFollowing && !isFollowRequest) { // 언팔로우 요청을 하는데 팔로우 관계가 존재하지 않는 경우 throw new InvalidStateException(USER_ALREADY_UNFOLLOWED); } + return isFollowRequest; } } diff --git a/src/main/java/konkuk/thip/user/domain/User.java b/src/main/java/konkuk/thip/user/domain/User.java index 6a7494782..5d0ee1c75 100644 --- a/src/main/java/konkuk/thip/user/domain/User.java +++ b/src/main/java/konkuk/thip/user/domain/User.java @@ -33,19 +33,11 @@ public static User withoutId(String nickname, String userRole, String oauth2Id, .build(); } - public void updateFollowerCount(boolean isFollowing) { - if (isFollowing) { - increaseFollowerCount(); - } else { - decreaseFollowerCount(); - } - } - public void increaseFollowerCount() { followerCount++; } - private void decreaseFollowerCount() { + public void decreaseFollowerCount() { if(followerCount == 0) { throw new InvalidStateException(ErrorCode.FOLLOW_COUNT_IS_ZERO); } diff --git a/src/test/java/konkuk/thip/book/adapter/in/web/BookQueryControllerTest.java b/src/test/java/konkuk/thip/book/adapter/in/web/BookQueryControllerTest.java index 142d5dc29..52794d323 100644 --- a/src/test/java/konkuk/thip/book/adapter/in/web/BookQueryControllerTest.java +++ b/src/test/java/konkuk/thip/book/adapter/in/web/BookQueryControllerTest.java @@ -4,7 +4,7 @@ import konkuk.thip.common.security.util.JwtUtil; 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.jpa.RecentSearchType; 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; @@ -64,7 +64,7 @@ void setUp() { RecentSearchJpaEntity recentSearch = recentSearchJpaRepository.save(RecentSearchJpaEntity.builder() .searchTerm("테스트검색어") - .type(SearchType.BOOK_SEARCH) + .type(RecentSearchType.BOOK_SEARCH) .userJpaEntity(user) .build()); @@ -186,7 +186,7 @@ void searchBooks_savesRecentSearch() throws Exception { assertThat(recentSearch).isNotNull(); assertThat(recentSearch.getSearchTerm()).isEqualTo(keyword); - assertThat(recentSearch.getType()).isEqualTo(SearchType.BOOK_SEARCH); + assertThat(recentSearch.getType()).isEqualTo(RecentSearchType.BOOK_SEARCH); assertThat(recentSearch.getUserJpaEntity().getUserId()).isEqualTo(user.getUserId()); } } diff --git a/src/test/java/konkuk/thip/recentSearch/adapter/in/web/RecentSearchDeleteApiTest.java b/src/test/java/konkuk/thip/recentSearch/adapter/in/web/RecentSearchDeleteApiTest.java new file mode 100644 index 000000000..54dc00a47 --- /dev/null +++ b/src/test/java/konkuk/thip/recentSearch/adapter/in/web/RecentSearchDeleteApiTest.java @@ -0,0 +1,116 @@ +package konkuk.thip.recentSearch.adapter.in.web; + +import konkuk.thip.common.exception.code.ErrorCode; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchJpaEntity; +import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchType; +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.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +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 RecentSearchDeleteApiTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private AliasJpaRepository aliasJpaRepository; + + @Autowired + private RecentSearchJpaRepository recentSearchJpaRepository; + + private Long currentUserId; + private Long otherUserId; + private Long recentSearchId; + + @BeforeEach + void setUp() { + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createLiteratureAlias()); + + // 요청 사용자 + UserJpaEntity currentUser = userJpaRepository.save(TestEntityFactory.createUser(alias, "요청자")); + currentUserId = currentUser.getUserId(); + + // 다른 사용자 + UserJpaEntity otherUser = userJpaRepository.save(TestEntityFactory.createUser(alias, "다른유저")); + otherUserId = otherUser.getUserId(); + + // currentUser가 추가한 최근 검색어 + RecentSearchJpaEntity entity = recentSearchJpaRepository.save( + RecentSearchJpaEntity.builder() + .searchTerm("삭제테스트") + .type(RecentSearchType.USER_SEARCH) + .userJpaEntity(currentUser) + .build() + ); + recentSearchId = entity.getRecentSearchId(); + } + + @Test + @DisplayName("성공적으로 최근 검색어를 삭제한다") + void deleteRecentSearch_success() throws Exception { + // when + mockMvc.perform(delete("/recent-searches/{recentSearchId}", recentSearchId) + .requestAttr("userId", currentUserId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + + // then + List list = recentSearchJpaRepository.findAll(); + assertThat(list).isEmpty(); + } + + @Test + @DisplayName("다른 사용자가 추가한 최근 검색어는 삭제할 수 없다") + void deleteRecentSearch_fail_notOwner() throws Exception { + mockMvc.perform(delete("/recent-searches/{recentSearchId}", recentSearchId) + .requestAttr("userId", otherUserId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.RECENT_SEARCH_NOT_ADDED_BY_USER.getCode())); + + // DB에 여전히 남아있음 + assertThat(recentSearchJpaRepository.findById(recentSearchId)).isPresent(); + } + + @Test + @DisplayName("존재하지 않는 최근 검색어를 삭제하려 하면 예외가 발생한다") + void deleteRecentSearch_fail_notFound() throws Exception { + Long notExistingId = 9999L; + + mockMvc.perform(delete("/recent-searches/{recentSearchId}", notExistingId) + .requestAttr("userId", currentUserId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + + // 기존 데이터는 그대로 + assertThat(recentSearchJpaRepository.findAll()).hasSize(1); + } +} \ No newline at end of file diff --git a/src/test/java/konkuk/thip/recentSearch/adapter/in/web/RecentSearchGetApiTest.java b/src/test/java/konkuk/thip/recentSearch/adapter/in/web/RecentSearchGetApiTest.java new file mode 100644 index 000000000..6abea1462 --- /dev/null +++ b/src/test/java/konkuk/thip/recentSearch/adapter/in/web/RecentSearchGetApiTest.java @@ -0,0 +1,106 @@ +package konkuk.thip.recentSearch.adapter.in.web; + +import jakarta.persistence.EntityManager; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchJpaEntity; +import konkuk.thip.recentSearch.adapter.out.jpa.RecentSearchType; +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.time.LocalDateTime; +import java.util.stream.IntStream; + +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 RecentSearchGetApiTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private EntityManager em; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private AliasJpaRepository aliasJpaRepository; + + @Autowired + private RecentSearchJpaRepository recentSearchJpaRepository; + + private Long currentUserId; + + @BeforeEach + void setUp() { + // 사용자 및 별칭 생성 + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createLiteratureAlias()); + UserJpaEntity currentUser = userJpaRepository.save(TestEntityFactory.createUser(alias, "검색자")); + currentUserId = currentUser.getUserId(); + + // 최근 검색어 6개 저장 (6개 중 최신 5개만 조회될 예정) + IntStream.rangeClosed(1, 6).forEach(i -> { + RecentSearchJpaEntity saved = recentSearchJpaRepository.save( + RecentSearchJpaEntity.builder() + .searchTerm("검색어" + i) + .type(RecentSearchType.USER_SEARCH) + .userJpaEntity(currentUser) + .build() + ); + + // JPQL update로 modifiedAt을 강제로 원하는 값으로 덮기 + em.createQuery("update RecentSearchJpaEntity r set r.modifiedAt = :time where r.id = :id") + .setParameter("time", LocalDateTime.now().minusMinutes(i)) + .setParameter("id", saved.getRecentSearchId()) + .executeUpdate(); + }); + + em.flush(); + em.clear(); + } + + @Test + @DisplayName("최근 검색어를 최신순으로 최대 5개까지 조회한다") + void getRecentSearches() throws Exception { + // when + ResultActions result = mockMvc.perform( + get("/recent-searches") + .param("type", "USER") + .requestAttr("userId", currentUserId) + ); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.recentSearchList", hasSize(5))) + .andExpect(jsonPath("$.data.recentSearchList[0].searchTerm").value("검색어1")) + .andExpect(jsonPath("$.data.recentSearchList[1].searchTerm").value("검색어2")) + .andExpect(jsonPath("$.data.recentSearchList[2].searchTerm").value("검색어3")) + .andExpect(jsonPath("$.data.recentSearchList[3].searchTerm").value("검색어4")) + .andExpect(jsonPath("$.data.recentSearchList[4].searchTerm").value("검색어5")); + + // DB에 저장된 최근 검색어 개수 검증 + assertEquals(6, recentSearchJpaRepository.findAll().size()); + } +} \ No newline at end of file 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 index 4e068fff1..88d0f2fc8 100644 --- a/src/test/java/konkuk/thip/user/adapter/in/web/UserSearchApiTest.java +++ b/src/test/java/konkuk/thip/user/adapter/in/web/UserSearchApiTest.java @@ -2,7 +2,7 @@ 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.jpa.RecentSearchType; 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; @@ -88,7 +88,7 @@ void searchUsersAndSaveRecentSearch() throws Exception { assertEquals(1, recentSearches.size()); RecentSearchJpaEntity saved = recentSearches.get(0); assertEquals(keyword, saved.getSearchTerm()); - assertEquals(SearchType.USER_SEARCH, saved.getType()); + assertEquals(RecentSearchType.USER_SEARCH, saved.getType()); assertEquals(currentUserId, saved.getUserJpaEntity().getUserId()); } } \ No newline at end of file diff --git a/src/test/java/konkuk/thip/user/application/service/UserFollowServiceTest.java b/src/test/java/konkuk/thip/user/application/service/UserFollowServiceTest.java index 706bba7ad..1d0c41878 100644 --- a/src/test/java/konkuk/thip/user/application/service/UserFollowServiceTest.java +++ b/src/test/java/konkuk/thip/user/application/service/UserFollowServiceTest.java @@ -1,6 +1,5 @@ package konkuk.thip.user.application.service; -import konkuk.thip.common.entity.StatusType; import konkuk.thip.common.exception.BusinessException; import konkuk.thip.user.application.port.in.dto.UserFollowCommand; import konkuk.thip.user.application.port.out.FollowingCommandPort; @@ -40,44 +39,33 @@ void setUp() { class Follow { @Test - @DisplayName("기존 inactive row가 존재하면 active로 변경 + followerCount 증가") - void activate_existingFollowing() { + @DisplayName("팔로우 관계가 이미 존재하면 예외 발생") + void follow_alreadyExists() { // given Long userId = 1L, targetUserId = 2L; - Following inactiveFollowing = Following.builder() - .id(10L) - .userId(userId) - .followingUserId(targetUserId) - .status(StatusType.INACTIVE) - .build(); - - User user = createUserWithFollowingCount(0); - + Following existing = Following.withoutId(userId, targetUserId); when(followingCommandPort.findByUserIdAndTargetUserId(userId, targetUserId)) - .thenReturn(Optional.of(inactiveFollowing)); + .thenReturn(Optional.of(existing)); + + User user = createUserWithFollowerCount(0); when(userCommandPort.findById(targetUserId)).thenReturn(user); UserFollowCommand command = new UserFollowCommand(userId, targetUserId, true); - // when - Boolean result = userFollowService.changeFollowingState(command); - // then - assertThat(result).isTrue(); - assertThat(inactiveFollowing.getStatus()).isEqualTo(StatusType.ACTIVE); - assertThat(user.getFollowerCount()).isEqualTo(1); // followerCount 증가 확인 - verify(followingCommandPort).deleteFollowing(inactiveFollowing, user); + assertThatThrownBy(() -> userFollowService.changeFollowingState(command)) + .isInstanceOf(BusinessException.class); } @Test - @DisplayName("팔로우 관계가 존재하지 않으면 새로 생성 + followerCount 증가") - void create_newFollowing() { + @DisplayName("팔로우 관계가 없으면 새로 생성 + followerCount 증가") + void follow_newRelation() { // given Long userId = 1L, targetUserId = 2L; when(followingCommandPort.findByUserIdAndTargetUserId(userId, targetUserId)) .thenReturn(Optional.empty()); - User user = createUserWithFollowingCount(0); + User user = createUserWithFollowerCount(0); when(userCommandPort.findById(targetUserId)).thenReturn(user); UserFollowCommand command = new UserFollowCommand(userId, targetUserId, true); @@ -87,15 +75,13 @@ void create_newFollowing() { // then assertThat(result).isTrue(); - assertThat(user.getFollowerCount()).isEqualTo(1); // followerCount 증가 확인 + assertThat(user.getFollowerCount()).isEqualTo(1); // followerCount 증가 ArgumentCaptor captor = ArgumentCaptor.forClass(Following.class); verify(followingCommandPort).save(captor.capture(), eq(user)); - Following saved = captor.getValue(); assertThat(saved.getUserId()).isEqualTo(userId); assertThat(saved.getFollowingUserId()).isEqualTo(targetUserId); - assertThat(saved.getStatus()).isEqualTo(StatusType.ACTIVE); } } @@ -104,21 +90,16 @@ void create_newFollowing() { class Unfollow { @Test - @DisplayName("active row가 존재하면 inactive로 변경 + followerCount 감소") - void deactivate_existingFollowing() { + @DisplayName("팔로우 관계가 존재하면 삭제 + followerCount 감소") + void unfollow_existingRelation() { // given Long userId = 1L, targetUserId = 2L; - Following activeFollowing = Following.builder() - .id(10L) - .userId(userId) - .followingUserId(targetUserId) - .status(StatusType.ACTIVE) - .build(); + Following existing = Following.withoutId(userId, targetUserId); - User user = createUserWithFollowingCount(1); + User user = createUserWithFollowerCount(1); when(followingCommandPort.findByUserIdAndTargetUserId(userId, targetUserId)) - .thenReturn(Optional.of(activeFollowing)); + .thenReturn(Optional.of(existing)); when(userCommandPort.findById(targetUserId)).thenReturn(user); UserFollowCommand command = new UserFollowCommand(userId, targetUserId, false); @@ -128,9 +109,8 @@ void deactivate_existingFollowing() { // then assertThat(result).isFalse(); - assertThat(activeFollowing.getStatus()).isEqualTo(StatusType.INACTIVE); - assertThat(user.getFollowerCount()).isEqualTo(0); // followerCount 감소 확인 - verify(followingCommandPort).deleteFollowing(activeFollowing, user); + assertThat(user.getFollowerCount()).isEqualTo(0); // followerCount 감소 + verify(followingCommandPort).deleteFollowing(existing, user); } @Test @@ -143,7 +123,7 @@ void unfollow_withoutRelation() { UserFollowCommand command = new UserFollowCommand(userId, targetUserId, false); - // when & then + // then assertThatThrownBy(() -> userFollowService.changeFollowingState(command)) .isInstanceOf(BusinessException.class) .hasMessageContaining(USER_ALREADY_UNFOLLOWED.getMessage()); @@ -161,9 +141,9 @@ void cannot_follow_self() { .hasMessageContaining(USER_CANNOT_FOLLOW_SELF.getMessage()); } - private User createUserWithFollowingCount(int count) { + private User createUserWithFollowerCount(int count) { return User.builder() - .id(1L) + .id(100L) .nickname("tester") .userRole("USER") .oauth2Id("oauth-id") diff --git a/src/test/java/konkuk/thip/user/domain/FollowingTest.java b/src/test/java/konkuk/thip/user/domain/FollowingTest.java index c4426ad7d..c33f84b46 100644 --- a/src/test/java/konkuk/thip/user/domain/FollowingTest.java +++ b/src/test/java/konkuk/thip/user/domain/FollowingTest.java @@ -1,93 +1,70 @@ package konkuk.thip.user.domain; -import konkuk.thip.common.entity.StatusType; import konkuk.thip.common.exception.InvalidStateException; +import konkuk.thip.common.exception.code.ErrorCode; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import static konkuk.thip.common.exception.code.ErrorCode.USER_ALREADY_FOLLOWED; -import static konkuk.thip.common.exception.code.ErrorCode.USER_ALREADY_UNFOLLOWED; -import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThat; +@DisplayName("[단위] Following 단위 테스트") class FollowingTest { @Nested - @DisplayName("팔로우 요청") - class Follow { + @DisplayName("팔로우 요청인 경우") + class FollowRequest { - @Test - @DisplayName("inactive 상태에서 follow 요청 → active로 변경") - void follow_from_inactive() { - Following following = Following.builder() - .userId(1L) - .followingUserId(2L) - .status(StatusType.INACTIVE) - .build(); + private final boolean isFollowRequest = true; - boolean result = following.changeFollowingState(true); + @Test + @DisplayName("이미 팔로우 중이면 예외 발생") + void alreadyFollowed_shouldThrowException() { + boolean isExistingFollowing = true; - assertThat(result).isTrue(); - assertThat(following.getStatus()).isEqualTo(StatusType.ACTIVE); + assertThatThrownBy(() -> + Following.validateFollowingState(isExistingFollowing, isFollowRequest)) + .isInstanceOf(InvalidStateException.class) + .hasMessageContaining(ErrorCode.USER_ALREADY_FOLLOWED.getMessage()); } @Test - @DisplayName("이미 active 상태에서 follow 요청 → 예외 발생") - void follow_from_active_should_throw() { - Following following = Following.builder() - .userId(1L) - .followingUserId(2L) - .status(StatusType.ACTIVE) - .build(); - - assertThatThrownBy(() -> following.changeFollowingState(true)) - .isInstanceOf(InvalidStateException.class) - .hasMessage(USER_ALREADY_FOLLOWED.getMessage()); + @DisplayName("팔로우 관계가 없으면 true 반환") + void notFollowed_shouldReturnTrue() { + boolean isExistingFollowing = false; + + boolean result = Following.validateFollowingState(isExistingFollowing, isFollowRequest); + + assertThat(result).isTrue(); } } @Nested - @DisplayName("언팔로우 요청") - class Unfollow { + @DisplayName("언팔로우 요청인 경우") + class UnfollowRequest { - @Test - @DisplayName("active 상태에서 unfollow 요청 → inactive로 변경") - void unfollow_from_active() { - Following following = Following.builder() - .userId(1L) - .followingUserId(2L) - .status(StatusType.ACTIVE) - .build(); + private final boolean isFollowRequest = false; - boolean result = following.changeFollowingState(false); + @Test + @DisplayName("팔로우 관계가 없으면 예외 발생") + void notFollowed_shouldThrowException() { + boolean isExistingFollowing = false; - assertThat(result).isFalse(); - assertThat(following.getStatus()).isEqualTo(StatusType.INACTIVE); + assertThatThrownBy(() -> + Following.validateFollowingState(isExistingFollowing, isFollowRequest)) + .isInstanceOf(InvalidStateException.class) + .hasMessageContaining(ErrorCode.USER_ALREADY_UNFOLLOWED.getMessage()); } @Test - @DisplayName("이미 inactive 상태에서 unfollow 요청 → 예외 발생") - void unfollow_from_inactive_should_throw() { - Following following = Following.builder() - .userId(1L) - .followingUserId(2L) - .status(StatusType.INACTIVE) - .build(); - - assertThatThrownBy(() -> following.changeFollowingState(false)) - .isInstanceOf(InvalidStateException.class) - .hasMessage(USER_ALREADY_UNFOLLOWED.getMessage()); - } - } + @DisplayName("팔로우 중이면 false 반환") + void alreadyFollowed_shouldReturnFalse() { + boolean isExistingFollowing = true; - @Test - @DisplayName("새로운 팔로우 생성 시 상태는 ACTIVE") - void create_following_should_be_active() { - Following following = Following.withoutId(1L, 2L); + boolean result = Following.validateFollowingState(isExistingFollowing, isFollowRequest); - assertThat(following.getUserId()).isEqualTo(1L); - assertThat(following.getFollowingUserId()).isEqualTo(2L); - assertThat(following.getStatus()).isEqualTo(StatusType.ACTIVE); + assertThat(result).isFalse(); + } } } \ No newline at end of file