From 98691f04ebfa7b26771f0f83bd0243ba5329c152 Mon Sep 17 00:00:00 2001 From: Seoyoung-Kyung Date: Fri, 6 Feb 2026 10:32:36 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=A3=BC=EC=A0=9C=20=EC=A2=8B=EC=95=84?= =?UTF-8?q?=EC=9A=94=20=EC=97=AC=EB=B6=80=20=ED=95=84=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EC=9D=91=EB=8B=B5=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/dokdok/topic/api/TopicApi.java | 14 +-- .../response/TopicsWithActionsResponse.java | 40 ++++-- .../topic/repository/TopicLikeRepository.java | 16 ++- .../dokdok/topic/service/TopicService.java | 4 +- .../topic/service/TopicServiceTest.java | 116 +++++++++++++----- 5 files changed, 137 insertions(+), 53 deletions(-) diff --git a/src/main/java/com/dokdok/topic/api/TopicApi.java b/src/main/java/com/dokdok/topic/api/TopicApi.java index 40d2778f..bb8d7475 100644 --- a/src/main/java/com/dokdok/topic/api/TopicApi.java +++ b/src/main/java/com/dokdok/topic/api/TopicApi.java @@ -1,7 +1,6 @@ package com.dokdok.topic.api; import com.dokdok.global.response.ApiResponse; -import com.dokdok.global.response.CursorResponse; import com.dokdok.topic.dto.request.ConfirmTopicsRequest; import com.dokdok.topic.dto.request.SuggestTopicRequest; import com.dokdok.topic.dto.response.ConfirmTopicsResponse; @@ -161,12 +160,11 @@ ResponseEntity> createTopic( - 다음 페이지: `?pageSize=10&cursorLikeCount={nextCursor.likeCount}&cursorTopicId={nextCursor.topicId}` **응답 구조** - - page: 주제 목록 페이지 정보 - - items: 주제 목록 - - pageSize: 페이지 크기 - - hasNext: 다음 페이지 존재 여부 - - nextCursor: 다음 페이지 요청 시 사용할 커서 (hasNext가 false면 null) - - totalCount : 전체 주제 수 (첫 요청 시만 포함, 이후 요청에서는 생략) + - items: 주제 목록 + - pageSize: 페이지 크기 + - hasNext: 다음 페이지 존재 여부 + - nextCursor: 다음 페이지 요청 시 사용할 커서 (hasNext가 false면 null) + - totalCount : 전체 주제 수 (첫 요청 시만 포함, 이후 요청에서는 생략) - actions: 현재 사용자의 권한 정보 - canConfirm: 주제 확정 가능 여부 (모임장과 약속장만 true) - canSuggest: 주제 제안 가능 여부 (약속 멤버이고 약속 상태가 CONFIRMED일 때 true) @@ -187,7 +185,7 @@ ResponseEntity> createTopic( mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = TopicsWithActionsResponse.class), examples = @ExampleObject(value = """ - {"code":"SUCCESS","message":"제안된 주제 조회를 성공했습니다.","data":{"page":{"items":[{"topicId":1,"meetingId":10,"title":"이 책의 핵심 메시지는 무엇인가?","description":"저자가 전달하고자 하는 핵심 메시지에 대해 토론합니다.","topicType":"DISCUSSION","topicTypeLabel":"토론형","topicStatus":"PROPOSED","likeCount":5,"canDelete":true,"createdByInfo":{"userId":1,"nickname":"독서왕"}}],"pageSize":10,"hasNext":true,"nextCursor":{"likeCount":5,"topicId":1},"totalCount":25},"actions":{"canConfirm":false,"canSuggest":true}}} + {"code":"SUCCESS","message":"제안된 주제 조회를 성공했습니다.","data":{"items":[{"topicId":1,"meetingId":10,"title":"이 책의 핵심 메시지는 무엇인가?","description":"저자가 전달하고자 하는 핵심 메시지에 대해 토론합니다.","topicType":"DISCUSSION","topicTypeLabel":"토론형","topicStatus":"PROPOSED","likeCount":5,"canDelete":true,"isLiked":false,"createdByInfo":{"userId":1,"nickname":"독서왕"}}],"pageSize":10,"hasNext":true,"nextCursor":{"likeCount":5,"topicId":1},"totalCount":25,"actions":{"canConfirm":false,"canSuggest":true}}} """) ) ), diff --git a/src/main/java/com/dokdok/topic/dto/response/TopicsWithActionsResponse.java b/src/main/java/com/dokdok/topic/dto/response/TopicsWithActionsResponse.java index abe608c9..76b9ac71 100644 --- a/src/main/java/com/dokdok/topic/dto/response/TopicsWithActionsResponse.java +++ b/src/main/java/com/dokdok/topic/dto/response/TopicsWithActionsResponse.java @@ -1,6 +1,5 @@ package com.dokdok.topic.dto.response; -import com.dokdok.global.response.CursorResponse; import com.dokdok.topic.entity.Topic; import com.dokdok.topic.entity.TopicStatus; import com.dokdok.topic.entity.TopicType; @@ -13,8 +12,20 @@ @Builder @Schema(description = "주제 목록 및 권한 정보 응답") public record TopicsWithActionsResponse( - @Schema(description = "주제 목록 페이지 정보") - CursorResponse page, + @Schema(description = "주제 목록") + List items, + + @Schema(description = "페이지 크기", example = "10") + int pageSize, + + @Schema(description = "다음 페이지 존재 여부") + boolean hasNext, + + @Schema(description = "다음 페이지 커서 정보") + TopicsCursor nextCursor, + + @Schema(description = "전체 주제 수") + Integer totalCount, @Schema(description = "사용자 권한 정보") Actions actions @@ -65,10 +76,13 @@ public record TopicDto( @Schema(description = "삭제 가능 여부", example = "true") Boolean canDelete, + @Schema(description = "좋아요 여부", example = "true") + Boolean isLiked, + @Schema(description = "작성자 정보") CreatedByInfo createdByInfo ) { - public static TopicDto from(Topic topic, Boolean canDelete) { + public static TopicDto from(Topic topic, Boolean canDelete, Boolean isLiked) { return TopicDto.builder() .topicId(topic.getId()) .meetingId(topic.getMeeting().getId()) @@ -79,6 +93,7 @@ public static TopicDto from(Topic topic, Boolean canDelete) { .topicStatus(topic.getTopicStatus()) .likeCount(topic.getLikeCount()) .canDelete(canDelete) + .isLiked(isLiked) .createdByInfo( CreatedByInfo.of( topic.getProposedBy().getId(), @@ -93,13 +108,15 @@ public static TopicsWithActionsResponse from( int pageSize, boolean hasNext, Set deletableTopicIds, + Set likedTopicIds, Actions actions, Long totalCount ) { List topicDtos = topics.stream() .map(topic -> TopicDto.from( topic, - deletableTopicIds.contains(topic.getId()) + deletableTopicIds.contains(topic.getId()), + likedTopicIds.contains(topic.getId()) )) .toList(); @@ -109,10 +126,13 @@ public static TopicsWithActionsResponse from( cursor = TopicsCursor.from(lastTopic); } - CursorResponse page = - CursorResponse.of(topicDtos, pageSize, hasNext, cursor, - totalCount != null ? totalCount.intValue() : null); - - return new TopicsWithActionsResponse(page, actions); + return new TopicsWithActionsResponse( + topicDtos, + pageSize, + hasNext, + cursor, + totalCount != null ? totalCount.intValue() : null, + actions + ); } } diff --git a/src/main/java/com/dokdok/topic/repository/TopicLikeRepository.java b/src/main/java/com/dokdok/topic/repository/TopicLikeRepository.java index 850a9479..f18bf37a 100644 --- a/src/main/java/com/dokdok/topic/repository/TopicLikeRepository.java +++ b/src/main/java/com/dokdok/topic/repository/TopicLikeRepository.java @@ -1,12 +1,13 @@ package com.dokdok.topic.repository; -import com.dokdok.topic.entity.Topic; import com.dokdok.topic.entity.TopicLike; 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.Optional; +import java.util.List; +import java.util.Set; @Repository public interface TopicLikeRepository extends JpaRepository { @@ -14,4 +15,15 @@ public interface TopicLikeRepository extends JpaRepository { boolean existsByTopicId(Long topicId); void deleteByTopicIdAndUserId(Long topicId, Long id); + + @Query(""" + SELECT tl.topic.id + FROM TopicLike tl + WHERE tl.topic.id IN :topicIds + AND tl.user.id = :userId + """) + Set findLikedTopicIds( + @Param("topicIds") List topicIds, + @Param("userId") Long userId + ); } diff --git a/src/main/java/com/dokdok/topic/service/TopicService.java b/src/main/java/com/dokdok/topic/service/TopicService.java index e956750b..14556f8e 100644 --- a/src/main/java/com/dokdok/topic/service/TopicService.java +++ b/src/main/java/com/dokdok/topic/service/TopicService.java @@ -117,12 +117,14 @@ public TopicsWithActionsResponse getTopics( } Set deletableTopicIds = Set.of(); + Set likedTopicIds = Set.of(); if (userId != null && !topics.isEmpty()) { List topicIds = topics.stream() .map(Topic::getId) .toList(); deletableTopicIds = topicRepository.findDeletableTopicIds(topicIds, userId); + likedTopicIds = topicLikeRepository.findLikedTopicIds(topicIds, userId); } Long totalCount = null; @@ -130,7 +132,7 @@ public TopicsWithActionsResponse getTopics( totalCount = topicRepository.countByMeetingIdAndDeletedAtIsNull(meetingId); } - return TopicsWithActionsResponse.from(topics, pageSize, hasNext, deletableTopicIds, actions, totalCount); + return TopicsWithActionsResponse.from(topics, pageSize, hasNext, deletableTopicIds, likedTopicIds, actions, totalCount); } @Transactional diff --git a/src/test/java/com/dokdok/topic/service/TopicServiceTest.java b/src/test/java/com/dokdok/topic/service/TopicServiceTest.java index b8a5f584..8da1b65e 100644 --- a/src/test/java/com/dokdok/topic/service/TopicServiceTest.java +++ b/src/test/java/com/dokdok/topic/service/TopicServiceTest.java @@ -549,12 +549,21 @@ void getTopics_FirstPage_WithAuthenticatedUser() { doNothing().when(meetingValidator) .validateMeetingInGathering(meetingId, gatheringId); + given(topicRepository.canConfirmTopic(meetingId, userId)) + .willReturn(false); + + given(topicRepository.canSuggestTopic(meetingId, userId)) + .willReturn(true); + given(topicRepository.findTopicsFirstPage(eq(meetingId), any(Pageable.class))) .willReturn(topics); given(topicRepository.findDeletableTopicIds(any(), eq(userId))) .willReturn(Set.of(1L)); + given(topicLikeRepository.findLikedTopicIds(any(), eq(userId))) + .willReturn(Set.of(1L)); + given(topicRepository.countByMeetingIdAndDeletedAtIsNull(meetingId)) .willReturn(2L); @@ -563,25 +572,31 @@ void getTopics_FirstPage_WithAuthenticatedUser() { ); assertThat(response).isNotNull(); - assertThat(response.page().items()).hasSize(2); - assertThat(response.page().pageSize()).isEqualTo(10); - assertThat(response.page().hasNext()).isFalse(); - assertThat(response.page().nextCursor()).isNull(); - assertThat(response.page().totalCount()).isEqualTo(2); - - assertThat(response.page().items().get(0).title()).isEqualTo("의미 있는 이름 짓기"); - assertThat(response.page().items().get(0).likeCount()).isEqualTo(5); - assertThat(response.page().items().get(0).topicType()).isEqualTo(TopicType.DISCUSSION); - assertThat(response.page().items().get(0).canDelete()).isTrue(); - - assertThat(response.page().items().get(1).title()).isEqualTo("함수 작성 원칙"); - assertThat(response.page().items().get(1).likeCount()).isEqualTo(3); - assertThat(response.page().items().get(1).canDelete()).isFalse(); + assertThat(response.items()).hasSize(2); + assertThat(response.pageSize()).isEqualTo(10); + assertThat(response.hasNext()).isFalse(); + assertThat(response.nextCursor()).isNull(); + assertThat(response.totalCount()).isEqualTo(2); + + assertThat(response.actions().canConfirm()).isFalse(); + assertThat(response.actions().canSuggest()).isTrue(); + + assertThat(response.items().get(0).title()).isEqualTo("의미 있는 이름 짓기"); + assertThat(response.items().get(0).likeCount()).isEqualTo(5); + assertThat(response.items().get(0).topicType()).isEqualTo(TopicType.DISCUSSION); + assertThat(response.items().get(0).canDelete()).isTrue(); + assertThat(response.items().get(0).isLiked()).isTrue(); + + assertThat(response.items().get(1).title()).isEqualTo("함수 작성 원칙"); + assertThat(response.items().get(1).likeCount()).isEqualTo(3); + assertThat(response.items().get(1).canDelete()).isFalse(); + assertThat(response.items().get(1).isLiked()).isFalse(); verify(gatheringValidator).validateGathering(gatheringId); verify(meetingValidator).validateMeetingInGathering(meetingId, gatheringId); verify(topicRepository).findTopicsFirstPage(eq(meetingId), any(Pageable.class)); verify(topicRepository).findDeletableTopicIds(any(), eq(userId)); + verify(topicLikeRepository).findLikedTopicIds(any(), eq(userId)); } } @@ -621,6 +636,12 @@ void getTopics_FirstPage_WithAnonymousUser() { doNothing().when(meetingValidator) .validateMeetingInGathering(meetingId, gatheringId); + given(topicRepository.canConfirmTopic(meetingId, null)) + .willReturn(false); + + given(topicRepository.canSuggestTopic(meetingId, null)) + .willReturn(false); + given(topicRepository.findTopicsFirstPage(eq(meetingId), any(Pageable.class))) .willReturn(topics); @@ -632,11 +653,15 @@ void getTopics_FirstPage_WithAnonymousUser() { ); assertThat(response).isNotNull(); - assertThat(response.page().items()).hasSize(1); - assertThat(response.page().items().get(0).canDelete()).isFalse(); - assertThat(response.page().totalCount()).isEqualTo(1); + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).canDelete()).isFalse(); + assertThat(response.items().get(0).isLiked()).isFalse(); + assertThat(response.totalCount()).isEqualTo(1); + assertThat(response.actions().canConfirm()).isFalse(); + assertThat(response.actions().canSuggest()).isFalse(); verify(topicRepository, never()).findDeletableTopicIds(any(), any()); + verify(topicLikeRepository, never()).findLikedTopicIds(any(), any()); } } @@ -658,6 +683,12 @@ void getTopics_EmptyList() { doNothing().when(meetingValidator) .validateMeetingInGathering(meetingId, gatheringId); + given(topicRepository.canConfirmTopic(meetingId, userId)) + .willReturn(false); + + given(topicRepository.canSuggestTopic(meetingId, userId)) + .willReturn(false); + given(topicRepository.findTopicsFirstPage(eq(meetingId), any(Pageable.class))) .willReturn(List.of()); @@ -669,16 +700,17 @@ void getTopics_EmptyList() { ); assertThat(response).isNotNull(); - assertThat(response.page().items()).isEmpty(); - assertThat(response.page().pageSize()).isEqualTo(10); - assertThat(response.page().hasNext()).isFalse(); - assertThat(response.page().nextCursor()).isNull(); - assertThat(response.page().totalCount()).isEqualTo(0); + assertThat(response.items()).isEmpty(); + assertThat(response.pageSize()).isEqualTo(10); + assertThat(response.hasNext()).isFalse(); + assertThat(response.nextCursor()).isNull(); + assertThat(response.totalCount()).isEqualTo(0); verify(gatheringValidator).validateGathering(gatheringId); verify(meetingValidator).validateMeetingInGathering(meetingId, gatheringId); verify(topicRepository).findTopicsFirstPage(eq(meetingId), any(Pageable.class)); verify(topicRepository, never()).findDeletableTopicIds(any(), any()); + verify(topicLikeRepository, never()).findLikedTopicIds(any(), any()); } } @@ -731,12 +763,21 @@ void getTopics_WithNextPage() { doNothing().when(meetingValidator) .validateMeetingInGathering(meetingId, gatheringId); + given(topicRepository.canConfirmTopic(meetingId, userId)) + .willReturn(false); + + given(topicRepository.canSuggestTopic(meetingId, userId)) + .willReturn(true); + given(topicRepository.findTopicsFirstPage(eq(meetingId), any(Pageable.class))) .willReturn(topics); given(topicRepository.findDeletableTopicIds(any(), eq(userId))) .willReturn(Set.of()); + given(topicLikeRepository.findLikedTopicIds(any(), eq(userId))) + .willReturn(Set.of()); + given(topicRepository.countByMeetingIdAndDeletedAtIsNull(meetingId)) .willReturn(10L); @@ -745,12 +786,13 @@ void getTopics_WithNextPage() { ); assertThat(response).isNotNull(); - assertThat(response.page().items()).hasSize(2); - assertThat(response.page().hasNext()).isTrue(); - assertThat(response.page().nextCursor()).isNotNull(); - assertThat(response.page().nextCursor().likeCount()).isEqualTo(5); - assertThat(response.page().nextCursor().topicId()).isEqualTo(2L); - assertThat(response.page().totalCount()).isEqualTo(10); + assertThat(response.items()).hasSize(2); + assertThat(response.pageSize()).isEqualTo(2); + assertThat(response.hasNext()).isTrue(); + assertThat(response.nextCursor()).isNotNull(); + assertThat(response.nextCursor().likeCount()).isEqualTo(5); + assertThat(response.nextCursor().topicId()).isEqualTo(2L); + assertThat(response.totalCount()).isEqualTo(10); verify(topicRepository).findTopicsFirstPage(eq(meetingId), any(Pageable.class)); } @@ -788,6 +830,12 @@ void getTopics_WithCursor() { doNothing().when(meetingValidator) .validateMeetingInGathering(meetingId, gatheringId); + given(topicRepository.canConfirmTopic(meetingId, userId)) + .willReturn(false); + + given(topicRepository.canSuggestTopic(meetingId, userId)) + .willReturn(true); + given(topicRepository.findTopicsAfterCursor( eq(meetingId), eq(cursorLikeCount), eq(cursorTopicId), any(Pageable.class) )).willReturn(topics); @@ -795,15 +843,19 @@ void getTopics_WithCursor() { given(topicRepository.findDeletableTopicIds(any(), eq(userId))) .willReturn(Set.of()); + given(topicLikeRepository.findLikedTopicIds(any(), eq(userId))) + .willReturn(Set.of(3L)); + TopicsWithActionsResponse response = topicService.getTopics( gatheringId, meetingId, pageSize, cursorLikeCount, cursorTopicId ); assertThat(response).isNotNull(); - assertThat(response.page().items()).hasSize(1); - assertThat(response.page().hasNext()).isFalse(); - assertThat(response.page().nextCursor()).isNull(); - assertThat(response.page().totalCount()).isNull(); + assertThat(response.items()).hasSize(1); + assertThat(response.hasNext()).isFalse(); + assertThat(response.nextCursor()).isNull(); + assertThat(response.totalCount()).isNull(); + assertThat(response.items().get(0).isLiked()).isTrue(); verify(topicRepository).findTopicsAfterCursor( eq(meetingId), eq(cursorLikeCount), eq(cursorTopicId), any(Pageable.class)