From ce5f2e30c043ce79a1bad132babe13317c4d77a5 Mon Sep 17 00:00:00 2001 From: MODUGGAGI Date: Sat, 11 Jul 2026 19:54:14 +0900 Subject: [PATCH 01/11] =?UTF-8?q?test(club-meeting):=20=ED=8C=80=20?= =?UTF-8?q?=ED=8E=B8=EC=84=B1=EC=9D=98=20=EA=B8=B0=EC=A1=B4=20=EB=8F=99?= =?UTF-8?q?=EC=9E=91=EC=9D=84=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ClubMeetingCommandServiceTest.java | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 src/test/java/checkmo/clubMeeting/internal/service/command/ClubMeetingCommandServiceTest.java 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..da5b644a --- /dev/null +++ b/src/test/java/checkmo/clubMeeting/internal/service/command/ClubMeetingCommandServiceTest.java @@ -0,0 +1,222 @@ +package checkmo.clubMeeting.internal.service.command; + +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.verifyNoInteractions; +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.repository.TeamRepository; +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 TeamRepository teamRepository; + @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 = team(1, 10L); + Team second = team(2, 20L); + + service.manageTeam(CLUB_ID, MEETING_ID, ACTOR_ID, request(null)); + + assertSoftly(softly -> { + softly.assertThat(meeting.getTeams()).isEmpty(); + softly.assertThat(first.getMeeting()).isNull(); + softly.assertThat(second.getMeeting()).isNull(); + }); + verify(meetingRepository).save(meeting); + verifyNoInteractions(teamRepository); + } + + @Test + void 팀원_목록이_비어있으면_모든_팀을_제거하고_모임을_한_번_저장한다() { + Team first = team(1, 10L); + Team second = team(2, 20L); + + service.manageTeam(CLUB_ID, MEETING_ID, ACTOR_ID, request(List.of())); + + assertSoftly(softly -> { + softly.assertThat(meeting.getTeams()).isEmpty(); + softly.assertThat(first.getMeeting()).isNull(); + softly.assertThat(second.getMeeting()).isNull(); + }); + verify(meetingRepository).save(meeting); + verifyNoInteractions(teamRepository); + } + + @Test + void 요청에_남은_팀번호는_기존_Team_인스턴스를_재사용한다() { + Team retained = team(1, 10L); + when(teamRepository.findAllByMeetingIdOrderByTeamNumberAsc(MEETING_ID)).thenReturn(List.of(retained)); + + service.manageTeam(CLUB_ID, MEETING_ID, ACTOR_ID, request(List.of(member(1, 11L)))); + + assertThat(meeting.getTeams()).singleElement().isSameAs(retained); + } + + @Test + void 요청한_새_팀은_추가하고_생략한_기존_팀은_제거한다() { + Team removed = team(1, 10L); + Team retained = team(2, 20L); + when(teamRepository.findAllByMeetingIdOrderByTeamNumberAsc(MEETING_ID)) + .thenReturn(List.of(removed, retained)); + + service.manageTeam(CLUB_ID, MEETING_ID, ACTOR_ID, + request(List.of(member(2, 21L), member(3, 31L)))); + + Team created = findTeam(3); + assertSoftly(softly -> { + softly.assertThat(meeting.getTeams()).containsExactlyInAnyOrder(retained, created); + softly.assertThat(findTeam(2)).isSameAs(retained); + softly.assertThat(created.getMeeting()).isSameAs(meeting); + softly.assertThat(removed.getMeeting()).isNull(); + }); + } + + @Test + void 기존_팀원은_요청한_distinct_ID의_자식으로_교체하고_역방향_연관을_연결한다() { + Team retained = team(1, 10L, 20L); + when(teamRepository.findAllByMeetingIdOrderByTeamNumberAsc(MEETING_ID)).thenReturn(List.of(retained)); + + 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 = team(1, 10L); + Team omitted = team(2, 20L); + doAnswer(invocation -> { + assertThat(invocation.>getArgument(1)).containsExactlyInAnyOrder(30L, 40L); + assertThat(meeting.getTeams()).containsExactly(retained, omitted); + assertThat(retained.getClubMemberTeams()) + .extracting(ClubMemberTeam::getClubMemberId) + .containsExactly(10L); + return null; + }).when(clubManagementAPI).validateActiveClubMembers(CLUB_ID, Set.of(30L, 40L)); + when(teamRepository.findAllByMeetingIdOrderByTeamNumberAsc(MEETING_ID)) + .thenReturn(List.of(retained, omitted)); + + 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 = team(1, 10L); + Team omitted = team(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(meeting.getTeams()).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); + verifyNoInteractions(teamRepository); + } + + private Team team(int teamNumber, Long... clubMemberIds) { + Team team = Team.builder().teamNumber(teamNumber).build(); + meeting.addTeam(team); + for (Long clubMemberId : clubMemberIds) { + team.addClubMemberTeam(ClubMemberTeam.builder().clubMemberId(clubMemberId).build()); + } + return team; + } + + private Team findTeam(int teamNumber) { + return meeting.getTeams().stream() + .filter(team -> team.getTeamNumber() == teamNumber) + .findFirst() + .orElseThrow(); + } + + 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; + } +} From ef99886ed97c90d644c042f41ec4124b0844f63c Mon Sep 17 00:00:00 2001 From: MODUGGAGI Date: Sat, 11 Jul 2026 23:04:32 +0900 Subject: [PATCH 02/11] =?UTF-8?q?refactor(club-meeting):=20=ED=8C=80?= =?UTF-8?q?=EC=9B=90=20=EA=B5=90=EC=B2=B4=20=EC=B1=85=EC=9E=84=EC=9D=84=20?= =?UTF-8?q?Team=EC=97=90=20=EC=9C=84=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../clubMeeting/internal/entity/Team.java | 30 ++++++--- .../command/ClubMeetingCommandService.java | 9 +-- .../clubMeeting/internal/entity/TeamTest.java | 65 +++++++++++++++++++ .../ClubMeetingCommandServiceTest.java | 4 +- 4 files changed, 88 insertions(+), 20 deletions(-) create mode 100644 src/test/java/checkmo/clubMeeting/internal/entity/TeamTest.java diff --git a/src/main/java/checkmo/clubMeeting/internal/entity/Team.java b/src/main/java/checkmo/clubMeeting/internal/entity/Team.java index 49348b09..bbb3370e 100644 --- a/src/main/java/checkmo/clubMeeting/internal/entity/Team.java +++ b/src/main/java/checkmo/clubMeeting/internal/entity/Team.java @@ -76,23 +76,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/service/command/ClubMeetingCommandService.java b/src/main/java/checkmo/clubMeeting/internal/service/command/ClubMeetingCommandService.java index b0f7c811..7e6a45fa 100644 --- a/src/main/java/checkmo/clubMeeting/internal/service/command/ClubMeetingCommandService.java +++ b/src/main/java/checkmo/clubMeeting/internal/service/command/ClubMeetingCommandService.java @@ -5,7 +5,6 @@ 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; @@ -141,13 +140,7 @@ public void manageTeam(Long clubId, Long meetingId, Long memberId, MeetingReques 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); - } + team.replaceMembers(clubMemberIds); } meetingRepository.save(meeting); 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..85667063 --- /dev/null +++ b/src/test/java/checkmo/clubMeeting/internal/entity/TeamTest.java @@ -0,0 +1,65 @@ +package checkmo.clubMeeting.internal.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class TeamTest { + + @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/service/command/ClubMeetingCommandServiceTest.java b/src/test/java/checkmo/clubMeeting/internal/service/command/ClubMeetingCommandServiceTest.java index da5b644a..e2a78b8b 100644 --- a/src/test/java/checkmo/clubMeeting/internal/service/command/ClubMeetingCommandServiceTest.java +++ b/src/test/java/checkmo/clubMeeting/internal/service/command/ClubMeetingCommandServiceTest.java @@ -194,9 +194,7 @@ void setUp() { private Team team(int teamNumber, Long... clubMemberIds) { Team team = Team.builder().teamNumber(teamNumber).build(); meeting.addTeam(team); - for (Long clubMemberId : clubMemberIds) { - team.addClubMemberTeam(ClubMemberTeam.builder().clubMemberId(clubMemberId).build()); - } + team.replaceMembers(Arrays.asList(clubMemberIds)); return team; } From 54233559613a7334b5394b91e639bf6501345c1e Mon Sep 17 00:00:00 2001 From: MODUGGAGI Date: Sun, 12 Jul 2026 12:15:00 +0900 Subject: [PATCH 03/11] =?UTF-8?q?refactor(club-meeting):=20=ED=8C=80=20?= =?UTF-8?q?=EC=9E=AC=EA=B5=AC=EC=84=B1=20=EC=B1=85=EC=9E=84=EC=9D=84=20Mee?= =?UTF-8?q?ting=EC=97=90=20=EC=9C=84=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 팀 구성 변경의 판단과 순서를 aggregate root에 모으고 서비스에는 검증, 조회, 저장 orchestration만 남긴다. Constraint: 유지되는 Team과 TeamTopic의 영속성 identity를 보존해야 함 Rejected: 모든 Team 삭제 후 재생성 | TeamTopic과 기존 Team identity가 소실됨 Confidence: high Scope-risk: moderate Directive: Meeting-Team 매핑을 바꿀 때 cascade와 orphanRemoval 대체 전략을 먼저 마련할 것 Tested: ./gradlew test --tests checkmo.clubMeeting.internal.entity.MeetingTest --tests checkmo.clubMeeting.internal.entity.MeetingTeamPersistenceTest --tests checkmo.clubMeeting.internal.service.command.ClubMeetingCommandServiceTest Not-tested: MySQL/Testcontainers 영속성 검증 --- .../clubMeeting/internal/entity/Meeting.java | 43 +++++- .../command/ClubMeetingCommandService.java | 36 +---- .../entity/MeetingTeamPersistenceTest.java | 126 ++++++++++++++++++ .../internal/entity/MeetingTest.java | 56 ++++++++ 4 files changed, 223 insertions(+), 38 deletions(-) create mode 100644 src/test/java/checkmo/clubMeeting/internal/entity/MeetingTeamPersistenceTest.java create mode 100644 src/test/java/checkmo/clubMeeting/internal/entity/MeetingTest.java diff --git a/src/main/java/checkmo/clubMeeting/internal/entity/Meeting.java b/src/main/java/checkmo/clubMeeting/internal/entity/Meeting.java index 011e320c..b48ce685 100644 --- a/src/main/java/checkmo/clubMeeting/internal/entity/Meeting.java +++ b/src/main/java/checkmo/clubMeeting/internal/entity/Meeting.java @@ -1,12 +1,25 @@ package checkmo.clubMeeting.internal.entity; 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 @@ -113,4 +126,28 @@ public void removeTeam(Team team) { team.removeMeeting(); } + public void reconfigureTeams( + List existingTeams, + Map> requestedMembersByTeamNumber + ) { + Map existingTeamsByTeamNumber = existingTeams.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<>(existingTeams)) { + if (!requestedMembersByTeamNumber.containsKey(team.getTeamNumber())) { + removeTeam(team); + } + } + } } 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 7e6a45fa..c4db8298 100644 --- a/src/main/java/checkmo/clubMeeting/internal/service/command/ClubMeetingCommandService.java +++ b/src/main/java/checkmo/clubMeeting/internal/service/command/ClubMeetingCommandService.java @@ -110,38 +110,11 @@ 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.replaceMembers(clubMemberIds); - } + meeting.reconfigureTeams(existingTeams, requestTeamNumberToClubMemberIds); meetingRepository.save(meeting); } @@ -162,13 +135,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/test/java/checkmo/clubMeeting/internal/entity/MeetingTeamPersistenceTest.java b/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTeamPersistenceTest.java new file mode 100644 index 00000000..cdee2f13 --- /dev/null +++ b/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTeamPersistenceTest.java @@ -0,0 +1,126 @@ +package checkmo.clubMeeting.internal.entity; + +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(); + List existingTeams = teamRepository.findAllByMeetingIdOrderByTeamNumberAsc(meetingId); + Map> requestedMembersByTeamNumber = new LinkedHashMap<>(); + requestedMembersByTeamNumber.put(2, List.of(21L, 22L)); + requestedMembersByTeamNumber.put(3, List.of(31L)); + + reloadedMeeting.reconfigureTeams(existingTeams, 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); + } + + private Team addTeam(Meeting meeting, int teamNumber, Long... clubMemberIds) { + Team team = Team.builder().teamNumber(teamNumber).build(); + meeting.addTeam(team); + team.replaceMembers(List.of(clubMemberIds)); + return team; + } +} 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..6d133294 --- /dev/null +++ b/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTest.java @@ -0,0 +1,56 @@ +package checkmo.clubMeeting.internal.entity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +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 = team(meeting, 1, 10L); + Team retained = team(meeting, 2, 20L); + Map> requestedMembersByTeamNumber = new LinkedHashMap<>(); + requestedMembersByTeamNumber.put(2, List.of(21L, 22L)); + requestedMembersByTeamNumber.put(3, List.of(31L)); + + meeting.reconfigureTeams(List.of(removed, retained), 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)); + } + + private Team team(Meeting meeting, int teamNumber, Long... clubMemberIds) { + Team team = Team.builder().teamNumber(teamNumber).build(); + meeting.addTeam(team); + team.replaceMembers(List.of(clubMemberIds)); + return team; + } + + private Team findTeam(Meeting meeting, int teamNumber) { + return meeting.getTeams().stream() + .filter(team -> team.getTeamNumber() == teamNumber) + .findFirst() + .orElseThrow(); + } +} From 41d64233cc93ee53b5333fec176ffb264aa31f00 Mon Sep 17 00:00:00 2001 From: MODUGGAGI Date: Sun, 12 Jul 2026 18:10:28 +0900 Subject: [PATCH 04/11] =?UTF-8?q?refactor(club-meeting):=20Meeting=20?= =?UTF-8?q?=ED=8C=80=20=ED=8E=B8=EC=84=B1=20=EC=BA=A1=EC=8A=90=ED=99=94?= =?UTF-8?q?=EB=A5=BC=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 팀 편성 컬렉션과 연관관계 조작을 aggregate 내부로 숨기고, command service가 별도 Team 조회 없이 Meeting의 organizeTeams만 호출하도록 정리한다. Constraint: 기존 HTTP 계약과 orphanRemoval 영속화 결과를 유지 Rejected: TeamRepository로 기존 팀을 별도 조회 | Meeting 컬렉션과 중복 조회 및 책임 누출 Confidence: high Scope-risk: moderate Directive: 팀 편성 변경은 Meeting.organizeTeams를 통해 수행 Tested: 집중 테스트, clubMeeting 모듈 테스트, Modulith 검증, 실제 팀 편성 API 흐름 Not-tested: MySQL 실행 계획 --- .../clubMeeting/internal/entity/Meeting.java | 54 +++++++------- .../command/ClubMeetingCommandService.java | 6 +- .../entity/MeetingTeamPersistenceTest.java | 10 +-- .../entity/MeetingTeamTestFixture.java | 28 ++++++++ .../internal/entity/MeetingTest.java | 25 +++---- .../ClubMeetingCommandServiceTest.java | 70 ++++++------------- 6 files changed, 92 insertions(+), 101 deletions(-) create mode 100644 src/test/java/checkmo/clubMeeting/internal/entity/MeetingTeamTestFixture.java diff --git a/src/main/java/checkmo/clubMeeting/internal/entity/Meeting.java b/src/main/java/checkmo/clubMeeting/internal/entity/Meeting.java index b48ce685..afab9314 100644 --- a/src/main/java/checkmo/clubMeeting/internal/entity/Meeting.java +++ b/src/main/java/checkmo/clubMeeting/internal/entity/Meeting.java @@ -57,14 +57,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<>(); @@ -105,32 +108,8 @@ public LocalDateTime getChatDeadline() { return this.getMeetingTime().plusDays(CHAT_AVAILABLE_DAYS_AFTER_MEETING); } - // ========= 연관관계 메서드 ========= - public void addTeam(Team team) { - if (team == null) { - return; - } - team.setMeeting(this); - } - - public void removeAllTeams() { - for (Team team : new ArrayList<>(this.teams)) { - removeTeam(team); - } - } - - public void removeTeam(Team team) { - if (team == null) { - return; - } - team.removeMeeting(); - } - - public void reconfigureTeams( - List existingTeams, - Map> requestedMembersByTeamNumber - ) { - Map existingTeamsByTeamNumber = existingTeams.stream() + public void organizeTeams(Map> requestedMembersByTeamNumber) { + Map existingTeamsByTeamNumber = this.teams.stream() .collect(Collectors.toMap(Team::getTeamNumber, Function.identity())); for (Map.Entry> entry : requestedMembersByTeamNumber.entrySet()) { @@ -144,10 +123,31 @@ public void reconfigureTeams( team.replaceMembers(entry.getValue()); } - for (Team team : new ArrayList<>(existingTeams)) { + for (Team team : new ArrayList<>(this.teams)) { if (!requestedMembersByTeamNumber.containsKey(team.getTeamNumber())) { removeTeam(team); } } } + + // ========= 연관관계 메서드 ========= + private void addTeam(Team team) { + if (team == null) { + return; + } + team.setMeeting(this); + } + + public void removeAllTeams() { + for (Team team : new ArrayList<>(this.teams)) { + removeTeam(team); + } + } + + private void removeTeam(Team team) { + if (team == null) { + return; + } + team.removeMeeting(); + } } 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 c4db8298..3537047e 100644 --- a/src/main/java/checkmo/clubMeeting/internal/service/command/ClubMeetingCommandService.java +++ b/src/main/java/checkmo/clubMeeting/internal/service/command/ClubMeetingCommandService.java @@ -6,9 +6,7 @@ import checkmo.clubMeeting.ClubMeetingEvent.ClubMeetingDeleted; import checkmo.clubMeeting.internal.converter.ClubMeetingConverter; 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; @@ -37,7 +35,6 @@ public class ClubMeetingCommandService { private final ClubMeetingQueryService clubMeetingQueryService; private final MeetingRepository meetingRepository; - private final TeamRepository teamRepository; private final ApplicationEventPublisher applicationEventPublisher; @@ -113,8 +110,7 @@ public void manageTeam(Long clubId, Long meetingId, Long memberId, MeetingReques // 요청 clubMemberIds 배치 검증 validateRequestClubMembers(clubId, requestTeamNumberToClubMemberIds); - List existingTeams = teamRepository.findAllByMeetingIdOrderByTeamNumberAsc(meeting.getId()); - meeting.reconfigureTeams(existingTeams, requestTeamNumberToClubMemberIds); + meeting.organizeTeams(requestTeamNumberToClubMemberIds); meetingRepository.save(meeting); } diff --git a/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTeamPersistenceTest.java b/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTeamPersistenceTest.java index cdee2f13..56001658 100644 --- a/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTeamPersistenceTest.java +++ b/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTeamPersistenceTest.java @@ -1,5 +1,6 @@ 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; @@ -87,12 +88,11 @@ class MeetingTeamPersistenceTest { entityManager.clear(); Meeting reloadedMeeting = meetingRepository.findById(meetingId).orElseThrow(); - List existingTeams = teamRepository.findAllByMeetingIdOrderByTeamNumberAsc(meetingId); Map> requestedMembersByTeamNumber = new LinkedHashMap<>(); requestedMembersByTeamNumber.put(2, List.of(21L, 22L)); requestedMembersByTeamNumber.put(3, List.of(31L)); - reloadedMeeting.reconfigureTeams(existingTeams, requestedMembersByTeamNumber); + reloadedMeeting.organizeTeams(requestedMembersByTeamNumber); meetingRepository.save(reloadedMeeting); entityManager.flush(); entityManager.clear(); @@ -117,10 +117,4 @@ class MeetingTeamPersistenceTest { .containsExactly(2, 3); } - private Team addTeam(Meeting meeting, int teamNumber, Long... clubMemberIds) { - Team team = Team.builder().teamNumber(teamNumber).build(); - meeting.addTeam(team); - team.replaceMembers(List.of(clubMemberIds)); - return team; - } } 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 index 6d133294..77d1ba79 100644 --- a/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTest.java +++ b/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTest.java @@ -1,8 +1,11 @@ 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.SoftAssertions.assertSoftly; +import java.lang.reflect.Method; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -13,13 +16,13 @@ class MeetingTest { @Test void 요청한_팀번호에_맞춰_기존_팀을_유지하고_새_팀을_추가하며_빠진_팀을_제거한다() { Meeting meeting = Meeting.builder().clubId(1L).bookId("book").build(); - Team removed = team(meeting, 1, 10L); - Team retained = team(meeting, 2, 20L); + 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.reconfigureTeams(List.of(removed, retained), requestedMembersByTeamNumber); + meeting.organizeTeams(requestedMembersByTeamNumber); Team created = findTeam(meeting, 3); assertSoftly(softly -> { @@ -40,17 +43,11 @@ class MeetingTest { .allSatisfy(member -> assertThat(member.getTeam()).isSameAs(created)); } - private Team team(Meeting meeting, int teamNumber, Long... clubMemberIds) { - Team team = Team.builder().teamNumber(teamNumber).build(); - meeting.addTeam(team); - team.replaceMembers(List.of(clubMemberIds)); - return team; + @Test + void 연관관계_내부_조작과_컬렉션을_public_API로_노출하지_않는다() { + assertThat(Meeting.class.getMethods()) + .extracting(Method::getName) + .doesNotContain("addTeam", "removeTeam", "getTeams", "getTopics", "getBookReviews"); } - private 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/service/command/ClubMeetingCommandServiceTest.java b/src/test/java/checkmo/clubMeeting/internal/service/command/ClubMeetingCommandServiceTest.java index e2a78b8b..078ec8c1 100644 --- a/src/test/java/checkmo/clubMeeting/internal/service/command/ClubMeetingCommandServiceTest.java +++ b/src/test/java/checkmo/clubMeeting/internal/service/command/ClubMeetingCommandServiceTest.java @@ -1,5 +1,8 @@ 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; @@ -7,7 +10,6 @@ import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import checkmo.book.BookAPI; @@ -18,7 +20,6 @@ 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.meeting.MeetingRequestDTO.TeamManage; import checkmo.clubMeeting.web.dto.meeting.MeetingRequestDTO.TeamMember; @@ -52,8 +53,6 @@ class ClubMeetingCommandServiceTest { @Mock private MeetingRepository meetingRepository; @Mock - private TeamRepository teamRepository; - @Mock private ApplicationEventPublisher applicationEventPublisher; @InjectMocks private ClubMeetingCommandService service; @@ -68,60 +67,55 @@ void setUp() { @Test void 팀원_목록이_null이면_모든_팀을_제거하고_모임을_한_번_저장한다() { - Team first = team(1, 10L); - Team second = team(2, 20L); + 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(meeting.getTeams()).isEmpty(); + softly.assertThat(teamsOf(meeting)).isEmpty(); softly.assertThat(first.getMeeting()).isNull(); softly.assertThat(second.getMeeting()).isNull(); }); verify(meetingRepository).save(meeting); - verifyNoInteractions(teamRepository); } @Test void 팀원_목록이_비어있으면_모든_팀을_제거하고_모임을_한_번_저장한다() { - Team first = team(1, 10L); - Team second = team(2, 20L); + 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(meeting.getTeams()).isEmpty(); + softly.assertThat(teamsOf(meeting)).isEmpty(); softly.assertThat(first.getMeeting()).isNull(); softly.assertThat(second.getMeeting()).isNull(); }); verify(meetingRepository).save(meeting); - verifyNoInteractions(teamRepository); } @Test void 요청에_남은_팀번호는_기존_Team_인스턴스를_재사용한다() { - Team retained = team(1, 10L); - when(teamRepository.findAllByMeetingIdOrderByTeamNumberAsc(MEETING_ID)).thenReturn(List.of(retained)); + Team retained = addTeam(meeting, 1, 10L); service.manageTeam(CLUB_ID, MEETING_ID, ACTOR_ID, request(List.of(member(1, 11L)))); - assertThat(meeting.getTeams()).singleElement().isSameAs(retained); + assertThat(teamsOf(meeting)).singleElement().isSameAs(retained); } @Test void 요청한_새_팀은_추가하고_생략한_기존_팀은_제거한다() { - Team removed = team(1, 10L); - Team retained = team(2, 20L); - when(teamRepository.findAllByMeetingIdOrderByTeamNumberAsc(MEETING_ID)) - .thenReturn(List.of(removed, retained)); + 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(3); + Team created = findTeam(meeting, 3); assertSoftly(softly -> { - softly.assertThat(meeting.getTeams()).containsExactlyInAnyOrder(retained, created); - softly.assertThat(findTeam(2)).isSameAs(retained); + 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(); }); @@ -129,8 +123,7 @@ void setUp() { @Test void 기존_팀원은_요청한_distinct_ID의_자식으로_교체하고_역방향_연관을_연결한다() { - Team retained = team(1, 10L, 20L); - when(teamRepository.findAllByMeetingIdOrderByTeamNumberAsc(MEETING_ID)).thenReturn(List.of(retained)); + Team retained = addTeam(meeting, 1, 10L, 20L); service.manageTeam(CLUB_ID, MEETING_ID, ACTOR_ID, request(List.of(member(1, 30L, 30L, null, 40L)))); @@ -143,18 +136,16 @@ void setUp() { @Test void active_회원_배치검증은_정확한_요청_ID_set으로_aggregate_변경_전에_실행한다() { - Team retained = team(1, 10L); - Team omitted = team(2, 20L); + Team retained = addTeam(meeting, 1, 10L); + Team omitted = addTeam(meeting, 2, 20L); doAnswer(invocation -> { assertThat(invocation.>getArgument(1)).containsExactlyInAnyOrder(30L, 40L); - assertThat(meeting.getTeams()).containsExactly(retained, omitted); + 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)); - when(teamRepository.findAllByMeetingIdOrderByTeamNumberAsc(MEETING_ID)) - .thenReturn(List.of(retained, omitted)); service.manageTeam(CLUB_ID, MEETING_ID, ACTOR_ID, request(List.of(member(1, 30L, 30L), member(3, 40L)))); @@ -169,8 +160,8 @@ void setUp() { void 요청_회원_검증이_실패하면_aggregate를_변경하지_않고_저장하지_않는다( ClubManagementErrorStatus errorStatus ) { - Team retained = team(1, 10L); - Team omitted = team(2, 20L); + 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)); @@ -179,7 +170,7 @@ void setUp() { .isSameAs(failure); assertSoftly(softly -> { - softly.assertThat(meeting.getTeams()).containsExactly(retained, omitted); + softly.assertThat(teamsOf(meeting)).containsExactly(retained, omitted); softly.assertThat(retained.getClubMemberTeams()) .extracting(ClubMemberTeam::getClubMemberId) .containsExactly(10L); @@ -188,21 +179,6 @@ void setUp() { .containsExactly(20L); }); verify(meetingRepository, never()).save(meeting); - verifyNoInteractions(teamRepository); - } - - private Team team(int teamNumber, Long... clubMemberIds) { - Team team = Team.builder().teamNumber(teamNumber).build(); - meeting.addTeam(team); - team.replaceMembers(Arrays.asList(clubMemberIds)); - return team; - } - - private Team findTeam(int teamNumber) { - return meeting.getTeams().stream() - .filter(team -> team.getTeamNumber() == teamNumber) - .findFirst() - .orElseThrow(); } private TeamManage request(List members) { From bf4804da54750fd13eb89e4ec48a93d146cc4c11 Mon Sep 17 00:00:00 2001 From: MODUGGAGI Date: Sun, 12 Jul 2026 18:10:36 +0900 Subject: [PATCH 05/11] =?UTF-8?q?perf(club-meeting):=20=ED=8C=80=20?= =?UTF-8?q?=EC=9E=90=EC=8B=9D=20=EC=BB=AC=EB=A0=89=EC=85=98=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A5=BC=20=EB=B0=B0=EC=B9=98=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 팀 최대 개수인 12에 맞춰 Team의 팀원과 발제 선택 컬렉션을 같은 영속성 컨텍스트에서 묶어 조회한다. Constraint: 팀 번호는 1부터 12까지로 제한 Rejected: Team마다 자식 컬렉션을 개별 지연 조회 | 최대 1+N 쿼리 발생 Confidence: high Scope-risk: narrow Directive: 팀 최대 개수 정책 변경 시 BatchSize 값도 함께 검토 Tested: RED 2건, 집중 테스트, clubMeeting 모듈 테스트, Modulith 검증, 실제 API 흐름, Hibernate IN(12) SQL Not-tested: MySQL 실행 계획 --- .../checkmo/clubMeeting/internal/entity/Team.java | 3 +++ .../clubMeeting/internal/entity/TeamTest.java | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/main/java/checkmo/clubMeeting/internal/entity/Team.java b/src/main/java/checkmo/clubMeeting/internal/entity/Team.java index bbb3370e..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<>(); diff --git a/src/test/java/checkmo/clubMeeting/internal/entity/TeamTest.java b/src/test/java/checkmo/clubMeeting/internal/entity/TeamTest.java index 85667063..a688e07f 100644 --- a/src/test/java/checkmo/clubMeeting/internal/entity/TeamTest.java +++ b/src/test/java/checkmo/clubMeeting/internal/entity/TeamTest.java @@ -3,10 +3,22 @@ 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(); From adcf9f6a0bbe5c2099a5dad00a123a3d872837d3 Mon Sep 17 00:00:00 2001 From: MODUGGAGI Date: Sun, 12 Jul 2026 18:33:52 +0900 Subject: [PATCH 06/11] =?UTF-8?q?test(club-meeting):=20=ED=95=9C=EC=A4=84?= =?UTF-8?q?=ED=8F=89=EA=B3=BC=20=EB=B3=84=EC=A0=90=20=ED=95=A9=EA=B3=84=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=EC=9D=84=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../internal/entity/MeetingTest.java | 13 ++ .../ClubBookReviewCommandServiceTest.java | 214 ++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 src/test/java/checkmo/clubMeeting/internal/service/command/ClubBookReviewCommandServiceTest.java diff --git a/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTest.java b/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTest.java index 77d1ba79..221fd57b 100644 --- a/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTest.java +++ b/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTest.java @@ -50,4 +50,17 @@ class MeetingTest { .doesNotContain("addTeam", "removeTeam", "getTeams", "getTopics", "getBookReviews"); } + @Test + void 차감할_별점보다_합계가_작으면_현재_한줄평_합계를_다시_계산한_뒤_차감한다() { + Meeting meeting = Meeting.builder().clubId(1L).bookId("book").sumRate(1.0).build(); + BookReview.builder().id(1L).description("첫 번째").rate(3.0).clubMemberId(1L).memberId(1L).build() + .setMeeting(meeting); + BookReview.builder().id(2L).description("두 번째").rate(4.0).clubMemberId(2L).memberId(2L).build() + .setMeeting(meeting); + + meeting.subtractSumRate(3.0); + + assertThat(meeting.getSumRate()).isEqualTo(4.0); + } + } 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..9cb232f9 --- /dev/null +++ b/src/test/java/checkmo/clubMeeting/internal/service/command/ClubBookReviewCommandServiceTest.java @@ -0,0 +1,214 @@ +package checkmo.clubMeeting.internal.service.command; + +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 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.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.Mock; +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(4.0); + BookReview review = review("이전", 4.0, CLUB_MEMBER_ID); + review.setMeeting(meeting); + 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); + }); + } + + @Test + void 별점_수정은_review를_먼저_변경한_뒤_기존_별점을_차감하고_새_별점을_더한다() { + meeting = meetingWithSumRate(1.0); + BookReview review = review("이전", 4.0, CLUB_MEMBER_ID); + review.setMeeting(meeting); + review("다른 한줄평", 6.0, 99L).setMeeting(meeting); + 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(6.0); + }); + } + + @Test + void 한줄평_삭제는_별점을_먼저_차감한_뒤_모임_연관을_해제한다() { + meeting = meetingWithSumRate(1.0); + BookReview review = review("삭제 대상", 4.0, CLUB_MEMBER_ID); + review.setMeeting(meeting); + review("남는 한줄평", 6.0, 99L).setMeeting(meeting); + allowOwner(review); + + service.deleteBookReview(CLUB_ID, MEETING_ID, REVIEW_ID, MEMBER_ID); + + assertSoftly(softly -> { + softly.assertThat(review.getMeeting()).isNull(); + softly.assertThat(meeting.getSumRate()).isEqualTo(6.0); + softly.assertThat(meeting.calculateAverageRate()).isEqualTo(6.0); + }); + } + + @Test + void inactive_회원은_모임과_한줄평을_조회하기_전에_실패하고_상태를_바꾸지_않는다() { + meeting = meetingWithSumRate(4.0); + BookReview review = review("기존", 4.0, CLUB_MEMBER_ID); + review.setMeeting(meeting); + when(clubManagementAPI.fetchMembershipInfo(CLUB_ID, MEMBER_ID)) + .thenReturn(membership(CLUB_MEMBER_ID, false, false)); + + 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); + }); + verifyNoInteractions(clubMeetingQueryService, clubBookReviewQueryService); + } + + @Test + void 작성자도_운영진도_아니면_대상을_조회한_뒤_권한_오류로_실패하고_상태를_유지한다() { + meeting = meetingWithSumRate(4.0); + BookReview review = review("기존", 4.0, CLUB_MEMBER_ID); + review.setMeeting(meeting); + when(clubManagementAPI.fetchMembershipInfo(CLUB_ID, MEMBER_ID)) + .thenReturn(membership(99L, true, false)); + when(clubMeetingQueryService.validateMeeting(CLUB_ID, MEETING_ID)).thenReturn(meeting); + when(clubBookReviewQueryService.validateBookReview(REVIEW_ID, MEETING_ID)).thenReturn(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.getMeeting()).isSameAs(meeting); + softly.assertThat(meeting.getSumRate()).isEqualTo(4.0); + }); + verify(clubBookReviewQueryService).validateBookReview(REVIEW_ID, MEETING_ID); + } + + private void allowOwner(BookReview review) { + when(clubManagementAPI.fetchMembershipInfo(CLUB_ID, MEMBER_ID)) + .thenReturn(membership(CLUB_MEMBER_ID, true, false)); + when(clubMeetingQueryService.validateMeeting(CLUB_ID, MEETING_ID)).thenReturn(meeting); + when(clubBookReviewQueryService.validateBookReview(REVIEW_ID, MEETING_ID)).thenReturn(review); + } + + 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 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; + } +} From 2af627e1b154377e19b9987967056da3d6d86584 Mon Sep 17 00:00:00 2001 From: MODUGGAGI Date: Sun, 12 Jul 2026 19:00:02 +0900 Subject: [PATCH 07/11] =?UTF-8?q?refactor(club-meeting):=20=ED=95=9C?= =?UTF-8?q?=EC=A4=84=ED=8F=89=EA=B3=BC=20=EB=B3=84=EC=A0=90=20=ED=95=A9?= =?UTF-8?q?=EA=B3=84=20=EC=B1=85=EC=9E=84=EC=9D=84=20Meeting=EC=97=90=20?= =?UTF-8?q?=EC=9C=84=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../clubMeeting/internal/entity/Meeting.java | 20 +++++++ .../command/ClubBookReviewCommandService.java | 20 ++----- .../internal/entity/MeetingTest.java | 56 +++++++++++++++++++ 3 files changed, 80 insertions(+), 16 deletions(-) diff --git a/src/main/java/checkmo/clubMeeting/internal/entity/Meeting.java b/src/main/java/checkmo/clubMeeting/internal/entity/Meeting.java index afab9314..c5ed6b28 100644 --- a/src/main/java/checkmo/clubMeeting/internal/entity/Meeting.java +++ b/src/main/java/checkmo/clubMeeting/internal/entity/Meeting.java @@ -87,6 +87,26 @@ public void updateMeeting( this.tag = tag; } + public void addBookReview(BookReview review) { + review.setMeeting(this); + addSumRate(review.getRate()); + } + + public void reviseBookReview(BookReview review, String description, double newRate) { + double oldRate = review.getRate(); + review.updateBookReview(description, newRate); + + if (oldRate != newRate) { + subtractSumRate(oldRate); + addSumRate(newRate); + } + } + + public void removeBookReview(BookReview review) { + subtractSumRate(review.getRate()); + review.removeMeeting(); + } + public void addSumRate(double rate) { this.sumRate += rate; } 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..93119322 100644 --- a/src/main/java/checkmo/clubMeeting/internal/service/command/ClubBookReviewCommandService.java +++ b/src/main/java/checkmo/clubMeeting/internal/service/command/ClubBookReviewCommandService.java @@ -43,9 +43,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); } @@ -68,19 +66,11 @@ public void updateBookReview(Long clubId, Long meetingId, Long reviewId, Long me throw new ClubMeetingException(ClubMeetingErrorStatus.BOOK_REVIEW_FORBIDDEN); } - double oldRate = bookReview.getRate(); - double newRate = request.getRate(); - - bookReview.updateBookReview( + meeting.reviseBookReview( + bookReview, request.getDescription(), request.getRate() ); - - // 별점이 변경된 경우에만 미팅의 별점 합산 - if (oldRate != newRate) { - meeting.subtractSumRate(oldRate); - meeting.addSumRate(newRate); - } } @Retryable( @@ -101,9 +91,7 @@ public void deleteBookReview(Long clubId, Long meetingId, Long reviewId, Long me throw new ClubMeetingException(ClubMeetingErrorStatus.BOOK_REVIEW_FORBIDDEN); } - meeting.subtractSumRate(bookReview.getRate()); - - bookReview.removeMeeting(); + meeting.removeBookReview(bookReview); } } diff --git a/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTest.java b/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTest.java index 221fd57b..50b55a73 100644 --- a/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTest.java +++ b/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTest.java @@ -63,4 +63,60 @@ class MeetingTest { 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(1.0).build(); + BookReview review = bookReview(1L, 4.0); + review.setMeeting(meeting); + bookReview(2L, 6.0).setMeeting(meeting); + + meeting.reviseBookReview(review, "수정", 2.0); + + assertSoftly(softly -> { + softly.assertThat(review.getDescription()).isEqualTo("수정"); + softly.assertThat(review.getRate()).isEqualTo(2.0); + softly.assertThat(meeting.getSumRate()).isEqualTo(6.0); + }); + } + + @Test + void 한줄평을_삭제하면_별점을_먼저_차감한_뒤_모임_연관을_해제한다() { + Meeting meeting = Meeting.builder().clubId(1L).bookId("book").sumRate(1.0).build(); + BookReview review = bookReview(1L, 4.0); + review.setMeeting(meeting); + bookReview(2L, 6.0).setMeeting(meeting); + + meeting.removeBookReview(review); + + assertSoftly(softly -> { + softly.assertThat(review.getMeeting()).isNull(); + softly.assertThat(meeting.getSumRate()).isEqualTo(6.0); + softly.assertThat(meeting.calculateAverageRate()).isEqualTo(6.0); + }); + } + + private BookReview bookReview(Long id, double rate) { + return BookReview.builder() + .id(id) + .description("한줄평") + .rate(rate) + .clubMemberId(id) + .memberId(id) + .build(); + } + } From d91b2981bcbf09b1721f68f553d8f4ea20d1736e Mon Sep 17 00:00:00 2001 From: MODUGGAGI Date: Sun, 12 Jul 2026 19:22:40 +0900 Subject: [PATCH 08/11] =?UTF-8?q?test(club-meeting):=20=EC=BD=98=ED=85=90?= =?UTF-8?q?=EC=B8=A0=20=EA=B6=8C=ED=95=9C=EA=B3=BC=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=9A=B0=EC=84=A0=EC=88=9C=EC=9C=84=EB=A5=BC=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ClubBookReviewCommandServiceTest.java | 203 +++++++++++++- .../command/ClubTopicCommandServiceTest.java | 265 ++++++++++++++++++ 2 files changed, 453 insertions(+), 15 deletions(-) create mode 100644 src/test/java/checkmo/clubMeeting/internal/service/command/ClubTopicCommandServiceTest.java diff --git a/src/test/java/checkmo/clubMeeting/internal/service/command/ClubBookReviewCommandServiceTest.java b/src/test/java/checkmo/clubMeeting/internal/service/command/ClubBookReviewCommandServiceTest.java index 9cb232f9..be1ada18 100644 --- a/src/test/java/checkmo/clubMeeting/internal/service/command/ClubBookReviewCommandServiceTest.java +++ b/src/test/java/checkmo/clubMeeting/internal/service/command/ClubBookReviewCommandServiceTest.java @@ -1,6 +1,5 @@ package checkmo.clubMeeting.internal.service.command; -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 static org.mockito.Mockito.verify; @@ -22,7 +21,9 @@ 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; @@ -78,9 +79,9 @@ void setUp() { @Test void 같은_별점으로_수정하면_내용만_바꾸고_별점_합계는_유지한다() { meeting = meetingWithSumRate(4.0); - BookReview review = review("이전", 4.0, CLUB_MEMBER_ID); + BookReview review = Mockito.spy(review("이전", 4.0, CLUB_MEMBER_ID)); review.setMeeting(meeting); - allowOwner(review); + MembershipInfo ownerMembership = allowOwner(review); service.updateBookReview(CLUB_ID, MEETING_ID, REVIEW_ID, MEMBER_ID, request("수정", 4.0)); @@ -88,16 +89,19 @@ void setUp() { 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); }); + verifyAuthorizedValidationOrder(ownerMembership, review, false); + verifyNoInteractions(bookReviewRepository); } @Test void 별점_수정은_review를_먼저_변경한_뒤_기존_별점을_차감하고_새_별점을_더한다() { meeting = meetingWithSumRate(1.0); - BookReview review = review("이전", 4.0, CLUB_MEMBER_ID); + BookReview review = Mockito.spy(review("이전", 4.0, CLUB_MEMBER_ID)); review.setMeeting(meeting); review("다른 한줄평", 6.0, 99L).setMeeting(meeting); - allowOwner(review); + MembershipInfo ownerMembership = allowOwner(review); service.updateBookReview(CLUB_ID, MEETING_ID, REVIEW_ID, MEMBER_ID, request("수정", 2.0)); @@ -105,24 +109,69 @@ void setUp() { softly.assertThat(review.getDescription()).isEqualTo("수정"); softly.assertThat(review.getRate()).isEqualTo(2.0); softly.assertThat(meeting.getSumRate()).isEqualTo(6.0); + softly.assertThat(review.getMeeting()).isSameAs(meeting); + }); + verifyAuthorizedValidationOrder(ownerMembership, review, false); + verifyNoInteractions(bookReviewRepository); + } + + @Test + void 운영진은_다른_회원의_한줄평을_수정할_수_있다() { + meeting = meetingWithSumRate(4.0); + BookReview review = Mockito.spy(review("기존", 4.0, 99L)); + review.setMeeting(meeting); + 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); }); + verifyAuthorizedValidationOrder(staffMembership, review, true); + verifyNoInteractions(bookReviewRepository); } @Test void 한줄평_삭제는_별점을_먼저_차감한_뒤_모임_연관을_해제한다() { meeting = meetingWithSumRate(1.0); - BookReview review = review("삭제 대상", 4.0, CLUB_MEMBER_ID); + BookReview review = Mockito.spy(review("삭제 대상", 4.0, CLUB_MEMBER_ID)); review.setMeeting(meeting); review("남는 한줄평", 6.0, 99L).setMeeting(meeting); - allowOwner(review); + 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); }); + verifyAuthorizedValidationOrder(ownerMembership, review, false); + verifyNoInteractions(bookReviewRepository); + } + + @Test + void 운영진은_다른_회원의_한줄평을_삭제할_수_있다() { + meeting = meetingWithSumRate(4.0); + BookReview review = Mockito.spy(review("삭제 대상", 4.0, 99L)); + review.setMeeting(meeting); + 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(); + }); + verifyAuthorizedValidationOrder(staffMembership, review, true); + verifyNoInteractions(bookReviewRepository); } @Test @@ -130,8 +179,9 @@ void setUp() { meeting = meetingWithSumRate(4.0); BookReview review = review("기존", 4.0, CLUB_MEMBER_ID); review.setMeeting(meeting); + MembershipInfo inactiveMembership = Mockito.spy(membership(CLUB_MEMBER_ID, false, false)); when(clubManagementAPI.fetchMembershipInfo(CLUB_ID, MEMBER_ID)) - .thenReturn(membership(CLUB_MEMBER_ID, false, false)); + .thenReturn(inactiveMembership); ClubMeetingException thrown = catchThrowableOfType( ClubMeetingException.class, @@ -143,19 +193,68 @@ void setUp() { 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 작성자도_운영진도_아니면_대상을_조회한_뒤_권한_오류로_실패하고_상태를_유지한다() { + void inactive_회원은_한줄평_삭제_시_모임과_한줄평을_조회하기_전에_실패한다() { meeting = meetingWithSumRate(4.0); BookReview review = review("기존", 4.0, CLUB_MEMBER_ID); review.setMeeting(meeting); + MembershipInfo inactiveMembership = Mockito.spy(membership(CLUB_MEMBER_ID, false, false)); when(clubManagementAPI.fetchMembershipInfo(CLUB_ID, MEMBER_ID)) - .thenReturn(membership(99L, true, false)); - when(clubMeetingQueryService.validateMeeting(CLUB_ID, MEETING_ID)).thenReturn(meeting); - when(clubBookReviewQueryService.validateBookReview(REVIEW_ID, MEETING_ID)).thenReturn(review); + .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(4.0); + BookReview review = Mockito.spy(review("기존", 4.0, CLUB_MEMBER_ID)); + review.setMeeting(meeting); + 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); + }); + verifyForbiddenValidationOrder(ordinaryMembership, review); + verifyNoInteractions(bookReviewRepository); + } + + @Test + void 작성자도_운영진도_아니면_대상을_조회한_뒤_권한_오류로_실패하고_상태를_유지한다() { + meeting = meetingWithSumRate(4.0); + BookReview review = Mockito.spy(review("기존", 4.0, CLUB_MEMBER_ID)); + review.setMeeting(meeting); + MembershipInfo ordinaryMembership = denyOrdinaryMember(review); ClubMeetingException thrown = catchThrowableOfType( ClubMeetingException.class, @@ -164,17 +263,91 @@ void setUp() { 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); }); - verify(clubBookReviewQueryService).validateBookReview(REVIEW_ID, MEETING_ID); + verifyForbiddenValidationOrder(ordinaryMembership, review); + 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 void allowOwner(BookReview review) { + private MembershipInfo denyOrdinaryMember(BookReview review) { + MembershipInfo ordinaryMembership = Mockito.spy(membership(99L, true, false)); when(clubManagementAPI.fetchMembershipInfo(CLUB_ID, MEMBER_ID)) - .thenReturn(membership(CLUB_MEMBER_ID, true, false)); + .thenReturn(ordinaryMembership); when(clubMeetingQueryService.validateMeeting(CLUB_ID, MEETING_ID)).thenReturn(meeting); when(clubBookReviewQueryService.validateBookReview(REVIEW_ID, MEETING_ID)).thenReturn(review); + return ordinaryMembership; + } + + private void verifyAuthorizedValidationOrder( + MembershipInfo membership, + BookReview review, + boolean staffAuthorizationRequired + ) { + InOrder inOrder = Mockito.inOrder( + clubManagementAPI, + membership, + clubMeetingQueryService, + clubBookReviewQueryService, + review + ); + inOrder.verify(clubManagementAPI).validateClub(CLUB_ID); + inOrder.verify(clubManagementAPI).fetchMembershipInfo(CLUB_ID, MEMBER_ID); + inOrder.verify(membership).isActive(); + inOrder.verify(clubMeetingQueryService).validateMeeting(CLUB_ID, MEETING_ID); + inOrder.verify(clubBookReviewQueryService).validateBookReview(REVIEW_ID, MEETING_ID); + inOrder.verify(membership).getClubMemberId(); + inOrder.verify(review).isOwnedBy(CLUB_MEMBER_ID); + if (staffAuthorizationRequired) { + inOrder.verify(membership).isStaff(); + } else { + verify(membership, Mockito.never()).isStaff(); + } + } + + 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(); + } + + private void verifyForbiddenValidationOrder(MembershipInfo ordinaryMembership, BookReview review) { + InOrder inOrder = Mockito.inOrder( + clubManagementAPI, + ordinaryMembership, + clubMeetingQueryService, + clubBookReviewQueryService, + review + ); + inOrder.verify(clubManagementAPI).validateClub(CLUB_ID); + inOrder.verify(clubManagementAPI).fetchMembershipInfo(CLUB_ID, MEMBER_ID); + inOrder.verify(ordinaryMembership).isActive(); + inOrder.verify(clubMeetingQueryService).validateMeeting(CLUB_ID, MEETING_ID); + inOrder.verify(clubBookReviewQueryService).validateBookReview(REVIEW_ID, MEETING_ID); + inOrder.verify(ordinaryMembership).getClubMemberId(); + inOrder.verify(review).isOwnedBy(99L); + inOrder.verify(ordinaryMembership).isStaff(); } private MembershipInfo membership(Long clubMemberId, boolean active, boolean staff) { 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..d1c929da --- /dev/null +++ b/src/test/java/checkmo/clubMeeting/internal/service/command/ClubTopicCommandServiceTest.java @@ -0,0 +1,265 @@ +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.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.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); + verifyNoMutation(operation, topic); + 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 = verifyLookupAndOwnershipOrder(membership, topic, actorClubMemberId); + if (staffAuthorization) { + order.verify(membership).isStaff(); + } else { + verify(membership, never()).isStaff(); + } + verifyMutation(order, operation, topic); + } + + private void verifyForbiddenOrder(MembershipInfo membership, Topic topic, Long actorClubMemberId) { + InOrder order = verifyLookupAndOwnershipOrder(membership, topic, actorClubMemberId); + order.verify(membership).isStaff(); + } + + private InOrder verifyLookupAndOwnershipOrder( + MembershipInfo membership, + Topic topic, + Long actorClubMemberId + ) { + 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(clubMeetingQueryService).validateMeeting(CLUB_ID, MEETING_ID); + order.verify(clubTopicQueryService).validateTopic(TOPIC_ID, MEETING_ID); + order.verify(membership).getClubMemberId(); + order.verify(topic).isOwnedBy(actorClubMemberId); + return order; + } + + private void verifyMutation(InOrder order, Operation operation, Topic topic) { + if (operation == Operation.UPDATE) { + order.verify(topic).updateTopic(UPDATED_DESCRIPTION); + return; + } + order.verify(topic).removeMeeting(); + } + + private void verifyNoMutation(Operation operation, Topic topic) { + if (operation == Operation.UPDATE) { + verify(topic, never()).updateTopic(UPDATED_DESCRIPTION); + return; + } + verify(topic, never()).removeMeeting(); + } + + 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 + } +} From 1bcc3d6e3a1b755db4ab3b3d5e27a07249c77ca8 Mon Sep 17 00:00:00 2001 From: MODUGGAGI Date: Sun, 12 Jul 2026 23:54:13 +0900 Subject: [PATCH 09/11] =?UTF-8?q?refactor(club-meeting):=20=EC=BD=98?= =?UTF-8?q?=ED=85=90=EC=B8=A0=20=EA=B6=8C=ED=95=9C=EC=9D=84=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=AA=85=EB=A0=B9=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서비스의 작성자·운영진 판단을 clubMeeting 도메인 명령으로 이동하고 Long 식별자 전환 전의 발제 작성자 응답 의미를 복원한다. Constraint: API·DTO·Event·JPA·트랜잭션·retry·TeamTopic 계약 유지 Rejected: Converter에 clubMemberId 전달 | 기존 memberId 기반 작성자 의미를 바꿈 Confidence: high Scope-risk: moderate Directive: Stage 8에서 HTTP·AOP·DB reload 회귀를 확인할 것 Tested: 타깃 33/33 재실행, clubMeeting+Modulith 50/50 재실행, review-work 5/5 통과 Not-tested: 전체 저장소 테스트 및 실제 MySQL persistence Plan: .omo/plans/club-meeting-domain-refactor.md --- .../converter/ClubMeetingConverter.java | 4 +- .../internal/entity/BookReview.java | 6 +- .../internal/entity/ClubMeetingActor.java | 4 + .../clubMeeting/internal/entity/Meeting.java | 31 +++- .../clubMeeting/internal/entity/Topic.java | 26 ++-- .../command/ClubBookReviewCommandService.java | 22 +-- .../command/ClubTopicCommandService.java | 24 ++- .../converter/ClubMeetingConverterTest.java | 25 ++++ .../internal/entity/MeetingTest.java | 111 ++++++++++++-- .../internal/entity/TopicTest.java | 98 +++++++++++++ .../ClubBookReviewCommandServiceTest.java | 138 +++++++++++------- .../command/ClubTopicCommandServiceTest.java | 48 +++--- 12 files changed, 402 insertions(+), 135 deletions(-) create mode 100644 src/main/java/checkmo/clubMeeting/internal/entity/ClubMeetingActor.java create mode 100644 src/test/java/checkmo/clubMeeting/internal/converter/ClubMeetingConverterTest.java create mode 100644 src/test/java/checkmo/clubMeeting/internal/entity/TopicTest.java 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 c5ed6b28..21fff07a 100644 --- a/src/main/java/checkmo/clubMeeting/internal/entity/Meeting.java +++ b/src/main/java/checkmo/clubMeeting/internal/entity/Meeting.java @@ -1,5 +1,7 @@ package checkmo.clubMeeting.internal.entity; +import checkmo.clubMeeting.internal.exception.ClubMeetingErrorStatus; +import checkmo.clubMeeting.internal.exception.ClubMeetingException; import checkmo.common.BaseEntity; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; @@ -92,7 +94,28 @@ public void addBookReview(BookReview review) { addSumRate(review.getRate()); } - public void reviseBookReview(BookReview review, String description, double newRate) { + 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(); review.updateBookReview(description, newRate); @@ -102,16 +125,16 @@ public void reviseBookReview(BookReview review, String description, double newRa } } - public void removeBookReview(BookReview review) { + private void removeBookReview(BookReview review) { subtractSumRate(review.getRate()); review.removeMeeting(); } - public void addSumRate(double rate) { + 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) 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 93119322..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; @@ -59,14 +60,15 @@ 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); - } - - meeting.reviseBookReview( + meeting.reviseBookReviewBy( + actor, bookReview, request.getDescription(), request.getRate() @@ -84,14 +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.removeBookReview(bookReview); + meeting.removeBookReviewBy(actor, bookReview); } } 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/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/MeetingTest.java b/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTest.java index 50b55a73..08f44816 100644 --- a/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTest.java +++ b/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTest.java @@ -3,8 +3,11 @@ 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; @@ -47,18 +50,30 @@ class MeetingTest { void 연관관계_내부_조작과_컬렉션을_public_API로_노출하지_않는다() { assertThat(Meeting.class.getMethods()) .extracting(Method::getName) - .doesNotContain("addTeam", "removeTeam", "getTeams", "getTopics", "getBookReviews"); + .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(1.0).build(); - BookReview.builder().id(1L).description("첫 번째").rate(3.0).clubMemberId(1L).memberId(1L).build() - .setMeeting(meeting); - BookReview.builder().id(2L).description("두 번째").rate(4.0).clubMemberId(2L).memberId(2L).build() - .setMeeting(meeting); + 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.subtractSumRate(3.0); + meeting.removeBookReviewBy(new ClubMeetingActor(1L, false), removed); assertThat(meeting.getSumRate()).isEqualTo(4.0); } @@ -79,12 +94,12 @@ class MeetingTest { @Test void 한줄평_수정은_내용과_별점을_먼저_바꾼_뒤_기존_별점을_차감하고_새_별점을_더한다() { - Meeting meeting = Meeting.builder().clubId(1L).bookId("book").sumRate(1.0).build(); + Meeting meeting = Meeting.builder().clubId(1L).bookId("book").sumRate(-9.0).build(); BookReview review = bookReview(1L, 4.0); - review.setMeeting(meeting); - bookReview(2L, 6.0).setMeeting(meeting); + meeting.addBookReview(review); + meeting.addBookReview(bookReview(2L, 6.0)); - meeting.reviseBookReview(review, "수정", 2.0); + meeting.reviseBookReviewBy(new ClubMeetingActor(1L, false), review, "수정", 2.0); assertSoftly(softly -> { softly.assertThat(review.getDescription()).isEqualTo("수정"); @@ -95,12 +110,12 @@ class MeetingTest { @Test void 한줄평을_삭제하면_별점을_먼저_차감한_뒤_모임_연관을_해제한다() { - Meeting meeting = Meeting.builder().clubId(1L).bookId("book").sumRate(1.0).build(); + Meeting meeting = Meeting.builder().clubId(1L).bookId("book").sumRate(-9.0).build(); BookReview review = bookReview(1L, 4.0); - review.setMeeting(meeting); - bookReview(2L, 6.0).setMeeting(meeting); + meeting.addBookReview(review); + meeting.addBookReview(bookReview(2L, 6.0)); - meeting.removeBookReview(review); + meeting.removeBookReviewBy(new ClubMeetingActor(1L, false), review); assertSoftly(softly -> { softly.assertThat(review.getMeeting()).isNull(); @@ -109,6 +124,72 @@ class MeetingTest { }); } + @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) 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 index be1ada18..96e2bac2 100644 --- a/src/test/java/checkmo/clubMeeting/internal/service/command/ClubBookReviewCommandServiceTest.java +++ b/src/test/java/checkmo/clubMeeting/internal/service/command/ClubBookReviewCommandServiceTest.java @@ -9,6 +9,7 @@ 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; @@ -78,9 +79,9 @@ void setUp() { @Test void 같은_별점으로_수정하면_내용만_바꾸고_별점_합계는_유지한다() { - meeting = meetingWithSumRate(4.0); + meeting = meetingWithSumRate(0.0); BookReview review = Mockito.spy(review("이전", 4.0, CLUB_MEMBER_ID)); - review.setMeeting(meeting); + meeting.addBookReview(review); MembershipInfo ownerMembership = allowOwner(review); service.updateBookReview(CLUB_ID, MEETING_ID, REVIEW_ID, MEMBER_ID, request("수정", 4.0)); @@ -91,16 +92,22 @@ void setUp() { softly.assertThat(meeting.getSumRate()).isEqualTo(4.0); softly.assertThat(review.getMeeting()).isSameAs(meeting); }); - verifyAuthorizedValidationOrder(ownerMembership, review, false); + verifyUpdateOrder( + ownerMembership, + review, + new ClubMeetingActor(CLUB_MEMBER_ID, false), + "수정", + 4.0 + ); verifyNoInteractions(bookReviewRepository); } @Test void 별점_수정은_review를_먼저_변경한_뒤_기존_별점을_차감하고_새_별점을_더한다() { - meeting = meetingWithSumRate(1.0); + meeting = meetingWithSumRate(-9.0); BookReview review = Mockito.spy(review("이전", 4.0, CLUB_MEMBER_ID)); - review.setMeeting(meeting); - review("다른 한줄평", 6.0, 99L).setMeeting(meeting); + 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)); @@ -111,15 +118,21 @@ void setUp() { softly.assertThat(meeting.getSumRate()).isEqualTo(6.0); softly.assertThat(review.getMeeting()).isSameAs(meeting); }); - verifyAuthorizedValidationOrder(ownerMembership, review, false); + verifyUpdateOrder( + ownerMembership, + review, + new ClubMeetingActor(CLUB_MEMBER_ID, false), + "수정", + 2.0 + ); verifyNoInteractions(bookReviewRepository); } @Test void 운영진은_다른_회원의_한줄평을_수정할_수_있다() { - meeting = meetingWithSumRate(4.0); + meeting = meetingWithSumRate(0.0); BookReview review = Mockito.spy(review("기존", 4.0, 99L)); - review.setMeeting(meeting); + meeting.addBookReview(review); MembershipInfo staffMembership = allowStaff(review); service.updateBookReview(CLUB_ID, MEETING_ID, REVIEW_ID, MEMBER_ID, request("운영진 수정", 2.0)); @@ -130,16 +143,22 @@ void setUp() { softly.assertThat(meeting.getSumRate()).isEqualTo(2.0); softly.assertThat(review.getMeeting()).isSameAs(meeting); }); - verifyAuthorizedValidationOrder(staffMembership, review, true); + verifyUpdateOrder( + staffMembership, + review, + new ClubMeetingActor(CLUB_MEMBER_ID, true), + "운영진 수정", + 2.0 + ); verifyNoInteractions(bookReviewRepository); } @Test void 한줄평_삭제는_별점을_먼저_차감한_뒤_모임_연관을_해제한다() { - meeting = meetingWithSumRate(1.0); + meeting = meetingWithSumRate(-9.0); BookReview review = Mockito.spy(review("삭제 대상", 4.0, CLUB_MEMBER_ID)); - review.setMeeting(meeting); - review("남는 한줄평", 6.0, 99L).setMeeting(meeting); + meeting.addBookReview(review); + meeting.addBookReview(review("남는 한줄평", 6.0, 99L)); MembershipInfo ownerMembership = allowOwner(review); service.deleteBookReview(CLUB_ID, MEETING_ID, REVIEW_ID, MEMBER_ID); @@ -151,15 +170,15 @@ void setUp() { softly.assertThat(meeting.getSumRate()).isEqualTo(6.0); softly.assertThat(meeting.calculateAverageRate()).isEqualTo(6.0); }); - verifyAuthorizedValidationOrder(ownerMembership, review, false); + verifyDeleteOrder(ownerMembership, review, new ClubMeetingActor(CLUB_MEMBER_ID, false)); verifyNoInteractions(bookReviewRepository); } @Test void 운영진은_다른_회원의_한줄평을_삭제할_수_있다() { - meeting = meetingWithSumRate(4.0); + meeting = meetingWithSumRate(0.0); BookReview review = Mockito.spy(review("삭제 대상", 4.0, 99L)); - review.setMeeting(meeting); + meeting.addBookReview(review); MembershipInfo staffMembership = allowStaff(review); service.deleteBookReview(CLUB_ID, MEETING_ID, REVIEW_ID, MEMBER_ID); @@ -170,15 +189,15 @@ void setUp() { softly.assertThat(meeting.getSumRate()).isZero(); softly.assertThat(review.getMeeting()).isNull(); }); - verifyAuthorizedValidationOrder(staffMembership, review, true); + verifyDeleteOrder(staffMembership, review, new ClubMeetingActor(CLUB_MEMBER_ID, true)); verifyNoInteractions(bookReviewRepository); } @Test void inactive_회원은_모임과_한줄평을_조회하기_전에_실패하고_상태를_바꾸지_않는다() { - meeting = meetingWithSumRate(4.0); + meeting = meetingWithSumRate(0.0); BookReview review = review("기존", 4.0, CLUB_MEMBER_ID); - review.setMeeting(meeting); + meeting.addBookReview(review); MembershipInfo inactiveMembership = Mockito.spy(membership(CLUB_MEMBER_ID, false, false)); when(clubManagementAPI.fetchMembershipInfo(CLUB_ID, MEMBER_ID)) .thenReturn(inactiveMembership); @@ -202,9 +221,9 @@ void setUp() { @Test void inactive_회원은_한줄평_삭제_시_모임과_한줄평을_조회하기_전에_실패한다() { - meeting = meetingWithSumRate(4.0); + meeting = meetingWithSumRate(0.0); BookReview review = review("기존", 4.0, CLUB_MEMBER_ID); - review.setMeeting(meeting); + meeting.addBookReview(review); MembershipInfo inactiveMembership = Mockito.spy(membership(CLUB_MEMBER_ID, false, false)); when(clubManagementAPI.fetchMembershipInfo(CLUB_ID, MEMBER_ID)) .thenReturn(inactiveMembership); @@ -228,9 +247,9 @@ void setUp() { @Test void 작성자도_운영진도_아닌_활성_회원은_한줄평을_수정할_수_없다() { - meeting = meetingWithSumRate(4.0); + meeting = meetingWithSumRate(0.0); BookReview review = Mockito.spy(review("기존", 4.0, CLUB_MEMBER_ID)); - review.setMeeting(meeting); + meeting.addBookReview(review); MembershipInfo ordinaryMembership = denyOrdinaryMember(review); ClubMeetingException thrown = catchThrowableOfType( @@ -245,15 +264,21 @@ void setUp() { softly.assertThat(meeting.getSumRate()).isEqualTo(4.0); softly.assertThat(review.getMeeting()).isSameAs(meeting); }); - verifyForbiddenValidationOrder(ordinaryMembership, review); + verifyUpdateOrder( + ordinaryMembership, + review, + new ClubMeetingActor(99L, false), + "수정", + 2.0 + ); verifyNoInteractions(bookReviewRepository); } @Test void 작성자도_운영진도_아니면_대상을_조회한_뒤_권한_오류로_실패하고_상태를_유지한다() { - meeting = meetingWithSumRate(4.0); + meeting = meetingWithSumRate(0.0); BookReview review = Mockito.spy(review("기존", 4.0, CLUB_MEMBER_ID)); - review.setMeeting(meeting); + meeting.addBookReview(review); MembershipInfo ordinaryMembership = denyOrdinaryMember(review); ClubMeetingException thrown = catchThrowableOfType( @@ -268,7 +293,7 @@ void setUp() { softly.assertThat(review.getMeeting()).isSameAs(meeting); softly.assertThat(meeting.getSumRate()).isEqualTo(4.0); }); - verifyForbiddenValidationOrder(ordinaryMembership, review); + verifyDeleteOrder(ordinaryMembership, review, new ClubMeetingActor(99L, false)); verifyNoInteractions(bookReviewRepository); } @@ -299,30 +324,43 @@ private MembershipInfo denyOrdinaryMember(BookReview review) { return ordinaryMembership; } - private void verifyAuthorizedValidationOrder( + private void verifyUpdateOrder( MembershipInfo membership, BookReview review, - boolean staffAuthorizationRequired + 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, - review + 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); - inOrder.verify(membership).getClubMemberId(); - inOrder.verify(review).isOwnedBy(CLUB_MEMBER_ID); - if (staffAuthorizationRequired) { - inOrder.verify(membership).isStaff(); - } else { - verify(membership, Mockito.never()).isStaff(); - } + return inOrder; } private void verifyInactiveValidationOrder(MembershipInfo inactiveMembership) { @@ -330,24 +368,12 @@ private void verifyInactiveValidationOrder(MembershipInfo inactiveMembership) { inOrder.verify(clubManagementAPI).validateClub(CLUB_ID); inOrder.verify(clubManagementAPI).fetchMembershipInfo(CLUB_ID, MEMBER_ID); inOrder.verify(inactiveMembership).isActive(); - } - - private void verifyForbiddenValidationOrder(MembershipInfo ordinaryMembership, BookReview review) { - InOrder inOrder = Mockito.inOrder( - clubManagementAPI, - ordinaryMembership, - clubMeetingQueryService, - clubBookReviewQueryService, - review + verify(inactiveMembership, Mockito.never()).getClubMemberId(); + verify(inactiveMembership, Mockito.never()).isStaff(); + verify(meeting, Mockito.never()).reviseBookReviewBy( + Mockito.any(), Mockito.any(), Mockito.any(), Mockito.anyDouble() ); - inOrder.verify(clubManagementAPI).validateClub(CLUB_ID); - inOrder.verify(clubManagementAPI).fetchMembershipInfo(CLUB_ID, MEMBER_ID); - inOrder.verify(ordinaryMembership).isActive(); - inOrder.verify(clubMeetingQueryService).validateMeeting(CLUB_ID, MEETING_ID); - inOrder.verify(clubBookReviewQueryService).validateBookReview(REVIEW_ID, MEETING_ID); - inOrder.verify(ordinaryMembership).getClubMemberId(); - inOrder.verify(review).isOwnedBy(99L); - inOrder.verify(ordinaryMembership).isStaff(); + verify(meeting, Mockito.never()).removeBookReviewBy(Mockito.any(), Mockito.any()); } private MembershipInfo membership(Long clubMemberId, boolean active, boolean staff) { @@ -360,12 +386,12 @@ private MembershipInfo membership(Long clubMemberId, boolean active, boolean sta } private Meeting meetingWithSumRate(double sumRate) { - return Meeting.builder() + return Mockito.spy(Meeting.builder() .id(MEETING_ID) .clubId(CLUB_ID) .bookId("book") .sumRate(sumRate) - .build(); + .build()); } private BookReview review(String description, double rate, Long clubMemberId) { diff --git a/src/test/java/checkmo/clubMeeting/internal/service/command/ClubTopicCommandServiceTest.java b/src/test/java/checkmo/clubMeeting/internal/service/command/ClubTopicCommandServiceTest.java index d1c929da..dab317dc 100644 --- a/src/test/java/checkmo/clubMeeting/internal/service/command/ClubTopicCommandServiceTest.java +++ b/src/test/java/checkmo/clubMeeting/internal/service/command/ClubTopicCommandServiceTest.java @@ -2,6 +2,7 @@ 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; @@ -11,6 +12,7 @@ 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; @@ -107,8 +109,7 @@ void setUp() { () -> execute(operation) ); - verifyForbiddenOrder(membership, topic, ANOTHER_CLUB_MEMBER_ID); - verifyNoMutation(operation, topic); + verifyForbiddenOrder(membership, topic, ANOTHER_CLUB_MEMBER_ID, operation); assertFailureState(thrown, ClubMeetingErrorStatus.TOPIC_FORBIDDEN, topic); } @@ -148,25 +149,21 @@ private void verifyAuthorizedOrder( boolean staffAuthorization, Operation operation ) { - InOrder order = verifyLookupAndOwnershipOrder(membership, topic, actorClubMemberId); - if (staffAuthorization) { - order.verify(membership).isStaff(); - } else { - verify(membership, never()).isStaff(); - } - verifyMutation(order, operation, topic); + InOrder order = verifyLookupAndActorOrder(membership, topic); + verifyMutation(order, operation, topic, new ClubMeetingActor(actorClubMemberId, staffAuthorization)); } - private void verifyForbiddenOrder(MembershipInfo membership, Topic topic, Long actorClubMemberId) { - InOrder order = verifyLookupAndOwnershipOrder(membership, topic, actorClubMemberId); - order.verify(membership).isStaff(); - } - - private InOrder verifyLookupAndOwnershipOrder( + private void verifyForbiddenOrder( MembershipInfo membership, Topic topic, - Long actorClubMemberId + 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, @@ -177,27 +174,32 @@ private InOrder verifyLookupAndOwnershipOrder( 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); - order.verify(membership).getClubMemberId(); - order.verify(topic).isOwnedBy(actorClubMemberId); return order; } - private void verifyMutation(InOrder order, Operation operation, Topic topic) { + private void verifyMutation( + InOrder order, + Operation operation, + Topic topic, + ClubMeetingActor actor + ) { if (operation == Operation.UPDATE) { - order.verify(topic).updateTopic(UPDATED_DESCRIPTION); + order.verify(topic).updateBy(actor, UPDATED_DESCRIPTION); return; } - order.verify(topic).removeMeeting(); + order.verify(topic).removeBy(actor); } private void verifyNoMutation(Operation operation, Topic topic) { if (operation == Operation.UPDATE) { - verify(topic, never()).updateTopic(UPDATED_DESCRIPTION); + verify(topic, never()).updateBy(any(), any()); return; } - verify(topic, never()).removeMeeting(); + verify(topic, never()).removeBy(any()); } private void assertSuccessState(Operation operation, Topic topic) { From fdd3e2cb31b91294e6113246d51910efbda16041 Mon Sep 17 00:00:00 2001 From: MODUGGAGI Date: Mon, 13 Jul 2026 00:13:52 +0900 Subject: [PATCH 10/11] =?UTF-8?q?test(club-meeting):=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20HTTP=20?= =?UTF-8?q?=ED=9A=8C=EA=B7=80=EB=A5=BC=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ClubMeetingDomainRefactorApiTest.java | 379 ++++++++++++++++++ 1 file changed, 379 insertions(+) create mode 100644 src/test/java/checkmo/club/ClubMeetingDomainRefactorApiTest.java 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); + } +} From b8aba4620ae78e792b55f36308c64eff3daf0d31 Mon Sep 17 00:00:00 2001 From: MODUGGAGI Date: Mon, 13 Jul 2026 10:28:45 +0900 Subject: [PATCH 11/11] =?UTF-8?q?fix(club-meeting):=20fallback=20=EC=9E=AC?= =?UTF-8?q?=EA=B3=84=EC=82=B0=20=EC=8B=9C=20=EB=B3=84=EC=A0=90=20=ED=95=A9?= =?UTF-8?q?=EA=B3=84=20=EC=A0=95=ED=95=A9=EC=84=B1=EC=9D=84=20=EB=B3=B4?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 한줄평의 기존 별점을 차감한 뒤 리뷰를 변경하고 새 별점을 더하도록 순서를 보정한다. 서비스와 도메인의 책임 및 계산 순서를 컨벤션 문서에도 동일하게 반영한다. Constraint: PR #293 리뷰 지적을 반영하되 API·DTO·JPA·트랜잭션 계약은 유지 Rejected: 리뷰 변경 후 fallback 합계 재계산 | 새 별점이 포함된 합계에 증감을 다시 적용해 6.0으로 왜곡됨 Confidence: high Scope-risk: narrow Directive: sumRate fallback은 대상 리뷰의 기존 별점이 컬렉션에 남아 있을 때 수행할 것 Tested: ./gradlew cleanTest test --tests checkmo.clubMeeting.internal.entity.MeetingTest --tests checkmo.clubMeeting.internal.service.command.ClubBookReviewCommandServiceTest Not-tested: MySQL에서 손상된 sumRate를 재현하는 영속성 기반 시나리오 --- docs/conventions.md | 64 ++++++++++++++----- .../clubMeeting/internal/entity/Meeting.java | 6 +- .../internal/entity/MeetingTest.java | 4 +- .../ClubBookReviewCommandServiceTest.java | 4 +- 4 files changed, 57 insertions(+), 21 deletions(-) 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/entity/Meeting.java b/src/main/java/checkmo/clubMeeting/internal/entity/Meeting.java index 21fff07a..8ce3b318 100644 --- a/src/main/java/checkmo/clubMeeting/internal/entity/Meeting.java +++ b/src/main/java/checkmo/clubMeeting/internal/entity/Meeting.java @@ -117,10 +117,14 @@ private void validateBookReviewAuthorOrStaff(ClubMeetingActor actor, BookReview private void reviseBookReview(BookReview review, String description, double newRate) { double oldRate = review.getRate(); - review.updateBookReview(description, newRate); if (oldRate != newRate) { subtractSumRate(oldRate); + } + + review.updateBookReview(description, newRate); + + if (oldRate != newRate) { addSumRate(newRate); } } diff --git a/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTest.java b/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTest.java index 08f44816..ad19ecf5 100644 --- a/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTest.java +++ b/src/test/java/checkmo/clubMeeting/internal/entity/MeetingTest.java @@ -93,7 +93,7 @@ class MeetingTest { } @Test - void 한줄평_수정은_내용과_별점을_먼저_바꾼_뒤_기존_별점을_차감하고_새_별점을_더한다() { + void 한줄평_수정은_기존_별점을_차감한_뒤_내용과_별점을_바꾸고_새_별점을_더한다() { Meeting meeting = Meeting.builder().clubId(1L).bookId("book").sumRate(-9.0).build(); BookReview review = bookReview(1L, 4.0); meeting.addBookReview(review); @@ -104,7 +104,7 @@ class MeetingTest { assertSoftly(softly -> { softly.assertThat(review.getDescription()).isEqualTo("수정"); softly.assertThat(review.getRate()).isEqualTo(2.0); - softly.assertThat(meeting.getSumRate()).isEqualTo(6.0); + softly.assertThat(meeting.getSumRate()).isEqualTo(8.0); }); } diff --git a/src/test/java/checkmo/clubMeeting/internal/service/command/ClubBookReviewCommandServiceTest.java b/src/test/java/checkmo/clubMeeting/internal/service/command/ClubBookReviewCommandServiceTest.java index 96e2bac2..be4459dc 100644 --- a/src/test/java/checkmo/clubMeeting/internal/service/command/ClubBookReviewCommandServiceTest.java +++ b/src/test/java/checkmo/clubMeeting/internal/service/command/ClubBookReviewCommandServiceTest.java @@ -103,7 +103,7 @@ void setUp() { } @Test - void 별점_수정은_review를_먼저_변경한_뒤_기존_별점을_차감하고_새_별점을_더한다() { + void 별점_수정은_기존_별점을_차감한_뒤_review를_변경하고_새_별점을_더한다() { meeting = meetingWithSumRate(-9.0); BookReview review = Mockito.spy(review("이전", 4.0, CLUB_MEMBER_ID)); meeting.addBookReview(review); @@ -115,7 +115,7 @@ void setUp() { assertSoftly(softly -> { softly.assertThat(review.getDescription()).isEqualTo("수정"); softly.assertThat(review.getRate()).isEqualTo(2.0); - softly.assertThat(meeting.getSumRate()).isEqualTo(6.0); + softly.assertThat(meeting.getSumRate()).isEqualTo(8.0); softly.assertThat(review.getMeeting()).isSameAs(meeting); }); verifyUpdateOrder(