diff --git a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java index 04ac14024..c2c54d8ac 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -148,7 +148,7 @@ public enum ErrorCode implements ResponseCode { /** * 180000 : Post error */ - POST_TYPE_NOT_MATCH(HttpStatus.BAD_REQUEST, 180000, "일치하는 게시물 타입 이름이 없습니다. [feed, record, vote] 중 하나여야 합니다."), + POST_TYPE_NOT_MATCH(HttpStatus.BAD_REQUEST, 180000, "일치하는 게시물 타입 이름이 없습니다. [FEED, RECORD, VOTE] 중 하나여야 합니다."), /** * 190000 : Comment error diff --git a/src/main/java/konkuk/thip/common/post/PostType.java b/src/main/java/konkuk/thip/common/post/PostType.java index 05176a47c..10875289e 100644 --- a/src/main/java/konkuk/thip/common/post/PostType.java +++ b/src/main/java/konkuk/thip/common/post/PostType.java @@ -12,9 +12,9 @@ @RequiredArgsConstructor public enum PostType { - FEED("feed"), - RECORD("record"), - VOTE("vote"); + FEED("FEED"), + RECORD("RECORD"), + VOTE("VOTE"); private final String type; diff --git a/src/main/java/konkuk/thip/common/util/Cursor.java b/src/main/java/konkuk/thip/common/util/Cursor.java new file mode 100644 index 000000000..25b92273f --- /dev/null +++ b/src/main/java/konkuk/thip/common/util/Cursor.java @@ -0,0 +1,82 @@ +package konkuk.thip.common.util; + +import lombok.Getter; + +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.function.Function; + +@Getter +public class Cursor { + + private static final String SPLIT_DELIMITER = "\\|"; + private static final String JOIN_DELIMITER = "|"; + private static final int DEFAULT_PAGE_SIZE = 10; + + private final List rawCursorList; + private final int pageSize; + private final boolean isFirstRequest; + + // 인코딩용 생성자 (pageSize는 default 사용) + public Cursor(List rawCursorList) { + this(rawCursorList, DEFAULT_PAGE_SIZE); + } + + private Cursor(List rawCursorList, int pageSize) { + this.rawCursorList = rawCursorList; + this.pageSize = pageSize; + this.isFirstRequest = rawCursorList.isEmpty(); + } + + // 디코딩을 위한 정적 팩토리 메서드 + public static Cursor from(String encoded, int pageSize) { + if (encoded == null || !encoded.contains("|")) { + return new Cursor(List.of(), pageSize); // 빈 커서 생성 + } + String decoded = URLDecoder.decode(encoded, StandardCharsets.UTF_8); + List parts = Arrays.asList(decoded.split(SPLIT_DELIMITER)); + return new Cursor(parts, pageSize); + } + + public String toEncodedString() { + String raw = String.join(JOIN_DELIMITER, rawCursorList); + return URLEncoder.encode(raw, StandardCharsets.UTF_8); + } + + public LocalDateTime getLocalDateTime(int index) { + return getAs(index, LocalDateTime::parse, "LocalDateTime"); + } + + public Long getLong(int index) { + return getAs(index, Long::parseLong, "Long"); + } + + public Integer getInteger(int index) { + return getAs(index, Integer::parseInt, "Integer"); + } + + public String getString(int index) { + return get(index); + } + + private String get(int index) { + if (index < 0 || index >= rawCursorList.size()) { + throw new IndexOutOfBoundsException("인덱스가 범위를 벗어났습니다: " + index); + } + return rawCursorList.get(index); + } + + private T getAs(int index, Function parser, String typeName) { + try { + return parser.apply(get(index)); + } catch (Exception e) { + throw new IllegalArgumentException( + String.format("커서에서 %s 값을 파싱할 수 없습니다: '%s'", typeName, get(index)), e + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeJpaRepository.java b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeJpaRepository.java index b7598ba2d..fee891308 100644 --- a/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeJpaRepository.java +++ b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeJpaRepository.java @@ -2,11 +2,19 @@ import konkuk.thip.post.adapter.out.jpa.PostLikeJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.Set; + @Repository public interface PostLikeJpaRepository extends JpaRepository { int countByPostJpaEntity_PostId(Long postId); boolean existsByPostJpaEntity_PostIdAndUserJpaEntity_UserId(Long postId, Long userId); + + @Query(value = "SELECT pl.post_id FROM post_likes pl WHERE pl.user_id = :userId AND pl.post_id IN (:postIds)", nativeQuery = true) + Set findPostIdsLikedByUser(@Param("postIds") Set postIds, + @Param("userId") Long userId); } diff --git a/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeQueryPersistenceAdapter.java index 0494d97ef..54051b3f2 100644 --- a/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/post/adapter/out/persistence/PostLikeQueryPersistenceAdapter.java @@ -4,6 +4,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.Set; + @Repository @RequiredArgsConstructor public class PostLikeQueryPersistenceAdapter implements PostLikeQueryPort { @@ -11,12 +13,7 @@ public class PostLikeQueryPersistenceAdapter implements PostLikeQueryPort { private final PostLikeJpaRepository postLikeJpaRepository; @Override - public int countByPostId(Long postId) { - return postLikeJpaRepository.countByPostJpaEntity_PostId(postId); - } - - @Override - public boolean existsByPostIdAndUserId(Long postId, Long userId) { - return postLikeJpaRepository.existsByPostJpaEntity_PostIdAndUserJpaEntity_UserId(postId, userId); + public Set findPostIdsLikedByUser(Set postIds, Long userId) { + return postLikeJpaRepository.findPostIdsLikedByUser(postIds, userId); } } diff --git a/src/main/java/konkuk/thip/post/application/port/out/PostLikeQueryPort.java b/src/main/java/konkuk/thip/post/application/port/out/PostLikeQueryPort.java index 071babd08..32ad51287 100644 --- a/src/main/java/konkuk/thip/post/application/port/out/PostLikeQueryPort.java +++ b/src/main/java/konkuk/thip/post/application/port/out/PostLikeQueryPort.java @@ -1,8 +1,8 @@ package konkuk.thip.post.application.port.out; -public interface PostLikeQueryPort { +import java.util.Set; - int countByPostId(Long postId); - boolean existsByPostIdAndUserId(Long postId, Long userId); +public interface PostLikeQueryPort { + Set findPostIdsLikedByUser(Set postIds, Long userId); } diff --git a/src/main/java/konkuk/thip/record/adapter/in/web/RecordQueryController.java b/src/main/java/konkuk/thip/record/adapter/in/web/RecordQueryController.java index 93162c9c7..1b117774c 100644 --- a/src/main/java/konkuk/thip/record/adapter/in/web/RecordQueryController.java +++ b/src/main/java/konkuk/thip/record/adapter/in/web/RecordQueryController.java @@ -3,6 +3,7 @@ import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; import konkuk.thip.record.adapter.in.web.response.RecordSearchResponse; +import konkuk.thip.record.application.port.in.dto.RecordSearchQuery; import konkuk.thip.record.application.port.in.dto.RecordSearchUseCase; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -16,18 +17,43 @@ public class RecordQueryController { private final RecordSearchUseCase recordSearchUseCase; + /** + * 방의 게시글(기록, 투표) 목록 조회 + * @param roomId + * @param type : group , mine + * @param sort : 그룹 기록 -> 최신순 / 내 기록 -> 페이지 높은 순 default 정렬 + * @param pageStart + * @param pageEnd + * @param isOverview : 총평보기 필터 여부 + * @param isPageFilter : 페이지 필터 여부 + * @param userId + * @return + */ @GetMapping("/rooms/{roomId}/posts") public BaseResponse viewRecordList( @PathVariable final Long roomId, - @RequestParam(required = false) final String type, + @RequestParam(required = false, defaultValue = "group") final String type, @RequestParam(required = false) final String sort, @RequestParam(required = false) final Integer pageStart, @RequestParam(required = false) final Integer pageEnd, - @RequestParam final Boolean isOverview, - @RequestParam final Integer pageNum, + @RequestParam(required = false, defaultValue = "false") final Boolean isOverview, + @RequestParam(required = false, defaultValue = "false") final Boolean isPageFilter, + @RequestParam(required = false) final String cursor, @UserId final Long userId ) { - return BaseResponse.ok(recordSearchUseCase.search(roomId, type, sort, pageStart, pageEnd, isOverview, pageNum, userId)); + return BaseResponse.ok(recordSearchUseCase.search( + RecordSearchQuery.builder() + .roomId(roomId) + .type(type) + .sort(sort) + .pageStart(pageStart) + .pageEnd(pageEnd) + .isOverview(isOverview) + .isPageFilter(isPageFilter) + .nextCursor(cursor) + .userId(userId) + .build() + )); } } diff --git a/src/main/java/konkuk/thip/record/adapter/in/web/response/RecordDto.java b/src/main/java/konkuk/thip/record/adapter/in/web/response/RecordDto.java deleted file mode 100644 index f5c04298e..000000000 --- a/src/main/java/konkuk/thip/record/adapter/in/web/response/RecordDto.java +++ /dev/null @@ -1,39 +0,0 @@ -package konkuk.thip.record.adapter.in.web.response; - -import lombok.Builder; - -@Builder -public record RecordDto( - String postDate, - int page, - Long userId, - String nickName, - String profileImageUrl, - String content, - int likeCount, - int commentCount, - boolean isLiked, - boolean isWriter, - Long recordId -) implements RecordSearchResponse.RecordSearchResult { - @Override - public String type() { - return "RECORD"; - } - - public RecordDto withIsLiked(boolean isLiked) { - return new RecordDto( - postDate, - page, - userId, - nickName, - profileImageUrl, - content, - likeCount, - commentCount, - isLiked, - isWriter, - recordId - ); - } -} diff --git a/src/main/java/konkuk/thip/record/adapter/in/web/response/RecordSearchResponse.java b/src/main/java/konkuk/thip/record/adapter/in/web/response/RecordSearchResponse.java index 46f0edd21..7399ca3bd 100644 --- a/src/main/java/konkuk/thip/record/adapter/in/web/response/RecordSearchResponse.java +++ b/src/main/java/konkuk/thip/record/adapter/in/web/response/RecordSearchResponse.java @@ -1,42 +1,41 @@ package konkuk.thip.record.adapter.in.web.response; -import com.fasterxml.jackson.annotation.JsonSubTypes; -import com.fasterxml.jackson.annotation.JsonTypeInfo; +import lombok.Builder; import java.util.List; +@Builder public record RecordSearchResponse( - List recordList, - Integer page, - Integer size, - Boolean first, - Boolean last + List postList, + String nextCursor, + Boolean isLast ){ - - public static RecordSearchResponse of(List recordList, - Integer page, - Integer size, - Boolean first, - Boolean last) { - return new RecordSearchResponse(recordList, page, size, first, last); - } - - @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") - @JsonSubTypes({ - @JsonSubTypes.Type(value = RecordDto.class, name = "RECORD"), - @JsonSubTypes.Type(value = VoteDto.class, name = "VOTE") - }) - public sealed interface RecordSearchResult permits RecordDto, VoteDto { - String type(); - String postDate(); - int page(); - Long userId(); - String nickName(); - String profileImageUrl(); - String content(); - int likeCount(); - int commentCount(); - boolean isLiked(); - boolean isWriter(); + @Builder + public record PostDto( + Long postId, + String postDate, + String postType, + int page, + Long userId, + String nickName, + String profileImageUrl, + String content, + int likeCount, + int commentCount, + boolean isLiked, + boolean isWriter, + boolean isLocked, + List voteItems + ) { + public record VoteItemDto( + Long voteItemId, + String itemName, + int percentage, + boolean isVoted + ) { + public static VoteItemDto of(Long voteItemId, String itemName, int percentage, boolean isVoted) { + return new VoteItemDto(voteItemId, itemName, percentage, isVoted); + } + } } } diff --git a/src/main/java/konkuk/thip/record/adapter/in/web/response/VoteDto.java b/src/main/java/konkuk/thip/record/adapter/in/web/response/VoteDto.java deleted file mode 100644 index 6907ddde1..000000000 --- a/src/main/java/konkuk/thip/record/adapter/in/web/response/VoteDto.java +++ /dev/null @@ -1,60 +0,0 @@ -package konkuk.thip.record.adapter.in.web.response; - -import konkuk.thip.vote.domain.VoteItem; -import lombok.Builder; - -import java.util.List; - -@Builder -public record VoteDto( - String postDate, - int page, - Long userId, - String nickName, - String profileImageUrl, - String content, - int likeCount, - int commentCount, - boolean isLiked, - boolean isWriter, - Long voteId, - List voteItems -) implements RecordSearchResponse.RecordSearchResult { - @Override - public String type() { - return "VOTE"; - } - - public VoteDto withIsLikedAndVoteItems(boolean isLiked, List voteItems) { - return new VoteDto( - postDate, - page, - userId, - nickName, - profileImageUrl, - content, - likeCount, - commentCount, - isLiked, - isWriter, - voteId, - voteItems - ); - } - - public record VoteItemDto( - Long voteItemId, - String itemName, - int percentage, - boolean isVoted - ) { - public static VoteItemDto of(VoteItem voteItem, int percentage, boolean isVoted) { - return new VoteItemDto( - voteItem.getId(), - voteItem.getItemName(), - percentage, - isVoted - ); - } - } -} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/record/adapter/out/persistence/RecordQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/record/adapter/out/persistence/RecordQueryPersistenceAdapter.java index 362faf456..306bf35bf 100644 --- a/src/main/java/konkuk/thip/record/adapter/out/persistence/RecordQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/record/adapter/out/persistence/RecordQueryPersistenceAdapter.java @@ -1,13 +1,16 @@ package konkuk.thip.record.adapter.out.persistence; -import konkuk.thip.record.adapter.in.web.response.RecordSearchResponse; +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; +import konkuk.thip.record.adapter.out.persistence.constants.SortType; import konkuk.thip.record.adapter.out.persistence.repository.RecordJpaRepository; import konkuk.thip.record.application.port.out.RecordQueryPort; +import konkuk.thip.record.application.port.out.dto.PostQueryDto; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository @RequiredArgsConstructor public class RecordQueryPersistenceAdapter implements RecordQueryPort { @@ -15,8 +18,50 @@ public class RecordQueryPersistenceAdapter implements RecordQueryPort { private final RecordJpaRepository recordJpaRepository; @Override - public Page findRecordsByRoom(Long roomId, String type, Integer pageStart, Integer pageEnd, Boolean isOverview, Long userId, Pageable pageable) { - return recordJpaRepository.findRecordsByRoom(roomId, type, pageStart, pageEnd, isOverview, userId, pageable); + public CursorBasedList searchMyRecords(Long roomId, Long userId, Cursor cursor) { + List postQueryDtos = recordJpaRepository.findMyRecords(roomId, userId, cursor); + + return CursorBasedList.of(postQueryDtos, cursor.getPageSize(), postQueryDto -> { + Cursor nextCursor = new Cursor(List.of(postQueryDto.isOverview() ? "1" : "0", + postQueryDto.page().toString(), + postQueryDto.postId().toString())); + return nextCursor.toEncodedString(); + }); } -} + @Override + public CursorBasedList searchGroupRecordsByLatest(Long roomId, Long userId, Cursor cursor, Integer pageStart, Integer pageEnd, Boolean isOverview) { + List postQueryDtos = recordJpaRepository.findGroupRecordsOrderBySortType( + roomId, userId, cursor, pageStart, pageEnd, isOverview, SortType.CREATED_AT); + + return CursorBasedList.of(postQueryDtos, cursor.getPageSize(), postQueryDto -> { + Cursor nextCursor = new Cursor(List.of(postQueryDto.postDate().toString(), + postQueryDto.postId().toString())); + return nextCursor.toEncodedString(); + }); + } + + @Override + public CursorBasedList searchGroupRecordsByLike(Long roomId, Long userId, Cursor cursor, Integer pageStart, Integer pageEnd, Boolean isOverview) { + List postQueryDtos = recordJpaRepository.findGroupRecordsOrderBySortType( + roomId, userId, cursor, pageStart, pageEnd, isOverview, SortType.LIKE_COUNT); + + return CursorBasedList.of(postQueryDtos, cursor.getPageSize(), postQueryDto -> { + Cursor nextCursor = new Cursor(List.of(postQueryDto.likeCount().toString(), + postQueryDto.postId().toString())); + return nextCursor.toEncodedString(); + }); + } + + @Override + public CursorBasedList searchGroupRecordsByComment(Long roomId, Long userId, Cursor cursor, Integer pageStart, Integer pageEnd, Boolean isOverview) { + List postQueryDtos = recordJpaRepository.findGroupRecordsOrderBySortType( + roomId, userId, cursor, pageStart, pageEnd, isOverview, SortType.COMMENT_COUNT); + + return CursorBasedList.of(postQueryDtos, cursor.getPageSize(), postQueryDto -> { + Cursor nextCursor = new Cursor(List.of(postQueryDto.commentCount().toString(), + postQueryDto.postId().toString())); + return nextCursor.toEncodedString(); + }); + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/record/adapter/out/persistence/RecordSearchTypeParams.java b/src/main/java/konkuk/thip/record/adapter/out/persistence/RecordSearchTypeParams.java index 615a13ad7..f679984f6 100644 --- a/src/main/java/konkuk/thip/record/adapter/out/persistence/RecordSearchTypeParams.java +++ b/src/main/java/konkuk/thip/record/adapter/out/persistence/RecordSearchTypeParams.java @@ -22,7 +22,7 @@ public static RecordSearchTypeParams from(String value) { .filter(param -> param.getValue().equals(value)) .findFirst() .orElseThrow( - () -> new InvalidStateException(ErrorCode.API_INVALID_PARAM, new IllegalArgumentException("현재 타입 조건 param : " + value)) + () -> new InvalidStateException(ErrorCode.API_INVALID_PARAM, new IllegalArgumentException("type은 group 또는 mine이어야 합니다. 현재 타입 조건 param : " + value)) ); } } diff --git a/src/main/java/konkuk/thip/record/adapter/out/persistence/constants/SortType.java b/src/main/java/konkuk/thip/record/adapter/out/persistence/constants/SortType.java new file mode 100644 index 000000000..6caa640fe --- /dev/null +++ b/src/main/java/konkuk/thip/record/adapter/out/persistence/constants/SortType.java @@ -0,0 +1,5 @@ +package konkuk.thip.record.adapter.out.persistence.constants; + +public enum SortType { + CREATED_AT, LIKE_COUNT, COMMENT_COUNT, MINE +} diff --git a/src/main/java/konkuk/thip/record/adapter/out/persistence/repository/RecordQueryRepository.java b/src/main/java/konkuk/thip/record/adapter/out/persistence/repository/RecordQueryRepository.java index b268cd288..f62172185 100644 --- a/src/main/java/konkuk/thip/record/adapter/out/persistence/repository/RecordQueryRepository.java +++ b/src/main/java/konkuk/thip/record/adapter/out/persistence/repository/RecordQueryRepository.java @@ -1,11 +1,14 @@ package konkuk.thip.record.adapter.out.persistence.repository; -import konkuk.thip.record.adapter.in.web.response.RecordSearchResponse; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; +import konkuk.thip.common.util.Cursor; +import konkuk.thip.record.adapter.out.persistence.constants.SortType; +import konkuk.thip.record.application.port.out.dto.PostQueryDto; + +import java.util.List; public interface RecordQueryRepository { - Page findRecordsByRoom(Long roomId, String viewType, Integer pageStart, Integer pageEnd, Boolean isOverview, Long userId, Pageable pageable); + List findMyRecords(Long roomId, Long userId, Cursor cursor); + List findGroupRecordsOrderBySortType(Long roomId, Long userId, Cursor cursor, Integer pageStart, Integer pageEnd, Boolean isOverview, SortType sortType); } diff --git a/src/main/java/konkuk/thip/record/adapter/out/persistence/repository/RecordQueryRepositoryImpl.java b/src/main/java/konkuk/thip/record/adapter/out/persistence/repository/RecordQueryRepositoryImpl.java index 2f344f520..d834acdb7 100644 --- a/src/main/java/konkuk/thip/record/adapter/out/persistence/repository/RecordQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/record/adapter/out/persistence/repository/RecordQueryRepositoryImpl.java @@ -1,31 +1,30 @@ package konkuk.thip.record.adapter.out.persistence.repository; import com.querydsl.core.BooleanBuilder; -import com.querydsl.core.types.Order; import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.core.types.dsl.StringExpression; import com.querydsl.jpa.impl.JPAQueryFactory; -import konkuk.thip.common.exception.InvalidStateException; -import konkuk.thip.common.exception.code.ErrorCode; -import konkuk.thip.common.util.DateUtil; -import konkuk.thip.post.adapter.out.jpa.PostJpaEntity; +import konkuk.thip.common.entity.StatusType; +import konkuk.thip.common.util.Cursor; import konkuk.thip.post.adapter.out.jpa.QPostJpaEntity; -import konkuk.thip.record.adapter.in.web.response.RecordDto; -import konkuk.thip.record.adapter.in.web.response.RecordSearchResponse; -import konkuk.thip.record.adapter.in.web.response.VoteDto; import konkuk.thip.record.adapter.out.jpa.QRecordJpaEntity; import konkuk.thip.record.adapter.out.jpa.RecordJpaEntity; +import konkuk.thip.record.adapter.out.persistence.constants.SortType; +import konkuk.thip.record.application.port.out.dto.PostQueryDto; +import konkuk.thip.record.application.port.out.dto.QPostQueryDto; +import konkuk.thip.user.adapter.out.jpa.QUserJpaEntity; import konkuk.thip.vote.adapter.out.jpa.QVoteJpaEntity; import konkuk.thip.vote.adapter.out.jpa.VoteJpaEntity; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Repository; -import java.util.ArrayList; +import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; + +import static konkuk.thip.common.post.PostType.*; + @Repository @RequiredArgsConstructor @@ -33,109 +32,72 @@ public class RecordQueryRepositoryImpl implements RecordQueryRepository { private final JPAQueryFactory queryFactory; - @Override - public Page findRecordsByRoom(Long roomId, String viewType, Integer pageStart, Integer pageEnd, Boolean isOverview, Long loginUserId, Pageable pageable) { - QPostJpaEntity post = QPostJpaEntity.postJpaEntity; - QRecordJpaEntity record = QRecordJpaEntity.recordJpaEntity; - QVoteJpaEntity vote = QVoteJpaEntity.voteJpaEntity; + private final QPostJpaEntity post = QPostJpaEntity.postJpaEntity; + private final QRecordJpaEntity record = QRecordJpaEntity.recordJpaEntity; + private final QVoteJpaEntity vote = QVoteJpaEntity.voteJpaEntity; + private final QUserJpaEntity user = QUserJpaEntity.userJpaEntity; - BooleanBuilder where = new BooleanBuilder(); - where.and(buildRecordCondition(roomId, pageStart, pageEnd, isOverview, post, record). - or(buildVoteCondition(roomId, pageStart, pageEnd, isOverview, post, vote))); + @Override + public List findMyRecords(Long roomId, Long userId, Cursor cursor) { + BooleanBuilder where = buildMyRecordCondition(roomId, userId); + SortType sortType = SortType.MINE; - if ("mine".equals(viewType)) { - where.and(post.userJpaEntity.userId.eq(loginUserId)); + if (!cursor.isFirstRequest()) { + where.and(buildCursorPredicateForSortType(sortType, cursor)); } - List> orderSpecifiers = createOrderSpecifiers(pageable, record, vote, post); - - List posts = queryFactory - .selectFrom(post) + return queryFactory + .select(selectPostQueryDto()) + .from(post) .leftJoin(record).on(post.postId.eq(record.postId)) .leftJoin(vote).on(post.postId.eq(vote.postId)) + .join(post.userJpaEntity, user) .where(where) - .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) + .orderBy(getOrderSpecifiers(sortType)) + .limit(cursor.getPageSize() + 1) .fetch(); + } - List resultList = posts.stream() - .map(p -> { - if (p instanceof RecordJpaEntity r) { - return RecordDto.builder() - .postDate(DateUtil.formatBeforeTime(r.getCreatedAt())) - .page(r.getPage()) - .userId(r.getUserJpaEntity().getUserId()) - .nickName(r.getUserJpaEntity().getNickname()) - .profileImageUrl(r.getUserJpaEntity().getImageUrl()) - .content(r.getContent()) - .likeCount(safeInt(r.getLikeCount())) - .commentCount(safeInt(r.getCommentCount())) - .isLiked(false) // 초기값은 false, 서비스 레벨에서 처리 - .isWriter(loginUserId.equals(r.getUserJpaEntity().getUserId())) - .recordId(r.getPostId()) - .build(); - } else if (p instanceof VoteJpaEntity v) { - // VoteItem은 양방향 매핑이 없으므로 빈 리스트로 처리하고 서비스 레벨에서 파싱 - return VoteDto.builder() - .postDate(DateUtil.formatBeforeTime(v.getCreatedAt())) - .page(v.getPage()) - .userId(v.getUserJpaEntity().getUserId()) - .nickName(v.getUserJpaEntity().getNickname()) - .profileImageUrl(v.getUserJpaEntity().getImageUrl()) - .content(v.getContent()) - .likeCount(safeInt(v.getLikeCount())) - .commentCount(safeInt(v.getCommentCount())) - .isLiked(false) // 초기값은 false, 서비스 레벨에서 처리 - .isWriter(loginUserId.equals(v.getUserJpaEntity().getUserId())) - .voteId(v.getPostId()) - .voteItems(new ArrayList<>()) // 빈 리스트로 초기화, 서비스 레벨에서 처리 - .build(); - } else { - throw new InvalidStateException(ErrorCode.API_SERVER_ERROR, new IllegalStateException("지원되지 않는 게시물 타입: " + p.getClass().getSimpleName())); - } - }) - .map(result -> (RecordSearchResponse.RecordSearchResult) result) - .toList(); - - Long totalCount = queryFactory - .select(post.count()) - .from(post) - .leftJoin(record).on(post.postId.eq(record.postId)) - .leftJoin(vote).on(post.postId.eq(vote.postId)) - .where(where) - .fetchOne(); - long total = (totalCount != null) ? totalCount : 0L; + private BooleanBuilder buildMyRecordCondition(Long roomId, Long userId) { + BooleanBuilder where = new BooleanBuilder(); - return new PageImpl<>(resultList, pageable, total); - } + BooleanBuilder voteCondition = new BooleanBuilder(); + voteCondition.and(post.instanceOf(VoteJpaEntity.class)) + .and(vote.roomJpaEntity.roomId.eq(roomId)); + + BooleanBuilder recordCondition = new BooleanBuilder(); + recordCondition.and(post.instanceOf(RecordJpaEntity.class)) + .and(record.roomJpaEntity.roomId.eq(roomId)); - private Integer safeInt(Number number) { - return Optional.ofNullable(number).map(Number::intValue).orElse(0); + where.and(voteCondition.or(recordCondition)) + .and(post.userJpaEntity.userId.eq(userId)) + .and(post.status.eq(StatusType.ACTIVE)); + return where; } - private List> createOrderSpecifiers(Pageable pageable, QRecordJpaEntity record, QVoteJpaEntity vote, QPostJpaEntity post) { - List> orderSpecifiers = new ArrayList<>(); - for (Sort.Order order : pageable.getSort()) { - String property = order.getProperty(); - boolean asc = order.getDirection().isAscending(); - - if ("likeCount".equalsIgnoreCase(property)) { - orderSpecifiers.add(new OrderSpecifier<>(asc ? Order.ASC : Order.DESC, - record.likeCount.coalesce(0).add(vote.likeCount.coalesce(0)))); - } else if ("commentCount".equalsIgnoreCase(property)) { - orderSpecifiers.add(new OrderSpecifier<>(asc ? Order.ASC : Order.DESC, - record.commentCount.coalesce(0).add(vote.commentCount.coalesce(0)))); - } else if ("createdAt".equalsIgnoreCase(property)) { - orderSpecifiers.add(asc ? post.createdAt.asc() : post.createdAt.desc()); - } else { - orderSpecifiers.add(post.createdAt.desc()); - } + @Override + public List findGroupRecordsOrderBySortType(Long roomId, Long userId, Cursor cursor, Integer pageStart, Integer pageEnd, Boolean isOverview, SortType sortType) { + BooleanBuilder where = buildRecordVoteCondition(roomId, pageStart, pageEnd, isOverview); + + if (!cursor.isFirstRequest()) { + where.and(buildCursorPredicateForSortType(sortType, cursor)); } - return orderSpecifiers; + + return queryFactory + .select(selectPostQueryDto()) + .from(post) + .leftJoin(record).on(post.postId.eq(record.postId)) + .leftJoin(vote).on(post.postId.eq(vote.postId)) + .join(post.userJpaEntity, user) + .where(where) + .orderBy(getOrderSpecifiers(sortType)) + .limit(cursor.getPageSize() + 1) + .fetch(); } - private BooleanBuilder buildVoteCondition(Long roomId, Integer pageStart, Integer pageEnd, Boolean isOverview, QPostJpaEntity post, QVoteJpaEntity vote) { + private BooleanBuilder buildRecordVoteCondition(Long roomId, Integer pageStart, Integer pageEnd, Boolean isOverview) { + BooleanBuilder where = new BooleanBuilder(); + BooleanBuilder voteCondition = new BooleanBuilder(); voteCondition.and(post.instanceOf(VoteJpaEntity.class)) .and(vote.roomJpaEntity.roomId.eq(roomId)); @@ -146,10 +108,7 @@ private BooleanBuilder buildVoteCondition(Long roomId, Integer pageStart, Intege voteCondition.and(vote.isOverview.isFalse()) .and(vote.page.between(pageStart, pageEnd)); } - return voteCondition; - } - private BooleanBuilder buildRecordCondition(Long roomId, Integer pageStart, Integer pageEnd, Boolean isOverview, QPostJpaEntity post, QRecordJpaEntity record) { BooleanBuilder recordCondition = new BooleanBuilder(); recordCondition.and(post.instanceOf(RecordJpaEntity.class)) .and(record.roomJpaEntity.roomId.eq(roomId)); @@ -160,6 +119,95 @@ private BooleanBuilder buildRecordCondition(Long roomId, Integer pageStart, Inte recordCondition.and(record.isOverview.isFalse()) .and(record.page.between(pageStart, pageEnd)); } - return recordCondition; + + where.and(voteCondition.or(recordCondition)) + .and(post.status.eq(StatusType.ACTIVE)); + return where; + } + + // Case: pageExpr (Record, Vote 분기) + private NumberExpression pageExpr() { + return new CaseBuilder() + .when(post.instanceOf(RecordJpaEntity.class)).then(record.page) + .when(post.instanceOf(VoteJpaEntity.class)).then(vote.page) + .otherwise(0); + } + + // Case: isOverviewExpr (총평 여부를 정렬 기준으로 사용) + private NumberExpression isOverviewExpr() { + return new CaseBuilder() + .when(post.instanceOf(RecordJpaEntity.class)).then(record.isOverview.castToNum(Integer.class)) + .when(post.instanceOf(VoteJpaEntity.class)).then(vote.isOverview.castToNum(Integer.class)) + .otherwise(0); + } + + // Case: postTypeExpr ("RECORD" or "VOTE") + private StringExpression postTypeExpr() { + return new CaseBuilder() + .when(post.instanceOf(RecordJpaEntity.class)).then(RECORD.getType()) + .when(post.instanceOf(VoteJpaEntity.class)).then(VOTE.getType()) + .otherwise(FEED.getType()); + } + + private BooleanBuilder buildCursorPredicateForSortType(SortType sortType, Cursor cursor) { + BooleanBuilder builder = new BooleanBuilder(); + + switch (sortType) { + case CREATED_AT -> { + LocalDateTime createdAt = cursor.getLocalDateTime(0); + Long postId = cursor.getLong(1); + builder.and(post.createdAt.lt(createdAt) + .or(post.createdAt.eq(createdAt).and(post.postId.lt(postId)))); + } + case LIKE_COUNT -> { + Integer likeCount = cursor.getInteger(0); + Long postId = cursor.getLong(1); + builder.and(post.likeCount.lt(likeCount) + .or(post.likeCount.eq(likeCount).and(post.postId.lt(postId)))); + } + case COMMENT_COUNT -> { + Integer commentCount = cursor.getInteger(0); + Long postId = cursor.getLong(1); + builder.and(post.commentCount.lt(commentCount) + .or(post.commentCount.eq(commentCount).and(post.postId.lt(postId)))); + } + case MINE -> { + Integer isOverview = cursor.getInteger(0); + Integer page = cursor.getInteger(1); + Long postId = cursor.getLong(2); + builder.and( + isOverviewExpr().lt(isOverview) + .or(isOverviewExpr().eq(isOverview).and(pageExpr().lt(page))) + .or(isOverviewExpr().eq(isOverview).and(pageExpr().eq(page)).and(post.postId.lt(postId))) + ); + } + } + + return builder; + } + + private OrderSpecifier[] getOrderSpecifiers(SortType sortType) { + return switch (sortType) { + case CREATED_AT -> new OrderSpecifier[] { post.createdAt.desc(), post.postId.desc() }; + case LIKE_COUNT -> new OrderSpecifier[] { post.likeCount.desc(), post.postId.desc() }; + case COMMENT_COUNT -> new OrderSpecifier[] { post.commentCount.desc(), post.postId.desc() }; + case MINE -> new OrderSpecifier[] { isOverviewExpr().desc(), pageExpr().desc(), post.postId.desc() }; + }; + } + + private QPostQueryDto selectPostQueryDto() { + return new QPostQueryDto( + post.postId, + postTypeExpr(), //추후에 상속 구조 해지시 type 필드로 구분 + post.createdAt, + pageExpr(), + user.userId, + user.nickname, + user.imageUrl, + post.content, + post.likeCount, + post.commentCount, + isOverviewExpr().eq(1) + ); } } \ No newline at end of file diff --git a/src/main/java/konkuk/thip/record/application/port/in/dto/RecordSearchQuery.java b/src/main/java/konkuk/thip/record/application/port/in/dto/RecordSearchQuery.java new file mode 100644 index 000000000..e5d8a7c5f --- /dev/null +++ b/src/main/java/konkuk/thip/record/application/port/in/dto/RecordSearchQuery.java @@ -0,0 +1,17 @@ +package konkuk.thip.record.application.port.in.dto; + +import lombok.Builder; + +@Builder +public record RecordSearchQuery( + Long roomId, + String type, + String sort, + Integer pageStart, + Integer pageEnd, + Boolean isOverview, + Boolean isPageFilter, + String nextCursor, + Long userId +) { +} diff --git a/src/main/java/konkuk/thip/record/application/port/in/dto/RecordSearchResult.java b/src/main/java/konkuk/thip/record/application/port/in/dto/RecordSearchResult.java deleted file mode 100644 index f5ccb2881..000000000 --- a/src/main/java/konkuk/thip/record/application/port/in/dto/RecordSearchResult.java +++ /dev/null @@ -1,23 +0,0 @@ -package konkuk.thip.record.application.port.in.dto; - -import konkuk.thip.record.domain.Record; -import konkuk.thip.vote.domain.Vote; -import lombok.Builder; - -import java.util.List; - -@Builder -public record RecordSearchResult( - List records, - List votes -) { - public static RecordSearchResult of( - List records, - List votes - ) { - return RecordSearchResult.builder() - .records(records) - .votes(votes) - .build(); - } -} diff --git a/src/main/java/konkuk/thip/record/application/port/in/dto/RecordSearchUseCase.java b/src/main/java/konkuk/thip/record/application/port/in/dto/RecordSearchUseCase.java index f27e2da69..462a4054a 100644 --- a/src/main/java/konkuk/thip/record/application/port/in/dto/RecordSearchUseCase.java +++ b/src/main/java/konkuk/thip/record/application/port/in/dto/RecordSearchUseCase.java @@ -4,5 +4,5 @@ public interface RecordSearchUseCase { - RecordSearchResponse search(Long roomId, String type, String sort, Integer pageStart, Integer pageEnd, Boolean isOverview, Integer pageNum, Long userId); + RecordSearchResponse search(RecordSearchQuery recordSearchQuery); } diff --git a/src/main/java/konkuk/thip/record/application/port/out/RecordQueryPort.java b/src/main/java/konkuk/thip/record/application/port/out/RecordQueryPort.java index aad569a42..8b71d6e5d 100644 --- a/src/main/java/konkuk/thip/record/application/port/out/RecordQueryPort.java +++ b/src/main/java/konkuk/thip/record/application/port/out/RecordQueryPort.java @@ -1,11 +1,17 @@ package konkuk.thip.record.application.port.out; -import konkuk.thip.record.adapter.in.web.response.RecordSearchResponse; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; +import konkuk.thip.record.application.port.out.dto.PostQueryDto; public interface RecordQueryPort { - Page findRecordsByRoom(Long roomId, String type, Integer pageStart, Integer pageEnd, Boolean isOverview, Long userId, Pageable pageable); + CursorBasedList searchMyRecords(Long roomId, Long userId, Cursor cursor); + CursorBasedList searchGroupRecordsByLatest(Long roomId, Long userId, Cursor cursor, Integer pageStart, Integer pageEnd, Boolean isOverview); + + CursorBasedList searchGroupRecordsByLike(Long roomId, Long userId, Cursor cursor, Integer pageStart, Integer pageEnd, Boolean isOverview); + + CursorBasedList searchGroupRecordsByComment(Long roomId, Long userId, Cursor cursor, Integer pageStart, Integer pageEnd, Boolean isOverview); } + diff --git a/src/main/java/konkuk/thip/record/application/port/out/dto/PostQueryDto.java b/src/main/java/konkuk/thip/record/application/port/out/dto/PostQueryDto.java new file mode 100644 index 000000000..5cadfa69e --- /dev/null +++ b/src/main/java/konkuk/thip/record/application/port/out/dto/PostQueryDto.java @@ -0,0 +1,35 @@ +package konkuk.thip.record.application.port.out.dto; + +import com.querydsl.core.annotations.QueryProjection; +import org.springframework.util.Assert; + +import java.time.LocalDateTime; + +public record PostQueryDto( + Long postId, + String postType, + LocalDateTime postDate, + Integer page, + Long userId, + String nickName, + String profileImageUrl, + String content, + Integer likeCount, + Integer commentCount, + Boolean isOverview +) { + @QueryProjection + public PostQueryDto { + Assert.notNull(postId, "postId must not be null"); + Assert.notNull(postType, "postType must not be null"); + Assert.notNull(postDate, "postDate must not be null"); + Assert.notNull(page, "page must not be null"); + Assert.notNull(userId, "userId must not be null"); + Assert.notNull(nickName, "nickName must not be null"); + Assert.notNull(profileImageUrl, "profileImageUrl must not be null"); + Assert.notNull(content, "content must not be null"); + Assert.notNull(likeCount, "likeCount must not be null"); + Assert.notNull(commentCount, "commentCount must not be null"); + Assert.notNull(isOverview, "isOverview must not be null"); + } +} diff --git a/src/main/java/konkuk/thip/record/application/service/RecordSearchService.java b/src/main/java/konkuk/thip/record/application/service/RecordSearchService.java index dacac95f6..67ea21dac 100644 --- a/src/main/java/konkuk/thip/record/application/service/RecordSearchService.java +++ b/src/main/java/konkuk/thip/record/application/service/RecordSearchService.java @@ -2,29 +2,36 @@ import konkuk.thip.book.application.port.out.BookCommandPort; import konkuk.thip.book.domain.Book; -import konkuk.thip.common.exception.InvalidStateException; +import konkuk.thip.common.exception.BusinessException; import konkuk.thip.common.exception.code.ErrorCode; +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; +import konkuk.thip.common.util.DateUtil; import konkuk.thip.post.application.port.out.PostLikeQueryPort; -import konkuk.thip.record.adapter.in.web.response.RecordDto; import konkuk.thip.record.adapter.in.web.response.RecordSearchResponse; -import konkuk.thip.record.adapter.in.web.response.VoteDto; import konkuk.thip.record.adapter.out.persistence.RecordSearchSortParams; import konkuk.thip.record.adapter.out.persistence.RecordSearchTypeParams; +import konkuk.thip.record.application.port.in.dto.RecordSearchQuery; import konkuk.thip.record.application.port.in.dto.RecordSearchUseCase; import konkuk.thip.record.application.port.out.RecordQueryPort; -import konkuk.thip.vote.application.port.out.VoteCommandPort; +import konkuk.thip.record.application.port.out.dto.PostQueryDto; +import konkuk.thip.room.application.port.out.RoomParticipantCommandPort; +import konkuk.thip.room.domain.RoomParticipant; import konkuk.thip.vote.application.port.out.VoteQueryPort; +import konkuk.thip.vote.application.port.out.dto.VoteItemQueryDto; import konkuk.thip.vote.domain.VoteItem; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import static konkuk.thip.common.post.PostType.RECORD; @Slf4j @Service @@ -33,116 +40,193 @@ public class RecordSearchService implements RecordSearchUseCase { private final RecordQueryPort recordQueryPort; private final BookCommandPort bookCommandPort; - private final VoteCommandPort voteCommandPort; private final VoteQueryPort voteQueryPort; private final PostLikeQueryPort postLikeQueryPort; + private final RoomParticipantCommandPort roomParticipantCommandPort; private static final int DEFAULT_PAGE_SIZE = 10; + private static final String BLURRED_STRING = "여긴 못 지나가지롱~~"; @Override @Transactional(readOnly = true) - public RecordSearchResponse search(Long roomId, String type, String sort, Integer pageStart, Integer pageEnd, Boolean isOverview, Integer pageNum, Long userId) { - // 1. 유효성 검사 - validatePageStartAndEnd(pageStart, pageEnd, isOverview); - pageNum = validatePageNum(pageNum); - - // isOverview가 false일 때 pageStart와 pageEnd가 모두 null이면 전체 페이지 조회 - if(!isOverview && (pageStart == null || pageEnd == null)) { - Book book = bookCommandPort.findBookByRoomId(roomId); - pageStart = 1; - pageEnd = book.getPageCount(); - } - - // 2. 정렬 조건 확인 - RecordSearchSortParams sortVal = sort != null ? RecordSearchSortParams.from(sort) : RecordSearchSortParams.LATEST; - RecordSearchTypeParams typeVal = type != null ? RecordSearchTypeParams.from(type) : RecordSearchTypeParams.GROUP; - - // 3. 페이지 인덱스 및 Pageable 객체 생성 - int pageIndex = pageNum - 1; - Pageable pageable = PageRequest.of(pageIndex, DEFAULT_PAGE_SIZE, buildSort(sortVal)); - - // 4. 게시글 조회 - Page result = recordQueryPort.findRecordsByRoom( - roomId, - typeVal.getValue(), - pageStart, - pageEnd, - isOverview, - userId, - pageable - ); - - // 5. isLiked와 voteItems를 포함한 최종 결과 리스트 생성 - List finalList = result.getContent().stream() - .map(post -> { - if (post instanceof RecordDto recordDto) { - boolean isLiked = checkIfLiked(recordDto.recordId(), userId); - return recordDto.withIsLiked(isLiked); - } else if (post instanceof VoteDto voteDto) { - boolean isLiked = checkIfLiked(voteDto.voteId(), userId); - List items = voteCommandPort.findVoteItemsByVoteId(voteDto.voteId()); - List voteItemDtos = mapToVoteItemDtos(items, userId, voteDto.voteId()); - return voteDto.withIsLikedAndVoteItems(isLiked, voteItemDtos); - } else { - throw new InvalidStateException(ErrorCode.API_SERVER_ERROR, new IllegalStateException("지원되지 않는 게시물 타입입니다")); + public RecordSearchResponse search(RecordSearchQuery recordSearchQuery) { + // RecordSearchQuery에서 필드 반환 + Integer pageStart = recordSearchQuery.pageStart(); + Integer pageEnd = recordSearchQuery.pageEnd(); + Boolean isOverview = recordSearchQuery.isOverview(); + Boolean isPageFilter = recordSearchQuery.isPageFilter(); + Long userId = recordSearchQuery.userId(); + Long roomId = recordSearchQuery.roomId(); + + Book book = bookCommandPort.findBookByRoomId(recordSearchQuery.roomId()); + RoomParticipant roomParticipant = roomParticipantCommandPort.findByUserIdAndRoomIdOptional(recordSearchQuery.userId(), recordSearchQuery.roomId()) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_BELONG_TO_ROOM)); + + // cursor 파싱 + Cursor cursor = Cursor.from(recordSearchQuery.nextCursor(), DEFAULT_PAGE_SIZE); + + // Type에 따라 그룹기록, 내 기록 조회 분기처리 + CursorBasedList cursorBasedList = switch(RecordSearchTypeParams.from(recordSearchQuery.type())) { + case GROUP -> { + validateGroupRecordFilters(pageStart, pageEnd, isPageFilter, isOverview, book.getPageCount(), roomParticipant.getUserPercentage()); + // 총평 보기가 아닌 경우, pageStart와 pageEnd를 default 값 주입 + if(!isOverview) { + if(pageStart == null) { + pageStart = 0; } - }) - .map(finalResult -> (RecordSearchResponse.RecordSearchResult) finalResult) + if(pageEnd == null) { + pageEnd = book.getPageCount(); + } + } + yield getGroupRecordBySortParams(recordSearchQuery.sort(), roomId, userId, cursor, pageStart, pageEnd, isPageFilter, isOverview); + } + case MINE -> { + validateMyRecordFilters(pageStart, pageEnd, isPageFilter, isOverview, recordSearchQuery.sort()); + yield recordQueryPort.searchMyRecords(roomId, userId, cursor); + } + }; + + // VoteItem 한번에 조회 (투표 게시물에 대한 투표 항목 조회) + Map> voteItemQueryMap = voteQueryPort.findVoteItemsByVoteIds(cursorBasedList.contents().stream() + .filter(postQueryDto -> postQueryDto.postType().equals("VOTE")) + .map(PostQueryDto::postId) + .collect(Collectors.toSet()), userId); + + // 사용자가 좋아요를 누른 게시물 ID 목록 조회 + Set likedPostIds = postLikeQueryPort.findPostIdsLikedByUser(cursorBasedList.contents().stream() + .map(PostQueryDto::postId) + .collect(Collectors.toSet()), userId); + + // 게시물 DTO 변환 + var postDtos = cursorBasedList.contents().stream() + .map(postQueryDto -> toPostDto(postQueryDto, roomParticipant, userId, voteItemQueryMap, likedPostIds)) .toList(); - // 6. response 구성 - return new RecordSearchResponse( - finalList, - pageNum, - result.getNumberOfElements(), - result.isLast(), - result.isFirst()); + // RecordSearchResponse 생성 + return RecordSearchResponse.builder() + .postList(postDtos) + .nextCursor(cursorBasedList.nextCursor()) + .isLast(!cursorBasedList.hasNext()) + .build(); } - private void validatePageStartAndEnd(Integer pageStart, Integer pageEnd, Boolean isOverview) { - if((pageStart != null && pageEnd == null) || (pageStart == null && pageEnd != null)) { - throw new InvalidStateException(ErrorCode.API_INVALID_PARAM, new IllegalArgumentException("pageStart와 pageEnd는 모두 설정되거나(특정 페이지 조회) 모두 설정되지 않아야 합니다.(전체 페이지 조회)")); - } - if (pageStart != null && pageEnd != null && pageStart > pageEnd) { - throw new InvalidStateException(ErrorCode.API_INVALID_PARAM, new IllegalArgumentException("pageStart는 pageEnd보다 작거나 같아야 합니다.")); - } - if (isOverview && (pageStart != null || pageEnd != null)) { - throw new InvalidStateException(ErrorCode.API_INVALID_PARAM, new IllegalArgumentException("pageStart와 pageEnd는 isOverview가 true일 때 유효한 파라미터가 아닙니다.")); + private CursorBasedList getGroupRecordBySortParams(String sort, Long roomId, Long userId, Cursor cursor, Integer pageStart, Integer pageEnd, Boolean isPageFilter, Boolean isOverview) { + return switch(RecordSearchSortParams.from(sort)) { + case LATEST -> recordQueryPort.searchGroupRecordsByLatest(roomId, userId, cursor, pageStart, pageEnd, isOverview); + case LIKE -> recordQueryPort.searchGroupRecordsByLike(roomId, userId, cursor, pageStart, pageEnd, isOverview); + case COMMENT -> recordQueryPort.searchGroupRecordsByComment(roomId, userId, cursor, pageStart, pageEnd, isOverview); + }; + } + + private RecordSearchResponse.PostDto toPostDto(PostQueryDto dto, RoomParticipant participant, Long userId, Map> voteItemMap, Set likedPostIds) { + boolean isLocked = participant.getCurrentPage() < dto.page(); + boolean isWriter = dto.userId().equals(userId); + String content = isLocked ? createBlurredString(dto.content()) : dto.content(); + + return RecordSearchResponse.PostDto.builder() + .postId(dto.postId()) + .postDate(DateUtil.formatBeforeTime(dto.postDate())) + .postType(dto.postType()) + .page(dto.page()) + .userId(dto.userId()) + .nickName(dto.nickName()) + .profileImageUrl(dto.profileImageUrl()) + .content(content) + .likeCount(dto.likeCount()) + .commentCount(dto.commentCount()) + .isLiked(likedPostIds.contains(dto.postId())) + .isWriter(isWriter) + .isLocked(isLocked) + .voteItems(getVoteItemDtosIfApplicable(dto, voteItemMap, isLocked)) + .build(); + } + + private List getVoteItemDtosIfApplicable(PostQueryDto dto, Map> voteItemMap, boolean isLocked) { + if (RECORD.getType().equals(dto.postType())) { + return List.of(); } + + List items = voteItemMap.getOrDefault(dto.postId(), List.of()); + return mapToVoteItemDtos(items, isLocked); + } + + private List mapToVoteItemDtos(List items, boolean isLocked) { + // voteCount를 모아 리스트로 변환 + List counts = items.stream() + .map(VoteItemQueryDto::voteCount) + .toList(); + + // 도메인에게 계산 위임 + List percentages = VoteItem.calculatePercentages(counts); + + // 계산 결과를 이용해 DTO 조립 + return IntStream.range(0, items.size()) + .mapToObj(i -> RecordSearchResponse.PostDto.VoteItemDto.of( + items.get(i).voteItemId(), + isLocked ? createBlurredString(items.get(i).itemName()) : items.get(i).itemName(), + percentages.get(i), + items.get(i).isVoted() + )) + .toList(); } - private Integer validatePageNum(Integer pageNum) { - if (pageNum == null) { - return 1; // 기본값으로 첫 페이지 반환 + private String createBlurredString(String contents) { + if (contents == null || contents.isEmpty()) { + return contents; } - if (pageNum < 1) { - throw new InvalidStateException(ErrorCode.API_INVALID_PARAM, new IllegalArgumentException("pageNum은 1 이상의 값이어야 합니다.")); + + int originalLength = contents.length(); + int blurLen = BLURRED_STRING.length(); + + // 필요한 전체 반복 횟수 계산 + int repeat = originalLength / blurLen; + + StringBuilder sb = new StringBuilder(originalLength); + + // 몫 만큼 반복 + for (int i = 0; i < repeat + 1; i++) { + sb.append(BLURRED_STRING); } - return pageNum; - } - private Sort buildSort(RecordSearchSortParams sort) { - return switch (sort) { - case LIKE -> Sort.by(Sort.Direction.DESC, "likeCount"); - case COMMENT -> Sort.by(Sort.Direction.DESC, "commentCount"); - default -> Sort.by(Sort.Direction.DESC, "createdAt"); - }; + return sb.toString(); } - private List mapToVoteItemDtos(List items, Long userId, Long voteId) { - int total = items.stream().mapToInt(VoteItem::getCount).sum(); - return items.stream() - .map(item -> VoteDto.VoteItemDto.of( - item, - item.calculatePercentage(total), - voteQueryPort.isUserVoted(userId, voteId) - ) - ) - .toList(); + private void validateGroupRecordFilters(Integer pageStart, Integer pageEnd, Boolean isPageFilter, Boolean isOverview, int bookPageSize, double currentPercentage) { + if(!isPageFilter && !isOverview) { // 어떤 필터도 적용되지 않는 경우 + if (pageStart != null || pageEnd != null) { + throw new BusinessException(ErrorCode.API_INVALID_PARAM, new IllegalArgumentException("어떤 필터도 적용되지 않는 경우 pageStart와 pageEnd는 null이어야 합니다.")); + } + } + if(!isPageFilter && isOverview) { // 총평보기 필터만 적용된 경우 + if (pageStart != null || pageEnd != null) { + throw new BusinessException(ErrorCode.API_INVALID_PARAM, new IllegalArgumentException("총평보기 필터만 적용된 경우 pageStart와 pageEnd는 null이어야 합니다.")); + } + if (currentPercentage < 80) { + throw new BusinessException(ErrorCode.API_INVALID_PARAM, new IllegalArgumentException("총평보기 필터가 적용된 경우 현재 독서 진행률은 80% 이상이어야 합니다.")); + } + } + if(isPageFilter && !isOverview) { // 페이지 필터만 적용된 경우는 pageStart와 pageEnd가 null이여도 됨 + if(pageStart != null && (pageStart < 0 || pageStart > bookPageSize)) { + throw new BusinessException(ErrorCode.API_INVALID_PARAM, new IllegalArgumentException("pageStart는 책의 페이지 범위 내에 있어야 합니다.")); + } + if(pageEnd != null && (pageEnd < 0 || pageEnd > bookPageSize)) { + throw new BusinessException(ErrorCode.API_INVALID_PARAM, new IllegalArgumentException("pageEnd는 책의 페이지 범위 내에 있어야 합니다.")); + } + if(pageStart != null && pageEnd != null && pageStart > pageEnd) { + throw new BusinessException(ErrorCode.API_INVALID_PARAM, new IllegalArgumentException("pageStart는 pageEnd보다 작아야 합니다.")); + } + } + if(isPageFilter && isOverview) { // 페이지 필터와 총평보기 필터가 동시에 적용된 경우 + throw new BusinessException(ErrorCode.API_INVALID_PARAM, new IllegalArgumentException("페이지 필터와 총평보기 필터는 동시에 적용될 수 없습니다.")); + } } - private boolean checkIfLiked(Long postId, Long userId) { - return postLikeQueryPort.existsByPostIdAndUserId(postId, userId); + private void validateMyRecordFilters(Integer pageStart, Integer pageEnd, Boolean isPageFilter, Boolean isOverview, String sort) { + // 모든 파라미터중 하나라도 null이 아닌 경우 예외 발생 + if (pageStart != null || pageEnd != null || isPageFilter || isOverview || sort != null) { + throw new BusinessException(ErrorCode.API_INVALID_PARAM, new IllegalArgumentException("내 기록 조회에서는 roomId, type, cursor를 제외한 모든 파라미터는 null이어야 합니다.")); + } + } } diff --git a/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomParticipantJpaEntity.java b/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomParticipantJpaEntity.java index 861425f0a..2c286f803 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomParticipantJpaEntity.java +++ b/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomParticipantJpaEntity.java @@ -1,5 +1,6 @@ package konkuk.thip.room.adapter.out.jpa; +import com.google.common.annotations.VisibleForTesting; import jakarta.persistence.*; import konkuk.thip.common.entity.BaseJpaEntity; import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; @@ -39,4 +40,14 @@ public class RoomParticipantJpaEntity extends BaseJpaEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "room_id") private RoomJpaEntity roomJpaEntity; + + @VisibleForTesting + public void updateCurrentPage(int currentPage) { + this.currentPage = currentPage; + } + + @VisibleForTesting + public void updateUserPercentage(double userPercentage) { + this.userPercentage = userPercentage; + } } \ No newline at end of file diff --git a/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteCommandPersistenceAdapter.java index 162d51cce..700aed759 100644 --- a/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteCommandPersistenceAdapter.java @@ -1,7 +1,6 @@ package konkuk.thip.vote.adapter.out.persistence; import konkuk.thip.common.exception.EntityNotFoundException; -import konkuk.thip.common.exception.InvalidStateException; import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; @@ -64,19 +63,6 @@ public void saveAllVoteItems(List voteItems) { voteItemJpaRepository.saveAll(voteItemJpaEntities); } - @Override - public List findVoteItemsByVoteId(Long voteId) { - List voteItems = voteItemJpaRepository.findAllByVoteJpaEntity_PostId(voteId).stream() - .map(voteItemMapper::toDomainEntity) - .toList(); - - if (voteItems.isEmpty()) { - throw new InvalidStateException(VOTE_ITEM_NOT_FOUND); - } - - return voteItems; - } - @Override public Optional findById(Long id) { return voteJpaRepository.findById(id) diff --git a/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteQueryPersistenceAdapter.java index 0be699b95..e7b3458f2 100644 --- a/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteQueryPersistenceAdapter.java @@ -2,30 +2,39 @@ import konkuk.thip.room.adapter.in.web.response.RoomPlayingDetailViewResponse; import konkuk.thip.room.domain.Room; -import konkuk.thip.vote.adapter.out.mapper.VoteMapper; import konkuk.thip.vote.adapter.out.persistence.repository.VoteJpaRepository; -import konkuk.thip.vote.adapter.out.persistence.repository.VoteParticipantJpaRepository; import konkuk.thip.vote.application.port.out.VoteQueryPort; +import konkuk.thip.vote.application.port.out.dto.VoteItemQueryDto; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static java.util.stream.Collectors.groupingBy; @Repository @RequiredArgsConstructor public class VoteQueryPersistenceAdapter implements VoteQueryPort { private final VoteJpaRepository voteJpaRepository; - private final VoteMapper voteMapper; - private final VoteParticipantJpaRepository voteParticipantJpaRepository; @Override - public boolean isUserVoted(Long userId, Long voteItemId) { - return voteParticipantJpaRepository.existsByUserJpaEntity_UserIdAndVoteItemJpaEntity_VoteItemId(userId, voteItemId); + public List findTopParticipationVotesByRoom(Room room, int count) { + return voteJpaRepository.findTopParticipationVotesByRoom(room.getId(), count); } @Override - public List findTopParticipationVotesByRoom(Room room, int count) { - return voteJpaRepository.findTopParticipationVotesByRoom(room.getId(), count); + public Map> findVoteItemsByVoteIds(Set voteIds, Long userId) { + return voteJpaRepository.mapVoteItemsByVoteIds(voteIds, userId).stream() + .collect( + groupingBy( + VoteItemQueryDto::voteId, + Collectors.mapping(Function.identity(), Collectors.toList()) + ) + ); } } diff --git a/src/main/java/konkuk/thip/vote/adapter/out/persistence/repository/VoteQueryRepository.java b/src/main/java/konkuk/thip/vote/adapter/out/persistence/repository/VoteQueryRepository.java index 784513e5e..1e365c780 100644 --- a/src/main/java/konkuk/thip/vote/adapter/out/persistence/repository/VoteQueryRepository.java +++ b/src/main/java/konkuk/thip/vote/adapter/out/persistence/repository/VoteQueryRepository.java @@ -2,12 +2,16 @@ import konkuk.thip.room.adapter.in.web.response.RoomPlayingDetailViewResponse; import konkuk.thip.vote.adapter.out.jpa.VoteJpaEntity; +import konkuk.thip.vote.application.port.out.dto.VoteItemQueryDto; import java.util.List; +import java.util.Set; public interface VoteQueryRepository { List findVotesByRoom(Long roomId, String type, Integer pageStart, Integer pageEnd, Long userId); List findTopParticipationVotesByRoom(Long roomId, int count); + + List mapVoteItemsByVoteIds(Set voteIds, Long userId); } diff --git a/src/main/java/konkuk/thip/vote/adapter/out/persistence/repository/VoteQueryRepositoryImpl.java b/src/main/java/konkuk/thip/vote/adapter/out/persistence/repository/VoteQueryRepositoryImpl.java index 7b892cf35..39197b812 100644 --- a/src/main/java/konkuk/thip/vote/adapter/out/persistence/repository/VoteQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/vote/adapter/out/persistence/repository/VoteQueryRepositoryImpl.java @@ -1,16 +1,21 @@ package konkuk.thip.vote.adapter.out.persistence.repository; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import konkuk.thip.room.adapter.in.web.response.RoomPlayingDetailViewResponse; import konkuk.thip.user.adapter.out.jpa.QUserJpaEntity; import konkuk.thip.vote.adapter.out.jpa.QVoteItemJpaEntity; import konkuk.thip.vote.adapter.out.jpa.QVoteJpaEntity; +import konkuk.thip.vote.adapter.out.jpa.QVoteParticipantJpaEntity; import konkuk.thip.vote.adapter.out.jpa.VoteJpaEntity; +import konkuk.thip.vote.application.port.out.dto.QVoteItemQueryDto; +import konkuk.thip.vote.application.port.out.dto.VoteItemQueryDto; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Set; @Repository @RequiredArgsConstructor @@ -21,6 +26,7 @@ public class VoteQueryRepositoryImpl implements VoteQueryRepository { private final QVoteJpaEntity vote = QVoteJpaEntity.voteJpaEntity; private final QUserJpaEntity user = QUserJpaEntity.userJpaEntity; private final QVoteItemJpaEntity voteItem = QVoteItemJpaEntity.voteItemJpaEntity; + private final QVoteParticipantJpaEntity voteParticipant = QVoteParticipantJpaEntity.voteParticipantJpaEntity; @Override public List findVotesByRoom(Long roomId, String type, Integer pageStart, Integer pageEnd, Long userId) { @@ -81,4 +87,29 @@ public List findTopParticipationVotes }) .toList(); } + + @Override + public List mapVoteItemsByVoteIds(Set voteIds, Long userId) { + QVoteItemJpaEntity voteItem = QVoteItemJpaEntity.voteItemJpaEntity; + QVoteParticipantJpaEntity voteParticipant = QVoteParticipantJpaEntity.voteParticipantJpaEntity; + + return jpaQueryFactory + .select(new QVoteItemQueryDto( + voteItem.voteJpaEntity.postId, + voteItem.voteItemId, + voteItem.itemName, + voteItem.count, + JPAExpressions + .selectOne() + .from(voteParticipant) + .where( + voteParticipant.voteItemJpaEntity.eq(voteItem), + voteParticipant.userJpaEntity.userId.eq(userId) + ) + .exists() // isVoted : 로그인한 사용자가 해당 투표 아이템에 투표했는지 여부 서브 쿼리 + )) + .from(voteItem) + .where(voteItem.voteJpaEntity.postId.in(voteIds)) + .fetch(); + } } diff --git a/src/main/java/konkuk/thip/vote/application/port/out/VoteCommandPort.java b/src/main/java/konkuk/thip/vote/application/port/out/VoteCommandPort.java index 9742cddb6..2f6cec244 100644 --- a/src/main/java/konkuk/thip/vote/application/port/out/VoteCommandPort.java +++ b/src/main/java/konkuk/thip/vote/application/port/out/VoteCommandPort.java @@ -17,8 +17,6 @@ public interface VoteCommandPort { void saveAllVoteItems(List voteItems); - List findVoteItemsByVoteId(Long voteId); - Optional findById(Long id); default Vote getByIdOrThrow(Long id) { diff --git a/src/main/java/konkuk/thip/vote/application/port/out/VoteQueryPort.java b/src/main/java/konkuk/thip/vote/application/port/out/VoteQueryPort.java index 73b820288..9ac11bd17 100644 --- a/src/main/java/konkuk/thip/vote/application/port/out/VoteQueryPort.java +++ b/src/main/java/konkuk/thip/vote/application/port/out/VoteQueryPort.java @@ -2,12 +2,15 @@ import konkuk.thip.room.adapter.in.web.response.RoomPlayingDetailViewResponse; import konkuk.thip.room.domain.Room; +import konkuk.thip.vote.application.port.out.dto.VoteItemQueryDto; import java.util.List; +import java.util.Map; +import java.util.Set; public interface VoteQueryPort { - boolean isUserVoted(Long userId, Long voteId); - List findTopParticipationVotesByRoom(Room room, int count); + + Map> findVoteItemsByVoteIds(Set voteIds, Long userId); } diff --git a/src/main/java/konkuk/thip/vote/application/port/out/dto/VoteItemQueryDto.java b/src/main/java/konkuk/thip/vote/application/port/out/dto/VoteItemQueryDto.java new file mode 100644 index 000000000..3b609bebf --- /dev/null +++ b/src/main/java/konkuk/thip/vote/application/port/out/dto/VoteItemQueryDto.java @@ -0,0 +1,21 @@ +package konkuk.thip.vote.application.port.out.dto; + +import com.querydsl.core.annotations.QueryProjection; +import org.springframework.util.Assert; + +public record VoteItemQueryDto( + Long voteId, + Long voteItemId, + String itemName, + Integer voteCount, + Boolean isVoted +) { + @QueryProjection + public VoteItemQueryDto { + Assert.notNull(voteId, "voteId must not be null"); + Assert.notNull(voteItemId, "voteItemId must not be null"); + Assert.notNull(itemName, "itemName must not be null"); + Assert.notNull(voteCount, "voteCount must not be null"); + Assert.notNull(isVoted, "isVoted must not be null"); + } +} diff --git a/src/main/java/konkuk/thip/vote/domain/VoteItem.java b/src/main/java/konkuk/thip/vote/domain/VoteItem.java index 1aa575f81..ab2c8ec10 100644 --- a/src/main/java/konkuk/thip/vote/domain/VoteItem.java +++ b/src/main/java/konkuk/thip/vote/domain/VoteItem.java @@ -4,6 +4,10 @@ import lombok.Getter; import lombok.experimental.SuperBuilder; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + @Getter @SuperBuilder public class VoteItem extends BaseDomainEntity { @@ -25,8 +29,49 @@ public static VoteItem withoutId(String itemName, int count, Long voteId) { .build(); } - //todo 총 퍼센트가 100이 되는 알고리즘으로 수정! - public int calculatePercentage(int totalCount) { - return totalCount == 0 ? 0 : (int) Math.round((this.count * 100.0) / totalCount); + + /** + * 투표 항목의 비율을 계산 + * @param counts 각 투표 항목의 카운트 리스트 + * @return 각 항목의 백분율을 나타내는 리스트 + */ + public static List calculatePercentages(List counts) { + int total = counts.stream().mapToInt(Integer::intValue).sum(); + int n = counts.size(); + List result = new ArrayList<>(Collections.nCopies(n, 0)); + + if (total == 0 || n == 0) { + return result; + } + + double[] fractional = new double[n]; + int sum = 0; + + for (int i = 0; i < n; i++) { + double exact = counts.get(i) * 100.0 / total; + int base = (int) exact; // 정수 부분 + result.set(i, base); + fractional[i] = exact - base; // 소수 부분 + sum += base; + } + + int remaining = 100 - sum; + + // fractional과 index를 묶어서 정렬 + List order = new ArrayList<>(); + for (int i = 0; i < n; i++) { + order.add(new int[]{i, (int) (fractional[i] * 1_000_000)}); // 정밀도 위해 정수화 + } + + // fractional 값이 큰 순서대로 내림차순 정렬 + order.sort((a, b) -> Integer.compare(b[1], a[1])); + + // 남은 %를 fractional이 큰 순서대로 분배 + for (int i = 0; i < remaining; i++) { + int index = order.get(i % n)[0]; + result.set(index, result.get(index) + 1); + } + + return result; } } diff --git a/src/test/java/konkuk/thip/record/adapter/in/web/RecordQueryControllerTest.java b/src/test/java/konkuk/thip/record/adapter/in/web/RecordQueryControllerTest.java deleted file mode 100644 index 8927a4a81..000000000 --- a/src/test/java/konkuk/thip/record/adapter/in/web/RecordQueryControllerTest.java +++ /dev/null @@ -1,181 +0,0 @@ -package konkuk.thip.record.adapter.in.web; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; -import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; -import konkuk.thip.common.util.DateUtil; -import konkuk.thip.common.util.TestEntityFactory; -import konkuk.thip.record.adapter.out.jpa.RecordJpaEntity; -import konkuk.thip.record.adapter.out.persistence.repository.RecordJpaRepository; -import konkuk.thip.room.adapter.out.jpa.CategoryJpaEntity; -import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; -import konkuk.thip.room.adapter.out.persistence.repository.category.CategoryJpaRepository; -import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; -import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; -import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; -import konkuk.thip.user.adapter.out.jpa.UserRole; -import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; -import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; -import konkuk.thip.vote.adapter.out.jpa.VoteItemJpaEntity; -import konkuk.thip.vote.adapter.out.jpa.VoteJpaEntity; -import konkuk.thip.vote.adapter.out.persistence.repository.VoteItemJpaRepository; -import konkuk.thip.vote.adapter.out.persistence.repository.VoteJpaRepository; -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.http.MediaType; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.ResultActions; - -import java.time.LocalDate; -import java.time.LocalDateTime; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@SpringBootTest -@ActiveProfiles("test") -@DisplayName("[통합] RecordSearchController 테스트") -@AutoConfigureMockMvc(addFilters = false) -class RecordSearchControllerTest { - - @Autowired private MockMvc mockMvc; - @Autowired private ObjectMapper objectMapper; - @Autowired private VoteJpaRepository voteJpaRepository; - @Autowired private VoteItemJpaRepository voteItemJpaRepository; - @Autowired private RecordJpaRepository recordJpaRepository; - @Autowired private RoomJpaRepository roomJpaRepository; - @Autowired private BookJpaRepository bookJpaRepository; - @Autowired private CategoryJpaRepository categoryJpaRepository; - @Autowired private AliasJpaRepository aliasJpaRepository; - @Autowired private UserJpaRepository userJpaRepository; - - @AfterEach - void tearDown() { - voteItemJpaRepository.deleteAll(); - voteJpaRepository.deleteAll(); - recordJpaRepository.deleteAll(); - roomJpaRepository.deleteAll(); - bookJpaRepository.deleteAll(); - categoryJpaRepository.deleteAll(); - userJpaRepository.deleteAll(); - aliasJpaRepository.deleteAll(); - } - - @Test - @DisplayName("기록장 조회 시 record와 vote 모두 조회") - void record_with_vote_response_success() throws Exception { - // given - AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createLiteratureAlias()); - - UserJpaEntity user = userJpaRepository.save(UserJpaEntity.builder() - .oauth2Id("kakao_123") - .nickname("사용자") - .imageUrl("http://user.img") - .role(UserRole.USER) - .aliasForUserJpaEntity(alias) - .build()); - - BookJpaEntity book = bookJpaRepository.save(BookJpaEntity.builder() - .isbn("1234567890") - .title("테스트책") - .authorName("작가") - .publisher("출판사") - .pageCount(200) - .description("책 설명") - .imageUrl("http://book.img") - .bestSeller(false) - .build()); - - CategoryJpaEntity category = categoryJpaRepository.save(TestEntityFactory.createLiteratureCategory(alias)); - - RoomJpaEntity room = roomJpaRepository.save(RoomJpaEntity.builder() - .title("방 제목") - .description("설명") - .isPublic(true) - .roomPercentage(0.0) - .startDate(LocalDate.now().plusDays(1)) - .endDate(LocalDate.now().plusDays(30)) - .recruitCount(5) - .bookJpaEntity(book) - .categoryJpaEntity(category) - .build()); - - RecordJpaEntity record = recordJpaRepository.save(RecordJpaEntity.builder() - .userJpaEntity(user) - .roomJpaEntity(room) - .likeCount(1) - .commentCount(2) - .page(1) - .content("레코드 내용") - .build()); - - VoteJpaEntity vote = voteJpaRepository.save(VoteJpaEntity.builder() - .userJpaEntity(user) - .roomJpaEntity(room) - .likeCount(1) - .commentCount(2) - .page(1) - .content("투표 내용") - .build()); - - voteItemJpaRepository.save(VoteItemJpaEntity.builder() - .voteJpaEntity(vote) - .itemName("찬성") - .count(3) - .build()); - - voteItemJpaRepository.save(VoteItemJpaEntity.builder() - .voteJpaEntity(vote) - .itemName("반대") - .count(1) - .build()); - - // when - ResultActions result = mockMvc.perform(get("/rooms/" + room.getRoomId() + "/posts") - .requestAttr("userId", 1L) - .param("type", "group") - .param("sort", "latest") - .param("pageStart", "1") - .param("pageEnd", "10") - .param("pageNum", "1") - .param("isOverview", "false") - .contentType(MediaType.APPLICATION_JSON)); - - // then - result.andExpect(status().isOk()); - String json = result.andReturn().getResponse().getContentAsString(); - JsonNode jsonNode = objectMapper.readTree(json); - - JsonNode voteNode = jsonNode.path("data").path("recordList").get(0); - assertThat(voteNode.path("type").asText()).isEqualTo("VOTE"); - assertThat(voteNode.path("page").asInt()).isEqualTo(1); - assertThat(voteNode.path("content").asText()).isEqualTo("투표 내용"); - assertThat(voteNode.path("nickName").asText()).isEqualTo("사용자"); - assertThat(voteNode.path("postDate").asText()).isEqualTo(DateUtil.formatBeforeTime(LocalDateTime.now())); - - JsonNode voteItems = voteNode.path("voteItems"); - assertThat(voteItems).hasSize(2); - assertThat(voteItems.get(0).get("itemName").asText()).isEqualTo("찬성"); - assertThat(voteItems.get(0).get("isVoted").asBoolean()).isEqualTo(false); - assertThat(voteItems.get(0).get("percentage").asInt()).isEqualTo(75); - - JsonNode recordNode = jsonNode.path("data").path("recordList").get(1); - assertThat(recordNode.path("type").asText()).isEqualTo("RECORD"); - assertThat(recordNode.path("page").asInt()).isEqualTo(1); - assertThat(recordNode.path("content").asText()).isEqualTo("레코드 내용"); - assertThat(recordNode.path("nickName").asText()).isEqualTo("사용자"); - assertThat(recordNode.path("postDate").asText()).isEqualTo(DateUtil.formatBeforeTime(LocalDateTime.now())); - assertThat(recordNode.path("likeCount").asInt()).isEqualTo(1); - assertThat(recordNode.path("commentCount").asInt()).isEqualTo(2); - - } - - -} \ No newline at end of file diff --git a/src/test/java/konkuk/thip/record/adapter/in/web/RecordSearchApiTest.java b/src/test/java/konkuk/thip/record/adapter/in/web/RecordSearchApiTest.java new file mode 100644 index 000000000..ae3b675fb --- /dev/null +++ b/src/test/java/konkuk/thip/record/adapter/in/web/RecordSearchApiTest.java @@ -0,0 +1,604 @@ +package konkuk.thip.record.adapter.in.web; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.record.adapter.out.jpa.RecordJpaEntity; +import konkuk.thip.record.adapter.out.persistence.repository.RecordJpaRepository; +import konkuk.thip.room.adapter.out.jpa.CategoryJpaEntity; +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.category.CategoryJpaRepository; +import konkuk.thip.room.adapter.out.persistence.repository.roomparticipant.RoomParticipantJpaRepository; +import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserRole; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; +import konkuk.thip.vote.adapter.out.jpa.VoteItemJpaEntity; +import konkuk.thip.vote.adapter.out.jpa.VoteJpaEntity; +import konkuk.thip.vote.adapter.out.persistence.repository.VoteItemJpaRepository; +import konkuk.thip.vote.adapter.out.persistence.repository.VoteJpaRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +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") +@DisplayName("[통합] RecordSearchApiTest 테스트") +@AutoConfigureMockMvc(addFilters = false) +@Transactional +class RecordSearchApiTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + @Autowired private VoteJpaRepository voteJpaRepository; + @Autowired private VoteItemJpaRepository voteItemJpaRepository; + @Autowired private RecordJpaRepository recordJpaRepository; + @Autowired private RoomJpaRepository roomJpaRepository; + @Autowired private RoomParticipantJpaRepository roomParticipantJpaRepository; + @Autowired private BookJpaRepository bookJpaRepository; + @Autowired private CategoryJpaRepository categoryJpaRepository; + @Autowired private AliasJpaRepository aliasJpaRepository; + @Autowired private UserJpaRepository userJpaRepository; + + @Test + @DisplayName("그룹 기록 조회 - 기본 조회 성공") + void searchGroupRecords_basic_success() throws Exception { + // given + TestData testData = createTestData(); + + // when + ResultActions result = mockMvc.perform(get("/rooms/" + testData.room.getRoomId() + "/posts") + .requestAttr("userId", testData.user.getUserId()) + .param("type", "group") + .param("sort", "latest") + .contentType(MediaType.APPLICATION_JSON)); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.data.postList").isNotEmpty()) + .andExpect(jsonPath("$.data.isLast").isBoolean()); + + String json = result.andReturn().getResponse().getContentAsString(); + JsonNode jsonNode = objectMapper.readTree(json); + JsonNode postList = jsonNode.path("data").path("postList"); + boolean isLast = jsonNode.path("data").path("isLast").asBoolean(); + JsonNode nextCursor = jsonNode.path("data").path("nextCursor"); + + assertThat(postList.isEmpty()).isFalse(); + if(isLast) { + assertThat(nextCursor.isNull()).isTrue(); + } else { + assertThat(nextCursor.isTextual()).isTrue(); + } + + } + + @Test + @DisplayName("그룹 기록 조회 - 투표와 기록 모두 포함") + void searchGroupRecords_with_vote_and_record_success() throws Exception { + // given + TestData testData = createTestDataWithVote(); + + // when + ResultActions result = mockMvc.perform(get("/rooms/" + testData.room.getRoomId() + "/posts") + .requestAttr("userId", testData.user.getUserId()) + .param("type", "group") + .param("sort", "latest") + .contentType(MediaType.APPLICATION_JSON)); + + // then + result.andExpect(status().isOk()); + + String json = result.andReturn().getResponse().getContentAsString(); + JsonNode jsonNode = objectMapper.readTree(json); + JsonNode postList = jsonNode.path("data").path("postList"); + + // 투표와 기록이 모두 포함되어 있는지 확인 + boolean hasRecord = false; + boolean hasVote = false; + + for (JsonNode post : postList) { + String postType = post.path("postType").asText(); + if ("RECORD".equals(postType)) { + hasRecord = true; + assertThat(post.path("voteItems")).isEmpty(); + } else if ("VOTE".equals(postType)) { + hasVote = true; + assertThat(post.path("voteItems")).isNotEmpty(); + assertThat(post.path("voteItems").size()).isGreaterThan(0); + + // 투표 항목 검증 + JsonNode voteItems = post.path("voteItems"); + for (JsonNode voteItem : voteItems) { + assertThat(voteItem.path("voteItemId")).isNotNull(); + assertThat(voteItem.path("itemName")).isNotNull(); + assertThat(voteItem.path("percentage")).isNotNull(); + assertThat(voteItem.path("isVoted")).isNotNull(); + } + } + } + + assertThat(hasRecord).isTrue(); + assertThat(hasVote).isTrue(); + } + + @Test + @DisplayName("내 기록 조회 성공") + void searchMyRecords_success() throws Exception { + // given + TestData testData = createTestData(); + + // when + ResultActions result = mockMvc.perform(get("/rooms/" + testData.room.getRoomId() + "/posts") + .requestAttr("userId", testData.user.getUserId()) + .param("type", "mine") + .contentType(MediaType.APPLICATION_JSON)); + + // then + result.andExpect(status().isOk()); + + String json = result.andReturn().getResponse().getContentAsString(); + JsonNode jsonNode = objectMapper.readTree(json); + JsonNode postList = jsonNode.path("data").path("postList"); + + // 모든 게시물이 요청한 사용자의 것인지 확인 + for (JsonNode post : postList) { + assertThat(post.path("userId").asLong()).isEqualTo(testData.user.getUserId()); + assertThat(post.path("isWriter").asBoolean()).isTrue(); + } + } + + @Test + @DisplayName("페이지 필터 적용 조회 성공") + void searchGroupRecords_with_page_filter_success() throws Exception { + // given + TestData testData = createTestData(); + + // when + ResultActions result = mockMvc.perform(get("/rooms/" + testData.room.getRoomId() + "/posts") + .requestAttr("userId", testData.user.getUserId()) + .param("type", "group") + .param("sort", "latest") + .param("pageStart", "1") + .param("pageEnd", "5") + .param("isPageFilter", "true") + .contentType(MediaType.APPLICATION_JSON)); + + // then + result.andExpect(status().isOk()); + + String json = result.andReturn().getResponse().getContentAsString(); + JsonNode jsonNode = objectMapper.readTree(json); + JsonNode postList = jsonNode.path("data").path("postList"); + + // 페이지 범위 내의 게시물만 조회되는지 확인 + for (JsonNode post : postList) { + int page = post.path("page").asInt(); + assertThat(page).isBetween(1, 5); + } + } + + @Test + @DisplayName("총평보기 필터 적용 조회 성공") + void searchGroupRecords_with_overview_filter_success() throws Exception { + // given + TestData testData = createTestDataWithOverview(); + + // when + ResultActions result = mockMvc.perform(get("/rooms/" + testData.room.getRoomId() + "/posts") + .requestAttr("userId", testData.user.getUserId()) + .param("type", "group") + .param("sort", "latest") + .param("isOverview", "true") + .contentType(MediaType.APPLICATION_JSON)); + + // then + result.andExpect(status().isOk()); + + String json = result.andReturn().getResponse().getContentAsString(); + JsonNode jsonNode = objectMapper.readTree(json); + JsonNode postList = jsonNode.path("data").path("postList"); + + // 모든 게시물이 총평인지 확인 + for (JsonNode post : postList) { + int page = post.path("page").asInt(); + assertThat(page).isEqualTo(testData.book.getPageCount()); + } + } + + @Test + @DisplayName("좋아요순 정렬 조회 성공") + void searchGroupRecords_sort_by_like_success() throws Exception { + // given + TestData testData = createTestData(); + + // when + ResultActions result = mockMvc.perform(get("/rooms/" + testData.room.getRoomId() + "/posts") + .requestAttr("userId", testData.user.getUserId()) + .param("type", "group") + .param("sort", "like") + .contentType(MediaType.APPLICATION_JSON)); + + // then + result.andExpect(status().isOk()); + + String json = result.andReturn().getResponse().getContentAsString(); + JsonNode jsonNode = objectMapper.readTree(json); + JsonNode postList = jsonNode.path("data").path("postList"); + + // 좋아요 수 내림차순 정렬 확인 + int prevLikeCount = Integer.MAX_VALUE; + for (JsonNode post : postList) { + int currentLikeCount = post.path("likeCount").asInt(); + assertThat(currentLikeCount).isLessThanOrEqualTo(prevLikeCount); + prevLikeCount = currentLikeCount; + } + } + + @Test + @DisplayName("댓글순 정렬 조회 성공") + void searchGroupRecords_sort_by_comment_success() throws Exception { + // given + TestData testData = createTestData(); + + // when + ResultActions result = mockMvc.perform(get("/rooms/" + testData.room.getRoomId() + "/posts") + .requestAttr("userId", testData.user.getUserId()) + .param("type", "group") + .param("sort", "comment") + .contentType(MediaType.APPLICATION_JSON)); + + // then + result.andExpect(status().isOk()); + + String json = result.andReturn().getResponse().getContentAsString(); + JsonNode jsonNode = objectMapper.readTree(json); + JsonNode postList = jsonNode.path("data").path("postList"); + + // 댓글 수 내림차순 정렬 확인 + int prevCommentCount = Integer.MAX_VALUE; + for (JsonNode post : postList) { + int currentCommentCount = post.path("commentCount").asInt(); + assertThat(currentCommentCount).isLessThanOrEqualTo(prevCommentCount); + prevCommentCount = currentCommentCount; + } + } + + @Test + @DisplayName("커서 기반 페이징 동작 확인") + void searchGroupRecords_cursor_paging_success() throws Exception { + // given + TestData testData = createTestData(); + + // 첫 번째 페이지 조회 + ResultActions firstResult = mockMvc.perform(get("/rooms/" + testData.room.getRoomId() + "/posts") + .requestAttr("userId", testData.user.getUserId()) + .param("type", "group") + .param("sort", "latest") + .contentType(MediaType.APPLICATION_JSON)); + + String firstJson = firstResult.andReturn().getResponse().getContentAsString(); + JsonNode firstJsonNode = objectMapper.readTree(firstJson); + String nextCursor = firstJsonNode.path("data").path("nextCursor").asText(); + boolean isLast = firstJsonNode.path("data").path("isLast").asBoolean(); + + if (!isLast && !nextCursor.isEmpty()) { + // when - 다음 페이지 조회 + ResultActions secondResult = mockMvc.perform(get("/rooms/" + testData.room.getRoomId() + "/posts") + .requestAttr("userId", testData.user.getUserId()) + .param("type", "group") + .param("sort", "latest") + .param("cursor", nextCursor) + .contentType(MediaType.APPLICATION_JSON)); + + // then + secondResult.andExpect(status().isOk()); + + String secondJson = secondResult.andReturn().getResponse().getContentAsString(); + JsonNode secondJsonNode = objectMapper.readTree(secondJson); + JsonNode secondPostList = secondJsonNode.path("data").path("postList"); + + // 두 번째 페이지도 결과가 있는지 확인 + assertThat(secondPostList.isEmpty()).isFalse(); + } + } + + @Test + @DisplayName("잠긴 게시물 블러 처리 확인") + void searchGroupRecords_locked_content_blurred() throws Exception { + // given + TestData testData = createTestDataWithLockedContent(); + + // when + ResultActions result = mockMvc.perform(get("/rooms/" + testData.room.getRoomId() + "/posts") + .requestAttr("userId", testData.user.getUserId()) + .param("type", "group") + .param("sort", "latest") + .contentType(MediaType.APPLICATION_JSON)); + + // then + result.andExpect(status().isOk()); + + String json = result.andReturn().getResponse().getContentAsString(); + JsonNode jsonNode = objectMapper.readTree(json); + JsonNode postList = jsonNode.path("data").path("postList"); + + for (JsonNode post : postList) { + boolean isLocked = post.path("isLocked").asBoolean(); + String content = post.path("content").asText(); + + if (isLocked) { + assertThat(content).contains("여긴 못 지나가지롱~~"); + } + } + } + + @Test + @DisplayName("사용자가 방에 속하지 않는 경우 오류 반환") + void searchRecords_user_not_in_room_error() throws Exception { + // given + TestData testData = createTestData(); + Long nonParticipantUserId = 99999L; + + // when & then + mockMvc.perform(get("/rooms/" + testData.room.getRoomId() + "/posts") + .requestAttr("userId", nonParticipantUserId) + .param("type", "group") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("잘못된 파라미터로 조회 시 오류 반환") + void searchRecords_invalid_parameters_error() throws Exception { + // given + TestData testData = createTestData(); + + // when & then - 페이지 필터와 총평보기 동시 적용 + mockMvc.perform(get("/rooms/" + testData.room.getRoomId() + "/posts") + .requestAttr("userId", testData.user.getUserId()) + .param("type", "group") + .param("pageStart", "1") + .param("pageEnd", "10") + .param("isPageFilter", "true") + .param("isOverview", "true") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("내 기록 조회 시 불필요한 파라미터 포함하면 오류 반환") + void searchMyRecords_with_invalid_parameters_error() throws Exception { + // given + TestData testData = createTestData(); + + // when & then + mockMvc.perform(get("/rooms/" + testData.room.getRoomId() + "/posts") + .requestAttr("userId", testData.user.getUserId()) + .param("type", "mine") + .param("sort", "latest") // 내 기록 조회에서는 sort 파라미터가 null이어야 함 + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + private TestData createTestData() { + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createLiteratureAlias()); + + UserJpaEntity user = userJpaRepository.save(UserJpaEntity.builder() + .oauth2Id("kakao_123") + .nickname("테스트사용자") + .imageUrl("http://user.img") + .role(UserRole.USER) + .aliasForUserJpaEntity(alias) + .build()); + + BookJpaEntity book = bookJpaRepository.save(BookJpaEntity.builder() + .isbn("1234567890") + .title("테스트책") + .authorName("작가") + .publisher("출판사") + .pageCount(200) + .description("책 설명") + .imageUrl("http://book.img") + .bestSeller(false) + .build()); + + CategoryJpaEntity category = categoryJpaRepository.save(TestEntityFactory.createLiteratureCategory(alias)); + + RoomJpaEntity room = roomJpaRepository.save(RoomJpaEntity.builder() + .title("방 제목") + .description("설명") + .isPublic(true) + .roomPercentage(0.0) + .startDate(LocalDate.now().plusDays(1)) + .endDate(LocalDate.now().plusDays(30)) + .recruitCount(5) + .bookJpaEntity(book) + .categoryJpaEntity(category) + .build()); + + // 방 참가자 생성 + roomParticipantJpaRepository.save(RoomParticipantJpaEntity.builder() + .userJpaEntity(user) + .roomJpaEntity(room) + .currentPage(50) + .userPercentage(25.0) + .roomParticipantRole(RoomParticipantRole.MEMBER) + .build()); + + // 기록 생성 + for (int i = 1; i <= 5; i++) { + recordJpaRepository.save(RecordJpaEntity.builder() + .userJpaEntity(user) + .roomJpaEntity(room) + .likeCount(i * 2) + .commentCount(i) + .page(i * 10) + .content("기록 내용 " + i) + .isOverview(false) + .build()); + } + + return new TestData(user, book, room); + } + + private TestData createTestDataWithVote() { + TestData testData = createTestData(); + + // 투표 게시물 생성 + VoteJpaEntity vote = voteJpaRepository.save(VoteJpaEntity.builder() + .userJpaEntity(testData.user) + .roomJpaEntity(testData.room) + .likeCount(5) + .commentCount(3) + .page(30) + .content("투표 내용") + .isOverview(false) + .build()); + + // 투표 항목 생성 + voteItemJpaRepository.save(VoteItemJpaEntity.builder() + .voteJpaEntity(vote) + .itemName("찬성") + .count(3) + .build()); + + voteItemJpaRepository.save(VoteItemJpaEntity.builder() + .voteJpaEntity(vote) + .itemName("반대") + .count(1) + .build()); + + return testData; + } + + private TestData createTestDataWithOverview() { + TestData testData = createTestData(); + + // 총평 기록 생성 + recordJpaRepository.save(RecordJpaEntity.builder() + .userJpaEntity(testData.user) + .roomJpaEntity(testData.room) + .likeCount(10) + .commentCount(5) + .page(testData.book.getPageCount()) + .content("총평 내용") + .isOverview(true) + .build()); + + // 사용자 진행률을 80% 이상으로 업데이트 + RoomParticipantJpaEntity participant = roomParticipantJpaRepository + .findByUserIdAndRoomId(testData.user.getUserId(), testData.room.getRoomId()) + .orElseThrow(); + + participant.updateCurrentPage(180); // 90% 진행 + participant.updateUserPercentage(90.0); + + roomParticipantJpaRepository.save(participant); + + return testData; + } + + private TestData createTestDataWithLockedContent() { + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createLiteratureAlias()); + + UserJpaEntity user = userJpaRepository.save(UserJpaEntity.builder() + .oauth2Id("kakao_123") + .nickname("테스트사용자") + .imageUrl("http://user.img") + .role(UserRole.USER) + .aliasForUserJpaEntity(alias) + .build()); + + BookJpaEntity book = bookJpaRepository.save(BookJpaEntity.builder() + .isbn("1234567890") + .title("테스트책") + .authorName("작가") + .publisher("출판사") + .pageCount(200) + .description("책 설명") + .imageUrl("http://book.img") + .bestSeller(false) + .build()); + + CategoryJpaEntity category = categoryJpaRepository.save(TestEntityFactory.createLiteratureCategory(alias)); + + RoomJpaEntity room = roomJpaRepository.save(RoomJpaEntity.builder() + .title("방 제목") + .description("설명") + .isPublic(true) + .roomPercentage(0.0) + .startDate(LocalDate.now().plusDays(1)) + .endDate(LocalDate.now().plusDays(30)) + .recruitCount(5) + .bookJpaEntity(book) + .categoryJpaEntity(category) + .build()); + + // 방 참가자 생성 (현재 페이지 10으로 설정) + roomParticipantJpaRepository.save(RoomParticipantJpaEntity.builder() + .userJpaEntity(user) + .roomJpaEntity(room) + .currentPage(10) + .userPercentage(5.0) + .roomParticipantRole(RoomParticipantRole.MEMBER) + .build()); + + // 잠긴 기록 생성 (현재 페이지보다 높은 페이지) + recordJpaRepository.save(RecordJpaEntity.builder() + .userJpaEntity(user) + .roomJpaEntity(room) + .likeCount(5) + .commentCount(2) + .page(50) // 현재 페이지(10)보다 높음 + .content("잠긴 내용입니다") + .isOverview(false) + .build()); + + // 잠기지 않은 기록 생성 + recordJpaRepository.save(RecordJpaEntity.builder() + .userJpaEntity(user) + .roomJpaEntity(room) + .likeCount(3) + .commentCount(1) + .page(5) // 현재 페이지(10)보다 낮음 + .content("잠기지 않은 내용입니다") + .isOverview(false) + .build()); + + return new TestData(user, book, room); + } + + private static class TestData { + final UserJpaEntity user; + final BookJpaEntity book; + final RoomJpaEntity room; + + TestData(UserJpaEntity user, BookJpaEntity book, RoomJpaEntity room) { + this.user = user; + this.book = book; + this.room = room; + } + } +} \ No newline at end of file diff --git a/src/test/java/konkuk/thip/record/adapter/out/persistence/RecordQueryRepositoryImplTest.java b/src/test/java/konkuk/thip/record/adapter/out/persistence/RecordQueryRepositoryImplTest.java deleted file mode 100644 index 7b0bd89a0..000000000 --- a/src/test/java/konkuk/thip/record/adapter/out/persistence/RecordQueryRepositoryImplTest.java +++ /dev/null @@ -1,204 +0,0 @@ -package konkuk.thip.record.adapter.out.persistence; - -import jakarta.persistence.EntityManager; -import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; -import konkuk.thip.common.util.TestEntityFactory; -import konkuk.thip.config.TestQuerydslConfig; -import konkuk.thip.record.adapter.in.web.response.RecordSearchResponse; -import konkuk.thip.record.adapter.out.jpa.RecordJpaEntity; -import konkuk.thip.record.adapter.out.persistence.repository.RecordQueryRepositoryImpl; -import konkuk.thip.room.adapter.out.jpa.CategoryJpaEntity; -import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; -import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; -import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.context.annotation.Import; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.test.context.ActiveProfiles; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; - -@DataJpaTest -@ActiveProfiles("test") -@Import(TestQuerydslConfig.class) -@DisplayName("[JPA] RecordQueryRepositoryImpl 테스트") -class RecordQueryRepositoryImplTest { - - @Autowired - private RecordQueryRepositoryImpl recordQueryRepository; - - @Autowired - private EntityManager em; - - private RoomJpaEntity room; - private UserJpaEntity user1; - private UserJpaEntity user2; - - @BeforeEach - void setUp() { - AliasJpaEntity alias = TestEntityFactory.createLiteratureAlias(); - em.persist(alias); - - user1 = TestEntityFactory.createUser(alias); - user2 = TestEntityFactory.createUser(alias); - em.persist(user1); - em.persist(user2); - - BookJpaEntity book = TestEntityFactory.createBook(); - em.persist(book); - - CategoryJpaEntity category = TestEntityFactory.createLiteratureCategory(alias); - em.persist(category); - - room = TestEntityFactory.createRoom(book, category); - em.persist(room); - - for (int i = 0; i < 10; i++) { - RecordJpaEntity record = RecordJpaEntity.builder() - .userJpaEntity(i % 2 == 0 ? user1 : user2) - .roomJpaEntity(room) - .content("레코드 " + i) - .likeCount(10 - i) - .commentCount(i) - .isOverview(false) - .page(1) - .build(); - em.persist(record); - } - - for (int i = 0; i < 10; i++) { - RecordJpaEntity record = RecordJpaEntity.builder() - .userJpaEntity(i % 2 == 0 ? user1 : user2) - .roomJpaEntity(room) - .content("레코드 " + i) - .likeCount(1) - .commentCount(1) - .isOverview(true) - .page(book.getPageCount()) // 총평은 책 전체 페이지로 저장 - .build(); - em.persist(record); - } - - em.flush(); - em.clear(); - } - - @Test - @DisplayName("기본 조회 및 페이징 동작 확인") - void test_paging() { - Page result = recordQueryRepository.findRecordsByRoom( - room.getRoomId(), - "group", - 1, - 1, - false, - user1.getUserId(), - PageRequest.of(0, 5) - ); - - assertThat(result.getNumberOfElements()).isEqualTo(5); - assertThat(result.getTotalElements()).isEqualTo(10); - assertThat(result.isLast()).isFalse(); - assertThat(result.isFirst()).isTrue(); - } - - @Test - @DisplayName("viewType이 mine일 때 user1의 레코드만 조회된다") - void test_viewType_mine() { - Page result = recordQueryRepository.findRecordsByRoom( - room.getRoomId(), - "mine", - 1, - 1, - false, - user1.getUserId(), - PageRequest.of(0, 10) - ); - - assertThat(result).allSatisfy(record -> - assertThat(record.userId()).isEqualTo(user1.getUserId())); - assertThat(result.getNumberOfElements()).isEqualTo(5); // user1이 작성한 레코드가 5개 - } - - @Test - @DisplayName("isOverview가 true일 때 레코드가 총평 기록만 조회된다.") - void test_isOverview_true() { - Page result = recordQueryRepository.findRecordsByRoom( - room.getRoomId(), - "group", - 1, - 1, - true, - user1.getUserId(), - PageRequest.of(0, 10) - ); - - assertThat(result.getNumberOfElements()).isEqualTo(10); - assertThat(result.getContent()).allSatisfy(record -> - assertThat(record.page()).isEqualTo(room.getBookJpaEntity().getPageCount())); - } - - @Test - @DisplayName("latest 기준 정렬 확인") - void test_sortingBy_latest() { - Page result = recordQueryRepository.findRecordsByRoom( - room.getRoomId(), - null, - 1, - 1, - false, - user1.getUserId(), - PageRequest.of(0, 10, org.springframework.data.domain.Sort.by("createdAt").descending()) - ); - - List content = result.getContent(); - for (int i = 1; i < content.size(); i++) { - assertThat(content.get(i - 1).postDate()).isGreaterThanOrEqualTo(content.get(i).postDate()); - } - } - - @Test - @DisplayName("likeCount 기준 정렬 확인") - void test_sortingBy_likeCount() { - Page result = recordQueryRepository.findRecordsByRoom( - room.getRoomId(), - null, - 1, - 1, - false, - user1.getUserId(), - PageRequest.of(0, 10, org.springframework.data.domain.Sort.by("likeCount").descending()) - ); - - List content = result.getContent(); - for (int i = 1; i < content.size(); i++) { - assertThat(content.get(i - 1).likeCount()).isGreaterThanOrEqualTo(content.get(i).likeCount()); - } - } - - @Test - @DisplayName("commentCount 기준 정렬 확인") - void test_sortingBy_commentCount() { - Page result = recordQueryRepository.findRecordsByRoom( - room.getRoomId(), - null, - 1, - 1, - false, - user1.getUserId(), - PageRequest.of(0, 10, org.springframework.data.domain.Sort.by("commentCount").descending()) - ); - - List content = result.getContent(); - for (int i = 1; i < content.size(); i++) { - assertThat(content.get(i - 1).commentCount()).isGreaterThanOrEqualTo(content.get(i).commentCount()); - } - } -} \ No newline at end of file diff --git a/src/test/java/konkuk/thip/record/application/service/RecordSearchServiceTest.java b/src/test/java/konkuk/thip/record/application/service/RecordSearchServiceTest.java deleted file mode 100644 index 51ddd5f89..000000000 --- a/src/test/java/konkuk/thip/record/application/service/RecordSearchServiceTest.java +++ /dev/null @@ -1,163 +0,0 @@ -package konkuk.thip.record.application.service; - -import konkuk.thip.book.application.port.out.BookCommandPort; -import konkuk.thip.book.domain.Book; -import konkuk.thip.common.exception.InvalidStateException; -import konkuk.thip.post.application.port.out.PostLikeQueryPort; -import konkuk.thip.record.adapter.in.web.response.RecordDto; -import konkuk.thip.record.adapter.in.web.response.RecordSearchResponse; -import konkuk.thip.record.application.port.out.RecordQueryPort; -import konkuk.thip.vote.application.port.out.VoteCommandPort; -import konkuk.thip.vote.application.port.out.VoteQueryPort; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.data.domain.PageImpl; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.*; - -@DisplayName("[단위] RecordSearchService 테스트") -class RecordSearchServiceTest { - - private RecordQueryPort recordQueryPort; - private BookCommandPort bookCommandPort; - private VoteCommandPort voteCommandPort; - private VoteQueryPort voteQueryPort; - private PostLikeQueryPort postLikeQueryPort; - - private RecordSearchService recordSearchService; - - @BeforeEach - void setUp() { - recordQueryPort = mock(RecordQueryPort.class); - bookCommandPort = mock(BookCommandPort.class); - voteCommandPort = mock(VoteCommandPort.class); - voteQueryPort = mock(VoteQueryPort.class); - postLikeQueryPort = mock(PostLikeQueryPort.class); - - recordSearchService = new RecordSearchService( - recordQueryPort, bookCommandPort, voteCommandPort, voteQueryPort, postLikeQueryPort); - } - - @Test - @DisplayName("전체 페이지 조회 성공") - void search_all_pages_success() { - Long roomId = 1L; - String type = "group"; - String sort = "latest"; - Integer pageStart = null; - Integer pageEnd = null; - Boolean isOverview = false; - Integer pageNum = 1; - Long userId = 1L; - - when(bookCommandPort.findBookByRoomId(roomId)).thenReturn(Book.builder().pageCount(100).build()); - RecordDto mockDto = new RecordDto("방금 전", 1, 1L, "사용자", "url", "내용", 1, 1, false, true, 1L); - when(recordQueryPort.findRecordsByRoom(any(), any(), any(), any(), any(), any(), any())) - .thenReturn(new PageImpl<>(List.of(mockDto))); - when(postLikeQueryPort.existsByPostIdAndUserId(1L, userId)).thenReturn(true); - - RecordSearchResponse response = recordSearchService.search(roomId, type, sort, pageStart, pageEnd, isOverview, pageNum, userId); - - assertThat(response.recordList()).hasSize(1); - assertThat(((RecordDto) response.recordList().get(0)).isLiked()).isTrue(); - } - - @Test - @DisplayName("pageStart와 pageEnd가 하나만 null이면 예외 발생") - void invalid_page_range() { - Long roomId = 1L; - String type = null; - String sort = "latest"; - Integer pageStart = 1; - Integer pageEnd = null; - Boolean isOverview = false; - Integer pageNum = 1; - Long userId = 1L; - - assertThrows(InvalidStateException.class, () -> - recordSearchService.search(roomId, type, sort, pageStart, pageEnd, isOverview, pageNum, userId)); - } - - @Test - @DisplayName("pageStart > pageEnd면 예외 발생") - void invalid_page_range_order() { - Long roomId = 1L; - Integer pageStart = 10; - Integer pageEnd = 5; - Boolean isOverview = false; - Integer pageNum = 1; - Long userId = 1L; - - assertThrows(InvalidStateException.class, () -> - recordSearchService.search(roomId, null, null, pageStart, pageEnd, isOverview, pageNum, userId)); - } - - @Test - @DisplayName("pageNum이 1보다 작으면 예외 발생") - void invalid_pageNum() { - Long roomId = 1L; - Integer pageNum = 0; - - assertThrows(InvalidStateException.class, () -> - recordSearchService.search(roomId, null, null, null, null, true, pageNum, 1L)); - } - - @Test - @DisplayName("pageNum이 null인 경우 첫번째 페이지 조회") - void search_with_null_pageNum() { - Long roomId = 1L; - String type = "group"; - String sort = "latest"; - Integer pageStart = null; - Integer pageEnd = null; - Boolean isOverview = true; - Long userId = 1L; - - RecordDto mockDto = new RecordDto("방금 전", 1, 1L, "사용자", "url", "내용", 1, 1, false, true, 1L); - when(recordQueryPort.findRecordsByRoom(any(), any(), any(), any(), any(), any(), any())) - .thenReturn(new PageImpl<>(List.of(mockDto))); - when(postLikeQueryPort.existsByPostIdAndUserId(1L, userId)).thenReturn(true); - - RecordSearchResponse response = recordSearchService.search(roomId, type, sort, pageStart, pageEnd, isOverview, null, userId); - - assertThat(response.page()).isEqualTo(1); - assertThat(response.recordList()).hasSize(1); - } - - @Test - @DisplayName("형식이 맞지 않는 type으로 조회 시 예외 발생") - void search_with_invalid_type() { - Long roomId = 1L; - String type = "invalidType"; - String sort = "latest"; - Integer pageStart = 1; - Integer pageEnd = 10; - Boolean isOverview = true; - Integer pageNum = 1; - Long userId = 1L; - - assertThrows(InvalidStateException.class, () -> - recordSearchService.search(roomId, type, sort, pageStart, pageEnd, isOverview, pageNum, userId)); - } - - @Test - @DisplayName("형식이 맞지 않는 sort로 조회 시 예외 발생") - void search_with_invalid_sort() { - Long roomId = 1L; - String type = "group"; - String sort = "invalidSort"; - Integer pageStart = 1; - Integer pageEnd = 10; - Boolean isOverview = true; - Integer pageNum = 1; - Long userId = 1L; - - assertThrows(InvalidStateException.class, () -> - recordSearchService.search(roomId, type, sort, pageStart, pageEnd, isOverview, pageNum, userId)); - } -} \ No newline at end of file diff --git a/src/test/java/konkuk/thip/vote/domain/VoteItemTest.java b/src/test/java/konkuk/thip/vote/domain/VoteItemTest.java new file mode 100644 index 000000000..863734db0 --- /dev/null +++ b/src/test/java/konkuk/thip/vote/domain/VoteItemTest.java @@ -0,0 +1,74 @@ +package konkuk.thip.vote.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("[단위] VoteItem 단위 테스트") +class VoteItemTest { + + @Test + @DisplayName("투표수가 0개일 경우 모든 비율은 0이 된다") + void calculatePercentages_allZero() { + List counts = List.of(0, 0, 0); + + List result = VoteItem.calculatePercentages(counts); + + assertThat(result).containsExactly(0, 0, 0); + } + + @Test + @DisplayName("투표수가 하나만 있을 때 100%") + void calculatePercentages_singleCandidate() { + List counts = List.of(10); + + List result = VoteItem.calculatePercentages(counts); + + assertThat(result).containsExactly(100); + } + + @Test + @DisplayName("투표수가 균등할 경우 비율이 100%를 합산하고 균등하게 분배된다") + void calculatePercentages_equalCounts() { + List counts = List.of(3, 3, 3); + + List result = VoteItem.calculatePercentages(counts); + + int sum = result.stream().mapToInt(Integer::intValue).sum(); + + assertThat(sum).isEqualTo(100); + assertThat(result).containsExactly(34, 33, 33); // 소수점 오차 보정된 분배 + } + + @Test + @DisplayName("투표수가 다를 경우 비율이 올바르게 계산되고 합계가 100이 된다") + void calculatePercentages_variedCounts() { + List counts = List.of(3, 3, 4); // 합계 10 + + List result = VoteItem.calculatePercentages(counts); + + int sum = result.stream().mapToInt(Integer::intValue).sum(); + + assertThat(sum).isEqualTo(100); + // 30, 30, 40 순서일 것으로 기대 + assertThat(result).containsExactly(30, 30, 40); + } + + @Test + @DisplayName("순서가 유지되는지 확인") + void calculatePercentages_orderIsPreserved() { + List counts = List.of(1, 2, 7); // 합계 10 + + List result = VoteItem.calculatePercentages(counts); + + // 합계 확인 + int sum = result.stream().mapToInt(Integer::intValue).sum(); + assertThat(sum).isEqualTo(100); + + // 10%, 20%, 70% 순서를 유지 + assertThat(result).containsExactly(10, 20, 70); + } +} \ No newline at end of file