diff --git a/.github/workflows/cd-workflow-prod.yml b/.github/workflows/cd-workflow-prod.yml index 878f1de36..651aecdfa 100644 --- a/.github/workflows/cd-workflow-prod.yml +++ b/.github/workflows/cd-workflow-prod.yml @@ -4,7 +4,6 @@ on: push: branches: - 'main' - - 'develop' permissions: contents: read diff --git a/src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java b/src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java index 1d872ed94..4baac7825 100644 --- a/src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java +++ b/src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java @@ -5,10 +5,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.Pattern; import konkuk.thip.book.adapter.in.web.response.*; -import konkuk.thip.book.application.port.in.BookMostSearchUseCase; -import konkuk.thip.book.application.port.in.BookRecruitingRoomsUseCase; -import konkuk.thip.book.application.port.in.BookSearchUseCase; -import konkuk.thip.book.application.port.in.BookSelectableListUseCase; +import konkuk.thip.book.application.port.in.*; import konkuk.thip.book.application.port.in.dto.BookSelectableType; import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; @@ -32,6 +29,7 @@ public class BookQueryController { private final BookMostSearchUseCase bookMostSearchUseCase; private final BookRecruitingRoomsUseCase bookRecruitingRoomsUseCase; private final BookSelectableListUseCase bookSelectableListUseCase; + private final BookShowSavedListUseCase bookShowSavedListUseCase; @Operation( summary = "책 검색결과 조회", @@ -103,4 +101,13 @@ public BaseResponse showSelectableBookList( ); } + @Operation( + summary = "저장한 책 조회", + description = "사용자가 저장한 책을 조회 합니다." + ) + @GetMapping("/books/saved") + public BaseResponse showSavedBookList(@Parameter(hidden = true) @UserId final Long userId) { + return BaseResponse.ok(BookShowSavedListResponse.of(bookShowSavedListUseCase.getSavedBookList(userId))); + } + } diff --git a/src/main/java/konkuk/thip/book/adapter/in/web/response/BookShowSavedListResponse.java b/src/main/java/konkuk/thip/book/adapter/in/web/response/BookShowSavedListResponse.java new file mode 100644 index 000000000..59b5b9bc5 --- /dev/null +++ b/src/main/java/konkuk/thip/book/adapter/in/web/response/BookShowSavedListResponse.java @@ -0,0 +1,13 @@ +package konkuk.thip.book.adapter.in.web.response; + +import konkuk.thip.book.application.port.in.dto.BookShowSavedInfoResult; + +import java.util.List; + +public record BookShowSavedListResponse( + List bookList +) { + public static BookShowSavedListResponse of(List bookSavedInfoResultList) { + return new BookShowSavedListResponse(bookSavedInfoResultList); + } +} diff --git a/src/main/java/konkuk/thip/book/application/mapper/BookQueryMapper.java b/src/main/java/konkuk/thip/book/application/mapper/BookQueryMapper.java index 2b789b5b4..ceb66faee 100644 --- a/src/main/java/konkuk/thip/book/application/mapper/BookQueryMapper.java +++ b/src/main/java/konkuk/thip/book/application/mapper/BookQueryMapper.java @@ -2,6 +2,7 @@ import konkuk.thip.book.adapter.in.web.response.BookRecruitingRoomsResponse; import konkuk.thip.book.application.port.in.dto.BookSelectableResult; +import konkuk.thip.book.application.port.in.dto.BookShowSavedInfoResult; import konkuk.thip.book.domain.Book; import konkuk.thip.common.util.DateUtil; import konkuk.thip.room.application.port.out.dto.RoomQueryDto; @@ -35,4 +36,16 @@ public interface BookQueryMapper { BookSelectableResult toBookSelectableResult(Book book); List toBookSelectableResultList(List books); + + + @Mapping(target = "bookId", source = "book.id") + @Mapping(target = "bookTitle", source = "book.title") + @Mapping(target = "authorName", source = "book.authorName") + @Mapping(target = "publisher", source = "book.publisher") + @Mapping(target = "bookImageUrl", source = "book.imageUrl") + @Mapping(target = "isbn", source = "book.isbn") + @Mapping(target = "isSaved", constant = "true") + BookShowSavedInfoResult toBookShowSavedInfoResult(Book book); + + List toBookShowSavedInfoResultList(List savedBookList); } diff --git a/src/main/java/konkuk/thip/book/application/port/in/BookShowSavedListUseCase.java b/src/main/java/konkuk/thip/book/application/port/in/BookShowSavedListUseCase.java new file mode 100644 index 000000000..36a9d1ab6 --- /dev/null +++ b/src/main/java/konkuk/thip/book/application/port/in/BookShowSavedListUseCase.java @@ -0,0 +1,9 @@ +package konkuk.thip.book.application.port.in; + +import konkuk.thip.book.application.port.in.dto.BookShowSavedInfoResult; + +import java.util.List; + +public interface BookShowSavedListUseCase { + List getSavedBookList(Long userId); +} diff --git a/src/main/java/konkuk/thip/book/application/port/in/dto/BookShowSavedInfoResult.java b/src/main/java/konkuk/thip/book/application/port/in/dto/BookShowSavedInfoResult.java new file mode 100644 index 000000000..7a52300aa --- /dev/null +++ b/src/main/java/konkuk/thip/book/application/port/in/dto/BookShowSavedInfoResult.java @@ -0,0 +1,12 @@ +package konkuk.thip.book.application.port.in.dto; + +public record BookShowSavedInfoResult( + Long bookId, + String bookTitle, + String authorName, + String publisher, + String bookImageUrl, + String isbn, + boolean isSaved +) { +} diff --git a/src/main/java/konkuk/thip/book/application/service/BookSavedListService.java b/src/main/java/konkuk/thip/book/application/service/BookSavedListService.java new file mode 100644 index 000000000..9621abbdd --- /dev/null +++ b/src/main/java/konkuk/thip/book/application/service/BookSavedListService.java @@ -0,0 +1,27 @@ +package konkuk.thip.book.application.service; + +import konkuk.thip.book.application.mapper.BookQueryMapper; +import konkuk.thip.book.application.port.in.BookShowSavedListUseCase; +import konkuk.thip.book.application.port.in.dto.BookShowSavedInfoResult; +import konkuk.thip.book.application.port.out.BookQueryPort; +import konkuk.thip.book.domain.Book; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BookSavedListService implements BookShowSavedListUseCase { + + private final BookQueryPort bookQueryPort; + private final BookQueryMapper bookQueryMapper; + + @Override + @Transactional(readOnly = true) + public List getSavedBookList(Long userId) { + List savedBookList = bookQueryPort.findSavedBooksByUserId(userId); + return bookQueryMapper.toBookShowSavedInfoResultList(savedBookList); + } +} diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/FeedQueryController.java b/src/main/java/konkuk/thip/feed/adapter/in/web/FeedQueryController.java index 9fb7dd946..fefb00758 100644 --- a/src/main/java/konkuk/thip/feed/adapter/in/web/FeedQueryController.java +++ b/src/main/java/konkuk/thip/feed/adapter/in/web/FeedQueryController.java @@ -31,6 +31,7 @@ public class FeedQueryController { private final FeedShowSingleUseCase feedShowSingleUseCase; private final FeedShowWriteInfoUseCase feedShowWriteInfoUseCase; private final FeedRelatedWithBookUseCase feedRelatedWithBookUseCase; + private final FeedSavedListUseCase feedSavedListUseCase; @Operation( summary = "피드 전체 조회", @@ -134,4 +135,18 @@ public BaseResponse showFeedsByBook( .build()) ); } + + @Operation( + summary = "저장한 피드 조회", + description = "사용자가 저장한 피드를 조회합니다." + ) + @GetMapping("/feeds/saved") + public BaseResponse showSavedFeedList( + @Parameter(description = "커서 (첫번째 요청시 : null, 다음 요청시 : 이전 요청에서 반환받은 nextCursor 값)") + @RequestParam(required = false) final String cursor, + @Parameter(hidden = true) @UserId final Long userId + ) { + return BaseResponse.ok(feedSavedListUseCase.getSavedFeedList(userId,cursor)); + } + } diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedShowSavedListResponse.java b/src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedShowSavedListResponse.java new file mode 100644 index 000000000..3d62131d1 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedShowSavedListResponse.java @@ -0,0 +1,29 @@ +package konkuk.thip.feed.adapter.in.web.response; + +import java.util.List; + +public record FeedShowSavedListResponse( + List feedList, + String nextCursor, + boolean isLast +) { + public record FeedShowSavedInfoDto( + Long feedId, + Long creatorId, + String creatorNickname, + String creatorProfileImageUrl, + String aliasName, + String aliasColor, + String postDate, + String isbn, + String bookTitle, + String bookAuthor, + String contentBody, + String[] contentUrls, + int likeCount, + int commentCount, + boolean isSaved, + boolean isLiked, + boolean isWriter + ) { } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java index ea880cef8..54de7ad26 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java @@ -109,6 +109,19 @@ public boolean existsSavedFeedByUserIdAndFeedId(Long userId, Long feedId) { return savedFeedJpaRepository.existsByUserIdAndFeedId(userId, feedId); } + @Override + public CursorBasedList findSavedFeedsByCreatedAt(Long userId, Cursor cursor) { + LocalDateTime lastCreatedAt = cursor.isFirstRequest() ? null : cursor.getLocalDateTime(0); + int size = cursor.getPageSize(); + + List feedQueryDtos = feedJpaRepository.findSavedFeedsByCreatedAt(userId, lastCreatedAt, size); + + return CursorBasedList.of(feedQueryDtos, size, feedQueryDto -> { + Cursor nextCursor = new Cursor(List.of(feedQueryDto.savedCreatedAt().toString())); + return nextCursor.toEncodedString(); + }); + } + @Override public List findAllTags() { return feedJpaRepository.findAllTags(); diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java index 51e7c1ce6..c2588f32c 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java @@ -25,4 +25,6 @@ public interface FeedQueryRepository { List findFeedsByBookIsbnOrderByCreatedAt(String isbn, Long userId, LocalDateTime lastCreatedAt, int size); List findLatestPublicFeedCreatorsIn(Set userIds, int size); + + List findSavedFeedsByCreatedAt(Long userId, LocalDateTime lastCreatedAt, int size); } diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java index 3bf650402..74467d4d7 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java @@ -38,6 +38,7 @@ public class FeedQueryRepositoryImpl implements FeedQueryRepository { private final QAliasJpaEntity alias = QAliasJpaEntity.aliasJpaEntity; private final QBookJpaEntity book = QBookJpaEntity.bookJpaEntity; private final QFollowingJpaEntity following = QFollowingJpaEntity.followingJpaEntity; + private final QSavedFeedJpaEntity savedFeed = QSavedFeedJpaEntity.savedFeedJpaEntity; @Override public Set findUserIdsByBookId(Long bookId) { @@ -364,6 +365,7 @@ private QFeedQueryDto toQueryDto() { feed.likeCount, feed.commentCount, feed.isPublic, + Expressions.nullExpression(), Expressions.nullExpression() ); } @@ -399,4 +401,58 @@ public List findLatestPublicFeedCreatorsIn(Set userIds, int size) { .limit(size) .fetch(); } + + @Override + public List findSavedFeedsByCreatedAt(Long userId, LocalDateTime lastSavedAt, int size) { + + BooleanExpression where = savedFeed.userJpaEntity.userId.eq(userId) + .and(savedFeed.feedJpaEntity.status.eq(StatusType.ACTIVE)) + .and( + savedFeed.feedJpaEntity.userJpaEntity.userId.eq(userId) + .or(savedFeed.feedJpaEntity.isPublic.eq(true)) + ); + + if (lastSavedAt != null) { + where = where.and(savedFeed.createdAt.lt(lastSavedAt)); + } + + return jpaQueryFactory + .select(toSavedFeedQueryDto()) + .from(savedFeed) + .join(savedFeed.feedJpaEntity, feed) + .join(feed.userJpaEntity, user) + .join(feed.bookJpaEntity, book) + .where(where) + .orderBy(savedFeed.createdAt.desc()) + .limit(size + 1) + .fetch(); + } + + /** + * SavedFeed 전용 DTO 매핑 + */ + private QFeedQueryDto toSavedFeedQueryDto() { + return new QFeedQueryDto( + feed.postId, + feed.userJpaEntity.userId, + user.nickname, + user.aliasForUserJpaEntity.imageUrl, + user.aliasForUserJpaEntity.value, + feed.createdAt, + book.isbn, + book.title, + book.authorName, + feed.content, + // Content는 N:1 방지 위해 서브쿼리 사용 + JPAExpressions + .select(contentUrlAggExpr()) + .from(content) + .where(content.postJpaEntity.postId.eq(feed.postId)), + feed.likeCount, + feed.commentCount, + feed.isPublic, + Expressions.nullExpression(), + savedFeed.createdAt + ); + } } diff --git a/src/main/java/konkuk/thip/feed/application/mapper/FeedQueryMapper.java b/src/main/java/konkuk/thip/feed/application/mapper/FeedQueryMapper.java index 81a1a3b05..0423a30fb 100644 --- a/src/main/java/konkuk/thip/feed/application/mapper/FeedQueryMapper.java +++ b/src/main/java/konkuk/thip/feed/application/mapper/FeedQueryMapper.java @@ -174,4 +174,14 @@ default List toFeedRelatedWi .collect(Collectors.toList()); } -} + @Mapping(target = "aliasName", source = "dto.alias") + @Mapping(target = "aliasColor", expression = "java(Alias.from(dto.alias()).getColor())") + @Mapping(target = "isSaved", constant = "true") + @Mapping(target = "isLiked", expression = "java(likedFeedIds.contains(dto.feedId()))") + @Mapping( + target = "postDate", + expression = "java(DateUtil.formatBeforeTime(dto.createdAt()))" + ) + @Mapping(target = "isWriter", source = "dto.creatorId", qualifiedByName = "isWriter") + FeedShowSavedListResponse.FeedShowSavedInfoDto toFeedShowSavedListResponse(FeedQueryDto dto, Set likedFeedIds, @Context Long userId); +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/feed/application/port/in/FeedSavedListUseCase.java b/src/main/java/konkuk/thip/feed/application/port/in/FeedSavedListUseCase.java new file mode 100644 index 000000000..c732a0ceb --- /dev/null +++ b/src/main/java/konkuk/thip/feed/application/port/in/FeedSavedListUseCase.java @@ -0,0 +1,7 @@ +package konkuk.thip.feed.application.port.in; + +import konkuk.thip.feed.adapter.in.web.response.FeedShowSavedListResponse; + +public interface FeedSavedListUseCase { + FeedShowSavedListResponse getSavedFeedList(Long userId, String cursor); +} diff --git a/src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java b/src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java index b4d7a903d..d0e92d3e8 100644 --- a/src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java +++ b/src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java @@ -37,8 +37,8 @@ public interface FeedQueryPort { */ Set findSavedFeedIdsByUserIdAndFeedIds(Set feedIds, Long userId); - boolean existsSavedFeedByUserIdAndFeedId(Long userId, Long feedId); + CursorBasedList findSavedFeedsByCreatedAt(Long userId, Cursor cursor); /** * 특정 책으로 작성된 피드 조회 diff --git a/src/main/java/konkuk/thip/feed/application/port/out/dto/FeedQueryDto.java b/src/main/java/konkuk/thip/feed/application/port/out/dto/FeedQueryDto.java index 66b3f857b..27b93c606 100644 --- a/src/main/java/konkuk/thip/feed/application/port/out/dto/FeedQueryDto.java +++ b/src/main/java/konkuk/thip/feed/application/port/out/dto/FeedQueryDto.java @@ -22,7 +22,8 @@ public record FeedQueryDto( Integer likeCount, Integer commentCount, boolean isPublic, - @Nullable Boolean isPriorityFeed + @Nullable Boolean isPriorityFeed, + @Nullable LocalDateTime savedCreatedAt ) { @QueryProjection public FeedQueryDto( @@ -40,7 +41,8 @@ public FeedQueryDto( Integer likeCount, Integer commentCount, Boolean isPublic, - @Nullable Boolean isPriorityFeed + @Nullable Boolean isPriorityFeed, + @Nullable LocalDateTime savedCreatedAt ) { this( feedId, @@ -57,7 +59,8 @@ public FeedQueryDto( likeCount == null ? 0 : likeCount, commentCount == null ? 0 : commentCount, isPublic, - isPriorityFeed + isPriorityFeed, + savedCreatedAt ); } diff --git a/src/main/java/konkuk/thip/feed/application/service/FeedShowSavedListService.java b/src/main/java/konkuk/thip/feed/application/service/FeedShowSavedListService.java new file mode 100644 index 000000000..cec8e5203 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/application/service/FeedShowSavedListService.java @@ -0,0 +1,54 @@ +package konkuk.thip.feed.application.service; + +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; +import konkuk.thip.feed.adapter.in.web.response.FeedShowSavedListResponse; +import konkuk.thip.feed.application.mapper.FeedQueryMapper; +import konkuk.thip.feed.application.port.in.FeedSavedListUseCase; +import konkuk.thip.feed.application.port.out.FeedQueryPort; +import konkuk.thip.feed.application.port.out.dto.FeedQueryDto; +import konkuk.thip.post.application.port.out.PostLikeQueryPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class FeedShowSavedListService implements FeedSavedListUseCase { + + private static final int PAGE_SIZE = 10; + private final FeedQueryPort feedQueryPort; + private final PostLikeQueryPort postLikeQueryPort; + private final FeedQueryMapper feedQueryMapper; + + @Override + @Transactional(readOnly = true) + public FeedShowSavedListResponse getSavedFeedList(Long userId, String cursor) { + // 1. 커서 생성 + Cursor nextCursor = Cursor.from(cursor, PAGE_SIZE); + + // 2. 유저가 저장한 책 최신순으로 (페이징 처리 포함) + CursorBasedList result = feedQueryPort.findSavedFeedsByCreatedAt(userId, nextCursor); + Set feedIds = result.contents().stream() + .map(FeedQueryDto::feedId) + .collect(Collectors.toUnmodifiableSet()); + + // 3. 유저가 좋아한 피드들 조회 + Set likedFeedIdsByUser = postLikeQueryPort.findPostIdsLikedByUser(feedIds, userId); + + // 4. response 로의 매핑 + var feedList = result.contents().stream() + .map(dto -> feedQueryMapper.toFeedShowSavedListResponse(dto, likedFeedIdsByUser, userId)) + .toList(); + + return new FeedShowSavedListResponse( + feedList, + result.nextCursor(), + !result.hasNext() + ); + } +} diff --git a/src/test/java/konkuk/thip/book/adapter/in/web/BookShowSavedListApiTest.java b/src/test/java/konkuk/thip/book/adapter/in/web/BookShowSavedListApiTest.java new file mode 100644 index 000000000..d19521bbf --- /dev/null +++ b/src/test/java/konkuk/thip/book/adapter/in/web/BookShowSavedListApiTest.java @@ -0,0 +1,61 @@ +package konkuk.thip.book.adapter.in.web; + +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.book.adapter.out.persistence.repository.SavedBookJpaRepository; +import konkuk.thip.common.util.TestEntityFactory; +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.transaction.annotation.Transactional; + +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 +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +@Transactional +@DisplayName("[통합] 저장한 책 조회 API 통합 테스트") +class BookShowSavedListApiTest { + + @Autowired private MockMvc mockMvc; + + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private AliasJpaRepository aliasJpaRepository; + @Autowired private BookJpaRepository bookJpaRepository; + @Autowired private SavedBookJpaRepository savedBookJpaRepository; + + private UserJpaEntity user; + private BookJpaEntity savedBook; + + @BeforeEach + void setUp() { + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createLiteratureAlias()); + user = userJpaRepository.save(TestEntityFactory.createUser(alias)); + savedBook = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("1111111111111")); + savedBookJpaRepository.save(TestEntityFactory.createSavedBook(user, savedBook)); + } + + @Test + @DisplayName("사용자가 저장한 책을 정상적으로 호출한다.") + void getSavedBooks_success() throws Exception { + mockMvc.perform(get("/books/saved") + .requestAttr("userId", user.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.bookList").isArray()) + .andExpect(jsonPath("$.data.bookList.length()").value(1)) + .andExpect(jsonPath("$.data.bookList[0].isbn").value(savedBook.getIsbn())); + } + +} \ No newline at end of file diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedShowSavedListApiTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedShowSavedListApiTest.java new file mode 100644 index 000000000..6eea07861 --- /dev/null +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedShowSavedListApiTest.java @@ -0,0 +1,313 @@ +package konkuk.thip.feed.adapter.in.web; + +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; +import konkuk.thip.feed.adapter.out.jpa.SavedFeedJpaEntity; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; +import konkuk.thip.feed.adapter.out.persistence.repository.SavedFeedJpaRepository; +import konkuk.thip.post.adapter.out.jpa.PostLikeJpaEntity; +import konkuk.thip.post.adapter.out.persistence.PostLikeJpaRepository; +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.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.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.*; +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") +@Transactional +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[통합] 저장한 피드 조회 api 통합 테스트") +class FeedShowSavedListApiTest { + + @Autowired private MockMvc mockMvc; + + @Autowired private AliasJpaRepository aliasJpaRepository; + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private FeedJpaRepository feedJpaRepository; + @Autowired private BookJpaRepository bookJpaRepository; + @Autowired private SavedFeedJpaRepository savedFeedJpaRepository; + @Autowired private PostLikeJpaRepository postLikeJpaRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + @DisplayName("저장된 피드 조회 시 피드 정보를 피드를 저장한 최신순으로 정렬해서 반환한다.") + void saved_feed_show_test_success() throws Exception { + // given + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(alias, "me")); + UserJpaEntity user1 = userJpaRepository.save(TestEntityFactory.createUser(alias, "user1")); + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + + // 피드 생성 + LocalDateTime baseTime = LocalDateTime.now(); + FeedJpaEntity f1 = feedJpaRepository.save( + TestEntityFactory.createFeed(me, book, true, 10, 5, List.of("contentUrl1", "contentUrl2"))); + FeedJpaEntity f2 = feedJpaRepository.save( + TestEntityFactory.createFeed(user1, book, true, 50, 10, List.of())); + // me가 f2를 좋아요 + postLikeJpaRepository.save( + PostLikeJpaEntity.builder() + .userJpaEntity(me) + .postJpaEntity(f2) + .build() + ); + + // 저장 시연 - me가 f1 저장 + SavedFeedJpaEntity sf1 = savedFeedJpaRepository.save(SavedFeedJpaEntity.builder() + .userJpaEntity(me) + .feedJpaEntity(f1) + .build()); + + // 저장 시연 - me가 f2 저장 (조금 더 이전) + SavedFeedJpaEntity sf2 = savedFeedJpaRepository.save(SavedFeedJpaEntity.builder() + .userJpaEntity(me) + .feedJpaEntity(f2) + .build()); + + // flush 후 feed 저장일자 덮어쓰기 + // feed 저장 순서 : f2 -> f1 (f1 이 가장 최신) + savedFeedJpaRepository.flush(); + jdbcTemplate.update("UPDATE saved_feeds SET created_at = ? WHERE saved_id = ?", + Timestamp.valueOf(baseTime.minusMinutes(1)), sf1.getSavedId()); + jdbcTemplate.update("UPDATE saved_feeds SET created_at = ? WHERE saved_id = ?", + Timestamp.valueOf(baseTime.minusMinutes(10)), sf2.getSavedId()); + + // when & then + mockMvc.perform(get("/feeds/saved") + .requestAttr("userId", me.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedList", hasSize(2))) + // 저장한 최신순 -> f1 먼저 + .andExpect(jsonPath("$.data.feedList[0].feedId", is(f1.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[0].creatorNickname", is("me"))) + .andExpect(jsonPath("$.data.feedList[0].contentUrls", hasSize(2))) + .andExpect(jsonPath("$.data.feedList[0].likeCount", is(10))) + .andExpect(jsonPath("$.data.feedList[0].commentCount", is(5))) + .andExpect(jsonPath("$.data.feedList[0].isSaved", is(true))) + .andExpect(jsonPath("$.data.feedList[0].isLiked", is(false))) + // 두 번째는 f2 + .andExpect(jsonPath("$.data.feedList[1].feedId", is(f2.getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[1].creatorNickname", is("user1"))) + .andExpect(jsonPath("$.data.feedList[1].contentUrls", hasSize(0))) + .andExpect(jsonPath("$.data.feedList[1].likeCount", is(50))) + .andExpect(jsonPath("$.data.feedList[1].commentCount", is(10))) + .andExpect(jsonPath("$.data.feedList[1].isSaved", is(true))) + .andExpect(jsonPath("$.data.feedList[1].isLiked", is(true))); + } + + @Test + @DisplayName("request parameter의 cursor 값이 null일 경우, 첫번째 페이지에 해당하는 피드 10개와, nextCursor, last 값을 반환한다.") + void saved_feed_show_with_first_page() throws Exception { + + // given + AliasJpaEntity a0 = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(a0, "me")); + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + + // 피드 12개 생성 및 저장 + LocalDateTime baseTime = LocalDateTime.now(); + FeedJpaEntity[] feeds = new FeedJpaEntity[12]; + SavedFeedJpaEntity[] savedFeeds = new SavedFeedJpaEntity[12]; + + for (int i = 0; i < 12; i++) { + FeedJpaEntity feed = feedJpaRepository.save( + TestEntityFactory.createFeed(me, book, true, 10 + i, 5 + i, List.of("contentUrl" + i))); + feeds[i] = feed; + SavedFeedJpaEntity savedFeed = savedFeedJpaRepository.save( + SavedFeedJpaEntity.builder() + .userJpaEntity(me) + .feedJpaEntity(feed) + .build() + ); + savedFeeds[i] = savedFeed; + } + savedFeedJpaRepository.flush(); + + // created_at 덮어쓰기 feedId가 작을수록 최신 저장순 + for (int i = 0; i < 12; i++) { + jdbcTemplate.update("UPDATE saved_feeds SET created_at = ? WHERE saved_id = ?", + Timestamp.valueOf(baseTime.minusMinutes(i)), savedFeeds[i].getSavedId()); + } + + // when & then + mockMvc.perform(get("/feeds/saved") + .requestAttr("userId", me.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedList", hasSize(10))) + .andExpect(jsonPath("$.data.nextCursor", notNullValue())) + .andExpect(jsonPath("$.data.isLast", is(false))) + /** + * 정렬 조건 + * 저장한 피드를 저장한 순으로 조회 + */ + .andExpect(jsonPath("$.data.feedList[0].feedId", is(feeds[0].getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[1].feedId", is(feeds[1].getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[2].feedId", is(feeds[2].getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[3].feedId", is(feeds[3].getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[4].feedId", is(feeds[4].getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[5].feedId", is(feeds[5].getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[6].feedId", is(feeds[6].getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[7].feedId", is(feeds[7].getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[8].feedId", is(feeds[8].getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[9].feedId", is(feeds[9].getPostId().intValue()))); + } + + @Test + @DisplayName("request parameter의 cursor 값이 존재할 경우, 해당 페이지에 해당하는 피드 10개와, nextCursor, last 값을 반환한다.") + void saved_feed_show_with_cursor() throws Exception { + // given + AliasJpaEntity a0 = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(a0, "me")); + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + + // 피드 12개 생성 및 저장 + LocalDateTime baseTime = LocalDateTime.now(); + FeedJpaEntity[] feeds = new FeedJpaEntity[12]; + SavedFeedJpaEntity[] savedFeeds = new SavedFeedJpaEntity[12]; + + for (int i = 0; i < 12; i++) { + FeedJpaEntity feed = feedJpaRepository.save( + TestEntityFactory.createFeed(me, book, true, 10 + i, 5 + i, List.of("contentUrl" + i))); + feeds[i] = feed; + SavedFeedJpaEntity savedFeed = savedFeedJpaRepository.save( + SavedFeedJpaEntity.builder() + .userJpaEntity(me) + .feedJpaEntity(feed) + .build() + ); + savedFeeds[i] = savedFeed; + } + savedFeedJpaRepository.flush(); + + // created_at 덮어쓰기 feedId가 작을수록 최신 저장순 + for (int i = 0; i < 12; i++) { + jdbcTemplate.update("UPDATE saved_feeds SET created_at = ? WHERE saved_id = ?", + Timestamp.valueOf(baseTime.minusMinutes(i)), savedFeeds[i].getSavedId()); + } + + // DB에 저장된 sf9의 createdAt 값을 native query 로 조회 + LocalDateTime nextCursorVal = jdbcTemplate.queryForObject( + "SELECT created_at FROM saved_feeds WHERE saved_id = ?", + (rs, rowNum) -> rs.getTimestamp("created_at").toLocalDateTime(), savedFeeds[9].getSavedId() + ); + String nextCursor = nextCursorVal.toString(); + + //when //then + mockMvc.perform(get("/feeds/saved") + .requestAttr("userId", me.getUserId()) + .param("cursor", nextCursor)) // 이전에 f9 까지 조회 -> f9의 저장시간, sf9의 createdAt이 커서 + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.nextCursor", nullValue())) // nextCursor 는 null + .andExpect(jsonPath("$.data.isLast", is(true))) + .andExpect(jsonPath("$.data.feedList", hasSize(2))) + .andExpect(jsonPath("$.data.feedList[0].feedId", is(feeds[10].getPostId().intValue()))) + .andExpect(jsonPath("$.data.feedList[1].feedId", is(feeds[11].getPostId().intValue()))); + } + +// @Test +// @DisplayName("[깨짐 재현] 최신 저장 피드에 contents가 많으면 첫 페이지 결과 개수가 10개보다 적게 반환된다") +// void saved_feed_paging_breaks_with_many_contents() throws Exception { +// // given +// AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); +// UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(alias, "me")); +// BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); +// +// LocalDateTime baseTime = LocalDateTime.now(); +// +// // 피드 12개 생성. 가장 최신(f0)과 그 다음(f1, f2)에 많은 contents 부여 → row 폭발 유도 +// FeedJpaEntity[] feeds = new FeedJpaEntity[12]; +// SavedFeedJpaEntity[] savedFeeds = new SavedFeedJpaEntity[12]; +// +// for (int i = 0; i < 12; i++) { +// List contentUrls; +// if (i == 0) { +// // 최신 저장된 피드: 컨텐츠 3개 +// contentUrls = java.util.stream.IntStream.range(0, 3) +// .mapToObj(n -> "url_f0_" + n) +// .toList(); +// } else if (i == 1) { +// // 두 번째: 컨텐츠 3개 +// contentUrls = java.util.stream.IntStream.range(0, 3) +// .mapToObj(n -> "url_f1_" + n) +// .toList(); +// } else if (i == 2) { +// // 세 번째: 컨텐츠 2개 +// contentUrls = java.util.stream.IntStream.range(0, 2) +// .mapToObj(n -> "url_f2_" + n) +// .toList(); +// } else { +// // 나머지: 컨텐츠 0개 +// contentUrls = List.of(); +// } +// +// FeedJpaEntity feed = feedJpaRepository.save( +// TestEntityFactory.createFeed( +// me, // 작성자: me (가시성 조건 단순화) +// book, +// true, // 공개 +// 10 + i, // likeCount +// 5 + i, // commentCount +// contentUrls +// ) +// ); +// feeds[i] = feed; +// +// SavedFeedJpaEntity saved = savedFeedJpaRepository.save( +// SavedFeedJpaEntity.builder() +// .userJpaEntity(me) +// .feedJpaEntity(feed) +// .build() +// ); +// savedFeeds[i] = saved; +// } +// savedFeedJpaRepository.flush(); +// +// // created_at 덮어쓰기: i가 작을수록 더 최신 (f0가 가장 최신) +// for (int i = 0; i < 12; i++) { +// jdbcTemplate.update("UPDATE saved_feeds SET created_at = ? WHERE saved_id = ?", +// Timestamp.valueOf(baseTime.minusMinutes(i)), savedFeeds[i].getSavedId()); +// } +// +// // when & then +// mockMvc.perform(get("/feeds/saved") +// .requestAttr("userId", me.getUserId())) +// .andExpect(status().isOk()) +// // ⚠️ 현재 구현(컬렉션 fetch join + limit)에서는 행 기준으로 잘려 루트 엔티티 개수가 모자라게 반환됨 +// .andExpect(jsonPath("$.data.feedList", hasSize(10))) // 총 응답개수는 10개가 맞음 +// .andExpect(result -> { +// String body = result.getResponse().getContentAsString(); +// // com.jayway.jsonpath.JsonPath 사용 (spring-boot-starter-test에 포함) +// List ids = com.jayway.jsonpath.JsonPath.read(body, "$.data.feedList[*].feedId"); +// Set distinct = new HashSet<>(ids); +// // 중복이 있음을 기대(= 실패를 통해 버그 재현). 만약 여기서 실패하면 중복이 안 나온 것. +// assertThat(distinct.size()) +// .as("feedId가 중복 없이 10개 모두 달라야 하지만, 1:N 조인으로 인해 중복이 발생해야 합니다.") +// .isLessThan(ids.size()); +// }); +// } +}