diff --git a/docs/conventions.md b/docs/conventions.md index b819ecf9..3c3b69d4 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -121,37 +121,69 @@ public class ClubBookReviewCommandService { private final ClubManagementAPI clubManagementAPI; private final ClubMeetingQueryService clubMeetingQueryService; private final ClubBookReviewQueryService clubBookReviewQueryService; - private final BookReviewRepository bookReviewRepository; @Retryable( retryFor = OptimisticLockingFailureException.class, maxAttempts = 5, backoff = @Backoff(delay = 300) ) - public Long updateBookReview(Long meetingId, Long reviewId, String memberId, BookReviewCreate request) { - Meeting meeting = clubMeetingQueryService.validateMeeting(meetingId); - Long clubMemberId = clubManagementAPI.fetchActiveClubMemberId(meeting.getClubId(), memberId); - - BookReview bookReview = clubBookReviewQueryService.validateBookReview(reviewId, meeting.getId()); - if (!bookReview.getClubMemberId().equals(clubMemberId)) { - throw new ClubMeetingException(ClubMeetingErrorStatus.BOOK_REVIEW_FORBIDDEN); + public void updateBookReview( + Long clubId, + Long meetingId, + Long reviewId, + Long memberId, + BookReviewCreate request + ) { + clubManagementAPI.validateClub(clubId); + ClubManagementExternalDTO.MembershipInfo membership = + clubManagementAPI.fetchMembershipInfo(clubId, memberId); + if (!membership.isActive()) { + throw new ClubMeetingException(ClubMeetingErrorStatus.CLUB_MEMBER_INACTIVE); } + ClubMeetingActor actor = new ClubMeetingActor( + membership.getClubMemberId(), + membership.isStaff() + ); + Meeting meeting = clubMeetingQueryService.validateMeeting(clubId, meetingId); - double oldRate = bookReview.getRate(); - double newRate = request.getRate(); - - bookReview.updateBookReview( + BookReview bookReview = clubBookReviewQueryService.validateBookReview(reviewId, meeting.getId()); + meeting.reviseBookReviewBy( + actor, + bookReview, request.getDescription(), request.getRate() ); + } +} +``` + +서비스는 조회와 외부 모듈 협력을 조율하고, 한줄평 수정 권한과 별점 합계 변경 순서는 `Meeting`이 책임집니다. + +```java + +public class Meeting { + public void reviseBookReviewBy( + ClubMeetingActor actor, + BookReview review, + String description, + double newRate + ) { + validateBookReviewAuthorOrStaff(actor, review); + reviseBookReview(review, description, newRate); + } + + private void reviseBookReview(BookReview review, String description, double newRate) { + double oldRate = review.getRate(); - // 별점이 변경된 경우에만 미팅의 별점 합산 if (oldRate != newRate) { - meeting.subtractSumRate(oldRate); - meeting.addSumRate(newRate); + subtractSumRate(oldRate); } - return bookReview.getId(); + review.updateBookReview(description, newRate); + + if (oldRate != newRate) { + addSumRate(newRate); + } } } ``` diff --git a/src/main/java/checkmo/clubMeeting/internal/converter/ClubMeetingConverter.java b/src/main/java/checkmo/clubMeeting/internal/converter/ClubMeetingConverter.java index c455a528..920cf1c9 100644 --- a/src/main/java/checkmo/clubMeeting/internal/converter/ClubMeetingConverter.java +++ b/src/main/java/checkmo/clubMeeting/internal/converter/ClubMeetingConverter.java @@ -80,7 +80,7 @@ public static BookShelfResponseDTO.TopicDetail toTopicDetailDTO( .topicId(topic.getId()) .content(topic.getDescription()) .authorInfo(authorInfo) - .author(topic.isOwnedBy(memberId)) + .author(topic.isAuthoredBy(memberId)) .build(); } @@ -244,4 +244,4 @@ public static DetailInfo toMeetingInfoExternalDTO( .bookInfo(bookInfo) .build(); } -} \ No newline at end of file +} diff --git a/src/main/java/checkmo/clubMeeting/internal/entity/BookReview.java b/src/main/java/checkmo/clubMeeting/internal/entity/BookReview.java index 5767ca33..f0d60f1f 100644 --- a/src/main/java/checkmo/clubMeeting/internal/entity/BookReview.java +++ b/src/main/java/checkmo/clubMeeting/internal/entity/BookReview.java @@ -43,7 +43,7 @@ public class BookReview extends BaseEntity { @JoinColumn(name = "meeting_id") private Meeting meeting; - public void updateBookReview(String description, double rate) { + void updateBookReview(String description, double rate) { this.description = description; this.rate = rate; } @@ -52,7 +52,7 @@ public boolean isOwnedBy(Long clubMemberId) { return this.clubMemberId.equals(clubMemberId); } - public void setMeeting(Meeting meeting) { + void setMeeting(Meeting meeting) { if (this.meeting == meeting) { return; } @@ -68,7 +68,7 @@ public void setMeeting(Meeting meeting) { } } - public void removeMeeting() { + void removeMeeting() { if (this.meeting != null) { this.meeting.getBookReviews().remove(this); this.meeting = null; diff --git a/src/main/java/checkmo/clubMeeting/internal/entity/ClubMeetingActor.java b/src/main/java/checkmo/clubMeeting/internal/entity/ClubMeetingActor.java new file mode 100644 index 00000000..cffa8f13 --- /dev/null +++ b/src/main/java/checkmo/clubMeeting/internal/entity/ClubMeetingActor.java @@ -0,0 +1,4 @@ +package checkmo.clubMeeting.internal.entity; + +public record ClubMeetingActor(Long clubMemberId, boolean staff) { +} diff --git a/src/main/java/checkmo/clubMeeting/internal/entity/Meeting.java b/src/main/java/checkmo/clubMeeting/internal/entity/Meeting.java index 011e320c..8ce3b318 100644 --- a/src/main/java/checkmo/clubMeeting/internal/entity/Meeting.java +++ b/src/main/java/checkmo/clubMeeting/internal/entity/Meeting.java @@ -1,12 +1,27 @@ package checkmo.clubMeeting.internal.entity; +import checkmo.clubMeeting.internal.exception.ClubMeetingErrorStatus; +import checkmo.clubMeeting.internal.exception.ClubMeetingException; import checkmo.common.BaseEntity; -import jakarta.persistence.*; -import lombok.*; - +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Version; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Getter @@ -44,14 +59,17 @@ public class Meeting extends BaseEntity { @Column(name = "book_id", nullable = false) private String bookId; + @Getter(AccessLevel.PACKAGE) @Builder.Default @OneToMany(mappedBy = "meeting", cascade = CascadeType.ALL, orphanRemoval = true) private List teams = new ArrayList<>(); + @Getter(AccessLevel.PACKAGE) @Builder.Default @OneToMany(mappedBy = "meeting", cascade = CascadeType.REMOVE, orphanRemoval = true) private List topics = new ArrayList<>(); + @Getter(AccessLevel.PACKAGE) @Builder.Default @OneToMany(mappedBy = "meeting", cascade = CascadeType.REMOVE, orphanRemoval = true) private List bookReviews = new ArrayList<>(); @@ -71,11 +89,56 @@ public void updateMeeting( this.tag = tag; } - public void addSumRate(double rate) { + public void addBookReview(BookReview review) { + review.setMeeting(this); + addSumRate(review.getRate()); + } + + public void reviseBookReviewBy( + ClubMeetingActor actor, + BookReview review, + String description, + double newRate + ) { + validateBookReviewAuthorOrStaff(actor, review); + reviseBookReview(review, description, newRate); + } + + public void removeBookReviewBy(ClubMeetingActor actor, BookReview review) { + validateBookReviewAuthorOrStaff(actor, review); + removeBookReview(review); + } + + private void validateBookReviewAuthorOrStaff(ClubMeetingActor actor, BookReview review) { + if (!review.isOwnedBy(actor.clubMemberId()) && !actor.staff()) { + throw new ClubMeetingException(ClubMeetingErrorStatus.BOOK_REVIEW_FORBIDDEN); + } + } + + private void reviseBookReview(BookReview review, String description, double newRate) { + double oldRate = review.getRate(); + + if (oldRate != newRate) { + subtractSumRate(oldRate); + } + + review.updateBookReview(description, newRate); + + if (oldRate != newRate) { + addSumRate(newRate); + } + } + + private void removeBookReview(BookReview review) { + subtractSumRate(review.getRate()); + review.removeMeeting(); + } + + private void addSumRate(double rate) { this.sumRate += rate; } - public void subtractSumRate(double rate) { + private void subtractSumRate(double rate) { if (this.sumRate < rate) { this.sumRate = this.bookReviews.stream() .mapToDouble(BookReview::getRate) @@ -92,8 +155,30 @@ public LocalDateTime getChatDeadline() { return this.getMeetingTime().plusDays(CHAT_AVAILABLE_DAYS_AFTER_MEETING); } + public void organizeTeams(Map> requestedMembersByTeamNumber) { + Map existingTeamsByTeamNumber = this.teams.stream() + .collect(Collectors.toMap(Team::getTeamNumber, Function.identity())); + + for (Map.Entry> entry : requestedMembersByTeamNumber.entrySet()) { + Team team = existingTeamsByTeamNumber.get(entry.getKey()); + if (team == null) { + team = Team.builder() + .teamNumber(entry.getKey()) + .build(); + addTeam(team); + } + team.replaceMembers(entry.getValue()); + } + + for (Team team : new ArrayList<>(this.teams)) { + if (!requestedMembersByTeamNumber.containsKey(team.getTeamNumber())) { + removeTeam(team); + } + } + } + // ========= 연관관계 메서드 ========= - public void addTeam(Team team) { + private void addTeam(Team team) { if (team == null) { return; } @@ -106,11 +191,10 @@ public void removeAllTeams() { } } - public void removeTeam(Team team) { + private void removeTeam(Team team) { if (team == null) { return; } team.removeMeeting(); } - } diff --git a/src/main/java/checkmo/clubMeeting/internal/entity/Team.java b/src/main/java/checkmo/clubMeeting/internal/entity/Team.java index 49348b09..79af52af 100644 --- a/src/main/java/checkmo/clubMeeting/internal/entity/Team.java +++ b/src/main/java/checkmo/clubMeeting/internal/entity/Team.java @@ -22,6 +22,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.BatchSize; @Getter @Builder @@ -44,10 +45,12 @@ public class Team extends BaseEntity { @JoinColumn(name = "meeting_id", nullable = false) private Meeting meeting; + @BatchSize(size = 12) @OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List teamTopics = new ArrayList<>(); + @BatchSize(size = 12) @OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List clubMemberTeams = new ArrayList<>(); @@ -76,23 +79,35 @@ public void removeMeeting() { } } - public void addClubMemberTeam(ClubMemberTeam clubMemberTeam) { - if (clubMemberTeam == null) { + public void replaceMembers(List clubMemberIds) { + if (hasSameMembers(clubMemberIds)) { return; } - clubMemberTeam.setTeam(this); + this.clubMemberTeams.clear(); + for (Long clubMemberId : clubMemberIds) { + addClubMemberTeam(ClubMemberTeam.builder() + .clubMemberId(clubMemberId) + .build()); + } } - public void removeAllClubMemberTeams() { - for (ClubMemberTeam cmt : new ArrayList<>(this.clubMemberTeams)) { - removeClubMemberTeam(cmt); + private boolean hasSameMembers(List clubMemberIds) { + if (clubMemberTeams.size() != clubMemberIds.size()) { + return false; } + List unmatchedClubMemberIds = new ArrayList<>(clubMemberIds); + for (ClubMemberTeam clubMemberTeam : clubMemberTeams) { + if (!unmatchedClubMemberIds.remove(clubMemberTeam.getClubMemberId())) { + return false; + } + } + return unmatchedClubMemberIds.isEmpty(); } - private void removeClubMemberTeam(ClubMemberTeam clubMemberTeam) { - if (clubMemberTeam == null || !clubMemberTeams.contains(clubMemberTeam)) { + private void addClubMemberTeam(ClubMemberTeam clubMemberTeam) { + if (clubMemberTeam == null) { return; } - this.clubMemberTeams.remove(clubMemberTeam); + clubMemberTeam.setTeam(this); } } diff --git a/src/main/java/checkmo/clubMeeting/internal/entity/Topic.java b/src/main/java/checkmo/clubMeeting/internal/entity/Topic.java index c00b997d..1d7cbcd0 100644 --- a/src/main/java/checkmo/clubMeeting/internal/entity/Topic.java +++ b/src/main/java/checkmo/clubMeeting/internal/entity/Topic.java @@ -51,7 +51,7 @@ public class Topic extends BaseEntity { @OneToMany(mappedBy = "topic", cascade = CascadeType.REMOVE, orphanRemoval = true) private List teamTopics = new ArrayList<>(); - public boolean isOwnedBy(String anotherMemberId) { + public boolean isAuthoredBy(Long anotherMemberId) { return this.memberId.equals(anotherMemberId); } @@ -59,10 +59,25 @@ public boolean isOwnedBy(Long anotherClubMemberId) { return this.clubMemberId.equals(anotherClubMemberId); } - public void updateTopic(String description) { + public void updateBy(ClubMeetingActor actor, String description) { + validateAuthorOrStaff(actor); this.description = description; } + public void removeBy(ClubMeetingActor actor) { + validateAuthorOrStaff(actor); + if (this.meeting != null) { + this.meeting.getTopics().remove(this); + this.meeting = null; + } + } + + private void validateAuthorOrStaff(ClubMeetingActor actor) { + if (!isOwnedBy(actor.clubMemberId()) && !actor.staff()) { + throw new ClubMeetingException(ClubMeetingErrorStatus.TOPIC_FORBIDDEN); + } + } + // == 연관관계 메서드 == // public void setMeeting(Meeting meeting) { if (meeting == null) { @@ -79,11 +94,4 @@ public void setMeeting(Meeting meeting) { meeting.getTopics().add(this); } } - - public void removeMeeting() { - if (this.meeting != null) { - this.meeting.getTopics().remove(this); - this.meeting = null; - } - } } diff --git a/src/main/java/checkmo/clubMeeting/internal/service/command/ClubBookReviewCommandService.java b/src/main/java/checkmo/clubMeeting/internal/service/command/ClubBookReviewCommandService.java index c0e08127..95e87f73 100644 --- a/src/main/java/checkmo/clubMeeting/internal/service/command/ClubBookReviewCommandService.java +++ b/src/main/java/checkmo/clubMeeting/internal/service/command/ClubBookReviewCommandService.java @@ -4,6 +4,7 @@ import checkmo.clubManagement.ClubManagementExternalDTO; import checkmo.clubMeeting.internal.converter.ClubMeetingConverter; import checkmo.clubMeeting.internal.entity.BookReview; +import checkmo.clubMeeting.internal.entity.ClubMeetingActor; import checkmo.clubMeeting.internal.entity.Meeting; import checkmo.clubMeeting.internal.exception.ClubMeetingErrorStatus; import checkmo.clubMeeting.internal.exception.ClubMeetingException; @@ -43,9 +44,7 @@ public void createBookReview(Long clubId, Long meetingId, Long memberId, BookRev Meeting meeting = clubMeetingQueryService.validateMeeting(clubId, meetingId); BookReview bookReview = ClubMeetingConverter.toBookReview(request, clubMemberId, memberId); - bookReview.setMeeting(meeting); - - meeting.addSumRate(bookReview.getRate()); + meeting.addBookReview(bookReview); bookReviewRepository.save(bookReview); } @@ -61,26 +60,19 @@ public void updateBookReview(Long clubId, Long meetingId, Long reviewId, Long me if (!clubMembership.isActive()) { throw new ClubMeetingException(ClubMeetingErrorStatus.CLUB_MEMBER_INACTIVE); } + ClubMeetingActor actor = new ClubMeetingActor( + clubMembership.getClubMemberId(), + clubMembership.isStaff() + ); Meeting meeting = clubMeetingQueryService.validateMeeting(clubId, meetingId); BookReview bookReview = clubBookReviewQueryService.validateBookReview(reviewId, meeting.getId()); - if (!bookReview.isOwnedBy(clubMembership.getClubMemberId()) && !clubMembership.isStaff()) { - throw new ClubMeetingException(ClubMeetingErrorStatus.BOOK_REVIEW_FORBIDDEN); - } - - double oldRate = bookReview.getRate(); - double newRate = request.getRate(); - - bookReview.updateBookReview( + meeting.reviseBookReviewBy( + actor, + bookReview, request.getDescription(), request.getRate() ); - - // 별점이 변경된 경우에만 미팅의 별점 합산 - if (oldRate != newRate) { - meeting.subtractSumRate(oldRate); - meeting.addSumRate(newRate); - } } @Retryable( @@ -94,16 +86,14 @@ public void deleteBookReview(Long clubId, Long meetingId, Long reviewId, Long me if (!clubMembership.isActive()) { throw new ClubMeetingException(ClubMeetingErrorStatus.CLUB_MEMBER_INACTIVE); } + ClubMeetingActor actor = new ClubMeetingActor( + clubMembership.getClubMemberId(), + clubMembership.isStaff() + ); Meeting meeting = clubMeetingQueryService.validateMeeting(clubId, meetingId); BookReview bookReview = clubBookReviewQueryService.validateBookReview(reviewId, meeting.getId()); - if (!bookReview.isOwnedBy(clubMembership.getClubMemberId()) && !clubMembership.isStaff()) { - throw new ClubMeetingException(ClubMeetingErrorStatus.BOOK_REVIEW_FORBIDDEN); - } - - meeting.subtractSumRate(bookReview.getRate()); - - bookReview.removeMeeting(); + meeting.removeBookReviewBy(actor, bookReview); } } diff --git a/src/main/java/checkmo/clubMeeting/internal/service/command/ClubMeetingCommandService.java b/src/main/java/checkmo/clubMeeting/internal/service/command/ClubMeetingCommandService.java index b0f7c811..3537047e 100644 --- a/src/main/java/checkmo/clubMeeting/internal/service/command/ClubMeetingCommandService.java +++ b/src/main/java/checkmo/clubMeeting/internal/service/command/ClubMeetingCommandService.java @@ -5,11 +5,8 @@ import checkmo.clubMeeting.ClubMeetingEvent.ClubMeetingCreated; import checkmo.clubMeeting.ClubMeetingEvent.ClubMeetingDeleted; import checkmo.clubMeeting.internal.converter.ClubMeetingConverter; -import checkmo.clubMeeting.internal.entity.ClubMemberTeam; import checkmo.clubMeeting.internal.entity.Meeting; -import checkmo.clubMeeting.internal.entity.Team; import checkmo.clubMeeting.internal.repository.MeetingRepository; -import checkmo.clubMeeting.internal.repository.TeamRepository; import checkmo.clubMeeting.internal.service.query.ClubMeetingQueryService; import checkmo.clubMeeting.web.dto.bookshelf.BookShelfRequestDTO.BookShelfCreate; import checkmo.clubMeeting.web.dto.bookshelf.BookShelfRequestDTO.BookShelfUpdate; @@ -38,7 +35,6 @@ public class ClubMeetingCommandService { private final ClubMeetingQueryService clubMeetingQueryService; private final MeetingRepository meetingRepository; - private final TeamRepository teamRepository; private final ApplicationEventPublisher applicationEventPublisher; @@ -111,44 +107,10 @@ public void manageTeam(Long clubId, Long meetingId, Long memberId, MeetingReques // 요청 정리: teamNumber -> distinct ClubMemberIds Map> requestTeamNumberToClubMemberIds = normalizeTeamManageRequest(request); - Set requestTeamNumbers = requestTeamNumberToClubMemberIds.keySet(); - // 요청 clubMemberIds 배치 검증 validateRequestClubMembers(clubId, requestTeamNumberToClubMemberIds); - // 기존 팀 조회 후 teamNumber -> Team Map (TeamTopic이 유지되도록 Team은 유지) - List existingTeams = teamRepository.findAllByMeetingIdOrderByTeamNumberAsc(meeting.getId()); - Map existingTeamNumberToTeam = existingTeams.stream() - .collect(Collectors.toMap(Team::getTeamNumber, t -> t)); - - // 요청에 있는데 아직 없는 teamNumber는 Team 생성 후 meeting에 추가 - for (Integer teamNumber : requestTeamNumbers) { - if (!existingTeamNumberToTeam.containsKey(teamNumber)) { - Team team = Team.builder() - .teamNumber(teamNumber) - .build(); - meeting.addTeam(team); - existingTeamNumberToTeam.put(teamNumber, team); - } - } - - // 요청에는 없는데 존재하는 팀(팀 발제, 팀원) 제거 - removeTeamsNotInRequest(existingTeams, requestTeamNumbers, meeting); - - // 요청 ClubMemberTeam 재생성 - for (Map.Entry> e : requestTeamNumberToClubMemberIds.entrySet()) { - Integer teamNumber = e.getKey(); - List clubMemberIds = e.getValue(); - - Team team = existingTeamNumberToTeam.get(teamNumber); - team.removeAllClubMemberTeams(); // 기존 팀원 제거 (중복 방지) - for (Long cmId : e.getValue()) { // 요청 팀원으로 다시 채우기 - ClubMemberTeam mt = ClubMemberTeam.builder() - .clubMemberId(cmId) - .build(); - team.addClubMemberTeam(mt); - } - } + meeting.organizeTeams(requestTeamNumberToClubMemberIds); meetingRepository.save(meeting); } @@ -169,13 +131,6 @@ private void validateRequestClubMembers(Long clubId, Map> te clubManagementAPI.validateActiveClubMembers(clubId, requestedClubMemberIds); } - private void removeTeamsNotInRequest(List existingTeams, Set requestTeamNumbers, Meeting meeting) { - List toRemove = existingTeams.stream() - .filter(t -> !requestTeamNumbers.contains(t.getTeamNumber())) - .toList(); - toRemove.forEach(meeting::removeTeam); - } - public void deleteAll(Long clubId) { List meetings = meetingRepository.findAllByClubId(clubId); meetingRepository.deleteAll(meetings); diff --git a/src/main/java/checkmo/clubMeeting/internal/service/command/ClubTopicCommandService.java b/src/main/java/checkmo/clubMeeting/internal/service/command/ClubTopicCommandService.java index 4573b821..49cb1e25 100644 --- a/src/main/java/checkmo/clubMeeting/internal/service/command/ClubTopicCommandService.java +++ b/src/main/java/checkmo/clubMeeting/internal/service/command/ClubTopicCommandService.java @@ -3,6 +3,7 @@ import checkmo.clubManagement.ClubManagementAPI; import checkmo.clubManagement.ClubManagementExternalDTO; import checkmo.clubMeeting.internal.converter.ClubMeetingConverter; +import checkmo.clubMeeting.internal.entity.ClubMeetingActor; import checkmo.clubMeeting.internal.entity.Meeting; import checkmo.clubMeeting.internal.entity.Team; import checkmo.clubMeeting.internal.entity.TeamTopic; @@ -54,16 +55,14 @@ public void updateTopic(Long clubId, Long meetingId, Long topicId, Long memberId if (!clubMembership.isActive()) { throw new ClubMeetingException(ClubMeetingErrorStatus.CLUB_MEMBER_INACTIVE); } + ClubMeetingActor actor = new ClubMeetingActor( + clubMembership.getClubMemberId(), + clubMembership.isStaff() + ); clubMeetingQueryService.validateMeeting(clubId, meetingId); Topic topic = clubTopicQueryService.validateTopic(topicId, meetingId); - if (!topic.isOwnedBy(clubMembership.getClubMemberId()) && !clubMembership.isStaff()) { - throw new ClubMeetingException(ClubMeetingErrorStatus.TOPIC_FORBIDDEN); - } - - topic.updateTopic( - request.getDescription() - ); + topic.updateBy(actor, request.getDescription()); } public void deleteTopic(Long clubId, Long meetingId, Long topicId, Long memberId) { @@ -72,15 +71,14 @@ public void deleteTopic(Long clubId, Long meetingId, Long topicId, Long memberId if (!clubMembership.isActive()) { throw new ClubMeetingException(ClubMeetingErrorStatus.CLUB_MEMBER_INACTIVE); } + ClubMeetingActor actor = new ClubMeetingActor( + clubMembership.getClubMemberId(), + clubMembership.isStaff() + ); clubMeetingQueryService.validateMeeting(clubId, meetingId); Topic topic = clubTopicQueryService.validateTopic(topicId, meetingId); - if (!topic.isOwnedBy(clubMembership.getClubMemberId()) && !clubMembership.isStaff()) { - throw new ClubMeetingException(ClubMeetingErrorStatus.TOPIC_FORBIDDEN); - } - - // 발제 삭제(Meeting의 orphanRemoval로 처리) - topic.removeMeeting(); + topic.removeBy(actor); } public boolean toggleTopic( diff --git a/src/test/java/checkmo/club/ClubMeetingDomainRefactorApiTest.java b/src/test/java/checkmo/club/ClubMeetingDomainRefactorApiTest.java new file mode 100644 index 00000000..7230b9d0 --- /dev/null +++ b/src/test/java/checkmo/club/ClubMeetingDomainRefactorApiTest.java @@ -0,0 +1,379 @@ +package checkmo.club; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.hamcrest.Matchers.equalTo; + +import checkmo.clubManagement.internal.entity.Club; +import checkmo.clubManagement.internal.entity.ClubMember; +import checkmo.clubManagement.internal.repository.ClubMemberRepository; +import checkmo.clubManagement.internal.repository.ClubRepository; +import checkmo.clubMeeting.internal.entity.BookReview; +import checkmo.clubMeeting.internal.entity.ClubMemberTeam; +import checkmo.clubMeeting.internal.entity.Meeting; +import checkmo.clubMeeting.internal.entity.Team; +import checkmo.clubMeeting.internal.entity.TeamTopic; +import checkmo.clubMeeting.internal.entity.Topic; +import checkmo.clubMeeting.internal.repository.BookReviewRepository; +import checkmo.clubMeeting.internal.repository.ClubMemberTeamRepository; +import checkmo.clubMeeting.internal.repository.MeetingRepository; +import checkmo.clubMeeting.internal.repository.TeamRepository; +import checkmo.clubMeeting.internal.repository.TeamTopicRepository; +import checkmo.clubMeeting.internal.repository.TopicRepository; +import checkmo.support.ApiTestSupport; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; + +class ClubMeetingDomainRefactorApiTest extends ApiTestSupport { + + @Autowired + ClubRepository clubRepository; + + @Autowired + ClubMemberRepository clubMemberRepository; + + @Autowired + MeetingRepository meetingRepository; + + @Autowired + TeamRepository teamRepository; + + @Autowired + ClubMemberTeamRepository clubMemberTeamRepository; + + @Autowired + TopicRepository topicRepository; + + @Autowired + TeamTopicRepository teamTopicRepository; + + @Autowired + BookReviewRepository bookReviewRepository; + + @Test + void 팀_재구성_HTTP는_팀_ID를_유지하고_자식_행을_교체한_뒤_빈_요청으로_모두_제거한다() { + TestUser owner = createUser(); + TestUser firstMember = createUser(); + TestUser secondMember = createUser(); + Club club = createClub(owner); + joinClub(firstMember, club.getId()); + joinClub(secondMember, club.getId()); + Meeting meeting = createMeeting(owner, club.getId()); + ClubMember ownerMembership = membershipOf(club, owner); + ClubMember firstMembership = membershipOf(club, firstMember); + ClubMember secondMembership = membershipOf(club, secondMember); + + manageTeams(owner, club.getId(), meeting.getId(), List.of( + teamPayload(1, List.of(ownerMembership.getId())) + )); + Team retainedTeam = teamRepository.findByMeetingIdAndTeamNumber(meeting.getId(), 1).orElseThrow(); + Long retainedTeamId = retainedTeam.getId(); + List replacedMemberRowIds = clubMemberTeamRepository.findAllByTeamIds(List.of(retainedTeamId)).stream() + .map(ClubMemberTeam::getId) + .toList(); + + manageTeams(owner, club.getId(), meeting.getId(), List.of( + teamPayload(1, List.of(firstMembership.getId(), secondMembership.getId())) + )); + + Team reloadedRetainedTeam = teamRepository.findByMeetingIdAndTeamNumber(meeting.getId(), 1).orElseThrow(); + List replacementRows = clubMemberTeamRepository.findAllByTeamIds(List.of(retainedTeamId)); + assertSoftly(softly -> { + softly.assertThat(reloadedRetainedTeam.getId()).isEqualTo(retainedTeamId); + softly.assertThat(replacementRows) + .extracting(ClubMemberTeam::getClubMemberId) + .containsExactlyInAnyOrder(firstMembership.getId(), secondMembership.getId()); + softly.assertThat(replacedMemberRowIds) + .allSatisfy(rowId -> softly.assertThat(clubMemberTeamRepository.findById(rowId)).isEmpty()); + }); + + Topic topic = createTopic(owner, club.getId(), meeting.getId(), "삭제될 팀의 발제"); + TeamTopic teamTopic = TeamTopic.builder() + .team(reloadedRetainedTeam) + .topic(topic) + .build(); + teamTopicRepository.saveAndFlush(teamTopic); + + manageTeams(owner, club.getId(), meeting.getId(), List.of()); + + assertSoftly(softly -> { + softly.assertThat(teamRepository.findAllByMeetingIdOrderByTeamNumberAsc(meeting.getId())).isEmpty(); + softly.assertThat(clubMemberTeamRepository.count()).isZero(); + softly.assertThat(teamTopicRepository.count()).isZero(); + }); + } + + @Test + void 한줄평_HTTP는_생성_수정_삭제마다_미팅_별점_합계와_행을_함께_갱신한다() { + TestUser owner = createUser(); + Club club = createClub(owner); + Meeting meeting = createMeeting(owner, club.getId()); + + given().cookie(accessTokenCookie(owner)) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(reviewPayload("좋았습니다", 4.5)) + .when().post("/api/v1/clubs/{clubId}/bookshelves/{meetingId}/reviews", club.getId(), meeting.getId()) + .then().statusCode(200); + + BookReview review = bookReviewRepository.findAll().getFirst(); + assertThat(meetingRepository.findById(meeting.getId()).orElseThrow().getSumRate()).isEqualTo(4.5); + + given().cookie(accessTokenCookie(owner)) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(reviewPayload("더 좋았습니다", 5.0)) + .when().patch( + "/api/v1/clubs/{clubId}/bookshelves/{meetingId}/reviews/{reviewId}", + club.getId(), meeting.getId(), review.getId() + ) + .then().statusCode(200); + + assertThat(meetingRepository.findById(meeting.getId()).orElseThrow().getSumRate()).isEqualTo(5.0); + + given().cookie(accessTokenCookie(owner)) + .when().delete( + "/api/v1/clubs/{clubId}/bookshelves/{meetingId}/reviews/{reviewId}", + club.getId(), meeting.getId(), review.getId() + ) + .then().statusCode(200); + + assertSoftly(softly -> { + softly.assertThat(meetingRepository.findById(meeting.getId()).orElseThrow().getSumRate()).isZero(); + softly.assertThat(bookReviewRepository.findById(review.getId())).isEmpty(); + }); + } + + @Test + void 콘텐츠_HTTP는_활동_여부와_작성자_권한을_구분하고_운영진에게_타인_콘텐츠_관리를_허용한다() { + TestUser owner = createUser(); + TestUser author = createUser(); + TestUser anotherActiveMember = createUser(); + TestUser inactiveMember = createUser(); + Club club = createClub(owner); + joinClub(author, club.getId()); + joinClub(anotherActiveMember, club.getId()); + joinClub(inactiveMember, club.getId()); + given().cookie(accessTokenCookie(inactiveMember)) + .when().delete("/api/v1/clubs/{clubId}/leave", club.getId()) + .then().statusCode(200); + Meeting meeting = createMeeting(owner, club.getId()); + Topic topic = createTopic(author, club.getId(), meeting.getId(), "작성자의 발제"); + BookReview review = createReview(author, club.getId(), meeting.getId(), "작성자의 한줄평", 4.0); + + assertForbiddenContentRequests( + anotherActiveMember, + club.getId(), + meeting.getId(), + topic.getId(), + review.getId(), + "TOPIC_403", + "BOOK_REVIEW_403" + ); + assertContentUnchanged( + meeting.getId(), topic.getId(), review.getId(), "작성자의 발제", "작성자의 한줄평", 4.0 + ); + + assertForbiddenContentRequests( + inactiveMember, + club.getId(), + meeting.getId(), + topic.getId(), + review.getId(), + "CLUB_MEETING_404", + "CLUB_MEETING_404" + ); + assertContentUnchanged( + meeting.getId(), topic.getId(), review.getId(), "작성자의 발제", "작성자의 한줄평", 4.0 + ); + + given().cookie(accessTokenCookie(owner)) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(Map.of("description", "운영진이 수정한 발제")) + .when().patch( + "/api/v1/clubs/{clubId}/bookshelves/{meetingId}/topics/{topicId}", + club.getId(), meeting.getId(), topic.getId() + ) + .then().statusCode(200); + given().cookie(accessTokenCookie(owner)) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(reviewPayload("운영진이 수정한 한줄평", 5.0)) + .when().patch( + "/api/v1/clubs/{clubId}/bookshelves/{meetingId}/reviews/{reviewId}", + club.getId(), meeting.getId(), review.getId() + ) + .then().statusCode(200); + + assertContentUnchanged( + meeting.getId(), topic.getId(), review.getId(), + "운영진이 수정한 발제", "운영진이 수정한 한줄평", 5.0 + ); + + given().cookie(accessTokenCookie(owner)) + .when().delete( + "/api/v1/clubs/{clubId}/bookshelves/{meetingId}/topics/{topicId}", + club.getId(), meeting.getId(), topic.getId() + ) + .then().statusCode(200); + given().cookie(accessTokenCookie(owner)) + .when().delete( + "/api/v1/clubs/{clubId}/bookshelves/{meetingId}/reviews/{reviewId}", + club.getId(), meeting.getId(), review.getId() + ) + .then().statusCode(200); + + assertSoftly(softly -> { + softly.assertThat(topicRepository.findById(topic.getId())).isEmpty(); + softly.assertThat(bookReviewRepository.findById(review.getId())).isEmpty(); + softly.assertThat(meetingRepository.findById(meeting.getId()).orElseThrow().getSumRate()).isZero(); + }); + } + + private void assertForbiddenContentRequests( + TestUser actor, + Long clubId, + Long meetingId, + Long topicId, + Long reviewId, + String expectedTopicCode, + String expectedReviewCode + ) { + given().cookie(accessTokenCookie(actor)) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(Map.of("description", "권한 없는 발제 수정")) + .when().patch( + "/api/v1/clubs/{clubId}/bookshelves/{meetingId}/topics/{topicId}", + clubId, meetingId, topicId + ) + .then().statusCode(403).body("code", equalTo(expectedTopicCode)); + given().cookie(accessTokenCookie(actor)) + .when().delete( + "/api/v1/clubs/{clubId}/bookshelves/{meetingId}/topics/{topicId}", + clubId, meetingId, topicId + ) + .then().statusCode(403).body("code", equalTo(expectedTopicCode)); + given().cookie(accessTokenCookie(actor)) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(reviewPayload("권한 없는 한줄평 수정", 1.0)) + .when().patch( + "/api/v1/clubs/{clubId}/bookshelves/{meetingId}/reviews/{reviewId}", + clubId, meetingId, reviewId + ) + .then().statusCode(403).body("code", equalTo(expectedReviewCode)); + given().cookie(accessTokenCookie(actor)) + .when().delete( + "/api/v1/clubs/{clubId}/bookshelves/{meetingId}/reviews/{reviewId}", + clubId, meetingId, reviewId + ) + .then().statusCode(403).body("code", equalTo(expectedReviewCode)); + } + + private void assertContentUnchanged( + Long meetingId, + Long topicId, + Long reviewId, + String topicDescription, + String reviewDescription, + double expectedRate + ) { + assertSoftly(softly -> { + Meeting reloadedMeeting = meetingRepository.findById(meetingId).orElseThrow(); + Topic reloadedTopic = topicRepository.findById(topicId).orElseThrow(); + BookReview reloadedReview = bookReviewRepository.findById(reviewId).orElseThrow(); + softly.assertThat(reloadedTopic.getDescription()).isEqualTo(topicDescription); + softly.assertThat(reloadedReview.getDescription()).isEqualTo(reviewDescription); + softly.assertThat(reloadedReview.getRate()).isEqualTo(expectedRate); + softly.assertThat(reloadedMeeting.getSumRate()).isEqualTo(expectedRate); + }); + } + + private Club createClub(TestUser owner) { + String name = "domain-refactor-" + UUID.randomUUID().toString().substring(0, 8); + given().cookie(accessTokenCookie(owner)) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(Map.of( + "name", name, + "description", "도메인 리팩토링 API 테스트", + "open", true, + "region", "서울", + "category", List.of("COMPUTER_IT"), + "participantTypes", List.of("ONLINE") + )) + .when().post("/api/v1/clubs") + .then().statusCode(200); + return clubRepository.findAll().stream() + .filter(club -> club.getName().equals(name)) + .findFirst() + .orElseThrow(); + } + + private void joinClub(TestUser user, Long clubId) { + given().cookie(accessTokenCookie(user)) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(Map.of("joinMessage", "함께 읽고 싶습니다.")) + .when().post("/api/v1/clubs/{clubId}/join", clubId) + .then().statusCode(200); + } + + private ClubMember membershipOf(Club club, TestUser user) { + return clubMemberRepository.findByClubIdAndMemberId(club.getId(), Long.valueOf(user.id())).orElseThrow(); + } + + private Meeting createMeeting(TestUser owner, Long clubId) { + String title = "회귀-" + UUID.randomUUID().toString().substring(0, 6); + given().cookie(accessTokenCookie(owner)) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(Map.of( + "title", title, + "meetingTime", LocalDateTime.now().plusDays(1).toString(), + "location", "온라인", + "generation", 1, + "tag", "소설", + "isbn", "9781234567890" + )) + .when().post("/api/v1/clubs/{clubId}/bookshelves", clubId) + .then().statusCode(200); + return meetingRepository.findAllByClubId(clubId).stream() + .filter(meeting -> meeting.getTitle().equals(title)) + .findFirst() + .orElseThrow(); + } + + private Topic createTopic(TestUser author, Long clubId, Long meetingId, String description) { + given().cookie(accessTokenCookie(author)) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(Map.of("description", description)) + .when().post("/api/v1/clubs/{clubId}/bookshelves/{meetingId}/topics", clubId, meetingId) + .then().statusCode(200); + return topicRepository.findAllByMeetingIdOrderByIdDesc(meetingId).getFirst(); + } + + private BookReview createReview(TestUser author, Long clubId, Long meetingId, String description, double rate) { + given().cookie(accessTokenCookie(author)) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(reviewPayload(description, rate)) + .when().post("/api/v1/clubs/{clubId}/bookshelves/{meetingId}/reviews", clubId, meetingId) + .then().statusCode(200); + return bookReviewRepository.findAll().getFirst(); + } + + private void manageTeams(TestUser staff, Long clubId, Long meetingId, List> teams) { + given().cookie(accessTokenCookie(staff)) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(Map.of("teamMemberList", teams)) + .when().put("/api/v1/clubs/{clubId}/meetings/{meetingId}/teams", clubId, meetingId) + .then().statusCode(200); + } + + private Map teamPayload(Integer teamNumber, List clubMemberIds) { + return Map.of("teamNumber", teamNumber, "clubMemberIds", clubMemberIds); + } + + private Map reviewPayload(String description, double rate) { + return Map.of("description", description, "rate", rate); + } +} diff --git a/src/test/java/checkmo/clubMeeting/internal/converter/ClubMeetingConverterTest.java b/src/test/java/checkmo/clubMeeting/internal/converter/ClubMeetingConverterTest.java new file mode 100644 index 00000000..07530c33 --- /dev/null +++ b/src/test/java/checkmo/clubMeeting/internal/converter/ClubMeetingConverterTest.java @@ -0,0 +1,25 @@ +package checkmo.clubMeeting.internal.converter; + +import static org.assertj.core.api.Assertions.assertThat; + +import checkmo.clubMeeting.internal.entity.Topic; +import checkmo.clubMeeting.web.dto.bookshelf.BookShelfResponseDTO.TopicDetail; +import org.junit.jupiter.api.Test; + +class ClubMeetingConverterTest { + + @Test + void 발제_작성자_여부는_클럽_회원_ID가_아닌_회원_ID로_판별한다() { + Long memberId = 100L; + Topic topic = Topic.builder() + .id(1L) + .description("발제") + .memberId(memberId) + .clubMemberId(7L) + .build(); + + TopicDetail result = ClubMeetingConverter.toTopicDetailDTO(topic, null, memberId); + + assertThat(result.isAuthor()).isTrue(); + } +} diff --git a/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTeamPersistenceTest.java b/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTeamPersistenceTest.java new file mode 100644 index 00000000..56001658 --- /dev/null +++ b/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTeamPersistenceTest.java @@ -0,0 +1,120 @@ +package checkmo.clubMeeting.internal.entity; + +import static checkmo.clubMeeting.internal.entity.MeetingTeamTestFixture.addTeam; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import checkmo.book.internal.scheduler.BookRecommendationScheduler; +import checkmo.bookStory.internal.scheduler.BookStoryViewScheduler; +import checkmo.clubMeeting.internal.repository.ClubMemberTeamRepository; +import checkmo.clubMeeting.internal.repository.MeetingRepository; +import checkmo.clubMeeting.internal.repository.TeamRepository; +import checkmo.clubMeeting.internal.repository.TeamTopicRepository; +import checkmo.clubMeeting.internal.repository.TopicRepository; +import checkmo.member.internal.scheduler.MemberCleanupScheduler; +import checkmo.support.SpringTest; +import jakarta.persistence.EntityManager; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Transactional; + +@SpringTest +@Transactional +class MeetingTeamPersistenceTest { + + @MockitoBean + BookRecommendationScheduler bookRecommendationScheduler; + + @MockitoBean + BookStoryViewScheduler bookStoryViewScheduler; + + @MockitoBean + MemberCleanupScheduler memberCleanupScheduler; + + private final EntityManager entityManager; + private final MeetingRepository meetingRepository; + private final TeamRepository teamRepository; + private final ClubMemberTeamRepository clubMemberTeamRepository; + private final TopicRepository topicRepository; + private final TeamTopicRepository teamTopicRepository; + + MeetingTeamPersistenceTest( + EntityManager entityManager, + MeetingRepository meetingRepository, + TeamRepository teamRepository, + ClubMemberTeamRepository clubMemberTeamRepository, + TopicRepository topicRepository, + TeamTopicRepository teamTopicRepository + ) { + this.entityManager = entityManager; + this.meetingRepository = meetingRepository; + this.teamRepository = teamRepository; + this.clubMemberTeamRepository = clubMemberTeamRepository; + this.topicRepository = topicRepository; + this.teamTopicRepository = teamTopicRepository; + } + + @Test + void 팀_재구성은_유지_팀의_ID를_보존하고_삭제_팀의_자식행을_제거하며_신규_팀을_저장한다() { + Meeting meeting = meetingRepository.save(Meeting.builder() + .title("meeting") + .clubId(1L) + .bookId("book") + .build()); + Team removed = addTeam(meeting, 1, 10L); + Team retained = addTeam(meeting, 2, 20L); + meetingRepository.flush(); + + Topic topic = Topic.builder() + .description("topic") + .clubMemberId(1L) + .memberId(1L) + .build(); + topic.setMeeting(meeting); + topicRepository.save(topic); + TeamTopic removedTeamTopic = TeamTopic.builder().build(); + removedTeamTopic.setTeam(removed); + removedTeamTopic.setTopic(topic); + entityManager.flush(); + + Long meetingId = meeting.getId(); + Long removedTeamId = removed.getId(); + Long removedMemberRowId = removed.getClubMemberTeams().get(0).getId(); + Long removedTeamTopicId = removedTeamTopic.getId(); + Long retainedTeamId = retained.getId(); + entityManager.clear(); + + Meeting reloadedMeeting = meetingRepository.findById(meetingId).orElseThrow(); + Map> requestedMembersByTeamNumber = new LinkedHashMap<>(); + requestedMembersByTeamNumber.put(2, List.of(21L, 22L)); + requestedMembersByTeamNumber.put(3, List.of(31L)); + + reloadedMeeting.organizeTeams(requestedMembersByTeamNumber); + meetingRepository.save(reloadedMeeting); + entityManager.flush(); + entityManager.clear(); + + Team persistedRetained = teamRepository.findByMeetingIdAndTeamNumber(meetingId, 2).orElseThrow(); + Team persistedCreated = teamRepository.findByMeetingIdAndTeamNumber(meetingId, 3).orElseThrow(); + assertSoftly(softly -> { + softly.assertThat(persistedRetained.getId()).isEqualTo(retainedTeamId); + softly.assertThat(teamRepository.findById(removedTeamId)).isEmpty(); + softly.assertThat(clubMemberTeamRepository.findById(removedMemberRowId)).isEmpty(); + softly.assertThat(teamTopicRepository.findById(removedTeamTopicId)).isEmpty(); + softly.assertThat(persistedCreated.getId()).isNotNull().isNotEqualTo(retainedTeamId); + softly.assertThat(clubMemberTeamRepository.findAllByTeamIds(List.of(persistedRetained.getId()))) + .extracting(ClubMemberTeam::getClubMemberId) + .containsExactly(21L, 22L); + softly.assertThat(clubMemberTeamRepository.findAllByTeamIds(List.of(persistedCreated.getId()))) + .extracting(ClubMemberTeam::getClubMemberId) + .containsExactly(31L); + }); + assertThat(teamRepository.findAllByMeetingIdOrderByTeamNumberAsc(meetingId)) + .extracting(Team::getTeamNumber) + .containsExactly(2, 3); + } + +} diff --git a/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTeamTestFixture.java b/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTeamTestFixture.java new file mode 100644 index 00000000..02905f90 --- /dev/null +++ b/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTeamTestFixture.java @@ -0,0 +1,28 @@ +package checkmo.clubMeeting.internal.entity; + +import java.util.Arrays; +import java.util.List; + +public final class MeetingTeamTestFixture { + + private MeetingTeamTestFixture() { + } + + public static Team addTeam(Meeting meeting, int teamNumber, Long... clubMemberIds) { + Team team = Team.builder().teamNumber(teamNumber).build(); + team.setMeeting(meeting); + team.replaceMembers(Arrays.asList(clubMemberIds)); + return team; + } + + public static List teamsOf(Meeting meeting) { + return List.copyOf(meeting.getTeams()); + } + + public static Team findTeam(Meeting meeting, int teamNumber) { + return meeting.getTeams().stream() + .filter(team -> team.getTeamNumber() == teamNumber) + .findFirst() + .orElseThrow(); + } +} diff --git a/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTest.java b/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTest.java new file mode 100644 index 00000000..ad19ecf5 --- /dev/null +++ b/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTest.java @@ -0,0 +1,203 @@ +package checkmo.clubMeeting.internal.entity; + +import static checkmo.clubMeeting.internal.entity.MeetingTeamTestFixture.addTeam; +import static checkmo.clubMeeting.internal.entity.MeetingTeamTestFixture.findTeam; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import checkmo.clubMeeting.internal.exception.ClubMeetingErrorStatus; +import checkmo.clubMeeting.internal.exception.ClubMeetingException; +import java.lang.reflect.Method; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class MeetingTest { + + @Test + void 요청한_팀번호에_맞춰_기존_팀을_유지하고_새_팀을_추가하며_빠진_팀을_제거한다() { + Meeting meeting = Meeting.builder().clubId(1L).bookId("book").build(); + Team removed = addTeam(meeting, 1, 10L); + Team retained = addTeam(meeting, 2, 20L); + Map> requestedMembersByTeamNumber = new LinkedHashMap<>(); + requestedMembersByTeamNumber.put(2, List.of(21L, 22L)); + requestedMembersByTeamNumber.put(3, List.of(31L)); + + meeting.organizeTeams(requestedMembersByTeamNumber); + + Team created = findTeam(meeting, 3); + assertSoftly(softly -> { + softly.assertThat(meeting.getTeams()).containsExactlyInAnyOrder(retained, created); + softly.assertThat(findTeam(meeting, 2)).isSameAs(retained); + softly.assertThat(removed.getMeeting()).isNull(); + softly.assertThat(created.getMeeting()).isSameAs(meeting); + softly.assertThat(retained.getClubMemberTeams()) + .extracting(ClubMemberTeam::getClubMemberId) + .containsExactly(21L, 22L); + softly.assertThat(created.getClubMemberTeams()) + .extracting(ClubMemberTeam::getClubMemberId) + .containsExactly(31L); + }); + assertThat(retained.getClubMemberTeams()) + .allSatisfy(member -> assertThat(member.getTeam()).isSameAs(retained)); + assertThat(created.getClubMemberTeams()) + .allSatisfy(member -> assertThat(member.getTeam()).isSameAs(created)); + } + + @Test + void 연관관계_내부_조작과_컬렉션을_public_API로_노출하지_않는다() { + assertThat(Meeting.class.getMethods()) + .extracting(Method::getName) + .doesNotContain( + "addTeam", + "removeTeam", + "getTeams", + "getTopics", + "getBookReviews", + "reviseBookReview", + "removeBookReview", + "addSumRate", + "subtractSumRate" + ); + assertThat(BookReview.class.getMethods()) + .extracting(Method::getName) + .doesNotContain("updateBookReview", "setMeeting", "removeMeeting"); + } + + @Test + void 차감할_별점보다_합계가_작으면_현재_한줄평_합계를_다시_계산한_뒤_차감한다() { + Meeting meeting = Meeting.builder().clubId(1L).bookId("book").sumRate(-6.0).build(); + BookReview removed = bookReview(1L, 3.0); + meeting.addBookReview(removed); + meeting.addBookReview(bookReview(2L, 4.0)); + + meeting.removeBookReviewBy(new ClubMeetingActor(1L, false), removed); + + assertThat(meeting.getSumRate()).isEqualTo(4.0); + } + + @Test + void 한줄평을_추가하면_모임에_연결하고_별점_합계를_더한다() { + Meeting meeting = Meeting.builder().clubId(1L).bookId("book").build(); + BookReview review = bookReview(1L, 4.0); + + meeting.addBookReview(review); + + assertSoftly(softly -> { + softly.assertThat(review.getMeeting()).isSameAs(meeting); + softly.assertThat(meeting.getSumRate()).isEqualTo(4.0); + softly.assertThat(meeting.calculateAverageRate()).isEqualTo(4.0); + }); + } + + @Test + void 한줄평_수정은_기존_별점을_차감한_뒤_내용과_별점을_바꾸고_새_별점을_더한다() { + Meeting meeting = Meeting.builder().clubId(1L).bookId("book").sumRate(-9.0).build(); + BookReview review = bookReview(1L, 4.0); + meeting.addBookReview(review); + meeting.addBookReview(bookReview(2L, 6.0)); + + meeting.reviseBookReviewBy(new ClubMeetingActor(1L, false), review, "수정", 2.0); + + assertSoftly(softly -> { + softly.assertThat(review.getDescription()).isEqualTo("수정"); + softly.assertThat(review.getRate()).isEqualTo(2.0); + softly.assertThat(meeting.getSumRate()).isEqualTo(8.0); + }); + } + + @Test + void 한줄평을_삭제하면_별점을_먼저_차감한_뒤_모임_연관을_해제한다() { + Meeting meeting = Meeting.builder().clubId(1L).bookId("book").sumRate(-9.0).build(); + BookReview review = bookReview(1L, 4.0); + meeting.addBookReview(review); + meeting.addBookReview(bookReview(2L, 6.0)); + + meeting.removeBookReviewBy(new ClubMeetingActor(1L, false), review); + + assertSoftly(softly -> { + softly.assertThat(review.getMeeting()).isNull(); + softly.assertThat(meeting.getSumRate()).isEqualTo(6.0); + softly.assertThat(meeting.calculateAverageRate()).isEqualTo(6.0); + }); + } + + @Test + void 운영진은_다른_회원의_한줄평을_수정하고_삭제할_수_있다() { + Meeting meeting = Meeting.builder().clubId(1L).bookId("book").build(); + BookReview review = bookReview(1L, 4.0); + meeting.addBookReview(review); + ClubMeetingActor staff = new ClubMeetingActor(2L, true); + + meeting.reviseBookReviewBy(staff, review, "운영진 수정", 2.0); + meeting.removeBookReviewBy(staff, review); + + assertSoftly(softly -> { + softly.assertThat(review.getDescription()).isEqualTo("운영진 수정"); + softly.assertThat(review.getRate()).isEqualTo(2.0); + softly.assertThat(review.getMeeting()).isNull(); + softly.assertThat(meeting.getSumRate()).isZero(); + }); + } + + @Test + void 일반_회원은_다른_회원의_한줄평을_수정할_수_없다() { + Meeting meeting = Meeting.builder().clubId(1L).bookId("book").build(); + BookReview review = bookReview(1L, 4.0); + meeting.addBookReview(review); + + ClubMeetingException thrown = + catchThrowableOfType( + ClubMeetingException.class, + () -> meeting.reviseBookReviewBy( + new ClubMeetingActor(2L, false), review, "수정", 2.0 + ) + ); + + assertSoftly(softly -> { + softly.assertThat(thrown.getErrorCode()).isEqualTo( + ClubMeetingErrorStatus.BOOK_REVIEW_FORBIDDEN + ); + softly.assertThat(review.getDescription()).isEqualTo("한줄평"); + softly.assertThat(review.getRate()).isEqualTo(4.0); + softly.assertThat(review.getMeeting()).isSameAs(meeting); + softly.assertThat(meeting.getSumRate()).isEqualTo(4.0); + }); + } + + @Test + void 일반_회원은_다른_회원의_한줄평을_삭제할_수_없다() { + Meeting meeting = Meeting.builder().clubId(1L).bookId("book").build(); + BookReview review = bookReview(1L, 4.0); + meeting.addBookReview(review); + + ClubMeetingException thrown = + catchThrowableOfType( + ClubMeetingException.class, + () -> meeting.removeBookReviewBy(new ClubMeetingActor(2L, false), review) + ); + + assertSoftly(softly -> { + softly.assertThat(thrown.getErrorCode()).isEqualTo( + ClubMeetingErrorStatus.BOOK_REVIEW_FORBIDDEN + ); + softly.assertThat(review.getDescription()).isEqualTo("한줄평"); + softly.assertThat(review.getRate()).isEqualTo(4.0); + softly.assertThat(review.getMeeting()).isSameAs(meeting); + softly.assertThat(meeting.getSumRate()).isEqualTo(4.0); + }); + } + + private BookReview bookReview(Long id, double rate) { + return BookReview.builder() + .id(id) + .description("한줄평") + .rate(rate) + .clubMemberId(id) + .memberId(id) + .build(); + } + +} diff --git a/src/test/java/checkmo/clubMeeting/internal/entity/TeamTest.java b/src/test/java/checkmo/clubMeeting/internal/entity/TeamTest.java new file mode 100644 index 00000000..a688e07f --- /dev/null +++ b/src/test/java/checkmo/clubMeeting/internal/entity/TeamTest.java @@ -0,0 +1,77 @@ +package checkmo.clubMeeting.internal.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.hibernate.annotations.BatchSize; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class TeamTest { + + @ParameterizedTest + @ValueSource(strings = {"clubMemberTeams", "teamTopics"}) + void 자식_컬렉션은_팀_최대_개수_단위로_batch_fetch한다(String fieldName) throws NoSuchFieldException { + BatchSize batchSize = Team.class.getDeclaredField(fieldName).getAnnotation(BatchSize.class); + + assertThat(batchSize).isNotNull(); + assertThat(batchSize.size()).isEqualTo(12); + } + + @Test + void 기존_팀원을_요청_순서대로_새_팀원으로_교체하고_역방향_연관을_연결한다() { + Team team = Team.builder().teamNumber(1).build(); + team.replaceMembers(List.of(10L, 20L)); + ClubMemberTeam removedFirst = team.getClubMemberTeams().get(0); + ClubMemberTeam removedSecond = team.getClubMemberTeams().get(1); + + team.replaceMembers(List.of(30L, 40L)); + + assertThat(team.getClubMemberTeams()) + .extracting(ClubMemberTeam::getClubMemberId) + .containsExactly(30L, 40L); + assertThat(team.getClubMemberTeams()) + .doesNotContain(removedFirst, removedSecond) + .allSatisfy(member -> assertThat(member.getTeam()).isSameAs(team)); + } + + @Test + void 빈_목록으로_교체하면_모든_팀원을_제거한다() { + Team team = Team.builder().teamNumber(1).build(); + team.replaceMembers(List.of(10L, 20L)); + + team.replaceMembers(List.of()); + + assertThat(team.getClubMemberTeams()).isEmpty(); + } + + @Test + void 같은_팀원_ID를_다른_순서로_요청하면_기존_객체와_목록_순서를_유지한다() { + Team team = Team.builder().teamNumber(1).build(); + team.replaceMembers(List.of(10L, 20L)); + ClubMemberTeam first = team.getClubMemberTeams().get(0); + ClubMemberTeam second = team.getClubMemberTeams().get(1); + + team.replaceMembers(List.of(20L, 10L)); + + assertThat(team.getClubMemberTeams()).containsExactly(first, second); + assertThat(team.getClubMemberTeams().get(0)).isSameAs(first); + assertThat(team.getClubMemberTeams().get(1)).isSameAs(second); + } + + @Test + void 중복_ID의_개수가_다르면_같은_팀원으로_판단하지_않는다() { + Team team = Team.builder().teamNumber(1).build(); + team.replaceMembers(List.of(10L, 10L)); + ClubMemberTeam duplicateFirst = team.getClubMemberTeams().get(0); + ClubMemberTeam duplicateSecond = team.getClubMemberTeams().get(1); + + team.replaceMembers(List.of(10L, 20L)); + + assertThat(team.getClubMemberTeams()) + .extracting(ClubMemberTeam::getClubMemberId) + .containsExactly(10L, 20L); + assertThat(team.getClubMemberTeams()).doesNotContain(duplicateFirst, duplicateSecond); + } +} diff --git a/src/test/java/checkmo/clubMeeting/internal/entity/TopicTest.java b/src/test/java/checkmo/clubMeeting/internal/entity/TopicTest.java new file mode 100644 index 00000000..aecda18f --- /dev/null +++ b/src/test/java/checkmo/clubMeeting/internal/entity/TopicTest.java @@ -0,0 +1,98 @@ +package checkmo.clubMeeting.internal.entity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import checkmo.clubMeeting.internal.exception.ClubMeetingErrorStatus; +import checkmo.clubMeeting.internal.exception.ClubMeetingException; +import java.lang.reflect.Method; +import org.junit.jupiter.api.Test; + +class TopicTest { + + @Test + void 작성자는_발제를_수정하고_삭제할_수_있다() { + Meeting meeting = meeting(); + Topic topic = topic(meeting); + ClubMeetingActor owner = new ClubMeetingActor(1L, false); + + topic.updateBy(owner, "수정"); + topic.removeBy(owner); + + assertSoftly(softly -> { + softly.assertThat(topic.getDescription()).isEqualTo("수정"); + softly.assertThat(topic.getMeeting()).isNull(); + }); + } + + @Test + void 운영진은_다른_작성자의_발제를_수정하고_삭제할_수_있다() { + Meeting meeting = meeting(); + Topic topic = topic(meeting); + ClubMeetingActor staff = new ClubMeetingActor(2L, true); + + topic.updateBy(staff, "운영진 수정"); + topic.removeBy(staff); + + assertSoftly(softly -> { + softly.assertThat(topic.getDescription()).isEqualTo("운영진 수정"); + softly.assertThat(topic.getMeeting()).isNull(); + }); + } + + @Test + void 일반_회원은_다른_작성자의_발제를_수정할_수_없다() { + Meeting meeting = meeting(); + Topic topic = topic(meeting); + + ClubMeetingException thrown = catchThrowableOfType( + ClubMeetingException.class, + () -> topic.updateBy(new ClubMeetingActor(2L, false), "수정") + ); + + assertSoftly(softly -> { + softly.assertThat(thrown.getErrorCode()).isEqualTo(ClubMeetingErrorStatus.TOPIC_FORBIDDEN); + softly.assertThat(topic.getDescription()).isEqualTo("기존"); + softly.assertThat(topic.getMeeting()).isSameAs(meeting); + }); + } + + @Test + void 일반_회원은_다른_작성자의_발제를_삭제할_수_없다() { + Meeting meeting = meeting(); + Topic topic = topic(meeting); + + ClubMeetingException thrown = catchThrowableOfType( + ClubMeetingException.class, + () -> topic.removeBy(new ClubMeetingActor(2L, false)) + ); + + assertSoftly(softly -> { + softly.assertThat(thrown.getErrorCode()).isEqualTo(ClubMeetingErrorStatus.TOPIC_FORBIDDEN); + softly.assertThat(topic.getDescription()).isEqualTo("기존"); + softly.assertThat(topic.getMeeting()).isSameAs(meeting); + }); + } + + @Test + void 저수준_발제_변경_메서드를_public_API로_노출하지_않는다() { + assertThat(Topic.class.getMethods()) + .extracting(Method::getName) + .doesNotContain("updateTopic", "removeMeeting"); + } + + private Meeting meeting() { + return Meeting.builder().clubId(1L).bookId("book").build(); + } + + private Topic topic(Meeting meeting) { + Topic topic = Topic.builder() + .description("기존") + .clubMemberId(1L) + .memberId(1L) + .build(); + topic.setMeeting(meeting); + return topic; + } +} diff --git a/src/test/java/checkmo/clubMeeting/internal/service/command/ClubBookReviewCommandServiceTest.java b/src/test/java/checkmo/clubMeeting/internal/service/command/ClubBookReviewCommandServiceTest.java new file mode 100644 index 00000000..be4459dc --- /dev/null +++ b/src/test/java/checkmo/clubMeeting/internal/service/command/ClubBookReviewCommandServiceTest.java @@ -0,0 +1,413 @@ +package checkmo.clubMeeting.internal.service.command; + +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import checkmo.clubManagement.ClubManagementAPI; +import checkmo.clubManagement.ClubManagementExternalDTO.MembershipInfo; +import checkmo.clubMeeting.internal.entity.BookReview; +import checkmo.clubMeeting.internal.entity.ClubMeetingActor; +import checkmo.clubMeeting.internal.entity.Meeting; +import checkmo.clubMeeting.internal.exception.ClubMeetingErrorStatus; +import checkmo.clubMeeting.internal.exception.ClubMeetingException; +import checkmo.clubMeeting.internal.repository.BookReviewRepository; +import checkmo.clubMeeting.internal.service.query.ClubBookReviewQueryService; +import checkmo.clubMeeting.internal.service.query.ClubMeetingQueryService; +import checkmo.clubMeeting.web.dto.bookshelf.BookShelfRequestDTO.BookReviewCreate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class ClubBookReviewCommandServiceTest { + + private static final Long CLUB_ID = 1L; + private static final Long MEETING_ID = 2L; + private static final Long REVIEW_ID = 3L; + private static final Long MEMBER_ID = 4L; + private static final Long CLUB_MEMBER_ID = 5L; + + @Mock + private ClubManagementAPI clubManagementAPI; + @Mock + private ClubMeetingQueryService clubMeetingQueryService; + @Mock + private ClubBookReviewQueryService clubBookReviewQueryService; + @Mock + private BookReviewRepository bookReviewRepository; + @InjectMocks + private ClubBookReviewCommandService service; + + private Meeting meeting; + + @BeforeEach + void setUp() { + meeting = meetingWithSumRate(0); + } + + @Test + void 한줄평을_모임에_연결하고_별점_합계를_더한_상태로_저장한다() { + when(clubManagementAPI.validateAndFetchActiveClubMemberId(CLUB_ID, MEMBER_ID)) + .thenReturn(CLUB_MEMBER_ID); + when(clubMeetingQueryService.validateMeeting(CLUB_ID, MEETING_ID)).thenReturn(meeting); + ArgumentCaptor captor = ArgumentCaptor.forClass(BookReview.class); + + service.createBookReview(CLUB_ID, MEETING_ID, MEMBER_ID, request("좋았어요", 4.5)); + + verify(bookReviewRepository).save(captor.capture()); + BookReview saved = captor.getValue(); + assertSoftly(softly -> { + softly.assertThat(saved.getDescription()).isEqualTo("좋았어요"); + softly.assertThat(saved.getRate()).isEqualTo(4.5); + softly.assertThat(saved.getClubMemberId()).isEqualTo(CLUB_MEMBER_ID); + softly.assertThat(saved.getMemberId()).isEqualTo(MEMBER_ID); + softly.assertThat(saved.getMeeting()).isSameAs(meeting); + softly.assertThat(meeting.getSumRate()).isEqualTo(4.5); + softly.assertThat(meeting.calculateAverageRate()).isEqualTo(4.5); + }); + } + + @Test + void 같은_별점으로_수정하면_내용만_바꾸고_별점_합계는_유지한다() { + meeting = meetingWithSumRate(0.0); + BookReview review = Mockito.spy(review("이전", 4.0, CLUB_MEMBER_ID)); + meeting.addBookReview(review); + MembershipInfo ownerMembership = allowOwner(review); + + service.updateBookReview(CLUB_ID, MEETING_ID, REVIEW_ID, MEMBER_ID, request("수정", 4.0)); + + assertSoftly(softly -> { + softly.assertThat(review.getDescription()).isEqualTo("수정"); + softly.assertThat(review.getRate()).isEqualTo(4.0); + softly.assertThat(meeting.getSumRate()).isEqualTo(4.0); + softly.assertThat(review.getMeeting()).isSameAs(meeting); + }); + verifyUpdateOrder( + ownerMembership, + review, + new ClubMeetingActor(CLUB_MEMBER_ID, false), + "수정", + 4.0 + ); + verifyNoInteractions(bookReviewRepository); + } + + @Test + void 별점_수정은_기존_별점을_차감한_뒤_review를_변경하고_새_별점을_더한다() { + meeting = meetingWithSumRate(-9.0); + BookReview review = Mockito.spy(review("이전", 4.0, CLUB_MEMBER_ID)); + meeting.addBookReview(review); + meeting.addBookReview(review("다른 한줄평", 6.0, 99L)); + MembershipInfo ownerMembership = allowOwner(review); + + service.updateBookReview(CLUB_ID, MEETING_ID, REVIEW_ID, MEMBER_ID, request("수정", 2.0)); + + assertSoftly(softly -> { + softly.assertThat(review.getDescription()).isEqualTo("수정"); + softly.assertThat(review.getRate()).isEqualTo(2.0); + softly.assertThat(meeting.getSumRate()).isEqualTo(8.0); + softly.assertThat(review.getMeeting()).isSameAs(meeting); + }); + verifyUpdateOrder( + ownerMembership, + review, + new ClubMeetingActor(CLUB_MEMBER_ID, false), + "수정", + 2.0 + ); + verifyNoInteractions(bookReviewRepository); + } + + @Test + void 운영진은_다른_회원의_한줄평을_수정할_수_있다() { + meeting = meetingWithSumRate(0.0); + BookReview review = Mockito.spy(review("기존", 4.0, 99L)); + meeting.addBookReview(review); + MembershipInfo staffMembership = allowStaff(review); + + service.updateBookReview(CLUB_ID, MEETING_ID, REVIEW_ID, MEMBER_ID, request("운영진 수정", 2.0)); + + assertSoftly(softly -> { + softly.assertThat(review.getDescription()).isEqualTo("운영진 수정"); + softly.assertThat(review.getRate()).isEqualTo(2.0); + softly.assertThat(meeting.getSumRate()).isEqualTo(2.0); + softly.assertThat(review.getMeeting()).isSameAs(meeting); + }); + verifyUpdateOrder( + staffMembership, + review, + new ClubMeetingActor(CLUB_MEMBER_ID, true), + "운영진 수정", + 2.0 + ); + verifyNoInteractions(bookReviewRepository); + } + + @Test + void 한줄평_삭제는_별점을_먼저_차감한_뒤_모임_연관을_해제한다() { + meeting = meetingWithSumRate(-9.0); + BookReview review = Mockito.spy(review("삭제 대상", 4.0, CLUB_MEMBER_ID)); + meeting.addBookReview(review); + meeting.addBookReview(review("남는 한줄평", 6.0, 99L)); + MembershipInfo ownerMembership = allowOwner(review); + + service.deleteBookReview(CLUB_ID, MEETING_ID, REVIEW_ID, MEMBER_ID); + + assertSoftly(softly -> { + softly.assertThat(review.getDescription()).isEqualTo("삭제 대상"); + softly.assertThat(review.getRate()).isEqualTo(4.0); + softly.assertThat(review.getMeeting()).isNull(); + softly.assertThat(meeting.getSumRate()).isEqualTo(6.0); + softly.assertThat(meeting.calculateAverageRate()).isEqualTo(6.0); + }); + verifyDeleteOrder(ownerMembership, review, new ClubMeetingActor(CLUB_MEMBER_ID, false)); + verifyNoInteractions(bookReviewRepository); + } + + @Test + void 운영진은_다른_회원의_한줄평을_삭제할_수_있다() { + meeting = meetingWithSumRate(0.0); + BookReview review = Mockito.spy(review("삭제 대상", 4.0, 99L)); + meeting.addBookReview(review); + MembershipInfo staffMembership = allowStaff(review); + + service.deleteBookReview(CLUB_ID, MEETING_ID, REVIEW_ID, MEMBER_ID); + + assertSoftly(softly -> { + softly.assertThat(review.getDescription()).isEqualTo("삭제 대상"); + softly.assertThat(review.getRate()).isEqualTo(4.0); + softly.assertThat(meeting.getSumRate()).isZero(); + softly.assertThat(review.getMeeting()).isNull(); + }); + verifyDeleteOrder(staffMembership, review, new ClubMeetingActor(CLUB_MEMBER_ID, true)); + verifyNoInteractions(bookReviewRepository); + } + + @Test + void inactive_회원은_모임과_한줄평을_조회하기_전에_실패하고_상태를_바꾸지_않는다() { + meeting = meetingWithSumRate(0.0); + BookReview review = review("기존", 4.0, CLUB_MEMBER_ID); + meeting.addBookReview(review); + MembershipInfo inactiveMembership = Mockito.spy(membership(CLUB_MEMBER_ID, false, false)); + when(clubManagementAPI.fetchMembershipInfo(CLUB_ID, MEMBER_ID)) + .thenReturn(inactiveMembership); + + ClubMeetingException thrown = catchThrowableOfType( + ClubMeetingException.class, + () -> service.updateBookReview(CLUB_ID, MEETING_ID, REVIEW_ID, MEMBER_ID, request("수정", 2.0)) + ); + + assertSoftly(softly -> { + softly.assertThat(thrown.getErrorCode()).isEqualTo(ClubMeetingErrorStatus.CLUB_MEMBER_INACTIVE); + softly.assertThat(review.getDescription()).isEqualTo("기존"); + softly.assertThat(review.getRate()).isEqualTo(4.0); + softly.assertThat(meeting.getSumRate()).isEqualTo(4.0); + softly.assertThat(review.getMeeting()).isSameAs(meeting); + }); + verifyInactiveValidationOrder(inactiveMembership); + verifyNoInteractions(clubMeetingQueryService, clubBookReviewQueryService); + verifyNoInteractions(bookReviewRepository); + } + + @Test + void inactive_회원은_한줄평_삭제_시_모임과_한줄평을_조회하기_전에_실패한다() { + meeting = meetingWithSumRate(0.0); + BookReview review = review("기존", 4.0, CLUB_MEMBER_ID); + meeting.addBookReview(review); + MembershipInfo inactiveMembership = Mockito.spy(membership(CLUB_MEMBER_ID, false, false)); + when(clubManagementAPI.fetchMembershipInfo(CLUB_ID, MEMBER_ID)) + .thenReturn(inactiveMembership); + + ClubMeetingException thrown = catchThrowableOfType( + ClubMeetingException.class, + () -> service.deleteBookReview(CLUB_ID, MEETING_ID, REVIEW_ID, MEMBER_ID) + ); + + assertSoftly(softly -> { + softly.assertThat(thrown.getErrorCode()).isEqualTo(ClubMeetingErrorStatus.CLUB_MEMBER_INACTIVE); + softly.assertThat(review.getDescription()).isEqualTo("기존"); + softly.assertThat(review.getRate()).isEqualTo(4.0); + softly.assertThat(meeting.getSumRate()).isEqualTo(4.0); + softly.assertThat(review.getMeeting()).isSameAs(meeting); + }); + verifyInactiveValidationOrder(inactiveMembership); + verifyNoInteractions(clubMeetingQueryService, clubBookReviewQueryService); + verifyNoInteractions(bookReviewRepository); + } + + @Test + void 작성자도_운영진도_아닌_활성_회원은_한줄평을_수정할_수_없다() { + meeting = meetingWithSumRate(0.0); + BookReview review = Mockito.spy(review("기존", 4.0, CLUB_MEMBER_ID)); + meeting.addBookReview(review); + MembershipInfo ordinaryMembership = denyOrdinaryMember(review); + + ClubMeetingException thrown = catchThrowableOfType( + ClubMeetingException.class, + () -> service.updateBookReview(CLUB_ID, MEETING_ID, REVIEW_ID, MEMBER_ID, request("수정", 2.0)) + ); + + assertSoftly(softly -> { + softly.assertThat(thrown.getErrorCode()).isEqualTo(ClubMeetingErrorStatus.BOOK_REVIEW_FORBIDDEN); + softly.assertThat(review.getDescription()).isEqualTo("기존"); + softly.assertThat(review.getRate()).isEqualTo(4.0); + softly.assertThat(meeting.getSumRate()).isEqualTo(4.0); + softly.assertThat(review.getMeeting()).isSameAs(meeting); + }); + verifyUpdateOrder( + ordinaryMembership, + review, + new ClubMeetingActor(99L, false), + "수정", + 2.0 + ); + verifyNoInteractions(bookReviewRepository); + } + + @Test + void 작성자도_운영진도_아니면_대상을_조회한_뒤_권한_오류로_실패하고_상태를_유지한다() { + meeting = meetingWithSumRate(0.0); + BookReview review = Mockito.spy(review("기존", 4.0, CLUB_MEMBER_ID)); + meeting.addBookReview(review); + MembershipInfo ordinaryMembership = denyOrdinaryMember(review); + + ClubMeetingException thrown = catchThrowableOfType( + ClubMeetingException.class, + () -> service.deleteBookReview(CLUB_ID, MEETING_ID, REVIEW_ID, MEMBER_ID) + ); + + assertSoftly(softly -> { + softly.assertThat(thrown.getErrorCode()).isEqualTo(ClubMeetingErrorStatus.BOOK_REVIEW_FORBIDDEN); + softly.assertThat(review.getDescription()).isEqualTo("기존"); + softly.assertThat(review.getRate()).isEqualTo(4.0); + softly.assertThat(review.getMeeting()).isSameAs(meeting); + softly.assertThat(meeting.getSumRate()).isEqualTo(4.0); + }); + verifyDeleteOrder(ordinaryMembership, review, new ClubMeetingActor(99L, false)); + verifyNoInteractions(bookReviewRepository); + } + + private MembershipInfo allowOwner(BookReview review) { + MembershipInfo ownerMembership = Mockito.spy(membership(CLUB_MEMBER_ID, true, false)); + when(clubManagementAPI.fetchMembershipInfo(CLUB_ID, MEMBER_ID)) + .thenReturn(ownerMembership); + when(clubMeetingQueryService.validateMeeting(CLUB_ID, MEETING_ID)).thenReturn(meeting); + when(clubBookReviewQueryService.validateBookReview(REVIEW_ID, MEETING_ID)).thenReturn(review); + return ownerMembership; + } + + private MembershipInfo allowStaff(BookReview review) { + MembershipInfo staffMembership = Mockito.spy(membership(CLUB_MEMBER_ID, true, true)); + when(clubManagementAPI.fetchMembershipInfo(CLUB_ID, MEMBER_ID)) + .thenReturn(staffMembership); + when(clubMeetingQueryService.validateMeeting(CLUB_ID, MEETING_ID)).thenReturn(meeting); + when(clubBookReviewQueryService.validateBookReview(REVIEW_ID, MEETING_ID)).thenReturn(review); + return staffMembership; + } + + private MembershipInfo denyOrdinaryMember(BookReview review) { + MembershipInfo ordinaryMembership = Mockito.spy(membership(99L, true, false)); + when(clubManagementAPI.fetchMembershipInfo(CLUB_ID, MEMBER_ID)) + .thenReturn(ordinaryMembership); + when(clubMeetingQueryService.validateMeeting(CLUB_ID, MEETING_ID)).thenReturn(meeting); + when(clubBookReviewQueryService.validateBookReview(REVIEW_ID, MEETING_ID)).thenReturn(review); + return ordinaryMembership; + } + + private void verifyUpdateOrder( + MembershipInfo membership, + BookReview review, + ClubMeetingActor actor, + String description, + double rate + ) { + InOrder inOrder = verifyActorAndLookupOrder(membership, review); + inOrder.verify(meeting).reviseBookReviewBy(actor, review, description, rate); + } + + private void verifyDeleteOrder( + MembershipInfo membership, + BookReview review, + ClubMeetingActor actor + ) { + InOrder inOrder = verifyActorAndLookupOrder(membership, review); + inOrder.verify(meeting).removeBookReviewBy(actor, review); + } + + private InOrder verifyActorAndLookupOrder(MembershipInfo membership, BookReview review) { + InOrder inOrder = Mockito.inOrder( + clubManagementAPI, + membership, + clubMeetingQueryService, + clubBookReviewQueryService, + meeting + ); + inOrder.verify(clubManagementAPI).validateClub(CLUB_ID); + inOrder.verify(clubManagementAPI).fetchMembershipInfo(CLUB_ID, MEMBER_ID); + inOrder.verify(membership).isActive(); + inOrder.verify(membership).getClubMemberId(); + inOrder.verify(membership).isStaff(); + inOrder.verify(clubMeetingQueryService).validateMeeting(CLUB_ID, MEETING_ID); + inOrder.verify(meeting).getId(); + inOrder.verify(clubBookReviewQueryService).validateBookReview(REVIEW_ID, MEETING_ID); + return inOrder; + } + + private void verifyInactiveValidationOrder(MembershipInfo inactiveMembership) { + InOrder inOrder = Mockito.inOrder(clubManagementAPI, inactiveMembership); + inOrder.verify(clubManagementAPI).validateClub(CLUB_ID); + inOrder.verify(clubManagementAPI).fetchMembershipInfo(CLUB_ID, MEMBER_ID); + inOrder.verify(inactiveMembership).isActive(); + verify(inactiveMembership, Mockito.never()).getClubMemberId(); + verify(inactiveMembership, Mockito.never()).isStaff(); + verify(meeting, Mockito.never()).reviseBookReviewBy( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.anyDouble() + ); + verify(meeting, Mockito.never()).removeBookReviewBy(Mockito.any(), Mockito.any()); + } + + private MembershipInfo membership(Long clubMemberId, boolean active, boolean staff) { + return MembershipInfo.builder() + .memberId(MEMBER_ID) + .clubMemberId(clubMemberId) + .active(active) + .staff(staff) + .build(); + } + + private Meeting meetingWithSumRate(double sumRate) { + return Mockito.spy(Meeting.builder() + .id(MEETING_ID) + .clubId(CLUB_ID) + .bookId("book") + .sumRate(sumRate) + .build()); + } + + private BookReview review(String description, double rate, Long clubMemberId) { + return BookReview.builder() + .id(clubMemberId) + .description(description) + .rate(rate) + .clubMemberId(clubMemberId) + .memberId(MEMBER_ID) + .build(); + } + + private BookReviewCreate request(String description, double rate) { + BookReviewCreate request = new BookReviewCreate(); + ReflectionTestUtils.setField(request, "description", description); + ReflectionTestUtils.setField(request, "rate", rate); + return request; + } +} diff --git a/src/test/java/checkmo/clubMeeting/internal/service/command/ClubMeetingCommandServiceTest.java b/src/test/java/checkmo/clubMeeting/internal/service/command/ClubMeetingCommandServiceTest.java new file mode 100644 index 00000000..078ec8c1 --- /dev/null +++ b/src/test/java/checkmo/clubMeeting/internal/service/command/ClubMeetingCommandServiceTest.java @@ -0,0 +1,196 @@ +package checkmo.clubMeeting.internal.service.command; + +import static checkmo.clubMeeting.internal.entity.MeetingTeamTestFixture.addTeam; +import static checkmo.clubMeeting.internal.entity.MeetingTeamTestFixture.findTeam; +import static checkmo.clubMeeting.internal.entity.MeetingTeamTestFixture.teamsOf; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import checkmo.book.BookAPI; +import checkmo.clubManagement.ClubManagementAPI; +import checkmo.clubManagement.internal.excepetion.ClubManagementErrorStatus; +import checkmo.clubManagement.internal.excepetion.ClubManagementException; +import checkmo.clubMeeting.internal.entity.ClubMemberTeam; +import checkmo.clubMeeting.internal.entity.Meeting; +import checkmo.clubMeeting.internal.entity.Team; +import checkmo.clubMeeting.internal.repository.MeetingRepository; +import checkmo.clubMeeting.internal.service.query.ClubMeetingQueryService; +import checkmo.clubMeeting.web.dto.meeting.MeetingRequestDTO.TeamManage; +import checkmo.clubMeeting.web.dto.meeting.MeetingRequestDTO.TeamMember; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class ClubMeetingCommandServiceTest { + + private static final Long CLUB_ID = 1L; + private static final Long MEETING_ID = 2L; + private static final Long ACTOR_ID = 3L; + + @Mock + private BookAPI bookAPI; + @Mock + private ClubManagementAPI clubManagementAPI; + @Mock + private ClubMeetingQueryService clubMeetingQueryService; + @Mock + private MeetingRepository meetingRepository; + @Mock + private ApplicationEventPublisher applicationEventPublisher; + @InjectMocks + private ClubMeetingCommandService service; + + private Meeting meeting; + + @BeforeEach + void setUp() { + meeting = Meeting.builder().id(MEETING_ID).clubId(CLUB_ID).bookId("book").build(); + when(clubMeetingQueryService.validateMeeting(CLUB_ID, MEETING_ID)).thenReturn(meeting); + } + + @Test + void 팀원_목록이_null이면_모든_팀을_제거하고_모임을_한_번_저장한다() { + Team first = addTeam(meeting, 1, 10L); + Team second = addTeam(meeting, 2, 20L); + + service.manageTeam(CLUB_ID, MEETING_ID, ACTOR_ID, request(null)); + + assertSoftly(softly -> { + softly.assertThat(teamsOf(meeting)).isEmpty(); + softly.assertThat(first.getMeeting()).isNull(); + softly.assertThat(second.getMeeting()).isNull(); + }); + verify(meetingRepository).save(meeting); + } + + @Test + void 팀원_목록이_비어있으면_모든_팀을_제거하고_모임을_한_번_저장한다() { + Team first = addTeam(meeting, 1, 10L); + Team second = addTeam(meeting, 2, 20L); + + service.manageTeam(CLUB_ID, MEETING_ID, ACTOR_ID, request(List.of())); + + assertSoftly(softly -> { + softly.assertThat(teamsOf(meeting)).isEmpty(); + softly.assertThat(first.getMeeting()).isNull(); + softly.assertThat(second.getMeeting()).isNull(); + }); + verify(meetingRepository).save(meeting); + } + + @Test + void 요청에_남은_팀번호는_기존_Team_인스턴스를_재사용한다() { + Team retained = addTeam(meeting, 1, 10L); + + service.manageTeam(CLUB_ID, MEETING_ID, ACTOR_ID, request(List.of(member(1, 11L)))); + + assertThat(teamsOf(meeting)).singleElement().isSameAs(retained); + } + + @Test + void 요청한_새_팀은_추가하고_생략한_기존_팀은_제거한다() { + Team removed = addTeam(meeting, 1, 10L); + Team retained = addTeam(meeting, 2, 20L); + + service.manageTeam(CLUB_ID, MEETING_ID, ACTOR_ID, + request(List.of(member(2, 21L), member(3, 31L)))); + + Team created = findTeam(meeting, 3); + assertSoftly(softly -> { + softly.assertThat(teamsOf(meeting)).containsExactlyInAnyOrder(retained, created); + softly.assertThat(findTeam(meeting, 2)).isSameAs(retained); + softly.assertThat(created.getMeeting()).isSameAs(meeting); + softly.assertThat(removed.getMeeting()).isNull(); + }); + } + + @Test + void 기존_팀원은_요청한_distinct_ID의_자식으로_교체하고_역방향_연관을_연결한다() { + Team retained = addTeam(meeting, 1, 10L, 20L); + + service.manageTeam(CLUB_ID, MEETING_ID, ACTOR_ID, + request(List.of(member(1, 30L, 30L, null, 40L)))); + + assertThat(retained.getClubMemberTeams()) + .extracting(ClubMemberTeam::getClubMemberId) + .containsExactly(30L, 40L); + assertThat(retained.getClubMemberTeams()).allSatisfy(child -> assertThat(child.getTeam()).isSameAs(retained)); + } + + @Test + void active_회원_배치검증은_정확한_요청_ID_set으로_aggregate_변경_전에_실행한다() { + Team retained = addTeam(meeting, 1, 10L); + Team omitted = addTeam(meeting, 2, 20L); + doAnswer(invocation -> { + assertThat(invocation.>getArgument(1)).containsExactlyInAnyOrder(30L, 40L); + assertThat(teamsOf(meeting)).containsExactly(retained, omitted); + assertThat(retained.getClubMemberTeams()) + .extracting(ClubMemberTeam::getClubMemberId) + .containsExactly(10L); + return null; + }).when(clubManagementAPI).validateActiveClubMembers(CLUB_ID, Set.of(30L, 40L)); + + service.manageTeam(CLUB_ID, MEETING_ID, ACTOR_ID, + request(List.of(member(1, 30L, 30L), member(3, 40L)))); + + verify(clubManagementAPI).validateActiveClubMembers(CLUB_ID, Set.of(30L, 40L)); + } + + @ParameterizedTest + @EnumSource(value = ClubManagementErrorStatus.class, names = { + "CLUB_MEMBER_NOT_FOUND", "CLUB_MEMBER_IS_NOT_ACTIVE" + }) + void 요청_회원_검증이_실패하면_aggregate를_변경하지_않고_저장하지_않는다( + ClubManagementErrorStatus errorStatus + ) { + Team retained = addTeam(meeting, 1, 10L); + Team omitted = addTeam(meeting, 2, 20L); + ClubManagementException failure = new ClubManagementException(errorStatus); + doThrow(failure).when(clubManagementAPI).validateActiveClubMembers(CLUB_ID, Set.of(30L, 40L)); + + assertThatThrownBy(() -> service.manageTeam(CLUB_ID, MEETING_ID, ACTOR_ID, + request(List.of(member(1, 30L), member(3, 40L))))) + .isSameAs(failure); + + assertSoftly(softly -> { + softly.assertThat(teamsOf(meeting)).containsExactly(retained, omitted); + softly.assertThat(retained.getClubMemberTeams()) + .extracting(ClubMemberTeam::getClubMemberId) + .containsExactly(10L); + softly.assertThat(omitted.getClubMemberTeams()) + .extracting(ClubMemberTeam::getClubMemberId) + .containsExactly(20L); + }); + verify(meetingRepository, never()).save(meeting); + } + + private TeamManage request(List members) { + TeamManage request = new TeamManage(); + ReflectionTestUtils.setField(request, "teamMemberList", members); + return request; + } + + private TeamMember member(int teamNumber, Long... clubMemberIds) { + TeamMember member = new TeamMember(); + ReflectionTestUtils.setField(member, "teamNumber", teamNumber); + ReflectionTestUtils.setField(member, "clubMemberIds", Arrays.asList(clubMemberIds)); + return member; + } +} diff --git a/src/test/java/checkmo/clubMeeting/internal/service/command/ClubTopicCommandServiceTest.java b/src/test/java/checkmo/clubMeeting/internal/service/command/ClubTopicCommandServiceTest.java new file mode 100644 index 00000000..dab317dc --- /dev/null +++ b/src/test/java/checkmo/clubMeeting/internal/service/command/ClubTopicCommandServiceTest.java @@ -0,0 +1,267 @@ +package checkmo.clubMeeting.internal.service.command; + +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import checkmo.clubManagement.ClubManagementAPI; +import checkmo.clubManagement.ClubManagementExternalDTO.MembershipInfo; +import checkmo.clubMeeting.internal.entity.ClubMeetingActor; +import checkmo.clubMeeting.internal.entity.Meeting; +import checkmo.clubMeeting.internal.entity.Topic; +import checkmo.clubMeeting.internal.exception.ClubMeetingErrorStatus; +import checkmo.clubMeeting.internal.exception.ClubMeetingException; +import checkmo.clubMeeting.internal.repository.TeamTopicRepository; +import checkmo.clubMeeting.internal.repository.TopicRepository; +import checkmo.clubMeeting.internal.service.query.ClubMeetingQueryService; +import checkmo.clubMeeting.internal.service.query.ClubMeetingTeamQueryService; +import checkmo.clubMeeting.internal.service.query.ClubTopicQueryService; +import checkmo.clubMeeting.web.dto.bookshelf.BookShelfRequestDTO.TopicCreate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class ClubTopicCommandServiceTest { + + private static final Long CLUB_ID = 1L; + private static final Long MEETING_ID = 2L; + private static final Long TOPIC_ID = 3L; + private static final Long MEMBER_ID = 4L; + private static final Long AUTHOR_CLUB_MEMBER_ID = 5L; + private static final Long ANOTHER_CLUB_MEMBER_ID = 6L; + private static final String ORIGINAL_DESCRIPTION = "기존 발제"; + private static final String UPDATED_DESCRIPTION = "수정된 발제"; + + @Mock + private ClubManagementAPI clubManagementAPI; + @Mock + private ClubMeetingQueryService clubMeetingQueryService; + @Mock + private ClubTopicQueryService clubTopicQueryService; + @Mock + private ClubMeetingTeamQueryService clubMeetingTeamQueryService; + @Mock + private TopicRepository topicRepository; + @Mock + private TeamTopicRepository teamTopicRepository; + @InjectMocks + private ClubTopicCommandService service; + + private Meeting meeting; + + @BeforeEach + void setUp() { + meeting = Meeting.builder() + .id(MEETING_ID) + .clubId(CLUB_ID) + .bookId("book") + .build(); + } + + @ParameterizedTest + @EnumSource(Operation.class) + void 작성자는_발제를_수정하거나_삭제할_수_있다(Operation operation) { + MembershipInfo membership = spy(membership(AUTHOR_CLUB_MEMBER_ID, true, false)); + Topic topic = topic(AUTHOR_CLUB_MEMBER_ID); + allow(membership, topic); + + execute(operation); + + verifyAuthorizedOrder(membership, topic, AUTHOR_CLUB_MEMBER_ID, false, operation); + assertSuccessState(operation, topic); + } + + @ParameterizedTest + @EnumSource(Operation.class) + void 운영진은_다른_작성자의_발제를_수정하거나_삭제할_수_있다(Operation operation) { + MembershipInfo membership = spy(membership(ANOTHER_CLUB_MEMBER_ID, true, true)); + Topic topic = topic(AUTHOR_CLUB_MEMBER_ID); + allow(membership, topic); + + execute(operation); + + verifyAuthorizedOrder(membership, topic, ANOTHER_CLUB_MEMBER_ID, true, operation); + assertSuccessState(operation, topic); + } + + @ParameterizedTest + @EnumSource(Operation.class) + void active_일반_회원은_대상_조회_후_권한_오류로_실패하고_발제를_변경하지_않는다(Operation operation) { + MembershipInfo membership = spy(membership(ANOTHER_CLUB_MEMBER_ID, true, false)); + Topic topic = topic(AUTHOR_CLUB_MEMBER_ID); + allow(membership, topic); + + ClubMeetingException thrown = catchThrowableOfType( + ClubMeetingException.class, + () -> execute(operation) + ); + + verifyForbiddenOrder(membership, topic, ANOTHER_CLUB_MEMBER_ID, operation); + assertFailureState(thrown, ClubMeetingErrorStatus.TOPIC_FORBIDDEN, topic); + } + + @ParameterizedTest + @EnumSource(Operation.class) + void inactive_회원은_모임과_발제_조회_전에_실패하고_발제를_변경하지_않는다(Operation operation) { + MembershipInfo membership = spy(membership(ANOTHER_CLUB_MEMBER_ID, false, false)); + Topic topic = topic(AUTHOR_CLUB_MEMBER_ID); + when(clubManagementAPI.fetchMembershipInfo(CLUB_ID, MEMBER_ID)).thenReturn(membership); + + ClubMeetingException thrown = catchThrowableOfType( + ClubMeetingException.class, + () -> execute(operation) + ); + + InOrder order = inOrder(clubManagementAPI, membership); + order.verify(clubManagementAPI).validateClub(CLUB_ID); + order.verify(clubManagementAPI).fetchMembershipInfo(CLUB_ID, MEMBER_ID); + order.verify(membership).isActive(); + verify(membership, never()).getClubMemberId(); + verify(membership, never()).isStaff(); + verifyNoInteractions(clubMeetingQueryService, clubTopicQueryService); + verifyNoMutation(operation, topic); + assertFailureState(thrown, ClubMeetingErrorStatus.CLUB_MEMBER_INACTIVE, topic); + } + + private void allow(MembershipInfo membership, Topic topic) { + when(clubManagementAPI.fetchMembershipInfo(CLUB_ID, MEMBER_ID)).thenReturn(membership); + when(clubMeetingQueryService.validateMeeting(CLUB_ID, MEETING_ID)).thenReturn(meeting); + when(clubTopicQueryService.validateTopic(TOPIC_ID, MEETING_ID)).thenReturn(topic); + } + + private void verifyAuthorizedOrder( + MembershipInfo membership, + Topic topic, + Long actorClubMemberId, + boolean staffAuthorization, + Operation operation + ) { + InOrder order = verifyLookupAndActorOrder(membership, topic); + verifyMutation(order, operation, topic, new ClubMeetingActor(actorClubMemberId, staffAuthorization)); + } + + private void verifyForbiddenOrder( + MembershipInfo membership, + Topic topic, + Long actorClubMemberId, + Operation operation + ) { + InOrder order = verifyLookupAndActorOrder(membership, topic); + verifyMutation(order, operation, topic, new ClubMeetingActor(actorClubMemberId, false)); + } + + private InOrder verifyLookupAndActorOrder(MembershipInfo membership, Topic topic) { + InOrder order = inOrder( + clubManagementAPI, + membership, + clubMeetingQueryService, + clubTopicQueryService, + topic + ); + order.verify(clubManagementAPI).validateClub(CLUB_ID); + order.verify(clubManagementAPI).fetchMembershipInfo(CLUB_ID, MEMBER_ID); + order.verify(membership).isActive(); + order.verify(membership).getClubMemberId(); + order.verify(membership).isStaff(); + order.verify(clubMeetingQueryService).validateMeeting(CLUB_ID, MEETING_ID); + order.verify(clubTopicQueryService).validateTopic(TOPIC_ID, MEETING_ID); + return order; + } + + private void verifyMutation( + InOrder order, + Operation operation, + Topic topic, + ClubMeetingActor actor + ) { + if (operation == Operation.UPDATE) { + order.verify(topic).updateBy(actor, UPDATED_DESCRIPTION); + return; + } + order.verify(topic).removeBy(actor); + } + + private void verifyNoMutation(Operation operation, Topic topic) { + if (operation == Operation.UPDATE) { + verify(topic, never()).updateBy(any(), any()); + return; + } + verify(topic, never()).removeBy(any()); + } + + private void assertSuccessState(Operation operation, Topic topic) { + assertSoftly(softly -> { + if (operation == Operation.UPDATE) { + softly.assertThat(topic.getDescription()).isEqualTo(UPDATED_DESCRIPTION); + softly.assertThat(topic.getMeeting()).isSameAs(meeting); + return; + } + softly.assertThat(topic.getDescription()).isEqualTo(ORIGINAL_DESCRIPTION); + softly.assertThat(topic.getMeeting()).isNull(); + }); + } + + private void assertFailureState( + ClubMeetingException thrown, + ClubMeetingErrorStatus expectedError, + Topic topic + ) { + assertSoftly(softly -> { + softly.assertThat(thrown.getErrorCode()).isEqualTo(expectedError); + softly.assertThat(topic.getDescription()).isEqualTo(ORIGINAL_DESCRIPTION); + softly.assertThat(topic.getMeeting()).isSameAs(meeting); + }); + } + + private void execute(Operation operation) { + if (operation == Operation.UPDATE) { + service.updateTopic(CLUB_ID, MEETING_ID, TOPIC_ID, MEMBER_ID, request(UPDATED_DESCRIPTION)); + return; + } + service.deleteTopic(CLUB_ID, MEETING_ID, TOPIC_ID, MEMBER_ID); + } + + private Topic topic(Long clubMemberId) { + Topic topic = spy(Topic.builder() + .id(TOPIC_ID) + .description(ORIGINAL_DESCRIPTION) + .clubMemberId(clubMemberId) + .memberId(MEMBER_ID) + .build()); + topic.setMeeting(meeting); + return topic; + } + + private MembershipInfo membership(Long clubMemberId, boolean active, boolean staff) { + return MembershipInfo.builder() + .memberId(MEMBER_ID) + .clubMemberId(clubMemberId) + .active(active) + .staff(staff) + .build(); + } + + private TopicCreate request(String description) { + TopicCreate request = new TopicCreate(); + ReflectionTestUtils.setField(request, "description", description); + return request; + } + + private enum Operation { + UPDATE, + DELETE + } +}