From 28ff824e26394b21fbf9e2cad6cabd15b2e8a56f Mon Sep 17 00:00:00 2001 From: Seoyoung-Kyung Date: Sun, 8 Feb 2026 18:07:38 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EC=82=AC=EC=A0=84=20=EC=9D=98?= =?UTF-8?q?=EA=B2=AC=20=EC=A1=B0=ED=9A=8C=20API=20response=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../book/repository/BookReviewRepository.java | 27 ++-- .../com/dokdok/topic/api/PreOpinionApi.java | 2 +- .../dto/response/PreOpinionResponse.java | 49 ++++-- .../topic/service/PreOpinionService.java | 140 +++++++++++++----- 4 files changed, 156 insertions(+), 62 deletions(-) diff --git a/src/main/java/com/dokdok/book/repository/BookReviewRepository.java b/src/main/java/com/dokdok/book/repository/BookReviewRepository.java index 84ac6375..57a04b99 100644 --- a/src/main/java/com/dokdok/book/repository/BookReviewRepository.java +++ b/src/main/java/com/dokdok/book/repository/BookReviewRepository.java @@ -5,9 +5,9 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; -import java.math.BigDecimal; +import org.springframework.data.repository.query.Param; + import java.util.List; -import java.util.Map; import java.util.Optional; public interface BookReviewRepository extends JpaRepository { @@ -15,18 +15,6 @@ public interface BookReviewRepository extends JpaRepository { boolean existsByBookIdAndUserId(Long bookId, Long userId); - @Query(""" - SELECT br - FROM BookReview br - WHERE br.user.id = :userId - AND br.createdAt = ( - SELECT MAX(br2.createdAt) - FROM BookReview br2 - WHERE br2.user.id = br.user.id - ) - """) - List findByUserId(Long userId); - @Query(""" SELECT br FROM BookReview br @@ -36,8 +24,17 @@ SELECT MAX(br2.createdAt) FROM BookReview br2 WHERE br2.user.id = br.user.id ) + AND EXISTS (SELECT ta + FROM TopicAnswer ta + JOIN ta.topic t + WHERE ta.user.id = br.user.id + AND t.meeting.id = :meetingId + AND ta.isSubmitted = true) """) - List findByUserIdIn(List userIds); + List findByUserIdIn( + @Param("userIds") List userIds, + @Param("meetingId") Long meetingId + ); @Query(""" SELECT new com.dokdok.gathering.dto.response.BookRatingAverage( diff --git a/src/main/java/com/dokdok/topic/api/PreOpinionApi.java b/src/main/java/com/dokdok/topic/api/PreOpinionApi.java index d610937c..a02dbba1 100644 --- a/src/main/java/com/dokdok/topic/api/PreOpinionApi.java +++ b/src/main/java/com/dokdok/topic/api/PreOpinionApi.java @@ -44,7 +44,7 @@ public interface PreOpinionApi { content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, schema = @Schema(implementation = PreOpinionResponse.class), examples = @ExampleObject(value = """ - {"code":"SUCCESS","message":"약속의 사전 의견 목록 조회를 성공했습니다.","data":{"topics":[{"topicId":1,"title":"책의 주요 메시지","description":"이 책에서 전달하고자 하는 핵심 메시지는 무엇인가요?","topicType":"DISCUSSION","topicTypeLabel":"DISCUSSION","confirmOrder":1}],"members":[{"memberInfo":{"memberId":1,"nickname":"독서왕","profileImage":"https://example.com/profile.jpg"},"bookReview":{"rating":4.5,"bookKeywords":["성장","관계"],"impressionKeywords":["여운이 남는","즐거운"]},"topicOpinions":[{"topicId":1,"content":"저는 이 책의 핵심 메시지가 자기 성찰이라고 생각합니다."}]}]}} + {"code":"SUCCESS","message":"약속의 사전 의견 목록 조회를 성공했습니다.","data":{"topics":[{"topicId":1,"title":"책의 주요 메시지","description":"이 책에서 전달하고자 하는 핵심 메시지는 무엇인가요?","topicType":"DISCUSSION","topicTypeLabel":"토론형","confirmOrder":1}],"members":[{"memberInfo":{"memberId":1,"nickname":"독서왕","profileImage":"https://example.com/profile.jpg","role":"GATHERING_LEADER"},"bookReview":{"rating":4.5,"keywordInfo":[{"id":1,"name":"성장","type":"BOOK"},{"id":2,"name":"여운이 남는","type":"IMPRESSION"}]},"topicOpinions":[{"topicId":1,"content":"저는 이 책의 핵심 메시지가 자기 성찰이라고 생각합니다."}],"isSubmitted":true}]}} """)) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( diff --git a/src/main/java/com/dokdok/topic/dto/response/PreOpinionResponse.java b/src/main/java/com/dokdok/topic/dto/response/PreOpinionResponse.java index 2e096b02..3ad4e28e 100644 --- a/src/main/java/com/dokdok/topic/dto/response/PreOpinionResponse.java +++ b/src/main/java/com/dokdok/topic/dto/response/PreOpinionResponse.java @@ -1,5 +1,6 @@ package com.dokdok.topic.dto.response; +import com.dokdok.book.entity.KeywordType; import com.dokdok.topic.entity.Topic; import com.dokdok.topic.entity.TopicAnswer; import com.dokdok.topic.entity.TopicType; @@ -59,7 +60,10 @@ public record MemberPreOpinion( BookReviewInfo bookReview, @Schema(description = "주제별 의견 목록") - List topicOpinions + List topicOpinions, + + @Schema(description = "답변 제출 여부") + Boolean isSubmitted ) { } @@ -72,10 +76,18 @@ public record MemberInfo( String nickname, @Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.jpg") - String profileImage + String profileImage, + + @Schema(description = "역할", example = "MEMBER") + String role ) { - public static MemberInfo of(Long memberId, String nickname, String profileImage) { - return new MemberInfo(memberId, nickname, profileImage); + public static MemberInfo of( + Long memberId, + String nickname, + String profileImage, + String role + ) { + return new MemberInfo(memberId, nickname, profileImage, role); } } @@ -85,17 +97,32 @@ public record BookReviewInfo( BigDecimal rating, @Schema(description = "책 키워드 목록", example = "[\"성장\", \"관계\"]") - List bookKeywords, - - @Schema(description = "인상 키워드 목록", example = "[\"여운이 남는\", \"즐거운\"]") - List impressionKeywords + List keywordInfo ) { public static BookReviewInfo of( BigDecimal rating, - List bookKeywords, - List impressionKeywords + List keywordInfo + ) { + return new BookReviewInfo(rating, keywordInfo); + } + } + + @Schema(description = "리뷰 키워드 정보") + public record KeywordInfo( + @Schema(description = "키워드 ID", example = "3") + Long id, + @Schema(description = "키워드 이름", example = "판타지") + String name, + @Schema(description = "키워드 타입", example = "BOOK") + KeywordType type + ) { + + public static KeywordInfo of( + Long id, + String name, + KeywordType type ) { - return new BookReviewInfo(rating, bookKeywords, impressionKeywords); + return new KeywordInfo(id, name, type); } } diff --git a/src/main/java/com/dokdok/topic/service/PreOpinionService.java b/src/main/java/com/dokdok/topic/service/PreOpinionService.java index ada34341..55c8f13f 100644 --- a/src/main/java/com/dokdok/topic/service/PreOpinionService.java +++ b/src/main/java/com/dokdok/topic/service/PreOpinionService.java @@ -2,17 +2,21 @@ import com.dokdok.book.entity.BookReview; import com.dokdok.book.entity.BookReviewKeyword; -import com.dokdok.book.entity.KeywordType; import com.dokdok.book.repository.BookReviewKeywordRepository; import com.dokdok.book.repository.BookReviewRepository; +import com.dokdok.gathering.entity.GatheringMember; +import com.dokdok.gathering.entity.GatheringRole; +import com.dokdok.gathering.repository.GatheringMemberRepository; import com.dokdok.gathering.service.GatheringValidator; import com.dokdok.global.util.SecurityUtil; import com.dokdok.meeting.entity.MeetingMember; +import com.dokdok.meeting.entity.MeetingMemberRole; import com.dokdok.meeting.repository.MeetingMemberRepository; import com.dokdok.meeting.service.MeetingValidator; import com.dokdok.storage.service.StorageService; import com.dokdok.topic.dto.response.PreOpinionResponse; import com.dokdok.topic.dto.response.PreOpinionResponse.BookReviewInfo; +import com.dokdok.topic.entity.TopicAnswer; import com.dokdok.topic.repository.TopicAnswerRepository; import com.dokdok.topic.repository.TopicRepository; import com.dokdok.user.entity.User; @@ -20,6 +24,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -28,6 +34,7 @@ @RequiredArgsConstructor public class PreOpinionService { + private final GatheringMemberRepository gatheringMemberRepository; private final GatheringValidator gatheringValidator; private final MeetingValidator meetingValidator; private final TopicValidator topicValidator; @@ -46,7 +53,7 @@ public PreOpinionResponse findPreOpinions(Long gatheringId, Long meetingId) { List topicInfos = buildTopicInfos(meetingId); List meetingMembers = meetingMemberRepository.findAllByMeetingId(meetingId); - List preOpinionData = buildPreOpinionData(meetingId, meetingMembers); + List preOpinionData = buildPreOpinionData(gatheringId, meetingId, meetingMembers); return new PreOpinionResponse(topicInfos, preOpinionData); } @@ -64,20 +71,39 @@ private List buildTopicInfos(Long meetingId) { .toList(); } - private List buildPreOpinionData(Long meetingId, List meetingMembers) { + private List buildPreOpinionData(Long gatheringId, Long meetingId, List meetingMembers) { + PreOpinionMaps maps = fetchPreOpinionMaps(gatheringId, meetingId, meetingMembers); + return assembleMembers(meetingMembers, maps); + } + + private record PreOpinionMaps( + Map gatheringRoleByUserId, + Map bookReviewByUserId, + Map> keywordsByReviewId, + Map> topicAnswersByUserId, + Map earliestAnswerByUserId + ) {} + + private PreOpinionMaps fetchPreOpinionMaps(Long gatheringId, Long meetingId, List meetingMembers) { List userIds = meetingMembers.stream() .map(mm -> mm.getUser().getId()) .toList(); - // 모든 멤버의 책 평가 일괄 조회 - Map bookReviewByUserId = bookReviewRepository.findByUserIdIn(userIds).stream() + Map gatheringRoleByUserId = gatheringMemberRepository + .findAllMembersByGatheringId(gatheringId).stream() + .collect(Collectors.toMap( + gm -> gm.getUser().getId(), + GatheringMember::getRole, + (existing, replacement) -> existing + )); + + Map bookReviewByUserId = bookReviewRepository.findByUserIdIn(userIds, meetingId).stream() .collect(Collectors.toMap( br -> br.getUser().getId(), br -> br, (existing, replacement) -> existing )); - // 책 평가별 키워드 일괄 조회 List bookReviewIds = bookReviewByUserId.values().stream() .map(BookReview::getId) .toList(); @@ -85,9 +111,10 @@ private List buildPreOpinionData(Long meeti .findByBookReviewIds(bookReviewIds).stream() .collect(Collectors.groupingBy(k -> k.getBookReview().getId())); - // 주제 답변 일괄 조회 + List allTopicAnswers = topicAnswerRepository.findByMeetingId(meetingId); + Map> topicAnswersByUserId = - topicAnswerRepository.findByMeetingId(meetingId).stream() + allTopicAnswers.stream() .collect(Collectors.groupingBy( ta -> ta.getUser().getId(), Collectors.mapping( @@ -96,47 +123,90 @@ private List buildPreOpinionData(Long meeti ) )); + Map earliestAnswerByUserId = allTopicAnswers.stream() + .collect(Collectors.toMap( + ta -> ta.getUser().getId(), + TopicAnswer::getCreatedAt, + (a, b) -> a.isBefore(b) ? a : b + )); + + return new PreOpinionMaps( + gatheringRoleByUserId, + bookReviewByUserId, + keywordsByReviewId, + topicAnswersByUserId, + earliestAnswerByUserId + ); + } + private List assembleMembers(List meetingMembers, PreOpinionMaps maps) { return meetingMembers.stream() - .map(mm -> { - User user = mm.getUser(); - Long memberId = user.getId(); + .sorted(Comparator.comparing( + (MeetingMember mm) -> maps.earliestAnswerByUserId().getOrDefault( + mm.getUser().getId(), LocalDateTime.MAX) + )) + .map(mm -> toMemberPreOpinion(mm, maps)) + .toList(); + } - String presignedUrl = storageService.getPresignedProfileImage(user.getProfileImageUrl()); - PreOpinionResponse.MemberInfo memberInfo - = PreOpinionResponse.MemberInfo.of(memberId, user.getNickname(), presignedUrl); + private PreOpinionResponse.MemberPreOpinion toMemberPreOpinion(MeetingMember mm, PreOpinionMaps maps) { + User user = mm.getUser(); + Long memberId = user.getId(); - BookReview review = bookReviewByUserId.get(memberId); - BookReviewInfo bookReviewInfo = review != null - ? toBookReviewInfo(review, keywordsByReviewId) - : null; + String presignedUrl = storageService.getPresignedProfileImage(user.getProfileImageUrl()); + String role = resolveRole(mm, maps.gatheringRoleByUserId()); - List topicAnswers = topicAnswersByUserId.getOrDefault(memberId, List.of()); + PreOpinionResponse.MemberInfo memberInfo + = PreOpinionResponse.MemberInfo.of(memberId, user.getNickname(), presignedUrl, role); - return new PreOpinionResponse.MemberPreOpinion(memberInfo, bookReviewInfo, topicAnswers); - }) - .toList(); + BookReview review = maps.bookReviewByUserId().get(memberId); + BookReviewInfo bookReviewInfo = review != null + ? toBookReviewInfo(review, maps.keywordsByReviewId()) + : null; + + List topicAnswers = maps.topicAnswersByUserId().getOrDefault(memberId, List.of()); + + return new PreOpinionResponse.MemberPreOpinion(memberInfo, bookReviewInfo, topicAnswers, maps.topicAnswersByUserId().containsKey(memberId)); + } + + /** + * 모임장 / 약속장 구별을 위한 메서드 + */ + private String resolveRole(MeetingMember mm, Map gatheringRoleByUserId) { + Long userId = mm.getUser().getId(); + GatheringRole gatheringRole = gatheringRoleByUserId.get(userId); + + if (gatheringRole == GatheringRole.LEADER) { + return "GATHERING_LEADER"; + } + if (mm.getMeetingRole() == MeetingMemberRole.LEADER) { + return "MEETING_LEADER"; + } + return "MEMBER"; } + /** + * 멤버들의 책 리뷰 조회 + * - 사전의견을 발행하지 않은 사용자는 책 평가도 반환하지 않음 + */ private BookReviewInfo toBookReviewInfo( BookReview bookReview, - Map> keywordsByReviewId) { + Map> keywordsByReviewId + ) { List reviewKeywords = keywordsByReviewId.getOrDefault(bookReview.getId(), List.of()); - Map> keywordMap = toKeywordMap(reviewKeywords); + + List keywordInfos = reviewKeywords.stream() + .map(rk -> PreOpinionResponse.KeywordInfo.of( + rk.getKeyword().getId(), + rk.getKeyword().getKeywordName(), + rk.getKeyword().getKeywordType() + )) + .toList(); return BookReviewInfo.of( bookReview.getRating(), - keywordMap.getOrDefault(KeywordType.BOOK, List.of()), - keywordMap.getOrDefault(KeywordType.IMPRESSION, List.of()) + keywordInfos ); } - - private Map> toKeywordMap(List keywords) { - return keywords.stream() - .collect(Collectors.groupingBy( - k -> k.getKeyword().getKeywordType(), - Collectors.mapping(k -> k.getKeyword().getKeywordName(), Collectors.toList()) - )); - } -} +} \ No newline at end of file From eec4bfb31728c0e110455f1116911f6724ce9036 Mon Sep 17 00:00:00 2001 From: Seoyoung-Kyung Date: Sun, 8 Feb 2026 18:32:22 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../topic/service/PreOpinionServiceTest.java | 454 ++++++++++++++++++ 1 file changed, 454 insertions(+) create mode 100644 src/test/java/com/dokdok/topic/service/PreOpinionServiceTest.java diff --git a/src/test/java/com/dokdok/topic/service/PreOpinionServiceTest.java b/src/test/java/com/dokdok/topic/service/PreOpinionServiceTest.java new file mode 100644 index 00000000..b7a9b708 --- /dev/null +++ b/src/test/java/com/dokdok/topic/service/PreOpinionServiceTest.java @@ -0,0 +1,454 @@ +package com.dokdok.topic.service; + +import com.dokdok.book.entity.BookReview; +import com.dokdok.book.entity.BookReviewKeyword; +import com.dokdok.book.entity.KeywordType; +import com.dokdok.book.repository.BookReviewKeywordRepository; +import com.dokdok.book.repository.BookReviewRepository; +import com.dokdok.gathering.entity.GatheringMember; +import com.dokdok.gathering.entity.GatheringRole; +import com.dokdok.gathering.repository.GatheringMemberRepository; +import com.dokdok.gathering.service.GatheringValidator; +import com.dokdok.keyword.entity.Keyword; +import com.dokdok.meeting.entity.MeetingMember; +import com.dokdok.meeting.entity.MeetingMemberRole; +import com.dokdok.meeting.repository.MeetingMemberRepository; +import com.dokdok.meeting.service.MeetingValidator; +import com.dokdok.storage.service.StorageService; +import com.dokdok.topic.dto.response.PreOpinionResponse; +import com.dokdok.topic.entity.Topic; +import com.dokdok.topic.entity.TopicAnswer; +import com.dokdok.topic.entity.TopicType; +import com.dokdok.topic.repository.TopicAnswerRepository; +import com.dokdok.topic.repository.TopicRepository; +import com.dokdok.user.entity.User; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; + +@ExtendWith(MockitoExtension.class) +class PreOpinionServiceTest { + + @Mock + private GatheringMemberRepository gatheringMemberRepository; + + @Mock + private GatheringValidator gatheringValidator; + + @Mock + private MeetingValidator meetingValidator; + + @Mock + private TopicValidator topicValidator; + + @Mock + private TopicRepository topicRepository; + + @Mock + private MeetingMemberRepository meetingMemberRepository; + + @Mock + private TopicAnswerRepository topicAnswerRepository; + + @Mock + private BookReviewRepository bookReviewRepository; + + @Mock + private BookReviewKeywordRepository bookReviewKeywordRepository; + + @Mock + private StorageService storageService; + + @InjectMocks + private PreOpinionService preOpinionService; + + private static final Long GATHERING_ID = 1L; + private static final Long MEETING_ID = 10L; + private static final String PRESIGNED_URL = "https://presigned-url.com/profile.jpg"; + + @BeforeEach + void setUpSecurityContext() { + User user = User.builder() + .id(1L) + .nickname("tester") + .kakaoId(1L) + .build(); + com.dokdok.oauth2.CustomOAuth2User principal = com.dokdok.oauth2.CustomOAuth2User.builder() + .user(user) + .attributes(Collections.emptyMap()) + .build(); + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + principal, + null, + principal.getAuthorities() + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + private void stubValidators() { + doNothing().when(gatheringValidator).validateGathering(GATHERING_ID); + doNothing().when(meetingValidator).validateMeetingInGathering(MEETING_ID, GATHERING_ID); + doNothing().when(meetingValidator).validateMeetingMember(MEETING_ID, 1L); + doNothing().when(topicValidator).validateUserHasWrittenAnswer(MEETING_ID, 1L); + } + + private User createUser(Long id, String nickname) { + return User.builder() + .id(id) + .nickname(nickname) + .kakaoId(id) + .profileImageUrl("profile_" + id + ".jpg") + .build(); + } + + private MeetingMember createMeetingMember(Long id, User user, MeetingMemberRole role) { + return MeetingMember.builder() + .id(id) + .user(user) + .meetingRole(role) + .build(); + } + + private GatheringMember createGatheringMember(Long id, User user, GatheringRole role) { + return GatheringMember.builder() + .id(id) + .user(user) + .role(role) + .build(); + } + + private Topic createTopic(Long id, String title, TopicType topicType, Integer confirmOrder) { + return Topic.builder() + .id(id) + .title(title) + .description(title + " 설명") + .topicType(topicType) + .confirmOrder(confirmOrder) + .build(); + } + + private TopicAnswer createTopicAnswer(Long id, Topic topic, User user, String content, LocalDateTime createdAt) { + TopicAnswer answer = TopicAnswer.builder() + .id(id) + .topic(topic) + .user(user) + .content(content) + .isSubmitted(true) + .build(); + ReflectionTestUtils.setField(answer, "createdAt", createdAt); + return answer; + } + + private BookReview createBookReview(Long id, User user, BigDecimal rating) { + return BookReview.builder() + .id(id) + .user(user) + .rating(rating) + .build(); + } + + private Keyword createKeyword(Long id, String name, KeywordType type) { + return Keyword.builder() + .id(id) + .keywordName(name) + .keywordType(type) + .level(1) + .build(); + } + + private BookReviewKeyword createBookReviewKeyword(Long id, BookReview review, Keyword keyword) { + return BookReviewKeyword.builder() + .id(id) + .bookReview(review) + .keyword(keyword) + .createdAt(LocalDateTime.now()) + .build(); + } + + @Test + @DisplayName("사전의견 조회 성공 - 모임장과 일반 멤버의 응답을 정상 반환한다") + void findPreOpinions_success() { + // given + stubValidators(); + + User user1 = createUser(1L, "모임장"); + User user2 = createUser(2L, "일반멤버"); + + MeetingMember mm1 = createMeetingMember(1L, user1, MeetingMemberRole.MEMBER); + MeetingMember mm2 = createMeetingMember(2L, user2, MeetingMemberRole.MEMBER); + + Topic topic = createTopic(100L, "토론 주제", TopicType.DISCUSSION, 1); + + TopicAnswer answer1 = createTopicAnswer( + 200L, topic, user1, "찬성합니다", + LocalDateTime.of(2025, 1, 1, 10, 0) + ); + + BookReview review1 = createBookReview(300L, user1, new BigDecimal("4.5")); + + GatheringMember gm1 = createGatheringMember(1L, user1, GatheringRole.LEADER); + GatheringMember gm2 = createGatheringMember(2L, user2, GatheringRole.MEMBER); + + given(topicRepository.findConfirmedTopics(MEETING_ID)).willReturn(List.of(topic)); + given(meetingMemberRepository.findAllByMeetingId(MEETING_ID)).willReturn(List.of(mm1, mm2)); + given(gatheringMemberRepository.findAllMembersByGatheringId(GATHERING_ID)).willReturn(List.of(gm1, gm2)); + given(bookReviewRepository.findByUserIdIn(anyList(), anyLong())).willReturn(List.of(review1)); + given(bookReviewKeywordRepository.findByBookReviewIds(anyList())).willReturn(List.of()); + given(topicAnswerRepository.findByMeetingId(MEETING_ID)).willReturn(List.of(answer1)); + given(storageService.getPresignedProfileImage(anyString())).willReturn(PRESIGNED_URL); + + // when + PreOpinionResponse response = preOpinionService.findPreOpinions(GATHERING_ID, MEETING_ID); + + // then + assertThat(response.topics()).hasSize(1); + PreOpinionResponse.TopicInfo topicInfo = response.topics().get(0); + assertThat(topicInfo.topicType()).isEqualTo(TopicType.DISCUSSION); + assertThat(topicInfo.topicTypeLabel()).isEqualTo("토론형"); + assertThat(topicInfo.title()).isEqualTo("토론 주제"); + assertThat(topicInfo.confirmOrder()).isEqualTo(1); + + assertThat(response.members()).hasSize(2); + + // user1 answered -> sorted first + PreOpinionResponse.MemberPreOpinion member1Opinion = response.members().get(0); + assertThat(member1Opinion.memberInfo().memberId()).isEqualTo(1L); + assertThat(member1Opinion.memberInfo().role()).isEqualTo("GATHERING_LEADER"); + assertThat(member1Opinion.bookReview()).isNotNull(); + assertThat(member1Opinion.topicOpinions()).isNotEmpty(); + assertThat(member1Opinion.isSubmitted()).isTrue(); + + // user2 did not answer -> sorted last + PreOpinionResponse.MemberPreOpinion member2Opinion = response.members().get(1); + assertThat(member2Opinion.memberInfo().memberId()).isEqualTo(2L); + assertThat(member2Opinion.memberInfo().role()).isEqualTo("MEMBER"); + assertThat(member2Opinion.bookReview()).isNull(); + assertThat(member2Opinion.topicOpinions()).isEmpty(); + assertThat(member2Opinion.isSubmitted()).isFalse(); + } + + @Test + @DisplayName("사전의견 조회 시 답변 시간 순으로 멤버가 정렬된다") + void findPreOpinions_membersOrderedByAnswerTime() { + // given + stubValidators(); + + User user1 = createUser(1L, "멤버1"); + User user2 = createUser(2L, "멤버2"); + User user3 = createUser(3L, "멤버3"); + + MeetingMember mm1 = createMeetingMember(1L, user1, MeetingMemberRole.MEMBER); + MeetingMember mm2 = createMeetingMember(2L, user2, MeetingMemberRole.MEMBER); + MeetingMember mm3 = createMeetingMember(3L, user3, MeetingMemberRole.MEMBER); + + Topic topic = createTopic(100L, "자유 주제", TopicType.FREE, 1); + + // member3 answered first, member1 second, member2 never + TopicAnswer answer3 = createTopicAnswer( + 201L, topic, user3, "가장 빨리 답변", + LocalDateTime.of(2025, 1, 1, 9, 0) + ); + TopicAnswer answer1 = createTopicAnswer( + 202L, topic, user1, "두번째 답변", + LocalDateTime.of(2025, 1, 1, 11, 0) + ); + + GatheringMember gm1 = createGatheringMember(1L, user1, GatheringRole.MEMBER); + GatheringMember gm2 = createGatheringMember(2L, user2, GatheringRole.MEMBER); + GatheringMember gm3 = createGatheringMember(3L, user3, GatheringRole.MEMBER); + + given(topicRepository.findConfirmedTopics(MEETING_ID)).willReturn(List.of(topic)); + given(meetingMemberRepository.findAllByMeetingId(MEETING_ID)).willReturn(List.of(mm1, mm2, mm3)); + given(gatheringMemberRepository.findAllMembersByGatheringId(GATHERING_ID)).willReturn(List.of(gm1, gm2, gm3)); + given(bookReviewRepository.findByUserIdIn(anyList(), anyLong())).willReturn(List.of()); + given(bookReviewKeywordRepository.findByBookReviewIds(anyList())).willReturn(List.of()); + given(topicAnswerRepository.findByMeetingId(MEETING_ID)).willReturn(List.of(answer3, answer1)); + given(storageService.getPresignedProfileImage(anyString())).willReturn(PRESIGNED_URL); + + // when + PreOpinionResponse response = preOpinionService.findPreOpinions(GATHERING_ID, MEETING_ID); + + // then - ordered: member3 (earliest) -> member1 (second) -> member2 (no answer) + assertThat(response.members()).hasSize(3); + assertThat(response.members().get(0).memberInfo().memberId()).isEqualTo(3L); + assertThat(response.members().get(1).memberInfo().memberId()).isEqualTo(1L); + assertThat(response.members().get(2).memberInfo().memberId()).isEqualTo(2L); + } + + @Test + @DisplayName("약속장(미팅 리더)이면서 모임장이 아닌 멤버는 MEETING_LEADER 역할을 가진다") + void findPreOpinions_meetingLeaderRole() { + // given + stubValidators(); + + User user1 = createUser(1L, "약속장"); + + MeetingMember mm1 = createMeetingMember(1L, user1, MeetingMemberRole.LEADER); + + Topic topic = createTopic(100L, "주제", TopicType.FREE, 1); + + GatheringMember gm1 = createGatheringMember(1L, user1, GatheringRole.MEMBER); + + given(topicRepository.findConfirmedTopics(MEETING_ID)).willReturn(List.of(topic)); + given(meetingMemberRepository.findAllByMeetingId(MEETING_ID)).willReturn(List.of(mm1)); + given(gatheringMemberRepository.findAllMembersByGatheringId(GATHERING_ID)).willReturn(List.of(gm1)); + given(bookReviewRepository.findByUserIdIn(anyList(), anyLong())).willReturn(List.of()); + given(bookReviewKeywordRepository.findByBookReviewIds(anyList())).willReturn(List.of()); + given(topicAnswerRepository.findByMeetingId(MEETING_ID)).willReturn(List.of()); + given(storageService.getPresignedProfileImage(anyString())).willReturn(PRESIGNED_URL); + + // when + PreOpinionResponse response = preOpinionService.findPreOpinions(GATHERING_ID, MEETING_ID); + + // then + assertThat(response.members()).hasSize(1); + assertThat(response.members().get(0).memberInfo().role()).isEqualTo("MEETING_LEADER"); + } + + @Test + @DisplayName("모임장이면서 약속장인 멤버는 GATHERING_LEADER 역할이 우선한다") + void findPreOpinions_gatheringLeaderTakesPrecedence() { + // given + stubValidators(); + + User user1 = createUser(1L, "모임장겸약속장"); + + MeetingMember mm1 = createMeetingMember(1L, user1, MeetingMemberRole.LEADER); + + Topic topic = createTopic(100L, "주제", TopicType.FREE, 1); + + GatheringMember gm1 = createGatheringMember(1L, user1, GatheringRole.LEADER); + + given(topicRepository.findConfirmedTopics(MEETING_ID)).willReturn(List.of(topic)); + given(meetingMemberRepository.findAllByMeetingId(MEETING_ID)).willReturn(List.of(mm1)); + given(gatheringMemberRepository.findAllMembersByGatheringId(GATHERING_ID)).willReturn(List.of(gm1)); + given(bookReviewRepository.findByUserIdIn(anyList(), anyLong())).willReturn(List.of()); + given(bookReviewKeywordRepository.findByBookReviewIds(anyList())).willReturn(List.of()); + given(topicAnswerRepository.findByMeetingId(MEETING_ID)).willReturn(List.of()); + given(storageService.getPresignedProfileImage(anyString())).willReturn(PRESIGNED_URL); + + // when + PreOpinionResponse response = preOpinionService.findPreOpinions(GATHERING_ID, MEETING_ID); + + // then + assertThat(response.members()).hasSize(1); + assertThat(response.members().get(0).memberInfo().role()).isEqualTo("GATHERING_LEADER"); + } + + @Test + @DisplayName("독서 리뷰가 없는 멤버는 bookReview가 null이다") + void findPreOpinions_memberWithNoBookReview() { + // given + stubValidators(); + + User user1 = createUser(1L, "리뷰없는멤버"); + + MeetingMember mm1 = createMeetingMember(1L, user1, MeetingMemberRole.MEMBER); + + Topic topic = createTopic(100L, "주제", TopicType.FREE, 1); + + GatheringMember gm1 = createGatheringMember(1L, user1, GatheringRole.MEMBER); + + given(topicRepository.findConfirmedTopics(MEETING_ID)).willReturn(List.of(topic)); + given(meetingMemberRepository.findAllByMeetingId(MEETING_ID)).willReturn(List.of(mm1)); + given(gatheringMemberRepository.findAllMembersByGatheringId(GATHERING_ID)).willReturn(List.of(gm1)); + given(bookReviewRepository.findByUserIdIn(anyList(), anyLong())).willReturn(List.of()); + given(bookReviewKeywordRepository.findByBookReviewIds(anyList())).willReturn(List.of()); + given(topicAnswerRepository.findByMeetingId(MEETING_ID)).willReturn(List.of()); + given(storageService.getPresignedProfileImage(anyString())).willReturn(PRESIGNED_URL); + + // when + PreOpinionResponse response = preOpinionService.findPreOpinions(GATHERING_ID, MEETING_ID); + + // then + assertThat(response.members()).hasSize(1); + assertThat(response.members().get(0).bookReview()).isNull(); + } + + @Test + @DisplayName("독서 리뷰 키워드가 KeywordInfo로 올바르게 매핑된다") + void findPreOpinions_keywordInfoMappedCorrectly() { + // given + stubValidators(); + + User user1 = createUser(1L, "리뷰작성자"); + + MeetingMember mm1 = createMeetingMember(1L, user1, MeetingMemberRole.MEMBER); + + Topic topic = createTopic(100L, "주제", TopicType.FREE, 1); + + TopicAnswer answer1 = createTopicAnswer( + 200L, topic, user1, "답변 내용", + LocalDateTime.of(2025, 1, 1, 10, 0) + ); + + BookReview review1 = createBookReview(300L, user1, new BigDecimal("4.0")); + + Keyword keyword1 = createKeyword(10L, "판타지", KeywordType.BOOK); + Keyword keyword2 = createKeyword(20L, "감동적인", KeywordType.IMPRESSION); + + BookReviewKeyword brk1 = createBookReviewKeyword(1L, review1, keyword1); + BookReviewKeyword brk2 = createBookReviewKeyword(2L, review1, keyword2); + + GatheringMember gm1 = createGatheringMember(1L, user1, GatheringRole.MEMBER); + + given(topicRepository.findConfirmedTopics(MEETING_ID)).willReturn(List.of(topic)); + given(meetingMemberRepository.findAllByMeetingId(MEETING_ID)).willReturn(List.of(mm1)); + given(gatheringMemberRepository.findAllMembersByGatheringId(GATHERING_ID)).willReturn(List.of(gm1)); + given(bookReviewRepository.findByUserIdIn(anyList(), anyLong())).willReturn(List.of(review1)); + given(bookReviewKeywordRepository.findByBookReviewIds(List.of(300L))).willReturn(List.of(brk1, brk2)); + given(topicAnswerRepository.findByMeetingId(MEETING_ID)).willReturn(List.of(answer1)); + given(storageService.getPresignedProfileImage(anyString())).willReturn(PRESIGNED_URL); + + // when + PreOpinionResponse response = preOpinionService.findPreOpinions(GATHERING_ID, MEETING_ID); + + // then + assertThat(response.members()).hasSize(1); + + PreOpinionResponse.BookReviewInfo bookReviewInfo = response.members().get(0).bookReview(); + assertThat(bookReviewInfo).isNotNull(); + assertThat(bookReviewInfo.rating()).isEqualByComparingTo(new BigDecimal("4.0")); + assertThat(bookReviewInfo.keywordInfo()).hasSize(2); + + PreOpinionResponse.KeywordInfo ki1 = bookReviewInfo.keywordInfo().stream() + .filter(ki -> ki.id().equals(10L)) + .findFirst() + .orElseThrow(); + assertThat(ki1.name()).isEqualTo("판타지"); + assertThat(ki1.type()).isEqualTo(KeywordType.BOOK); + + PreOpinionResponse.KeywordInfo ki2 = bookReviewInfo.keywordInfo().stream() + .filter(ki -> ki.id().equals(20L)) + .findFirst() + .orElseThrow(); + assertThat(ki2.name()).isEqualTo("감동적인"); + assertThat(ki2.type()).isEqualTo(KeywordType.IMPRESSION); + } +} \ No newline at end of file