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 4baac7825..63c7ce2c2 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 @@ -94,10 +94,12 @@ public BaseResponse showRecruitingRoomsWithBook( @GetMapping("/books/selectable-list") public BaseResponse showSelectableBookList( @Parameter(description = "저장한 책 또는 참여 중인 모임의 책을 구분하는 필드 (SAVED : 저장한 책 / JOINING : 모임 방의 책)", example = "SAVED") @RequestParam final String type, - @Parameter(hidden = true) @UserId final Long userId - ) { + @Parameter(hidden = true) @UserId final Long userId, + @Parameter(description = "커서 (첫번째 요청시 : null, 다음 요청시 : 이전 요청에서 반환받은 nextCursor 값)") + @RequestParam(required = false) final String cursor) + { return BaseResponse.ok( - BookSelectableListResponse.of(bookSelectableListUseCase.getSelectableBookList(BookSelectableType.from(type), userId)) + bookSelectableListUseCase.getSelectableBookList(BookSelectableType.from(type), userId, cursor) ); } @@ -106,8 +108,11 @@ public BaseResponse showSelectableBookList( description = "사용자가 저장한 책을 조회 합니다." ) @GetMapping("/books/saved") - public BaseResponse showSavedBookList(@Parameter(hidden = true) @UserId final Long userId) { - return BaseResponse.ok(BookShowSavedListResponse.of(bookShowSavedListUseCase.getSavedBookList(userId))); + public BaseResponse showSavedBookList( + @Parameter(hidden = true) @UserId final Long userId, + @Parameter(description = "커서 (첫번째 요청시 : null, 다음 요청시 : 이전 요청에서 반환받은 nextCursor 값)") + @RequestParam(required = false) final String cursor) { + return BaseResponse.ok(bookShowSavedListUseCase.getSavedBookList(userId, cursor)); } } diff --git a/src/main/java/konkuk/thip/book/adapter/in/web/response/BookSelectableListResponse.java b/src/main/java/konkuk/thip/book/adapter/in/web/response/BookSelectableListResponse.java index b45bae078..742fe944d 100644 --- a/src/main/java/konkuk/thip/book/adapter/in/web/response/BookSelectableListResponse.java +++ b/src/main/java/konkuk/thip/book/adapter/in/web/response/BookSelectableListResponse.java @@ -1,13 +1,22 @@ package konkuk.thip.book.adapter.in.web.response; -import konkuk.thip.book.application.port.in.dto.BookSelectableResult; - import java.util.List; public record BookSelectableListResponse( - List bookList + List bookList, + String nextCursor, + boolean isLast ) { - public static BookSelectableListResponse of(List bookSelectableResults) { - return new BookSelectableListResponse(bookSelectableResults); + public record BookSelectableDto( + Long bookId, + String bookTitle, + String authorName, + String publisher, + String bookImageUrl, + String isbn + ) {} + + public static BookSelectableListResponse of(List bookList, String nextCursor, boolean isLast) { + return new BookSelectableListResponse(bookList, nextCursor, isLast); } } 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 index 59b5b9bc5..f91e99e26 100644 --- 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 @@ -1,13 +1,23 @@ 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 + List bookList, + String nextCursor, + boolean isLast ) { - public static BookShowSavedListResponse of(List bookSavedInfoResultList) { - return new BookShowSavedListResponse(bookSavedInfoResultList); + public record BookShowSavedDto( + Long bookId, + String bookTitle, + String authorName, + String publisher, + String bookImageUrl, + String isbn, + boolean isSaved + ) { + } + public static BookShowSavedListResponse of(List bookList, String nextCursor, boolean isLast) { + return new BookShowSavedListResponse(bookList, nextCursor, isLast); } } diff --git a/src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java index e11deff83..8927c5453 100644 --- a/src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java @@ -4,18 +4,16 @@ import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; import konkuk.thip.book.adapter.out.persistence.repository.SavedBookJpaRepository; import konkuk.thip.book.application.port.out.BookQueryPort; -import konkuk.thip.book.domain.Book; -import konkuk.thip.common.exception.EntityNotFoundException; -import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.book.application.port.out.dto.BookQueryDto; +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; import java.util.List; import java.util.Set; -import java.util.stream.Collectors; - -import static konkuk.thip.common.exception.code.ErrorCode.USER_NOT_FOUND; @Repository @RequiredArgsConstructor @@ -37,28 +35,38 @@ public boolean existsBookByIsbn(String isbn) { } @Override - public List findSavedBooksByUserId(Long userId) { - UserJpaEntity user = userJpaRepository.findById(userId) - .orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND)); + public CursorBasedList findSavedBooksBySavedAt(Long userId, Cursor cursor) { + LocalDateTime lastSavedAt = cursor.isFirstRequest() ? null : cursor.getLocalDateTime(0); + int pageSize = cursor.getPageSize(); + + List dtos = bookJpaRepository.findSavedBooksBySavedAt(userId, lastSavedAt, pageSize); - return bookJpaRepository.findSavedBooksByUserId(user.getUserId()).stream() - .map(bookMapper::toDomainEntity) - .collect(Collectors.toList()); + return CursorBasedList.of(dtos, pageSize, dto -> { + Cursor nextCursor = new Cursor(List.of(dto.savedCreatedAt().toString())); + return nextCursor.toEncodedString(); + }); } @Override - public List findJoiningRoomsBooksByUserId(Long userId) { - UserJpaEntity user = userJpaRepository.findById(userId) - .orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND)); + public CursorBasedList findJoiningRoomsBooksByRoomPercentage(Long userId, Cursor cursor) { + Double lastRoomPercentage = cursor.isFirstRequest() ? null : cursor.getDouble(0); + Long lastBookId = cursor.isFirstRequest() ? null : cursor.getLong(1); + int pageSize = cursor.getPageSize(); - return bookJpaRepository.findJoiningRoomsBooksByUserId(user.getUserId()) - .stream() - .map(bookMapper::toDomainEntity) - .collect(Collectors.toList()); + List dtos = bookJpaRepository.findJoiningRoomsBooksByRoomPercentage(userId, lastRoomPercentage, lastBookId, pageSize); + + return CursorBasedList.of(dtos, pageSize, dto -> { + Cursor nextCursor = new Cursor(List.of( + dto.roomPercentage().toString(), // 내림차순 필드, 정렬순서 1 + dto.bookId().toString() // 고유 ID, 중복 방지용 + )); + return nextCursor.toEncodedString(); + }); } @Override public Set findUnusedBookIds() { return bookJpaRepository.findUnusedBookIds(); } + } diff --git a/src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookJpaRepository.java b/src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookJpaRepository.java index 60390d96e..dec4dd285 100644 --- a/src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookJpaRepository.java +++ b/src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookJpaRepository.java @@ -4,30 +4,12 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; -import java.util.List; import java.util.Optional; import java.util.Set; -public interface BookJpaRepository extends JpaRepository { +public interface BookJpaRepository extends JpaRepository,BookQueryRepository { Optional findByIsbn(String isbn); - @Query("SELECT b FROM BookJpaEntity b " + - "JOIN SavedBookJpaEntity s ON s.bookJpaEntity.bookId = b.bookId " + - "WHERE s.userJpaEntity.userId = :userId " + - "GROUP BY b " + - "ORDER BY MAX(s.createdAt) DESC") - List findSavedBooksByUserId(Long userId); - - @Query("SELECT b FROM BookJpaEntity b " + - "JOIN RoomJpaEntity r ON r.bookJpaEntity.bookId = b.bookId " + - "JOIN RoomParticipantJpaEntity rp ON rp.roomJpaEntity.roomId = r.roomId " + - "WHERE rp.userJpaEntity.userId = :userId " + - " AND r.status = 'ACTIVE' " + - " AND r.startDate <= CURRENT_TIMESTAMP " + // 진행 중인 방만 조회 (모집 중 / 만료된 방 x) - "GROUP BY b " + - "ORDER BY MAX(r.roomPercentage) DESC") // 방의 진행률이 높은 순서로 정렬 - List findJoiningRoomsBooksByUserId(Long userId); - boolean existsByIsbn(String isbn); // Room, Feed, SavedBook에 모두 참조되지 않은 책 ID만 찾는 쿼리 diff --git a/src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookQueryRepository.java b/src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookQueryRepository.java new file mode 100644 index 000000000..16c31d01f --- /dev/null +++ b/src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookQueryRepository.java @@ -0,0 +1,12 @@ +package konkuk.thip.book.adapter.out.persistence.repository; + +import konkuk.thip.book.application.port.out.dto.BookQueryDto; + +import java.time.LocalDateTime; +import java.util.List; + +public interface BookQueryRepository { + List findSavedBooksBySavedAt(Long userId, LocalDateTime lastSavedAt, int pageSize); + + List findJoiningRoomsBooksByRoomPercentage(Long userId, Double lastRoomPercentage, Long lastBookId, int pageSize); +} diff --git a/src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookQueryRepositoryImpl.java b/src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookQueryRepositoryImpl.java new file mode 100644 index 000000000..2f96dd978 --- /dev/null +++ b/src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookQueryRepositoryImpl.java @@ -0,0 +1,117 @@ +package konkuk.thip.book.adapter.out.persistence.repository; + +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import konkuk.thip.book.adapter.out.jpa.QBookJpaEntity; +import konkuk.thip.book.adapter.out.jpa.QSavedBookJpaEntity; +import konkuk.thip.book.application.port.out.dto.BookQueryDto; +import konkuk.thip.book.application.port.out.dto.QBookQueryDto; +import konkuk.thip.common.entity.StatusType; +import konkuk.thip.room.adapter.out.jpa.QRoomJpaEntity; +import konkuk.thip.room.adapter.out.jpa.QRoomParticipantJpaEntity; +import konkuk.thip.user.adapter.out.jpa.QUserJpaEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static konkuk.thip.common.entity.StatusType.ACTIVE; + +@Repository +@RequiredArgsConstructor +public class BookQueryRepositoryImpl implements BookQueryRepository { + + private final JPAQueryFactory jpaQueryFactory; + + private final QUserJpaEntity user = QUserJpaEntity.userJpaEntity; + private final QBookJpaEntity book = QBookJpaEntity.bookJpaEntity; + private final QSavedBookJpaEntity savedBook = QSavedBookJpaEntity.savedBookJpaEntity; + + @Override + public List findSavedBooksBySavedAt(Long userId, LocalDateTime savedAtCursor, int pageSize) { + + // 검색 조건(where) 조립 + // 유저가 저장한 책만: userId 조건 + // 존재하는 유저만: ACTIVE + BooleanBuilder where = new BooleanBuilder(); + where.and(savedBook.userJpaEntity.userId.eq(userId)); + where.and(user.status.eq(ACTIVE)); + + if (savedAtCursor != null) { + where.and(savedBook.createdAt.lt(savedAtCursor)); // 커서 기준: 저장일 기준 최신순 + } + + return jpaQueryFactory + .select(new QBookQueryDto( + book.bookId, + book.title, + book.authorName, + book.publisher, + book.imageUrl, + book.isbn, + savedBook.createdAt + )) + .from(savedBook) + .join(savedBook.userJpaEntity, user) + .join(savedBook.bookJpaEntity, book) + .where(where) + .orderBy(savedBook.createdAt.desc()) // 저장한 시간 최신순 (내림차순) + .limit(pageSize + 1) + .fetch(); + } + + @Override + public List findJoiningRoomsBooksByRoomPercentage(Long userId, Double roomPercentageCursor, Long bookIdCursor, int pageSize) { + + QRoomJpaEntity room = QRoomJpaEntity.roomJpaEntity; + QRoomParticipantJpaEntity participant = QRoomParticipantJpaEntity.roomParticipantJpaEntity; + + NumberExpression maxRoomPercentage = Expressions.numberTemplate(Double.class, "max({0})", room.roomPercentage); + + // 검색 조건(where) 조립 + // 유저가 참여한 방만: userId 조건 + // 존재하는 유저 방 관계만: ACTIVE, 존재하는 유저만: ACTIVE, 존재하는 방만: ACTIVE + // 활동 기간 중인 방만: startDate ≤ today ≤ endDate + BooleanBuilder where = new BooleanBuilder(); + where.and(participant.userJpaEntity.userId.eq(userId)); + where.and(participant.status.eq(ACTIVE)); + where.and(user.status.eq(ACTIVE)); + where.and(room.status.eq(StatusType.ACTIVE)); + where.and(room.startDate.loe(LocalDate.now())); + where.and(room.endDate.goe(LocalDate.now())); + + BooleanBuilder having = new BooleanBuilder(); + if (roomPercentageCursor != null && bookIdCursor != null) { + having.and( + maxRoomPercentage.lt(roomPercentageCursor) + .or(maxRoomPercentage.eq(roomPercentageCursor).and(book.bookId.gt(bookIdCursor))) + ); + } + + return jpaQueryFactory + .select(new QBookQueryDto( + book.bookId, + book.title, + book.authorName, + book.publisher, + book.imageUrl, + book.isbn, + maxRoomPercentage + )) + .from(room) + .join(participant).on(participant.roomJpaEntity.eq(room)) + .join(participant.userJpaEntity, user) + .join(room.bookJpaEntity, book) + .where(where) + .groupBy(book.bookId) + .having(having) // 집계 함수 조건은 having 절에 넣기 + .orderBy(maxRoomPercentage.desc(), book.bookId.asc()) // 방 진행도 높은 순 (내림차순), 같으면 책 아이디 작은 순 (오름차순) + .limit(pageSize + 1) + .fetch(); + } + +} 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 ceb66faee..dcbb2d0c5 100644 --- a/src/main/java/konkuk/thip/book/application/mapper/BookQueryMapper.java +++ b/src/main/java/konkuk/thip/book/application/mapper/BookQueryMapper.java @@ -1,8 +1,10 @@ package konkuk.thip.book.application.mapper; 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.adapter.in.web.response.BookSelectableListResponse; +import konkuk.thip.book.adapter.in.web.response.BookShowSavedListResponse; +import konkuk.thip.book.application.port.in.dto.BookPinResult; +import konkuk.thip.book.application.port.out.dto.BookQueryDto; import konkuk.thip.book.domain.Book; import konkuk.thip.common.util.DateUtil; import konkuk.thip.room.application.port.out.dto.RoomQueryDto; @@ -27,25 +29,18 @@ public interface BookQueryMapper { List toRecruitingRoomDtoList(List roomDtos); - @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") - BookSelectableResult toBookSelectableResult(Book book); + BookPinResult toBookPinResult(Book book); - List toBookSelectableResultList(List books); + @Mapping(target = "isSaved", constant = "true") + BookShowSavedListResponse.BookShowSavedDto toBookShowSavedDto(BookQueryDto dto); + List toBookShowSavedListResponse(List dtos); - @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); + BookSelectableListResponse.BookSelectableDto toBookSelectableDto(BookQueryDto dto); - List toBookShowSavedInfoResultList(List savedBookList); + List toBookSelectableListResponse(List contents); } diff --git a/src/main/java/konkuk/thip/book/application/port/in/BookSelectableListUseCase.java b/src/main/java/konkuk/thip/book/application/port/in/BookSelectableListUseCase.java index 794453c23..a5e85603a 100644 --- a/src/main/java/konkuk/thip/book/application/port/in/BookSelectableListUseCase.java +++ b/src/main/java/konkuk/thip/book/application/port/in/BookSelectableListUseCase.java @@ -1,10 +1,8 @@ package konkuk.thip.book.application.port.in; -import konkuk.thip.book.application.port.in.dto.BookSelectableResult; +import konkuk.thip.book.adapter.in.web.response.BookSelectableListResponse; import konkuk.thip.book.application.port.in.dto.BookSelectableType; -import java.util.List; - public interface BookSelectableListUseCase { - List getSelectableBookList(BookSelectableType bookSelectableType, Long userId); + BookSelectableListResponse getSelectableBookList(BookSelectableType bookSelectableType, Long userId, String cursor); } 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 index 36a9d1ab6..e76d5a762 100644 --- a/src/main/java/konkuk/thip/book/application/port/in/BookShowSavedListUseCase.java +++ b/src/main/java/konkuk/thip/book/application/port/in/BookShowSavedListUseCase.java @@ -1,9 +1,7 @@ package konkuk.thip.book.application.port.in; -import konkuk.thip.book.application.port.in.dto.BookShowSavedInfoResult; - -import java.util.List; +import konkuk.thip.book.adapter.in.web.response.BookShowSavedListResponse; public interface BookShowSavedListUseCase { - List getSavedBookList(Long userId); + BookShowSavedListResponse getSavedBookList(Long userId, String cursor); } diff --git a/src/main/java/konkuk/thip/book/application/port/in/dto/BookSelectableResult.java b/src/main/java/konkuk/thip/book/application/port/in/dto/BookPinResult.java similarity index 65% rename from src/main/java/konkuk/thip/book/application/port/in/dto/BookSelectableResult.java rename to src/main/java/konkuk/thip/book/application/port/in/dto/BookPinResult.java index 986767beb..e4ce1f80d 100644 --- a/src/main/java/konkuk/thip/book/application/port/in/dto/BookSelectableResult.java +++ b/src/main/java/konkuk/thip/book/application/port/in/dto/BookPinResult.java @@ -1,10 +1,8 @@ package konkuk.thip.book.application.port.in.dto; -public record BookSelectableResult( - Long bookId, +public record BookPinResult( String bookTitle, String authorName, - String publisher, String bookImageUrl, String isbn ) { diff --git a/src/main/java/konkuk/thip/book/application/port/in/dto/DummyCommand.java b/src/main/java/konkuk/thip/book/application/port/in/dto/DummyCommand.java deleted file mode 100644 index e96febcbe..000000000 --- a/src/main/java/konkuk/thip/book/application/port/in/dto/DummyCommand.java +++ /dev/null @@ -1,10 +0,0 @@ -package konkuk.thip.book.application.port.in.dto; - -import lombok.Builder; -import lombok.Getter; - -@Builder -@Getter -public class DummyCommand { - -} diff --git a/src/main/java/konkuk/thip/book/application/port/in/dto/DummyQuery.java b/src/main/java/konkuk/thip/book/application/port/in/dto/DummyQuery.java deleted file mode 100644 index ee2fbab4e..000000000 --- a/src/main/java/konkuk/thip/book/application/port/in/dto/DummyQuery.java +++ /dev/null @@ -1,9 +0,0 @@ -package konkuk.thip.book.application.port.in.dto; - -import lombok.Builder; -import lombok.Getter; - -@Builder -@Getter -public class DummyQuery { -} diff --git a/src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java b/src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java index 830d23f01..c2274461e 100644 --- a/src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java +++ b/src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java @@ -1,8 +1,9 @@ package konkuk.thip.book.application.port.out; -import konkuk.thip.book.domain.Book; +import konkuk.thip.book.application.port.out.dto.BookQueryDto; +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; -import java.util.List; import java.util.Set; public interface BookQueryPort { @@ -11,9 +12,9 @@ public interface BookQueryPort { boolean existsBookByIsbn(String isbn); - List findSavedBooksByUserId(Long userId); + CursorBasedList findSavedBooksBySavedAt(Long userId, Cursor cursor); - List findJoiningRoomsBooksByUserId(Long userId); + CursorBasedList findJoiningRoomsBooksByRoomPercentage(Long userId, Cursor cursor); Set findUnusedBookIds(); } diff --git a/src/main/java/konkuk/thip/book/application/port/out/dto/BookQueryDto.java b/src/main/java/konkuk/thip/book/application/port/out/dto/BookQueryDto.java new file mode 100644 index 000000000..1c8af1174 --- /dev/null +++ b/src/main/java/konkuk/thip/book/application/port/out/dto/BookQueryDto.java @@ -0,0 +1,46 @@ +package konkuk.thip.book.application.port.out.dto; + +import com.querydsl.core.annotations.QueryProjection; + +import javax.annotation.Nullable; +import java.time.LocalDateTime; + +public record BookQueryDto( + Long bookId, + String bookTitle, + String authorName, + String publisher, + String bookImageUrl, + String isbn, + @Nullable LocalDateTime savedCreatedAt, + @Nullable Double roomPercentage +) +{ + // 저장한 책 조회시 활용 + @QueryProjection + public BookQueryDto( + Long bookId, + String bookTitle, + String authorName, + String publisher, + String bookImageUrl, + String isbn, + LocalDateTime savedCreatedAt + ){ + this(bookId, bookTitle, authorName, publisher, bookImageUrl, isbn, savedCreatedAt,null); + } + + // 활동중인 모임방 책 조회시 활용 + @QueryProjection + public BookQueryDto( + Long bookId, + String bookTitle, + String authorName, + String publisher, + String bookImageUrl, + String isbn, + Double roomPercentage + ){ + this(bookId, bookTitle, authorName, publisher, bookImageUrl, isbn, null, roomPercentage); + } +} diff --git a/src/main/java/konkuk/thip/book/application/service/BookSavedListService.java b/src/main/java/konkuk/thip/book/application/service/BookSavedListService.java index 9621abbdd..e06572cdc 100644 --- a/src/main/java/konkuk/thip/book/application/service/BookSavedListService.java +++ b/src/main/java/konkuk/thip/book/application/service/BookSavedListService.java @@ -1,27 +1,35 @@ package konkuk.thip.book.application.service; +import konkuk.thip.book.adapter.in.web.response.BookShowSavedListResponse; 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 konkuk.thip.book.application.port.out.dto.BookQueryDto; +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; 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 static final int DEFAULT_PAGE_SIZE = 10; + 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); + public BookShowSavedListResponse getSavedBookList(Long userId, String cursorStr) { + + Cursor cursor = Cursor.from(cursorStr, DEFAULT_PAGE_SIZE); + + CursorBasedList result = bookQueryPort.findSavedBooksBySavedAt(userId, cursor); + + return BookShowSavedListResponse.of(bookQueryMapper.toBookShowSavedListResponse(result.contents()), + result.nextCursor(), + result.isLast()); } } diff --git a/src/main/java/konkuk/thip/book/application/service/BookSelectableListService.java b/src/main/java/konkuk/thip/book/application/service/BookSelectableListService.java index af3a13f97..b460ab98e 100644 --- a/src/main/java/konkuk/thip/book/application/service/BookSelectableListService.java +++ b/src/main/java/konkuk/thip/book/application/service/BookSelectableListService.java @@ -1,32 +1,40 @@ package konkuk.thip.book.application.service; +import konkuk.thip.book.adapter.in.web.response.BookSelectableListResponse; import konkuk.thip.book.application.mapper.BookQueryMapper; import konkuk.thip.book.application.port.in.BookSelectableListUseCase; -import konkuk.thip.book.application.port.in.dto.BookSelectableResult; import konkuk.thip.book.application.port.in.dto.BookSelectableType; import konkuk.thip.book.application.port.out.BookQueryPort; -import konkuk.thip.book.domain.Book; +import konkuk.thip.book.application.port.out.dto.BookQueryDto; +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; - @Service @RequiredArgsConstructor public class BookSelectableListService implements BookSelectableListUseCase { + private static final int DEFAULT_PAGE_SIZE = 10; + private final BookQueryPort bookQueryPort; private final BookQueryMapper bookQueryMapper; @Override @Transactional(readOnly = true) - public List getSelectableBookList(BookSelectableType bookSelectableType, Long userId) { - List bookList = switch(bookSelectableType) { - case SAVED -> bookQueryPort.findSavedBooksByUserId(userId); - case JOINING -> bookQueryPort.findJoiningRoomsBooksByUserId(userId); + public BookSelectableListResponse getSelectableBookList(BookSelectableType bookSelectableType, Long userId, String cursorStr) { + + Cursor cursor = Cursor.from(cursorStr, DEFAULT_PAGE_SIZE); + + CursorBasedList result = switch(bookSelectableType) { + case SAVED -> bookQueryPort.findSavedBooksBySavedAt(userId, cursor); + case JOINING -> bookQueryPort.findJoiningRoomsBooksByRoomPercentage(userId, cursor); }; - return bookQueryMapper.toBookSelectableResultList(bookList); + return BookSelectableListResponse.of(bookQueryMapper.toBookSelectableListResponse(result.contents()), + result.nextCursor(), + result.isLast()); + } } diff --git a/src/main/java/konkuk/thip/common/util/Cursor.java b/src/main/java/konkuk/thip/common/util/Cursor.java index 70900df3c..711989bcb 100644 --- a/src/main/java/konkuk/thip/common/util/Cursor.java +++ b/src/main/java/konkuk/thip/common/util/Cursor.java @@ -75,6 +75,10 @@ public Integer getInteger(int index) { return getAs(index, Integer::parseInt, "Integer"); } + public Double getDouble(int index) { + return getAs(index, Double::parseDouble, "Double"); + } + public String getString(int index) { return get(index); } 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 560170d35..9a78633ff 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 @@ -107,11 +107,11 @@ public boolean existsSavedFeedByUserIdAndFeedId(Long userId, Long feedId) { } @Override - public CursorBasedList findSavedFeedsByCreatedAt(Long userId, Cursor cursor) { - LocalDateTime lastCreatedAt = cursor.isFirstRequest() ? null : cursor.getLocalDateTime(0); + public CursorBasedList findSavedFeedsBySavedAt(Long userId, Cursor cursor) { + LocalDateTime lastSavedAt = cursor.isFirstRequest() ? null : cursor.getLocalDateTime(0); int size = cursor.getPageSize(); - List feedQueryDtos = feedJpaRepository.findSavedFeedsByCreatedAt(userId, lastCreatedAt, size); + List feedQueryDtos = feedJpaRepository.findSavedFeedsByCreatedAt(userId, lastSavedAt, size); return CursorBasedList.of(feedQueryDtos, size, feedQueryDto -> { Cursor nextCursor = new Cursor(List.of(feedQueryDto.savedCreatedAt().toString())); 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 6afdf4699..d358bc70e 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 @@ -2,7 +2,6 @@ import konkuk.thip.common.util.Cursor; import konkuk.thip.common.util.CursorBasedList; -import konkuk.thip.feed.application.port.out.dto.TagCategoryQueryDto; import konkuk.thip.feed.application.port.out.dto.FeedQueryDto; import java.util.List; @@ -38,7 +37,7 @@ public interface FeedQueryPort { Set findSavedFeedIdsByUserIdAndFeedIds(Set feedIds, Long userId); boolean existsSavedFeedByUserIdAndFeedId(Long userId, Long feedId); - CursorBasedList findSavedFeedsByCreatedAt(Long userId, Cursor cursor); + CursorBasedList findSavedFeedsBySavedAt(Long userId, Cursor cursor); /** * 특정 책으로 작성된 피드 조회 diff --git a/src/main/java/konkuk/thip/feed/application/service/FeedShowSavedListService.java b/src/main/java/konkuk/thip/feed/application/service/FeedShowSavedListService.java index cec8e5203..3ff75c8d0 100644 --- a/src/main/java/konkuk/thip/feed/application/service/FeedShowSavedListService.java +++ b/src/main/java/konkuk/thip/feed/application/service/FeedShowSavedListService.java @@ -12,7 +12,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; import java.util.Set; import java.util.stream.Collectors; @@ -31,8 +30,8 @@ public FeedShowSavedListResponse getSavedFeedList(Long userId, String cursor) { // 1. 커서 생성 Cursor nextCursor = Cursor.from(cursor, PAGE_SIZE); - // 2. 유저가 저장한 책 최신순으로 (페이징 처리 포함) - CursorBasedList result = feedQueryPort.findSavedFeedsByCreatedAt(userId, nextCursor); + // 2. 유저가 저장한 피드 최신순으로 (페이징 처리 포함) + CursorBasedList result = feedQueryPort.findSavedFeedsBySavedAt(userId, nextCursor); Set feedIds = result.contents().stream() .map(FeedQueryDto::feedId) .collect(Collectors.toUnmodifiableSet()); diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/RoomQueryController.java b/src/main/java/konkuk/thip/room/adapter/in/web/RoomQueryController.java index 4595ca5e5..12c04b8ba 100644 --- a/src/main/java/konkuk/thip/room/adapter/in/web/RoomQueryController.java +++ b/src/main/java/konkuk/thip/room/adapter/in/web/RoomQueryController.java @@ -85,11 +85,12 @@ public BaseResponse getRecruitingRoomDetailVie @GetMapping("/rooms/home/joined") public BaseResponse getHomeJoinedRooms( @Parameter(hidden = true) @UserId final Long userId, - @Parameter(description = "페이지 번호", example = "1") @RequestParam("page") final int page) { + @Parameter(description = "커서 (첫번째 요청시 : null, 다음 요청시 : 이전 요청에서 반환받은 nextCursor 값)") + @RequestParam(value = "cursor", required = false) final String cursor) { return BaseResponse.ok(roomGetHomeJoinedListUseCase.getHomeJoinedRoomList( RoomGetHomeJoinedListQuery.builder() .userId(userId) - .page(page).build())); + .cursorStr(cursor).build())); } @Operation( diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomGetHomeJoinedListResponse.java b/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomGetHomeJoinedListResponse.java index 061ed38a7..c67c97888 100644 --- a/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomGetHomeJoinedListResponse.java +++ b/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomGetHomeJoinedListResponse.java @@ -1,21 +1,13 @@ package konkuk.thip.room.adapter.in.web.response; -import lombok.Builder; - import java.util.List; -@Builder public record RoomGetHomeJoinedListResponse( List roomList, String nickname, - int page, // 현재 페이지 - int size, // 현재 페이지에 포함된 데이터 수 - boolean last, - boolean first + String nextCursor, + boolean isLast ) { - - - @Builder public record JoinedRoomInfo( Long roomId, String bookImageUrl, @@ -23,4 +15,7 @@ public record JoinedRoomInfo( int memberCount, int userPercentage ) {} -} + public static RoomGetHomeJoinedListResponse of(List roomList, + String nickname, String nextCursor, boolean isLast){ + return new RoomGetHomeJoinedListResponse(roomList, nickname, nextCursor, isLast);} +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java index ee264437a..667a85cb7 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java @@ -3,18 +3,16 @@ import konkuk.thip.common.util.Cursor; import konkuk.thip.common.util.CursorBasedList; import konkuk.thip.common.util.EnumMappings; -import konkuk.thip.room.adapter.in.web.response.RoomGetHomeJoinedListResponse; import konkuk.thip.room.adapter.in.web.response.RoomRecruitingDetailViewResponse; import konkuk.thip.room.adapter.out.persistence.function.IntegerCursorRoomQueryFunction; import konkuk.thip.room.adapter.out.persistence.function.LocalDateCursorRoomQueryFunction; import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; import konkuk.thip.room.application.port.out.RoomQueryPort; +import konkuk.thip.room.application.port.out.dto.RoomParticipantQueryDto; import konkuk.thip.room.application.port.out.dto.RoomQueryDto; import konkuk.thip.room.domain.value.Category; import konkuk.thip.room.domain.Room; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; import java.time.LocalDate; @@ -71,8 +69,24 @@ public List findOtherRecruitingR } @Override - public Page searchHomeJoinedRooms(Long userId, LocalDate date, Pageable pageable) { - return roomJpaRepository.searchHomeJoinedRooms(userId, date, pageable); + public CursorBasedList searchHomeJoinedRooms(Long userId, Cursor cursor) { + Double lastUserPercentage = cursor.isFirstRequest() ? null : cursor.getDouble(0); + LocalDate lastStartDate = cursor.isFirstRequest() ? null : cursor.getLocalDate(1); + Long lastRoomId = cursor.isFirstRequest() ? null : cursor.getLong(2); + int pageSize = cursor.getPageSize(); + + List dtos = roomJpaRepository.findHomeJoinedRoomsByUserPercentage( + userId, lastUserPercentage, lastStartDate, lastRoomId, pageSize + ); + + return CursorBasedList.of(dtos, pageSize, dto -> { + Cursor nextCursor = new Cursor(List.of( + dto.userPercentage().toString(), // 내림차순 필드, 정렬순서 1 + dto.startDate().toString(), // 오름차순 필드, 정렬순서 2 + dto.roomId().toString() // 고유 ID, 중복 방지용 + )); + return nextCursor.toEncodedString(); + }); } @Override diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepository.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepository.java index 49b730ba2..8f3916baa 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepository.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepository.java @@ -1,11 +1,9 @@ package konkuk.thip.room.adapter.out.persistence.repository; import konkuk.thip.room.adapter.in.web.response.RoomRecruitingDetailViewResponse; -import konkuk.thip.room.adapter.in.web.response.RoomGetHomeJoinedListResponse; +import konkuk.thip.room.application.port.out.dto.RoomParticipantQueryDto; import konkuk.thip.room.application.port.out.dto.RoomQueryDto; import konkuk.thip.room.domain.value.Category; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import java.util.List; @@ -23,7 +21,7 @@ public interface RoomQueryRepository { List findOtherRecruitingRoomsByCategoryOrderByStartDateAsc(Long roomId, Category category, int count); - Page searchHomeJoinedRooms(Long userId, LocalDate today, Pageable pageable); + List findHomeJoinedRoomsByUserPercentage(Long userId, Double userPercentageCursor, LocalDate startDateCursor, Long roomIdCursor, int pageSize); List findRecruitingRoomsUserParticipated(Long userId, LocalDate dateCursor, Long roomIdCursor, int pageSize); diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepositoryImpl.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepositoryImpl.java index 5a0358747..834e9d25c 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepositoryImpl.java @@ -10,24 +10,23 @@ import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import konkuk.thip.book.adapter.out.jpa.QBookJpaEntity; -import konkuk.thip.common.entity.StatusType; import konkuk.thip.common.util.DateUtil; -import konkuk.thip.room.adapter.in.web.response.RoomGetHomeJoinedListResponse; import konkuk.thip.room.adapter.in.web.response.RoomRecruitingDetailViewResponse; import konkuk.thip.room.adapter.out.jpa.QRoomJpaEntity; import konkuk.thip.room.adapter.out.jpa.QRoomParticipantJpaEntity; +import konkuk.thip.room.application.port.out.dto.QRoomParticipantQueryDto; import konkuk.thip.room.application.port.out.dto.QRoomQueryDto; +import konkuk.thip.room.application.port.out.dto.RoomParticipantQueryDto; import konkuk.thip.room.application.port.out.dto.RoomQueryDto; import konkuk.thip.room.domain.value.Category; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; import java.time.LocalDate; import java.util.List; +import static konkuk.thip.common.entity.StatusType.ACTIVE; + @Repository @RequiredArgsConstructor public class RoomQueryRepositoryImpl implements RoomQueryRepository { @@ -41,7 +40,7 @@ public class RoomQueryRepositoryImpl implements RoomQueryRepository { private BooleanBuilder recruitingActiveWhere(LocalDate today) { BooleanBuilder where = new BooleanBuilder(); where.and(room.startDate.after(today)) - .and(room.status.eq(StatusType.ACTIVE)); + .and(room.status.eq(ACTIVE)); return where; } @@ -199,67 +198,56 @@ public List findOtherRecruitingR } @Override - public Page searchHomeJoinedRooms(Long userId, LocalDate date, Pageable pageable) { - - QRoomParticipantJpaEntity userRoomSub = new QRoomParticipantJpaEntity("userRoomSub"); + public List findHomeJoinedRoomsByUserPercentage( + Long userId, + Double userPercentageCursor, + LocalDate startDateCursor, + Long roomIdCursor, + int pageSize) { - // 1. 검색 조건(where) 조립 + // 검색 조건(where) 조립 // 유저가 참여한 방만: userId 조건 // 활동 기간 중인 방만: startDate ≤ today ≤ endDate BooleanBuilder where = new BooleanBuilder(); where.and(participant.userJpaEntity.userId.eq(userId)); - where.and(participant.status.eq(StatusType.ACTIVE)); - where.and(room.startDate.loe(date)); - where.and(room.endDate.goe(date)); + where.and(participant.status.eq(ACTIVE)); + where.and(room.status.eq(ACTIVE)); + where.and(room.startDate.loe(LocalDate.now())); + where.and(room.endDate.goe(LocalDate.now())); + + // 커서 기반 추가 조건 + if (userPercentageCursor != null && startDateCursor != null && roomIdCursor != null) { + where.and(participant.userPercentage.lt(userPercentageCursor) + .or(participant.userPercentage.eq(userPercentageCursor) + .and(room.startDate.gt(startDateCursor)) + .or(participant.userPercentage.eq(userPercentageCursor) + .and(room.startDate.eq(startDateCursor)) + .and(room.roomId.gt(roomIdCursor)) + ) + ) + ); + } - // 2. 페이징된 목록 조회 - List tuples = queryFactory - .select( + return queryFactory + .select(new QRoomParticipantQueryDto( room.roomId, book.imageUrl, room.title, room.memberCount, - room.recruitCount, - room.startDate, - book.title, - participant.userPercentage - ) + participant.userPercentage, + room.startDate + )) .from(participant) .join(participant.roomJpaEntity, room) .join(room.bookJpaEntity, book) .where(where) .orderBy( participant.userPercentage.desc(), // 진행률 높은 순(내림차순) - room.startDate.asc() // 진행률 같으면 활동 시작일 빠른 순 (오름차순) + room.startDate.asc(), // 진행률 같으면 활동 시작일 빠른 순 (오름차순) + room.roomId.asc() // 둘 다 같으면 방 아이디 작은 순 (오름차순) ) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) + .limit(pageSize + 1) .fetch(); - // TODO : 추후에 오프셋 페이징이 아니라, 키셋 페이징 기법 도입 검토 - - // 3. Tuple → DTO 매핑 - List content = tuples.stream() - .map(t -> RoomGetHomeJoinedListResponse.JoinedRoomInfo.builder() - .roomId(t.get(room.roomId)) - .bookImageUrl(t.get(book.imageUrl)) - .roomTitle(t.get(room.title)) - .memberCount(t.get(room.memberCount)) - .userPercentage(t.get(participant.userPercentage).intValue()) - .build() - ) - .toList(); - - // 4. 전체 개수 조회 (페이징 정보 계산용) - Long totalCount = queryFactory - .select(participant.count()) - .from(participant) - .join(participant.roomJpaEntity, room) - .where(where) - .fetchOne(); - long total = (totalCount != null) ? totalCount : 0L; - - // 5. PageImpl 생성하여 반환 - return new PageImpl<>(content, pageable, total); } // 1) 모집중인 방 @@ -269,9 +257,9 @@ public List findRecruitingRoomsUserParticipated( ) { LocalDate today = LocalDate.now(); BooleanExpression base = participant.userJpaEntity.userId.eq(userId) - .and(participant.status.eq(StatusType.ACTIVE)) + .and(participant.status.eq(ACTIVE)) .and(room.startDate.after(today)) - .and(room.status.eq(StatusType.ACTIVE)); // 유저가 참여한 방 && 모집중인 방 + .and(room.status.eq(ACTIVE)); // 유저가 참여한 방 && 모집중인 방 DateExpression cursorExpr = room.startDate; // 커서 비교는 startDate(= 모집 마감일 - 1일) OrderSpecifier[] orders = new OrderSpecifier[]{ cursorExpr.asc(), room.roomId.asc() @@ -287,10 +275,10 @@ public List findPlayingRoomsUserParticipated( ) { LocalDate today = LocalDate.now(); BooleanExpression base = participant.userJpaEntity.userId.eq(userId) - .and(participant.status.eq(StatusType.ACTIVE)) + .and(participant.status.eq(ACTIVE)) .and(room.startDate.loe(today)) .and(room.endDate.goe(today)) - .and(room.status.eq(StatusType.ACTIVE)); // 유저가 참여한 방 && 현재 진행중인 방 + .and(room.status.eq(ACTIVE)); // 유저가 참여한 방 && 현재 진행중인 방 DateExpression cursorExpr = room.endDate; // 커서 비교는 endDate(= 진행 마감일) OrderSpecifier[] orders = new OrderSpecifier[]{ cursorExpr.asc(), room.roomId.asc() @@ -308,9 +296,9 @@ public List findPlayingAndRecruitingRoomsUserParticipated( BooleanExpression playing = room.startDate.loe(today).and(room.endDate.goe(today)); BooleanExpression recruiting = room.startDate.after(today); BooleanExpression base = participant.userJpaEntity.userId.eq(userId) - .and(participant.status.eq(StatusType.ACTIVE)) + .and(participant.status.eq(ACTIVE)) .and(playing.or(recruiting)) - .and(room.status.eq(StatusType.ACTIVE)); // 유저가 참여한 방 && 현재 진행중인 방 + 모집중인 방 + .and(room.status.eq(ACTIVE)); // 유저가 참여한 방 && 현재 진행중인 방 + 모집중인 방 // 진행중: cursor=endDate, 모집중: cursor=startDate DateExpression cursorExpr = new CaseBuilder() @@ -338,9 +326,9 @@ public List findExpiredRoomsUserParticipated( ) { LocalDate today = LocalDate.now(); BooleanExpression base = participant.userJpaEntity.userId.eq(userId) - .and(participant.status.eq(StatusType.ACTIVE)) + .and(participant.status.eq(ACTIVE)) .and(room.endDate.before(today)) - .and(room.status.eq(StatusType.ACTIVE)); // 유저가 참여한 방 && 만료된 방 + .and(room.status.eq(ACTIVE)); // 유저가 참여한 방 && 만료된 방 DateExpression cursorExpr = room.endDate; OrderSpecifier[] orders = new OrderSpecifier[]{ @@ -393,7 +381,7 @@ public List findRoomsByIsbnOrderByStartDateAsc(String isbn, LocalD DateExpression cursorExpr = room.startDate; // 커서 비교는 startDate(= 모집 마감일 - 1일) BooleanExpression baseCondition = room.bookJpaEntity.isbn.eq(isbn) .and(room.startDate.after(LocalDate.now())) // 모집 마감 시각 > 현재 시각 - .and(room.status.eq(StatusType.ACTIVE)); + .and(room.status.eq(ACTIVE)); if (dateCursor != null && roomIdCursor != null) { // 첫 페이지가 아닌 경우 baseCondition = baseCondition.and(cursorExpr.gt(dateCursor) @@ -423,7 +411,7 @@ private BooleanExpression findDeadlinePopularRoomCondition(Category category, Lo .and(room.startDate.after(LocalDate.now())) // 모집 마감 시각 > 현재 시각 .and(room.isPublic.isTrue()) // 공개 방만 조회 .and(userJoinedRoom(userId).not()) // 유저가 참여하지 않은 방만 조회 - .and(room.status.eq(StatusType.ACTIVE)); + .and(room.status.eq(ACTIVE)); } /** diff --git a/src/main/java/konkuk/thip/room/application/mapper/RoomParticipantQueryMapper.java b/src/main/java/konkuk/thip/room/application/mapper/RoomParticipantQueryMapper.java new file mode 100644 index 000000000..08fb5c95b --- /dev/null +++ b/src/main/java/konkuk/thip/room/application/mapper/RoomParticipantQueryMapper.java @@ -0,0 +1,23 @@ +package konkuk.thip.room.application.mapper; + +import konkuk.thip.common.util.DateUtil; +import konkuk.thip.room.adapter.in.web.response.RoomGetHomeJoinedListResponse; +import konkuk.thip.room.application.port.out.dto.RoomParticipantQueryDto; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +import java.util.List; + +@Mapper( + componentModel = "spring", + imports = DateUtil.class, + unmappedTargetPolicy = ReportingPolicy.IGNORE // 명시적으로 매핑하지 않은 필드를 무시하도록 설정 +) +public interface RoomParticipantQueryMapper { + + List toHomeJoinedRoomResponse(List dtos); + + @Mapping(target = "userPercentage", expression = "java(dto.userPercentage().intValue())") + RoomGetHomeJoinedListResponse.JoinedRoomInfo toJoinedRoomInfo(RoomParticipantQueryDto dto); +} diff --git a/src/main/java/konkuk/thip/room/application/port/in/dto/RoomGetHomeJoinedListQuery.java b/src/main/java/konkuk/thip/room/application/port/in/dto/RoomGetHomeJoinedListQuery.java index 77cb3263f..070d05269 100644 --- a/src/main/java/konkuk/thip/room/application/port/in/dto/RoomGetHomeJoinedListQuery.java +++ b/src/main/java/konkuk/thip/room/application/port/in/dto/RoomGetHomeJoinedListQuery.java @@ -5,6 +5,6 @@ @Builder public record RoomGetHomeJoinedListQuery( Long userId, - int page + String cursorStr ) { } diff --git a/src/main/java/konkuk/thip/room/application/port/out/RoomQueryPort.java b/src/main/java/konkuk/thip/room/application/port/out/RoomQueryPort.java index c04d6d541..b28d75426 100644 --- a/src/main/java/konkuk/thip/room/application/port/out/RoomQueryPort.java +++ b/src/main/java/konkuk/thip/room/application/port/out/RoomQueryPort.java @@ -2,13 +2,11 @@ import konkuk.thip.common.util.Cursor; import konkuk.thip.common.util.CursorBasedList; -import konkuk.thip.room.adapter.in.web.response.RoomGetHomeJoinedListResponse; import konkuk.thip.room.adapter.in.web.response.RoomRecruitingDetailViewResponse; +import konkuk.thip.room.application.port.out.dto.RoomParticipantQueryDto; import konkuk.thip.room.application.port.out.dto.RoomQueryDto; import konkuk.thip.room.domain.value.Category; import konkuk.thip.room.domain.Room; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import java.time.LocalDate; import java.util.List; @@ -27,7 +25,7 @@ public interface RoomQueryPort { List findOtherRecruitingRoomsByCategoryOrderByStartDateAsc(Room currentRoom, int count); - Page searchHomeJoinedRooms(Long userId, LocalDate today, Pageable pageable); + CursorBasedList searchHomeJoinedRooms(Long userId, Cursor cursor); CursorBasedList findRecruitingRoomsUserParticipated(Long userId, Cursor cursor); diff --git a/src/main/java/konkuk/thip/room/application/port/out/dto/RoomParticipantQueryDto.java b/src/main/java/konkuk/thip/room/application/port/out/dto/RoomParticipantQueryDto.java new file mode 100644 index 000000000..052fdea03 --- /dev/null +++ b/src/main/java/konkuk/thip/room/application/port/out/dto/RoomParticipantQueryDto.java @@ -0,0 +1,27 @@ +package konkuk.thip.room.application.port.out.dto; + +import com.querydsl.core.annotations.QueryProjection; +import lombok.Builder; +import org.springframework.util.Assert; + +import java.time.LocalDate; + +@Builder +public record RoomParticipantQueryDto( + Long roomId, + String bookImageUrl, + String roomTitle, + Integer memberCount, + Double userPercentage, + LocalDate startDate // 방 진행 시작일 +) { + @QueryProjection + public RoomParticipantQueryDto { + Assert.notNull(roomId, "roomId must not be null"); + Assert.notNull(bookImageUrl, "bookImageUrl must not be null"); + Assert.notNull(roomTitle, "roomName must not be null"); + Assert.notNull(memberCount, "memberCount must not be null"); + Assert.notNull(userPercentage, "userPercentage must not be null"); + Assert.notNull(startDate, "startDate must not be null"); + } +} diff --git a/src/main/java/konkuk/thip/room/application/service/RoomGetHomeJoinedListService.java b/src/main/java/konkuk/thip/room/application/service/RoomGetHomeJoinedListService.java index 552211f18..d12a72ea0 100644 --- a/src/main/java/konkuk/thip/room/application/service/RoomGetHomeJoinedListService.java +++ b/src/main/java/konkuk/thip/room/application/service/RoomGetHomeJoinedListService.java @@ -1,21 +1,18 @@ package konkuk.thip.room.application.service; -import konkuk.thip.common.exception.InvalidStateException; -import konkuk.thip.common.exception.code.ErrorCode; +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; import konkuk.thip.room.adapter.in.web.response.RoomGetHomeJoinedListResponse; +import konkuk.thip.room.application.mapper.RoomParticipantQueryMapper; import konkuk.thip.room.application.port.in.RoomGetHomeJoinedListUseCase; import konkuk.thip.room.application.port.in.dto.RoomGetHomeJoinedListQuery; import konkuk.thip.room.application.port.out.RoomQueryPort; +import konkuk.thip.room.application.port.out.dto.RoomParticipantQueryDto; import konkuk.thip.user.application.port.out.UserCommandPort; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDate; - @Service @RequiredArgsConstructor public class RoomGetHomeJoinedListService implements RoomGetHomeJoinedListUseCase { @@ -24,39 +21,24 @@ public class RoomGetHomeJoinedListService implements RoomGetHomeJoinedListUseCas private final RoomQueryPort roomQueryPort; private final UserCommandPort userCommandPort; + private final RoomParticipantQueryMapper roomParticipantQueryMapper; @Override @Transactional(readOnly = true) public RoomGetHomeJoinedListResponse getHomeJoinedRoomList(RoomGetHomeJoinedListQuery query) { - // 1. page 값 검증 - validatePage(query.page()); - - // 2. 유저 닉네임 조회 + // 1. 유저 닉네임 조회 String nickname = userCommandPort.findById(query.userId()).getNickname(); - // 3. Pageable 생성 - int pageIndex = query.page() > 0 ? query.page() - 1 : 0; - Pageable pageable = PageRequest.of(pageIndex, DEFAULT_PAGE_SIZE); - - // 4. 모임 홈에서 참여중인 모임 방 검색 - Page result = roomQueryPort.searchHomeJoinedRooms(query.userId(), LocalDate.now(), pageable); - - // 5. response 구성 - return RoomGetHomeJoinedListResponse.builder() - .roomList(result.getContent()) - .nickname(nickname) - .page(query.page()) - .size(result.getNumberOfElements()) - .last(result.isLast()) - .first(result.isFirst()) - .build(); - } + // 2. Cursor 생성 + Cursor cursor = Cursor.from(query.cursorStr(), DEFAULT_PAGE_SIZE); + + // 3. 모임 홈에서 참여중인 모임 방 검색 + CursorBasedList result = roomQueryPort.searchHomeJoinedRooms(query.userId(), cursor); - private void validatePage(int page) { - if(page< 1) { - throw new InvalidStateException(ErrorCode.API_INVALID_PARAM, new IllegalArgumentException("page은 1 이상의 값이어야 합니다.")); - } + return RoomGetHomeJoinedListResponse.of( + roomParticipantQueryMapper.toHomeJoinedRoomResponse(result.contents()), + nickname,result.nextCursor(),result.isLast()); } } diff --git a/src/main/java/konkuk/thip/roompost/adapter/in/web/RoomPostQueryController.java b/src/main/java/konkuk/thip/roompost/adapter/in/web/RoomPostQueryController.java index f62435f6e..f15332c83 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/in/web/RoomPostQueryController.java +++ b/src/main/java/konkuk/thip/roompost/adapter/in/web/RoomPostQueryController.java @@ -80,7 +80,11 @@ public BaseResponse pinRecord( @Parameter(description = "핀하려는 기록이 작성된 모임 ID", example = "1") @PathVariable("roomId") final Long roomId, @Parameter(description = "핀하려는 기록 ID", example = "1") @PathVariable("recordId") final Long recordId, @Parameter(hidden = true) @UserId final Long userId) { - return BaseResponse.ok(recordPinUseCase.pinRecord(new RecordPinQuery(roomId, recordId, userId))); + return BaseResponse.ok(RecordPinResponse.of(recordPinUseCase.pinRecord( + RecordPinQuery.builder() + .roomId(roomId). + recordId(recordId) + .userId(userId).build()))); } @Operation( diff --git a/src/main/java/konkuk/thip/roompost/adapter/in/web/response/RecordPinResponse.java b/src/main/java/konkuk/thip/roompost/adapter/in/web/response/RecordPinResponse.java index 9f31f093e..1612eea36 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/in/web/response/RecordPinResponse.java +++ b/src/main/java/konkuk/thip/roompost/adapter/in/web/response/RecordPinResponse.java @@ -1,6 +1,6 @@ package konkuk.thip.roompost.adapter.in.web.response; -import konkuk.thip.book.application.port.in.dto.BookSelectableResult; +import konkuk.thip.book.application.port.in.dto.BookPinResult; public record RecordPinResponse( String bookTitle, @@ -8,11 +8,12 @@ public record RecordPinResponse( String bookImageUrl, String isbn ) { - static public RecordPinResponse of(BookSelectableResult bookSelectableResult) { + static public RecordPinResponse of(BookPinResult book) { return new RecordPinResponse( - bookSelectableResult.bookTitle(), - bookSelectableResult.authorName(), - bookSelectableResult.bookImageUrl(), - bookSelectableResult.isbn()); + book.bookTitle(), + book.authorName(), + book.bookImageUrl(), + book.isbn() + ); } } \ No newline at end of file diff --git a/src/main/java/konkuk/thip/roompost/application/port/in/RecordPinUseCase.java b/src/main/java/konkuk/thip/roompost/application/port/in/RecordPinUseCase.java index f816bdcdd..778fecd4b 100644 --- a/src/main/java/konkuk/thip/roompost/application/port/in/RecordPinUseCase.java +++ b/src/main/java/konkuk/thip/roompost/application/port/in/RecordPinUseCase.java @@ -1,8 +1,8 @@ package konkuk.thip.roompost.application.port.in; -import konkuk.thip.roompost.adapter.in.web.response.RecordPinResponse; +import konkuk.thip.book.application.port.in.dto.BookPinResult; import konkuk.thip.roompost.application.port.in.dto.record.RecordPinQuery; public interface RecordPinUseCase { - RecordPinResponse pinRecord(RecordPinQuery query); + BookPinResult pinRecord(RecordPinQuery query); } diff --git a/src/main/java/konkuk/thip/roompost/application/port/in/dto/record/RecordPinQuery.java b/src/main/java/konkuk/thip/roompost/application/port/in/dto/record/RecordPinQuery.java index 1f9566123..faa38ae2e 100644 --- a/src/main/java/konkuk/thip/roompost/application/port/in/dto/record/RecordPinQuery.java +++ b/src/main/java/konkuk/thip/roompost/application/port/in/dto/record/RecordPinQuery.java @@ -1,5 +1,8 @@ package konkuk.thip.roompost.application.port.in.dto.record; +import lombok.Builder; + +@Builder public record RecordPinQuery( Long roomId, diff --git a/src/main/java/konkuk/thip/roompost/application/service/RecordPinService.java b/src/main/java/konkuk/thip/roompost/application/service/RecordPinService.java index 89bd8f5ee..1613698e2 100644 --- a/src/main/java/konkuk/thip/roompost/application/service/RecordPinService.java +++ b/src/main/java/konkuk/thip/roompost/application/service/RecordPinService.java @@ -1,9 +1,9 @@ package konkuk.thip.roompost.application.service; import konkuk.thip.book.application.mapper.BookQueryMapper; +import konkuk.thip.book.application.port.in.dto.BookPinResult; import konkuk.thip.book.application.port.out.BookCommandPort; import konkuk.thip.book.domain.Book; -import konkuk.thip.roompost.adapter.in.web.response.RecordPinResponse; import konkuk.thip.roompost.application.port.in.RecordPinUseCase; import konkuk.thip.roompost.application.port.in.dto.record.RecordPinQuery; import konkuk.thip.roompost.application.port.out.RecordCommandPort; @@ -25,7 +25,7 @@ public class RecordPinService implements RecordPinUseCase { @Override @Transactional(readOnly = true) - public RecordPinResponse pinRecord(RecordPinQuery query) { + public BookPinResult pinRecord(RecordPinQuery query) { // 1. 방 참여자 검증 roomParticipantValidator.validateUserIsRoomMember(query.roomId(), query.userId()); @@ -37,6 +37,6 @@ public RecordPinResponse pinRecord(RecordPinQuery query) { // 3. 책 정보 조회 및 반환 Book book = bookCommandPort.findBookByRoomId(query.roomId()); - return RecordPinResponse.of(bookQueryMapper.toBookSelectableResult(book)); + return bookQueryMapper.toBookPinResult(book); } } diff --git a/src/test/java/konkuk/thip/book/adapter/in/web/BookGetSelectableListApiTest.java b/src/test/java/konkuk/thip/book/adapter/in/web/BookGetSelectableListApiTest.java index 5e880dac9..928891ec7 100644 --- a/src/test/java/konkuk/thip/book/adapter/in/web/BookGetSelectableListApiTest.java +++ b/src/test/java/konkuk/thip/book/adapter/in/web/BookGetSelectableListApiTest.java @@ -1,10 +1,12 @@ package konkuk.thip.book.adapter.in.web; import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.jpa.SavedBookJpaEntity; 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.room.adapter.out.jpa.RoomJpaEntity; +import konkuk.thip.room.adapter.out.jpa.RoomParticipantJpaEntity; import konkuk.thip.room.adapter.out.jpa.RoomParticipantRole; import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; import konkuk.thip.room.adapter.out.persistence.repository.roomparticipant.RoomParticipantJpaRepository; @@ -12,16 +14,24 @@ import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; import konkuk.thip.user.domain.value.Alias; -import org.junit.jupiter.api.BeforeEach; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.AfterEach; 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 org.springframework.test.web.servlet.ResultActions; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; 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; @@ -29,7 +39,6 @@ @SpringBootTest @AutoConfigureMockMvc(addFilters = false) @ActiveProfiles("test") -@Transactional @DisplayName("[통합] 저장한 책 및 참여 중 책 리스트 조회 API 통합 테스트") class BookGetSelectableListApiTest { @@ -41,47 +50,348 @@ class BookGetSelectableListApiTest { @Autowired private RoomJpaRepository roomJpaRepository; @Autowired private RoomParticipantJpaRepository roomParticipantJpaRepository; - private UserJpaEntity user; - private BookJpaEntity savedBook; - private BookJpaEntity joiningBook; + @Autowired private JdbcTemplate jdbcTemplate; - @BeforeEach - void setUp() { - Alias alias = TestEntityFactory.createLiteratureAlias(); - user = userJpaRepository.save(TestEntityFactory.createUser(alias)); - savedBook = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("1111111111111")); - joiningBook = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("2222222222222")); + @AfterEach + void tearDown() { + roomParticipantJpaRepository.deleteAllInBatch(); + roomJpaRepository.deleteAllInBatch(); + savedBookJpaRepository.deleteAllInBatch(); + bookJpaRepository.deleteAllInBatch(); + userJpaRepository.deleteAllInBatch(); + } + + private RoomJpaEntity saveScienceRoomWithBookIsbn(String isbn, String roomName, double roomPercentage) { + BookJpaEntity book = bookJpaRepository.save(BookJpaEntity.builder() + .title("책이름") + .isbn(isbn) + .authorName("한강") + .bestSeller(false) + .publisher("문학동네") + .imageUrl("https://image1.jpg") + .pageCount(300) + .description("한강의 소설") + .build()); + + Category category = TestEntityFactory.createScienceCategory(); + + return roomJpaRepository.save(RoomJpaEntity.builder() + .title(roomName) + .description("한강 작품 읽기 모임") + .isPublic(true) + .roomPercentage(roomPercentage) + .startDate(LocalDate.now().minusDays(2)) // 진행중인 방 + .endDate(LocalDate.now().plusDays(10)) + .recruitCount(5) + .bookJpaEntity(book) + .category(category) + .build()); + } - // SAVED 책 등록 - savedBookJpaRepository.save(TestEntityFactory.createSavedBook(user, savedBook)); + private RoomJpaEntity saveScienceRoomWithBookEntity(BookJpaEntity book, String roomName, double roomPercentage) { + Category category = TestEntityFactory.createScienceCategory(); + + return roomJpaRepository.save(RoomJpaEntity.builder() + .title(roomName) + .description("한강 작품 읽기 모임") + .isPublic(true) + .roomPercentage(roomPercentage) + .startDate(LocalDate.now().minusDays(2)) // 진행중인 방 + .endDate(LocalDate.now().plusDays(10)) + .recruitCount(5) + .bookJpaEntity(book) + .category(category) + .build()); + } + + private void saveSingleUserToRoom(RoomJpaEntity roomJpaEntity, UserJpaEntity userJpaEntity) { + RoomParticipantJpaEntity roomParticipantJpaEntity = RoomParticipantJpaEntity.builder() + .userJpaEntity(userJpaEntity) + .roomJpaEntity(roomJpaEntity) + .roomParticipantRole(RoomParticipantRole.HOST) + .userPercentage(0.0) + .build(); + roomParticipantJpaRepository.save(roomParticipantJpaEntity); + roomJpaEntity.updateMemberCount(roomJpaEntity.getMemberCount() + 1); + roomJpaRepository.save(roomJpaEntity); - // JOINING 책용 방 생성 - Category category = TestEntityFactory.createLiteratureCategory(); - RoomJpaEntity room = roomJpaRepository.save(TestEntityFactory.createRoom(joiningBook, category)); - roomParticipantJpaRepository.save(TestEntityFactory.createRoomParticipant(room, user, RoomParticipantRole.HOST, 0.0)); } @Test - @DisplayName("SAVED 타입 - 저장한 책 리스트 조회 성공") + @DisplayName("SAVED 타입 - 저장된 책 조회 시 책 정보를 저장한 최신순으로 정렬해서 반환한다.") void getSelectableBooks_saved_success() throws Exception { + + // given + Alias alias = TestEntityFactory.createScienceAlias(); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(alias, "me")); + BookJpaEntity b1 = bookJpaRepository.save(TestEntityFactory.createBook()); + BookJpaEntity b2 = bookJpaRepository.save(TestEntityFactory.createBook()); + + // when + // me가 b1,b2 저장 + SavedBookJpaEntity sb1 = savedBookJpaRepository.save(TestEntityFactory.createSavedBook(me, b1)); + SavedBookJpaEntity sb2 = savedBookJpaRepository.save(TestEntityFactory.createSavedBook(me, b2)); + + // flush 후 책 저장일자 덮어쓰기 + // 책 저장 순서 : b2 -> b1 (b1 이 가장 최신) + LocalDateTime baseTime = LocalDateTime.now(); + savedBookJpaRepository.flush(); + jdbcTemplate.update("UPDATE saved_books SET created_at = ? WHERE saved_id = ?", + Timestamp.valueOf(baseTime.minusMinutes(1)), sb1.getSavedId()); + jdbcTemplate.update("UPDATE saved_books SET created_at = ? WHERE saved_id = ?", + Timestamp.valueOf(baseTime.minusMinutes(10)), sb2.getSavedId()); + + // then mockMvc.perform(get("/books/selectable-list") .param("type", "SAVED") - .requestAttr("userId", user.getUserId())) + .requestAttr("userId", me.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())); + .andExpect(jsonPath("$.data.isLast", is(true))) + .andExpect(jsonPath("$.data.bookList", hasSize(2))) + .andExpect(jsonPath("$.data.nextCursor").value(Matchers.nullValue())) + // 저장한 최신순 -> b1 먼저 + .andExpect(jsonPath("$.data.bookList[0].bookId").value(b1.getBookId().intValue())) + .andExpect(jsonPath("$.data.bookList[0].bookTitle").value(b1.getTitle())) + .andExpect(jsonPath("$.data.bookList[0].authorName").value(b1.getAuthorName())) + .andExpect(jsonPath("$.data.bookList[0].publisher").value(b1.getPublisher())) + .andExpect(jsonPath("$.data.bookList[0].bookImageUrl").value(b1.getImageUrl())) + .andExpect(jsonPath("$.data.bookList[0].isbn").value(b1.getIsbn())) + // b2 + .andExpect(jsonPath("$.data.bookList[1].bookId").value(b2.getBookId().intValue())) + .andExpect(jsonPath("$.data.bookList[1].bookTitle").value(b2.getTitle())) + .andExpect(jsonPath("$.data.bookList[1].authorName").value(b2.getAuthorName())) + .andExpect(jsonPath("$.data.bookList[1].publisher").value(b2.getPublisher())) + .andExpect(jsonPath("$.data.bookList[1].bookImageUrl").value(b2.getImageUrl())) + .andExpect(jsonPath("$.data.bookList[1].isbn").value(b2.getIsbn())); } @Test - @DisplayName("JOINING 타입 - 참여 중인 책 리스트 조회 성공") + @DisplayName("SAVED 타입 - 저장한 책 조회 시, 한번에 최대 10개의 데이터만을 반환한다." + + "다음 페이지에 해당하는 데이터가 있을 경우, 다음 페이지의 cursor 값을 반환한다." + + "또한 cursor 값을 기준으로 해당 페이지의 데이터를 반환한다.") + void getSavedBooks_pageWithCursor() throws Exception { + + // given + Alias alias = TestEntityFactory.createLiteratureAlias(); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(alias)); + + // when + // 책 12개 생성 및 저장 + LocalDateTime baseTime = LocalDateTime.now(); + BookJpaEntity[] books = new BookJpaEntity[12]; + SavedBookJpaEntity[] savedBooks = new SavedBookJpaEntity[12]; + + for (int i = 0; i < 12; i++) { + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + books[i] = book; + SavedBookJpaEntity savedBook = savedBookJpaRepository.save( + SavedBookJpaEntity.builder() + .userJpaEntity(me) + .bookJpaEntity(book) + .build() + ); + savedBooks[i] = savedBook; + } + savedBookJpaRepository.flush(); + + // created_at 덮어쓰기 bookId가 작을수록 최신 저장순 + for (int i = 0; i < 12; i++) { + jdbcTemplate.update("UPDATE saved_books SET created_at = ? WHERE saved_id = ?", + Timestamp.valueOf(baseTime.minusMinutes(i)), savedBooks[i].getSavedId()); + } + + //then + // 1. 첫 페이지 조회 (size=10), 커서 없음 + ResultActions firstPage = mockMvc.perform(get("/books/saved") + .requestAttr("userId", me.getUserId()) + ); + + firstPage.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.bookList", hasSize(10))) + .andExpect(jsonPath("$.data.nextCursor").exists()) + .andExpect(jsonPath("$.data.isLast").value(false)) + //책을 저장한 순으로 조회 + .andExpect(jsonPath("$.data.bookList[0].bookId", is(books[0].getBookId().intValue()))) + .andExpect(jsonPath("$.data.bookList[1].bookId", is(books[1].getBookId().intValue()))) + .andExpect(jsonPath("$.data.bookList[2].bookId", is(books[2].getBookId().intValue()))) + .andExpect(jsonPath("$.data.bookList[3].bookId", is(books[3].getBookId().intValue()))) + .andExpect(jsonPath("$.data.bookList[4].bookId", is(books[4].getBookId().intValue()))) + .andExpect(jsonPath("$.data.bookList[5].bookId", is(books[5].getBookId().intValue()))) + .andExpect(jsonPath("$.data.bookList[6].bookId", is(books[6].getBookId().intValue()))) + .andExpect(jsonPath("$.data.bookList[7].bookId", is(books[7].getBookId().intValue()))) + .andExpect(jsonPath("$.data.bookList[8].bookId", is(books[8].getBookId().intValue()))) + .andExpect(jsonPath("$.data.bookList[9].bookId", is(books[9].getBookId().intValue()))); + + + String responseBody = firstPage.andReturn().getResponse().getContentAsString(); + String nextCursor = com.jayway.jsonpath.JsonPath.read(responseBody, "$.data.nextCursor"); + + // when + // 2. 두 번째 페이지 조회(커서 사용, size=10) + ResultActions secondPage = mockMvc.perform(get("/books/saved") + .requestAttr("userId", me.getUserId()) + .param("cursor", nextCursor) // 이전에 b9 까지 조회 -> b9의 저장시간, sb9의 createdAt이 커서 + ); + + //then + secondPage.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.bookList", hasSize(2))) + .andExpect(jsonPath("$.data.nextCursor").value(Matchers.nullValue())) // nextCursor 는 null + .andExpect(jsonPath("$.data.isLast").value(true)) + .andExpect(jsonPath("$.data.bookList[0].bookId", is(books[10].getBookId().intValue()))) + .andExpect(jsonPath("$.data.bookList[1].bookId", is(books[11].getBookId().intValue()))); + + } + + @Test + @DisplayName("JOINING 타입 - 참여 중인 방의 책 조회 시 방의 진행도(내림차순), 책아이디(오름차순)으로 정렬해서 반환한다.") void getSelectableBooks_joining_success() throws Exception { + + // given + Alias alias = TestEntityFactory.createLiteratureAlias(); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(alias)); + RoomJpaEntity r1 = saveScienceRoomWithBookIsbn("isbn1","모집중인방1" , 80.0); + RoomJpaEntity r2 = saveScienceRoomWithBookIsbn("isbn2","모집중인방2" , 60.0); // 책아이디2 + RoomJpaEntity r3 = saveScienceRoomWithBookIsbn("isbn3","모집중인방3" , 60.0); // 책아이디3 + + saveSingleUserToRoom(r1,me); + saveSingleUserToRoom(r2,me); + saveSingleUserToRoom(r3,me); + + //when & then + mockMvc.perform(get("/books/selectable-list") + .param("type", "JOINING") + .requestAttr("userId", me.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isLast", is(true))) + .andExpect(jsonPath("$.data.bookList", hasSize(3))) + .andExpect(jsonPath("$.data.nextCursor").value(Matchers.nullValue())) + // 방 진행도 높은 순 -> 진행도 같으면 책 아이디 작은 순 + // 방 진행도 높은순 방1이 80로 진행도 제일 높음 + .andExpect(jsonPath("$.data.bookList[0].bookId").value(r1.getBookJpaEntity().getBookId().intValue())) + .andExpect(jsonPath("$.data.bookList[0].bookTitle").value(r1.getBookJpaEntity().getTitle())) + .andExpect(jsonPath("$.data.bookList[0].authorName").value(r1.getBookJpaEntity().getAuthorName())) + .andExpect(jsonPath("$.data.bookList[0].publisher").value(r1.getBookJpaEntity().getPublisher())) + .andExpect(jsonPath("$.data.bookList[0].bookImageUrl").value(r1.getBookJpaEntity().getImageUrl())) + .andExpect(jsonPath("$.data.bookList[0].isbn").value(r1.getBookJpaEntity().getIsbn())) + // 방2,방3 진행도 60으로 같아서 책아이디 작은 방2 + .andExpect(jsonPath("$.data.bookList[1].bookId").value(r2.getBookJpaEntity().getBookId().intValue())) + .andExpect(jsonPath("$.data.bookList[1].bookTitle").value(r2.getBookJpaEntity().getTitle())) + .andExpect(jsonPath("$.data.bookList[1].authorName").value(r2.getBookJpaEntity().getAuthorName())) + .andExpect(jsonPath("$.data.bookList[1].publisher").value(r2.getBookJpaEntity().getPublisher())) + .andExpect(jsonPath("$.data.bookList[1].bookImageUrl").value(r2.getBookJpaEntity().getImageUrl())) + .andExpect(jsonPath("$.data.bookList[1].isbn").value(r2.getBookJpaEntity().getIsbn())) + // 방2,방3 진행도 60으로 같아서 마지막으로 방3 + .andExpect(jsonPath("$.data.bookList[2].bookId").value(r3.getBookJpaEntity().getBookId().intValue())) + .andExpect(jsonPath("$.data.bookList[2].bookTitle").value(r3.getBookJpaEntity().getTitle())) + .andExpect(jsonPath("$.data.bookList[2].authorName").value(r3.getBookJpaEntity().getAuthorName())) + .andExpect(jsonPath("$.data.bookList[2].publisher").value(r3.getBookJpaEntity().getPublisher())) + .andExpect(jsonPath("$.data.bookList[2].bookImageUrl").value(r3.getBookJpaEntity().getImageUrl())) + .andExpect(jsonPath("$.data.bookList[2].isbn").value(r3.getBookJpaEntity().getIsbn())); + } + + @Test + @DisplayName("JOINING 타입 - 참여 중인 방의 책 조회 시 동일한 책으로 방이 여러개있을때 가장 높은 방의 진행도만 반영되어 정렬해서 반환한다.") + void shouldReturnBooksOrderedByHighestRoomPercentage_withCursorPagination() throws Exception { + + // given + Alias alias = TestEntityFactory.createLiteratureAlias(); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(alias)); + BookJpaEntity b1 = bookJpaRepository.save(TestEntityFactory.createBook()); + BookJpaEntity b2 = bookJpaRepository.save(TestEntityFactory.createBook()); + RoomJpaEntity r1 = saveScienceRoomWithBookEntity(b1,"모집중인방1" , 80.0); + RoomJpaEntity r2 = saveScienceRoomWithBookEntity(b2,"모집중인방1" , 60.0); + RoomJpaEntity r3 = saveScienceRoomWithBookEntity(b2,"모집중인방1" , 90.0); + saveSingleUserToRoom(r1,me); + saveSingleUserToRoom(r2,me); + saveSingleUserToRoom(r3,me); + + //when & then mockMvc.perform(get("/books/selectable-list") .param("type", "JOINING") - .requestAttr("userId", user.getUserId())) + .requestAttr("userId", me.getUserId())) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.bookList").isArray()) - .andExpect(jsonPath("$.data.bookList.length()").value(1)) - .andExpect(jsonPath("$.data.bookList[0].isbn").value(joiningBook.getIsbn())); + .andExpect(jsonPath("$.data.isLast", is(true))) + .andExpect(jsonPath("$.data.bookList", hasSize(2))) + .andExpect(jsonPath("$.data.nextCursor").value(Matchers.nullValue())) + // 방 진행도 높은 순 -> 진행도 같으면 책 아이디 작은 순 + // 방2의 진행도(60)가 방1 진행도(80)보다 낮지만 방2와 동일한 책인 방3의 진행도(90)가 방1 진행도 보다 높기때문에 책2가 제일먼저 오게됨 + .andExpect(jsonPath("$.data.bookList[0].bookId").value(r3.getBookJpaEntity().getBookId().intValue())) + .andExpect(jsonPath("$.data.bookList[0].bookTitle").value(r3.getBookJpaEntity().getTitle())) + .andExpect(jsonPath("$.data.bookList[0].authorName").value(r3.getBookJpaEntity().getAuthorName())) + .andExpect(jsonPath("$.data.bookList[0].publisher").value(r3.getBookJpaEntity().getPublisher())) + .andExpect(jsonPath("$.data.bookList[0].bookImageUrl").value(r3.getBookJpaEntity().getImageUrl())) + .andExpect(jsonPath("$.data.bookList[0].isbn").value(r3.getBookJpaEntity().getIsbn())) + // 책1이 마지막에 조회된다. + .andExpect(jsonPath("$.data.bookList[1].bookId").value(r1.getBookJpaEntity().getBookId().intValue())) + .andExpect(jsonPath("$.data.bookList[1].bookTitle").value(r1.getBookJpaEntity().getTitle())) + .andExpect(jsonPath("$.data.bookList[1].authorName").value(r1.getBookJpaEntity().getAuthorName())) + .andExpect(jsonPath("$.data.bookList[1].publisher").value(r1.getBookJpaEntity().getPublisher())) + .andExpect(jsonPath("$.data.bookList[1].bookImageUrl").value(r1.getBookJpaEntity().getImageUrl())) + .andExpect(jsonPath("$.data.bookList[1].isbn").value(r1.getBookJpaEntity().getIsbn())); + } + + @Test + @DisplayName("JOINING 타입 - 참여 중인 방의 책 조회 시, 한번에 최대 10개의 데이터만을 반환한다." + + "다음 페이지에 해당하는 데이터가 있을 경우, 다음 페이지의 cursor 값을 반환한다." + + "또한 cursor 값을 기준으로 해당 페이지의 데이터를 반환한다.") + void getSelectableBooks_joining_pageWithCursor() throws Exception { + + // given + Alias alias = TestEntityFactory.createLiteratureAlias(); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(alias)); + + // when + // 방,책 12개 생성 및 저장 + for (int i = 1; i < 13; i++) { + String isbn = "isbn" + i; + String title = "모집중인방" + i; + double roomPercentage = 90 - i; // 진행률은 방번호가 작을수록 높음 + RoomJpaEntity room = saveScienceRoomWithBookIsbn(isbn ,title, roomPercentage); + saveSingleUserToRoom(room,me); + } + + //then + // 1. 첫 페이지 조회 (size=10), 커서 없음 + ResultActions firstPage = mockMvc.perform(get("/books/selectable-list") + .param("type", "JOINING") + .requestAttr("userId", me.getUserId())); + + firstPage.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.bookList", hasSize(10))) + .andExpect(jsonPath("$.data.nextCursor").exists()) + .andExpect(jsonPath("$.data.isLast").value(false)) + // 방 진행도 높은 순으로 조회 + .andExpect(jsonPath("$.data.bookList[0].isbn", is("isbn1"))) + .andExpect(jsonPath("$.data.bookList[1].isbn", is("isbn2"))) + .andExpect(jsonPath("$.data.bookList[2].isbn", is("isbn3"))) + .andExpect(jsonPath("$.data.bookList[3].isbn", is("isbn4"))) + .andExpect(jsonPath("$.data.bookList[4].isbn", is("isbn5"))) + .andExpect(jsonPath("$.data.bookList[5].isbn", is("isbn6"))) + .andExpect(jsonPath("$.data.bookList[6].isbn", is("isbn7"))) + .andExpect(jsonPath("$.data.bookList[7].isbn", is("isbn8"))) + .andExpect(jsonPath("$.data.bookList[8].isbn", is("isbn9"))) + .andExpect(jsonPath("$.data.bookList[9].isbn", is("isbn10"))); + + + String responseBody = firstPage.andReturn().getResponse().getContentAsString(); + String nextCursor = com.jayway.jsonpath.JsonPath.read(responseBody, "$.data.nextCursor"); + + // when + // 2. 두 번째 페이지 조회(커서 사용, size=10) + ResultActions secondPage = mockMvc.perform(get("/books/selectable-list") + .requestAttr("userId", me.getUserId()) + .param("type", "JOINING") + .param("cursor", nextCursor) // 이전에 b9 까지 조회 -> b9의 방 r9의 진행도 + b9의 bookId가 커서 + ); + + //then + secondPage.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.bookList", hasSize(2))) + .andExpect(jsonPath("$.data.nextCursor").value(Matchers.nullValue())) // nextCursor 는 null + .andExpect(jsonPath("$.data.isLast").value(true)) + .andExpect(jsonPath("$.data.bookList[0].isbn", is("isbn11"))) + .andExpect(jsonPath("$.data.bookList[1].isbn", is("isbn12"))); + } } diff --git a/src/test/java/konkuk/thip/book/adapter/in/web/BookMostSearchedBooksApiTest.java b/src/test/java/konkuk/thip/book/adapter/in/web/BookMostSearchedBooksApiTest.java index b332bfd5a..13e416567 100644 --- a/src/test/java/konkuk/thip/book/adapter/in/web/BookMostSearchedBooksApiTest.java +++ b/src/test/java/konkuk/thip/book/adapter/in/web/BookMostSearchedBooksApiTest.java @@ -74,7 +74,7 @@ void setUp() { @AfterEach void tearDown() { - userJpaRepository.deleteAll(); + userJpaRepository.deleteAllInBatch(); } @Test 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 index 9d4f5be82..f8a49f643 100644 --- a/src/test/java/konkuk/thip/book/adapter/in/web/BookShowSavedListApiTest.java +++ b/src/test/java/konkuk/thip/book/adapter/in/web/BookShowSavedListApiTest.java @@ -1,22 +1,32 @@ package konkuk.thip.book.adapter.in.web; import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.jpa.SavedBookJpaEntity; 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.room.adapter.out.persistence.repository.RoomJpaRepository; +import konkuk.thip.room.adapter.out.persistence.repository.roomparticipant.RoomParticipantJpaRepository; import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; import konkuk.thip.user.domain.value.Alias; -import org.junit.jupiter.api.BeforeEach; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.AfterEach; 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 org.springframework.test.web.servlet.ResultActions; +import java.sql.Timestamp; +import java.time.LocalDateTime; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; 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; @@ -24,7 +34,6 @@ @SpringBootTest @AutoConfigureMockMvc(addFilters = false) @ActiveProfiles("test") -@Transactional @DisplayName("[통합] 저장한 책 조회 API 통합 테스트") class BookShowSavedListApiTest { @@ -33,26 +42,143 @@ class BookShowSavedListApiTest { @Autowired private UserJpaRepository userJpaRepository; @Autowired private BookJpaRepository bookJpaRepository; @Autowired private SavedBookJpaRepository savedBookJpaRepository; + @Autowired private RoomJpaRepository roomJpaRepository; + @Autowired private RoomParticipantJpaRepository roomParticipantJpaRepository; - private UserJpaEntity user; - private BookJpaEntity savedBook; + @Autowired private JdbcTemplate jdbcTemplate; - @BeforeEach - void setUp() { - Alias alias = TestEntityFactory.createLiteratureAlias(); - user = userJpaRepository.save(TestEntityFactory.createUser(alias)); - savedBook = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("1111111111111")); - savedBookJpaRepository.save(TestEntityFactory.createSavedBook(user, savedBook)); + @AfterEach + void tearDown() { + roomParticipantJpaRepository.deleteAllInBatch(); + roomJpaRepository.deleteAll(); + savedBookJpaRepository.deleteAllInBatch(); + bookJpaRepository.deleteAll(); + userJpaRepository.deleteAll(); } @Test - @DisplayName("사용자가 저장한 책을 정상적으로 호출한다.") + @DisplayName("저장된 책 조회 시 책 정보를 저장한 최신순으로 정렬해서 반환한다.") void getSavedBooks_success() throws Exception { + + // given + Alias alias = TestEntityFactory.createScienceAlias(); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(alias, "me")); + BookJpaEntity b1 = bookJpaRepository.save(TestEntityFactory.createBook()); + BookJpaEntity b2 = bookJpaRepository.save(TestEntityFactory.createBook()); + + // when + // me가 b1,b2 저장 + SavedBookJpaEntity sb1 = savedBookJpaRepository.save(TestEntityFactory.createSavedBook(me, b1)); + SavedBookJpaEntity sb2 = savedBookJpaRepository.save(TestEntityFactory.createSavedBook(me, b2)); + + // flush 후 책 저장일자 덮어쓰기 + // 책 저장 순서 : b2 -> b1 (b1 이 가장 최신) + LocalDateTime baseTime = LocalDateTime.now(); + savedBookJpaRepository.flush(); + jdbcTemplate.update("UPDATE saved_books SET created_at = ? WHERE saved_id = ?", + Timestamp.valueOf(baseTime.minusMinutes(1)), sb1.getSavedId()); + jdbcTemplate.update("UPDATE saved_books SET created_at = ? WHERE saved_id = ?", + Timestamp.valueOf(baseTime.minusMinutes(10)), sb2.getSavedId()); + + // then mockMvc.perform(get("/books/saved") - .requestAttr("userId", user.getUserId())) + .requestAttr("userId", me.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())); + .andExpect(jsonPath("$.data.isLast", is(true))) + .andExpect(jsonPath("$.data.bookList", hasSize(2))) + .andExpect(jsonPath("$.data.nextCursor").value(Matchers.nullValue())) + // 저장한 최신순 -> b1 먼저 + .andExpect(jsonPath("$.data.bookList[0].bookId").value(b1.getBookId().intValue())) + .andExpect(jsonPath("$.data.bookList[0].bookTitle").value(b1.getTitle())) + .andExpect(jsonPath("$.data.bookList[0].authorName").value(b1.getAuthorName())) + .andExpect(jsonPath("$.data.bookList[0].publisher").value(b1.getPublisher())) + .andExpect(jsonPath("$.data.bookList[0].bookImageUrl").value(b1.getImageUrl())) + .andExpect(jsonPath("$.data.bookList[0].isbn").value(b1.getIsbn())) + .andExpect(jsonPath("$.data.bookList[0].isSaved", is(true))) + // b2 + .andExpect(jsonPath("$.data.bookList[1].bookId").value(b2.getBookId().intValue())) + .andExpect(jsonPath("$.data.bookList[1].bookTitle").value(b2.getTitle())) + .andExpect(jsonPath("$.data.bookList[1].authorName").value(b2.getAuthorName())) + .andExpect(jsonPath("$.data.bookList[1].publisher").value(b2.getPublisher())) + .andExpect(jsonPath("$.data.bookList[1].bookImageUrl").value(b2.getImageUrl())) + .andExpect(jsonPath("$.data.bookList[1].isbn").value(b2.getIsbn())) + .andExpect(jsonPath("$.data.bookList[1].isSaved", is(true))); + } + + @Test + @DisplayName("한번에 최대 10개의 데이터만을 반환한다. 다음 페이지에 해당하는 데이터가 있을 경우, 다음 페이지의 cursor 값을 반환한다. 또한 cursor 값을 기준으로 해당 페이지의 데이터를 반환한다.") + void getSavedBooks_pageWithCursor() throws Exception { + + // given + Alias alias = TestEntityFactory.createLiteratureAlias(); + UserJpaEntity me = userJpaRepository.save(TestEntityFactory.createUser(alias)); + + // when + // 책 12개 생성 및 저장 + LocalDateTime baseTime = LocalDateTime.now(); + BookJpaEntity[] books = new BookJpaEntity[12]; + SavedBookJpaEntity[] savedBooks = new SavedBookJpaEntity[12]; + + for (int i = 0; i < 12; i++) { + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + books[i] = book; + SavedBookJpaEntity savedBook = savedBookJpaRepository.save( + SavedBookJpaEntity.builder() + .userJpaEntity(me) + .bookJpaEntity(book) + .build() + ); + savedBooks[i] = savedBook; + } + savedBookJpaRepository.flush(); + + // created_at 덮어쓰기 bookId가 작을수록 최신 저장순 + for (int i = 0; i < 12; i++) { + jdbcTemplate.update("UPDATE saved_books SET created_at = ? WHERE saved_id = ?", + Timestamp.valueOf(baseTime.minusMinutes(i)), savedBooks[i].getSavedId()); + } + + //then + // 1. 첫 페이지 조회 (size=10), 커서 없음 + ResultActions firstPage = mockMvc.perform(get("/books/saved") + .requestAttr("userId", me.getUserId()) + ); + + firstPage.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.bookList", hasSize(10))) + .andExpect(jsonPath("$.data.nextCursor").exists()) + .andExpect(jsonPath("$.data.isLast").value(false)) + //책을 저장한 순으로 조회 + .andExpect(jsonPath("$.data.bookList[0].bookId", is(books[0].getBookId().intValue()))) + .andExpect(jsonPath("$.data.bookList[1].bookId", is(books[1].getBookId().intValue()))) + .andExpect(jsonPath("$.data.bookList[2].bookId", is(books[2].getBookId().intValue()))) + .andExpect(jsonPath("$.data.bookList[3].bookId", is(books[3].getBookId().intValue()))) + .andExpect(jsonPath("$.data.bookList[4].bookId", is(books[4].getBookId().intValue()))) + .andExpect(jsonPath("$.data.bookList[5].bookId", is(books[5].getBookId().intValue()))) + .andExpect(jsonPath("$.data.bookList[6].bookId", is(books[6].getBookId().intValue()))) + .andExpect(jsonPath("$.data.bookList[7].bookId", is(books[7].getBookId().intValue()))) + .andExpect(jsonPath("$.data.bookList[8].bookId", is(books[8].getBookId().intValue()))) + .andExpect(jsonPath("$.data.bookList[9].bookId", is(books[9].getBookId().intValue()))); + + + String responseBody = firstPage.andReturn().getResponse().getContentAsString(); + String nextCursor = com.jayway.jsonpath.JsonPath.read(responseBody, "$.data.nextCursor"); + + // when + // 2. 두 번째 페이지 조회(커서 사용, size=10) + ResultActions secondPage = mockMvc.perform(get("/books/saved") + .requestAttr("userId", me.getUserId()) + .param("cursor", nextCursor) // 이전에 b9 까지 조회 -> b9의 저장시간, sb9의 createdAt이 커서 + ); + + //then + secondPage.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.bookList", hasSize(2))) + .andExpect(jsonPath("$.data.nextCursor").value(Matchers.nullValue())) // nextCursor 는 null + .andExpect(jsonPath("$.data.isLast").value(true)) + .andExpect(jsonPath("$.data.bookList[0].bookId", is(books[10].getBookId().intValue()))) + .andExpect(jsonPath("$.data.bookList[1].bookId", is(books[11].getBookId().intValue()))); + } + } diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetHomeJoinedRoomsApiTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetHomeJoinedRoomsApiTest.java index 54722b6ef..658146dfa 100644 --- a/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetHomeJoinedRoomsApiTest.java +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetHomeJoinedRoomsApiTest.java @@ -4,6 +4,7 @@ import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; import konkuk.thip.common.util.TestEntityFactory; import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; +import konkuk.thip.room.adapter.out.jpa.RoomParticipantJpaEntity; import konkuk.thip.room.adapter.out.jpa.RoomParticipantRole; import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; import konkuk.thip.room.domain.value.Category; @@ -11,6 +12,7 @@ import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; import konkuk.thip.room.adapter.out.persistence.repository.roomparticipant.RoomParticipantJpaRepository; import konkuk.thip.user.domain.value.Alias; +import org.hamcrest.Matchers; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -79,6 +81,52 @@ void tearDown() { userJpaRepository.deleteAll(); } + private RoomJpaEntity saveScienceRoom(String bookTitle, String isbn, String roomName, LocalDate startDate, LocalDate endDate, int recruitCount) { + BookJpaEntity book = bookJpaRepository.save(BookJpaEntity.builder() + .title(bookTitle) + .isbn(isbn) + .authorName("한강") + .bestSeller(false) + .publisher("문학동네") + .imageUrl("https://image1.jpg") + .pageCount(300) + .description("한강의 소설") + .build()); + + Category category = TestEntityFactory.createScienceCategory(); + + return roomJpaRepository.save(RoomJpaEntity.builder() + .title(roomName) + .description("한강 작품 읽기 모임") + .isPublic(true) + .roomPercentage(0.0) + .startDate(startDate) + .endDate(endDate) + .recruitCount(recruitCount) + .bookJpaEntity(book) + .category(category) + .build()); + } + + private void changeRoomMemberCount(RoomJpaEntity roomJpaEntity, int count) { + roomJpaEntity.updateMemberCount(count); + roomJpaRepository.save(roomJpaEntity); + } + + private void saveSingleUserToRoom(RoomJpaEntity roomJpaEntity, UserJpaEntity userJpaEntity, Double userPercentage) { + RoomParticipantJpaEntity roomParticipantJpaEntity = RoomParticipantJpaEntity.builder() + .userJpaEntity(userJpaEntity) + .roomJpaEntity(roomJpaEntity) + .roomParticipantRole(RoomParticipantRole.HOST) + .userPercentage(userPercentage) + .build(); + roomParticipantJpaRepository.save(roomParticipantJpaEntity); + + roomJpaEntity.updateMemberCount(roomJpaEntity.getMemberCount() + 1); + roomJpaRepository.save(roomJpaEntity); + + } + @Test @DisplayName("사용자가 참여중인 방 목록이 현재 진행되고 있는 방 중에서 진행률 내림차순, 시작일 오름차순으로 조회된다.") void getHomeJoinedRooms_success() throws Exception { @@ -88,16 +136,14 @@ void getHomeJoinedRooms_success() throws Exception { //when ResultActions result = mockMvc.perform(get("/rooms/home/joined") - .requestAttr("userId", userId) - .param("page", "1")); + .requestAttr("userId", userId)); //then result.andExpect(status().isOk()) .andExpect(jsonPath("$.data.roomList", hasSize(2))) .andExpect(jsonPath("$.data.nickname").exists()) - .andExpect(jsonPath("$.data.page", is(1))) - .andExpect(jsonPath("$.data.first", is(true))) - .andExpect(jsonPath("$.data.last", is(true))) + .andExpect(jsonPath("$.data.nextCursor").value(Matchers.nullValue())) + .andExpect(jsonPath("$.data.isLast", is(true))) // 진행률 내림차순, 시작일 오름차순 정렬 검증 .andExpect(jsonPath("$.data.roomList[0].userPercentage", is(80))) .andExpect(jsonPath("$.data.roomList[1].userPercentage", is(60))) @@ -135,12 +181,13 @@ void getHomeJoinedRooms_sortByStartDateWhenUserPercentageEquals() throws Excepti // when ResultActions result = mockMvc.perform(get("/rooms/home/joined") - .requestAttr("userId", userId) - .param("page", "1")); + .requestAttr("userId", userId)); // then result.andExpect(status().isOk()) .andExpect(jsonPath("$.data.roomList", hasSize(3))) + .andExpect(jsonPath("$.data.nextCursor").value(Matchers.nullValue())) + .andExpect(jsonPath("$.data.isLast", is(true))) // 모두 진행률 70% .andExpect(jsonPath("$.data.roomList[0].userPercentage", is(70))) .andExpect(jsonPath("$.data.roomList[1].userPercentage", is(70))) @@ -174,12 +221,13 @@ void getHomeJoinedRooms_excludeRecruitingRooms() throws Exception { // when ResultActions result = mockMvc.perform(get("/rooms/home/joined") - .requestAttr("userId", newUser.getUserId()) - .param("page", "1")); + .requestAttr("userId", newUser.getUserId())); // then result.andExpect(status().isOk()) .andExpect(jsonPath("$.data.roomList", hasSize(1))) + .andExpect(jsonPath("$.data.nextCursor").value(Matchers.nullValue())) + .andExpect(jsonPath("$.data.isLast", is(true))) .andExpect(jsonPath("$.data.roomList[0].roomId", is(activeRoom.getRoomId().intValue()))) .andExpect(jsonPath("$.data.roomList[0].userPercentage", is(50))); } @@ -195,15 +243,76 @@ void getHomeJoinedRooms_empty() throws Exception { //when ResultActions result = mockMvc.perform(get("/rooms/home/joined") - .requestAttr("userId", newUser.getUserId()) - .param("page", "1")); + .requestAttr("userId", newUser.getUserId())); //then result.andExpect(status().isOk()) .andExpect(jsonPath("$.data.roomList", hasSize(0))) - .andExpect(jsonPath("$.data.page", is(1))) - .andExpect(jsonPath("$.data.first", is(true))) - .andExpect(jsonPath("$.data.last", is(true))); + .andExpect(jsonPath("$.data.nextCursor").value(Matchers.nullValue())) + .andExpect(jsonPath("$.data.isLast", is(true))); } + @Test + @DisplayName("한번에 최대 10개의 데이터만을 반환한다. 다음 페이지에 해당하는 데이터가 있을 경우, 다음 페이지의 cursor 값을 반환한다. 또한 cursor 값을 기준으로 해당 페이지의 데이터를 반환한다.") + void getHomeJoinedRooms_page_1() throws Exception { + + //given + UserJpaEntity user = userJpaRepository.save(TestEntityFactory.createUser(TestEntityFactory.createScienceAlias())); + + // 방 생성 + 멤버 카운트 + 유저 참여 및 진행률 세팅 + for (int i = 1; i < 13; i++) { + String isbn = "isbn" + (i + 1); + String title = "과학-방-" + i + "일전-활동시작"; + LocalDate start = LocalDate.now().minusDays(i); + LocalDate end = LocalDate.now().plusDays(30); + + RoomJpaEntity room = saveScienceRoom("모집중인방-책-" + (i + 1), isbn, title, start, end, 10); + changeRoomMemberCount(room, 8); + + double userPercentage = 89.6 - i; // 진행률은 방번호가 작을수록 높음 + + saveSingleUserToRoom(room, user, userPercentage); + } + + //when 첫 페이지 조회 + ResultActions firstPage = mockMvc.perform(get("/rooms/home/joined") + .requestAttr("userId", user.getUserId())); + + //then + firstPage.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isLast", is(false))) + .andExpect(jsonPath("$.data.roomList", hasSize(10))) + .andExpect(jsonPath("$.data.nextCursor").exists()) + // 정렬 조건 : 유저 진행도 순 + .andExpect(jsonPath("$.data.roomList[0].roomTitle", is("과학-방-1일전-활동시작"))) + .andExpect(jsonPath("$.data.roomList[1].roomTitle", is("과학-방-2일전-활동시작"))) + .andExpect(jsonPath("$.data.roomList[2].roomTitle", is("과학-방-3일전-활동시작"))) + .andExpect(jsonPath("$.data.roomList[3].roomTitle", is("과학-방-4일전-활동시작"))) + .andExpect(jsonPath("$.data.roomList[4].roomTitle", is("과학-방-5일전-활동시작"))) + .andExpect(jsonPath("$.data.roomList[5].roomTitle", is("과학-방-6일전-활동시작"))) + .andExpect(jsonPath("$.data.roomList[6].roomTitle", is("과학-방-7일전-활동시작"))) + .andExpect(jsonPath("$.data.roomList[7].roomTitle", is("과학-방-8일전-활동시작"))) + .andExpect(jsonPath("$.data.roomList[8].roomTitle", is("과학-방-9일전-활동시작"))) + .andExpect(jsonPath("$.data.roomList[9].roomTitle", is("과학-방-10일전-활동시작"))); + + String responseBody = firstPage.andReturn().getResponse().getContentAsString(); + String nextCursor = com.jayway.jsonpath.JsonPath.read(responseBody, "$.data.nextCursor"); + + //when 두번째 페이지 조회 + ResultActions secondPage = mockMvc.perform(get("/rooms/home/joined") + .requestAttr("userId", user.getUserId()) + .param("size", "10") + .param("cursor", nextCursor) + ); + + secondPage.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roomList", hasSize(2))) + .andExpect(jsonPath("$.data.nextCursor").value(Matchers.nullValue())) + .andExpect(jsonPath("$.data.isLast", is(true))) + .andExpect(jsonPath("$.data.roomList[0].roomTitle", is("과학-방-11일전-활동시작"))) + .andExpect(jsonPath("$.data.roomList[1].roomTitle", is("과학-방-12일전-활동시작"))); + + } + + } diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetHomeJoinedRoomsControllerTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetHomeJoinedRoomsControllerTest.java deleted file mode 100644 index 54663deab..000000000 --- a/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetHomeJoinedRoomsControllerTest.java +++ /dev/null @@ -1,70 +0,0 @@ -package konkuk.thip.room.adapter.in.web; - -import org.junit.jupiter.api.*; -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 java.util.HashMap; -import java.util.Map; - -import static konkuk.thip.common.exception.code.ErrorCode.API_INVALID_PARAM; -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") -@AutoConfigureMockMvc(addFilters = false) -@DisplayName("[단위] 모임 홈 참여중인 내 모임방 조회 api controller 테스트") -class RoomGetHomeJoinedRoomsControllerTest { - - @Autowired - private MockMvc mockMvc; - - private Map buildValidRequest() { - Map request = new HashMap<>(); - request.put("userId", 1L); - request.put("page", 1); - return request; - } - - private void assertBad(Map req, String msg) throws Exception { - mockMvc.perform(get("/rooms/home/joined") - .requestAttr("userId", req.get("userId")) - .param("page", req.get("page") != null ? req.get("page").toString() : null) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(API_INVALID_PARAM.getCode())) - .andExpect(jsonPath("$.message", containsString(msg))); - } - - @Nested - @DisplayName("page 파라미터 검증") - class PageValidation { - - @Test - @DisplayName("page가 1 미만일 때 400 error") - void invalid_page() throws Exception { - Map req = buildValidRequest(); - req.put("page", 0); - assertBad(req, "page은 1 이상의 값이어야 합니다."); - } - - @Test - @DisplayName("page가 null일 때 400 error") - void missing_page() throws Exception { - Map req = buildValidRequest(); - req.remove("page"); - mockMvc.perform(get("/rooms/home/joined") - .requestAttr("userId", req.get("userId")) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isBadRequest()); - } - } - -} \ No newline at end of file