diff --git a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java index 5e849ab..df597e6 100644 --- a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java +++ b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java @@ -55,6 +55,8 @@ public enum ErrorStatus implements BaseErrorCode { "스터디장 탈퇴 시 다음 스터디장 ID가 필요합니다."), _NEXT_OWNER_NOT_EXIST_IN_STUDY(400, "STUDY4009", "다음 스터디장이 스터디 멤버에 존재하지 않습니다."), + _CANNOT_DELETE_STUDY_WITH_MEMBERS(400, "STUDY4010", + "스터디 멤버가 있는 상태에서는 스터디를 삭제할 수 없습니다."), // 게시글 관련 _POST_NOT_FOUND(404, "POST404", "게시글을 찾을 수 없습니다."), diff --git a/modules/study/src/main/java/kr/spot/study/application/command/WithdrawStudyService.java b/modules/study/src/main/java/kr/spot/study/application/command/WithdrawStudyService.java index 0af3e3d..f464685 100644 --- a/modules/study/src/main/java/kr/spot/study/application/command/WithdrawStudyService.java +++ b/modules/study/src/main/java/kr/spot/study/application/command/WithdrawStudyService.java @@ -3,7 +3,9 @@ import jakarta.transaction.Transactional; import kr.spot.code.status.ErrorStatus; import kr.spot.exception.GeneralException; +import kr.spot.study.domain.Study; import kr.spot.study.domain.associations.StudyMember; +import kr.spot.study.infrastructure.jpa.StudyRepository; import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; import kr.spot.study.presentation.command.dto.request.WithdrawStudyRequest; import lombok.RequiredArgsConstructor; @@ -14,14 +16,24 @@ @RequiredArgsConstructor public class WithdrawStudyService { + private final StudyRepository studyRepository; private final StudyMemberRepository studyMemberRepository; public void withdrawStudy(long studyId, long memberId, WithdrawStudyRequest request) { StudyMember studyMember = studyMemberRepository.getByMemberIdAndStudyId(memberId, studyId); + Study study = studyRepository.getStudyById(studyId); StudyMember nextOwner = findNextOwner(studyId, request.nextOwnerId()); studyMember.withdrawStudy(request.withdrawReason(), nextOwner); + study.decreaseMemberCount(); + } + + public void deleteStudy(long studyId, long memberId) { + StudyMember studyMember = studyMemberRepository.getByMemberIdAndStudyId(memberId, studyId); + Study study = studyRepository.getStudyById(studyId); + + study.finishStudy(studyMember); } private StudyMember findNextOwner(long studyId, Long nextOwnerId) { diff --git a/modules/study/src/main/java/kr/spot/study/application/mapper/StudyDTOMapper.java b/modules/study/src/main/java/kr/spot/study/application/mapper/StudyDTOMapper.java index f159424..e46df65 100644 --- a/modules/study/src/main/java/kr/spot/study/application/mapper/StudyDTOMapper.java +++ b/modules/study/src/main/java/kr/spot/study/application/mapper/StudyDTOMapper.java @@ -13,12 +13,14 @@ public static GetStudyOverviewResponse toDTO( List studies, Set likedStudyIds, Set ownedStudyIds, + Set aloneStudyIds, boolean hasNext, Long nextCursor, Long totalElements ) { - Set safelikedIds = likedStudyIds != null ? likedStudyIds : Collections.emptySet(); + Set safeLikedIds = likedStudyIds != null ? likedStudyIds : Collections.emptySet(); Set safeOwnedStudyIds = ownedStudyIds != null ? ownedStudyIds : Collections.emptySet(); + Set safeAloneStudyIds = aloneStudyIds != null ? aloneStudyIds : Collections.emptySet(); List list = studies.stream().map( study -> StudyOverview.of( @@ -28,8 +30,9 @@ public static GetStudyOverviewResponse toDTO( study.getMaxMembers(), study.getCurrentMembers(), 0, - safelikedIds.contains(study.getId()), + safeLikedIds.contains(study.getId()), safeOwnedStudyIds.contains(study.getId()), + safeAloneStudyIds.contains(study.getId()), 0, study.getImageUrl() ) diff --git a/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java b/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java index 6dd6808..79bdc52 100644 --- a/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java +++ b/modules/study/src/main/java/kr/spot/study/application/query/GetMyStudyInfoService.java @@ -200,7 +200,8 @@ public GetStudyOverviewResponse getRecommendedStudies(long viewerId) { Set likedStudyIds = studyLikeRepository.findStudyIdsByMemberId(viewerId); Set ownedStudyIds = studyMemberRepository.findStudyIdsByMemberIdAndStudyMemberStatus( viewerId, StudyMemberStatus.OWNER); - return StudyDTOMapper.toDTO(result, likedStudyIds, ownedStudyIds, false, null, + Set aloneStudyIds = studyMemberRepository.findAloneOwnerStudyIds(viewerId); + return StudyDTOMapper.toDTO(result, likedStudyIds, ownedStudyIds, aloneStudyIds, false, null, (long) result.size()); } @@ -247,8 +248,9 @@ private GetStudyOverviewResponse toCursorPage(List rows, long viewerId, i Set likedStudyIds = studyLikeRepository.findStudyIdsByMemberId(viewerId); Set ownedStudyIds = studyMemberRepository.findStudyIdsByMemberIdAndStudyMemberStatus( viewerId, StudyMemberStatus.OWNER); - return StudyDTOMapper.toDTO(pageContent, likedStudyIds, ownedStudyIds, hasNext, nextCursor, - totalElements); + Set aloneStudyIds = studyMemberRepository.findAloneOwnerStudyIds(viewerId); + return StudyDTOMapper.toDTO(pageContent, likedStudyIds, ownedStudyIds, aloneStudyIds, hasNext, + nextCursor, totalElements); } private List filterPreferredRegionCodes(List regionCodes, diff --git a/modules/study/src/main/java/kr/spot/study/domain/Study.java b/modules/study/src/main/java/kr/spot/study/domain/Study.java index faa94fa..23fa630 100644 --- a/modules/study/src/main/java/kr/spot/study/domain/Study.java +++ b/modules/study/src/main/java/kr/spot/study/domain/Study.java @@ -114,6 +114,12 @@ private void increaseMemberCountIfApproved(Decision decision) { } } + public void decreaseMemberCount() { + if (this.currentMembers > 0) { + this.currentMembers -= 1; + } + } + public void validateIsStudyOwner(Long requesterId) { if (!this.leaderId.equals(requesterId)) { throw new GeneralException(ErrorStatus._ONLY_LEADER_CAN_ACCESS); @@ -133,4 +139,18 @@ public StudyMember receiveApplication(Long id, Long memberId, String message) { public void updateImageUrl(String imageUrl) { this.imageUrl = imageUrl; } + + public void finishStudy(StudyMember studyMember) { + validateIsStudyOwner(studyMember.getMemberId()); + checkStudyCanDelete(); + this.recruitingStatus = RecruitingStatus.COMPLETED; + this.currentMembers = 0; + super.delete(); + } + + private void checkStudyCanDelete() { + if (currentMembers > 1) { + throw new GeneralException(ErrorStatus._CANNOT_DELETE_STUDY_WITH_MEMBERS); + } + } } diff --git a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java index 05e2334..2a0ecd3 100644 --- a/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java +++ b/modules/study/src/main/java/kr/spot/study/infrastructure/jpa/associations/StudyMemberRepository.java @@ -44,4 +44,11 @@ long countByMemberIdAndStudyMemberStatusIn(long memberId, Set findStudyIdsByMemberIdAndStudyMemberStatus( @Param("memberId") long memberId, @Param("studyMemberStatus") StudyMemberStatus studyMemberStatus); + + @Query("SELECT sm.studyId FROM StudyMember sm " + + "JOIN Study s ON sm.studyId = s.id " + + "WHERE sm.memberId = :memberId " + + "AND sm.studyMemberStatus = 'OWNER' " + + "AND s.currentMembers = 1") + Set findAloneOwnerStudyIds(@Param("memberId") long memberId); } diff --git a/modules/study/src/main/java/kr/spot/study/presentation/command/StudyCommandController.java b/modules/study/src/main/java/kr/spot/study/presentation/command/StudyCommandController.java index 2f5d456..f80de2a 100644 --- a/modules/study/src/main/java/kr/spot/study/presentation/command/StudyCommandController.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/command/StudyCommandController.java @@ -106,4 +106,14 @@ public ResponseEntity> reportStudyMember( reportStudyMemberService.reportStudyMember(studyId, targetMemberId, request); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); } + + @Operation(summary = "스터디 삭제", description = "스터디를 삭제합니다.") + @DeleteMapping("/{studyId}") + public ResponseEntity> deleteStudy( + @PathVariable Long studyId, + @CurrentMember @Parameter(hidden = true) Long memberId + ) { + withdrawStudyService.deleteStudy(studyId, memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } } diff --git a/modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetStudyOverviewResponse.java b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetStudyOverviewResponse.java index ffa4b0c..0809eb3 100644 --- a/modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetStudyOverviewResponse.java +++ b/modules/study/src/main/java/kr/spot/study/presentation/query/dto/response/GetStudyOverviewResponse.java @@ -27,6 +27,7 @@ public record StudyOverview( long likeCount, boolean isLiked, boolean isOwner, + boolean isAlone, long hitCount, String profileImageUrl ) { @@ -40,6 +41,7 @@ public static StudyOverview of( long likeCount, boolean isLiked, boolean isOwner, + boolean isAlone, long hitCount, String profileImageUrl ) { @@ -52,6 +54,7 @@ public static StudyOverview of( likeCount, isLiked, isOwner, + isAlone, hitCount, profileImageUrl ); diff --git a/modules/study/src/test/java/kr/spot/study/application/command/WithdrawStudyServiceTest.java b/modules/study/src/test/java/kr/spot/study/application/command/WithdrawStudyServiceTest.java index 67867e4..67b29e8 100644 --- a/modules/study/src/test/java/kr/spot/study/application/command/WithdrawStudyServiceTest.java +++ b/modules/study/src/test/java/kr/spot/study/application/command/WithdrawStudyServiceTest.java @@ -9,10 +9,14 @@ import java.util.Optional; import kr.spot.exception.GeneralException; +import kr.spot.study.domain.Study; import kr.spot.study.domain.associations.StudyMember; import kr.spot.study.domain.enums.Decision; +import kr.spot.study.domain.enums.RecruitingStatus; import kr.spot.study.domain.enums.StudyMemberStatus; import kr.spot.study.domain.enums.WithdrawReason; +import kr.spot.study.domain.vo.Fee; +import kr.spot.study.infrastructure.jpa.StudyRepository; import kr.spot.study.infrastructure.jpa.associations.StudyMemberRepository; import kr.spot.study.presentation.command.dto.request.WithdrawStudyRequest; import org.junit.jupiter.api.BeforeEach; @@ -26,6 +30,8 @@ @ExtendWith(MockitoExtension.class) class WithdrawStudyServiceTest { + @Mock + StudyRepository studyRepository; @Mock StudyMemberRepository studyMemberRepository; @@ -33,7 +39,7 @@ class WithdrawStudyServiceTest { @BeforeEach void setUp() { - withdrawStudyService = new WithdrawStudyService(studyMemberRepository); + withdrawStudyService = new WithdrawStudyService(studyRepository, studyMemberRepository); } @Nested @@ -41,28 +47,34 @@ void setUp() { class WithdrawStudy { @Test - @DisplayName("일반 멤버가 탈퇴하면 상태가 WITHDRAWN으로 변경된다") - void should_change_status_to_withdrawn_when_regular_member_withdraws() { + @DisplayName("일반 멤버가 탈퇴하면 상태가 WITHDRAWN으로 변경되고 회원 수가 감소한다") + void should_change_status_to_withdrawn_and_decrease_member_count() { // given long memberId = MEMBER_ID; StudyMember regularMember = StudyMember.apply(1L, STUDY_ID, memberId, "참여 메시지"); regularMember.decide(Decision.APPROVE); + Study study = Study.of(STUDY_ID, 999L, "테스트 스터디", 10, Fee.of(false, 0), "설명"); + WithdrawStudyRequest request = new WithdrawStudyRequest(WithdrawReason.NO_MORE_NEEDS, null); when(studyMemberRepository.getByMemberIdAndStudyId(memberId, STUDY_ID)) .thenReturn(regularMember); + when(studyRepository.getStudyById(STUDY_ID)).thenReturn(study); + + int initialMemberCount = study.getCurrentMembers(); // when withdrawStudyService.withdrawStudy(STUDY_ID, memberId, request); // then assertThat(regularMember.getStudyMemberStatus()).isEqualTo(StudyMemberStatus.WITHDRAWN); + assertThat(study.getCurrentMembers()).isEqualTo(initialMemberCount - 1); } @Test - @DisplayName("오너가 다음 오너를 지정하고 탈퇴하면 권한이 위임된다") - void should_transfer_ownership_when_owner_withdraws_with_next_owner() { + @DisplayName("오너가 다음 오너를 지정하고 탈퇴하면 권한이 위임되고 회원 수가 감소한다") + void should_transfer_ownership_and_decrease_member_count() { // given long ownerId = 100L; long nextOwnerId = 2L; @@ -70,12 +82,17 @@ void should_transfer_ownership_when_owner_withdraws_with_next_owner() { StudyMember nextOwner = StudyMember.apply(nextOwnerId, STUDY_ID, 200L, "다음 오너"); nextOwner.decide(Decision.APPROVE); + Study study = Study.of(STUDY_ID, ownerId, "테스트 스터디", 10, Fee.of(false, 0), "설명"); + WithdrawStudyRequest request = new WithdrawStudyRequest(WithdrawReason.FINISHED, nextOwnerId); when(studyMemberRepository.getByMemberIdAndStudyId(ownerId, STUDY_ID)) .thenReturn(ownerMember); when(studyMemberRepository.findByMemberIdAndStudyId(nextOwnerId, STUDY_ID)).thenReturn( Optional.of(nextOwner)); + when(studyRepository.getStudyById(STUDY_ID)).thenReturn(study); + + int initialMemberCount = study.getCurrentMembers(); // when withdrawStudyService.withdrawStudy(STUDY_ID, ownerId, request); @@ -83,6 +100,7 @@ void should_transfer_ownership_when_owner_withdraws_with_next_owner() { // then assertThat(ownerMember.getStudyMemberStatus()).isEqualTo(StudyMemberStatus.WITHDRAWN); assertThat(nextOwner.getStudyMemberStatus()).isEqualTo(StudyMemberStatus.OWNER); + assertThat(study.getCurrentMembers()).isEqualTo(initialMemberCount - 1); } @Test @@ -91,11 +109,13 @@ void should_throw_exception_when_owner_withdraws_without_next_owner() { // given long ownerId = 100L; StudyMember ownerMember = owner(1L, STUDY_ID, ownerId); + Study study = Study.of(STUDY_ID, ownerId, "테스트 스터디", 10, Fee.of(false, 0), "설명"); WithdrawStudyRequest request = new WithdrawStudyRequest(WithdrawReason.FINISHED, null); when(studyMemberRepository.getByMemberIdAndStudyId(ownerId, STUDY_ID)) .thenReturn(ownerMember); + when(studyRepository.getStudyById(STUDY_ID)).thenReturn(study); // when & then assertThatThrownBy( @@ -112,6 +132,7 @@ void should_throw_exception_when_next_owner_is_from_different_study() { long differentStudyId = 999L; StudyMember ownerMember = owner(1L, STUDY_ID, ownerId); StudyMember nextOwner = StudyMember.apply(nextOwnerId, differentStudyId, 200L, "다른 스터디 멤버"); + Study study = Study.of(STUDY_ID, ownerId, "테스트 스터디", 10, Fee.of(false, 0), "설명"); WithdrawStudyRequest request = new WithdrawStudyRequest(WithdrawReason.FINISHED, nextOwnerId); @@ -119,6 +140,7 @@ void should_throw_exception_when_next_owner_is_from_different_study() { .thenReturn(ownerMember); when(studyMemberRepository.findByMemberIdAndStudyId(nextOwnerId, STUDY_ID)).thenReturn( Optional.empty()); + when(studyRepository.getStudyById(STUDY_ID)).thenReturn(study); // when & then assertThatThrownBy( @@ -126,4 +148,72 @@ void should_throw_exception_when_next_owner_is_from_different_study() { .isInstanceOf(GeneralException.class); } } + + @Nested + @DisplayName("스터디 삭제 (deleteStudy)") + class DeleteStudy { + + @Test + @DisplayName("오너가 혼자일 때 스터디를 삭제할 수 있다") + void should_delete_study_when_owner_is_alone() { + // given + long ownerId = 100L; + StudyMember ownerMember = owner(1L, STUDY_ID, ownerId); + Study study = Study.of(STUDY_ID, ownerId, "테스트 스터디", 10, Fee.of(false, 0), "설명"); + + when(studyMemberRepository.getByMemberIdAndStudyId(ownerId, STUDY_ID)) + .thenReturn(ownerMember); + when(studyRepository.getStudyById(STUDY_ID)).thenReturn(study); + + // when + withdrawStudyService.deleteStudy(STUDY_ID, ownerId); + + // then + assertThat(study.getRecruitingStatus()).isEqualTo(RecruitingStatus.COMPLETED); + assertThat(study.getCurrentMembers()).isEqualTo(0); + } + + @Test + @DisplayName("오너가 아닌 멤버가 스터디 삭제를 시도하면 예외가 발생한다") + void should_throw_exception_when_non_owner_tries_to_delete() { + // given + long ownerId = 100L; + long regularMemberId = 200L; + StudyMember regularMember = StudyMember.apply(2L, STUDY_ID, regularMemberId, "참여 메시지"); + regularMember.decide(Decision.APPROVE); + + Study study = Study.of(STUDY_ID, ownerId, "테스트 스터디", 10, Fee.of(false, 0), "설명"); + + when(studyMemberRepository.getByMemberIdAndStudyId(regularMemberId, STUDY_ID)) + .thenReturn(regularMember); + when(studyRepository.getStudyById(STUDY_ID)).thenReturn(study); + + // when & then + assertThatThrownBy( + () -> withdrawStudyService.deleteStudy(STUDY_ID, regularMemberId)) + .isInstanceOf(GeneralException.class); + } + + @Test + @DisplayName("스터디에 멤버가 2명 이상이면 삭제할 수 없다") + void should_throw_exception_when_study_has_multiple_members() { + // given + long ownerId = 100L; + StudyMember ownerMember = owner(1L, STUDY_ID, ownerId); + Study study = Study.of(STUDY_ID, ownerId, "테스트 스터디", 10, Fee.of(false, 0), "설명"); + + // 멤버 추가 (currentMembers = 2) + StudyMember applicant = StudyMember.apply(2L, STUDY_ID, 200L, "참여 메시지"); + study.processApplication(applicant, ownerId, Decision.APPROVE); + + when(studyMemberRepository.getByMemberIdAndStudyId(ownerId, STUDY_ID)) + .thenReturn(ownerMember); + when(studyRepository.getStudyById(STUDY_ID)).thenReturn(study); + + // when & then + assertThatThrownBy( + () -> withdrawStudyService.deleteStudy(STUDY_ID, ownerId)) + .isInstanceOf(GeneralException.class); + } + } }