Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions common/api/src/main/java/kr/spot/code/status/ErrorStatus.java
Original file line number Diff line number Diff line change
Expand Up @@ -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", "게시글을 찾을 수 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ public static GetStudyOverviewResponse toDTO(
List<Study> studies,
Set<Long> likedStudyIds,
Set<Long> ownedStudyIds,
Set<Long> aloneStudyIds,
boolean hasNext,
Long nextCursor,
Long totalElements
) {
Set<Long> safelikedIds = likedStudyIds != null ? likedStudyIds : Collections.emptySet();
Set<Long> safeLikedIds = likedStudyIds != null ? likedStudyIds : Collections.emptySet();
Set<Long> safeOwnedStudyIds = ownedStudyIds != null ? ownedStudyIds : Collections.emptySet();
Set<Long> safeAloneStudyIds = aloneStudyIds != null ? aloneStudyIds : Collections.emptySet();

List<StudyOverview> list = studies.stream().map(
study -> StudyOverview.of(
Expand All @@ -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()
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,8 @@ public GetStudyOverviewResponse getRecommendedStudies(long viewerId) {
Set<Long> likedStudyIds = studyLikeRepository.findStudyIdsByMemberId(viewerId);
Set<Long> ownedStudyIds = studyMemberRepository.findStudyIdsByMemberIdAndStudyMemberStatus(
viewerId, StudyMemberStatus.OWNER);
return StudyDTOMapper.toDTO(result, likedStudyIds, ownedStudyIds, false, null,
Set<Long> aloneStudyIds = studyMemberRepository.findAloneOwnerStudyIds(viewerId);
return StudyDTOMapper.toDTO(result, likedStudyIds, ownedStudyIds, aloneStudyIds, false, null,
(long) result.size());
}

Expand Down Expand Up @@ -247,8 +248,9 @@ private GetStudyOverviewResponse toCursorPage(List<Study> rows, long viewerId, i
Set<Long> likedStudyIds = studyLikeRepository.findStudyIdsByMemberId(viewerId);
Set<Long> ownedStudyIds = studyMemberRepository.findStudyIdsByMemberIdAndStudyMemberStatus(
viewerId, StudyMemberStatus.OWNER);
return StudyDTOMapper.toDTO(pageContent, likedStudyIds, ownedStudyIds, hasNext, nextCursor,
totalElements);
Set<Long> aloneStudyIds = studyMemberRepository.findAloneOwnerStudyIds(viewerId);
return StudyDTOMapper.toDTO(pageContent, likedStudyIds, ownedStudyIds, aloneStudyIds, hasNext,
nextCursor, totalElements);
}

private List<String> filterPreferredRegionCodes(List<String> regionCodes,
Expand Down
20 changes: 20 additions & 0 deletions modules/study/src/main/java/kr/spot/study/domain/Study.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,11 @@ long countByMemberIdAndStudyMemberStatusIn(long memberId,
Set<Long> 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<Long> findAloneOwnerStudyIds(@Param("memberId") long memberId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,4 +106,14 @@ public ResponseEntity<ApiResponse<Void>> reportStudyMember(
reportStudyMemberService.reportStudyMember(studyId, targetMemberId, request);
return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK));
}

@Operation(summary = "스터디 삭제", description = "스터디를 삭제합니다.")
@DeleteMapping("/{studyId}")
public ResponseEntity<ApiResponse<Void>> deleteStudy(
@PathVariable Long studyId,
@CurrentMember @Parameter(hidden = true) Long memberId
) {
withdrawStudyService.deleteStudy(studyId, memberId);
return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public record StudyOverview(
long likeCount,
boolean isLiked,
boolean isOwner,
boolean isAlone,
long hitCount,
String profileImageUrl
) {
Expand All @@ -40,6 +41,7 @@ public static StudyOverview of(
long likeCount,
boolean isLiked,
boolean isOwner,
boolean isAlone,
long hitCount,
String profileImageUrl
) {
Expand All @@ -52,6 +54,7 @@ public static StudyOverview of(
likeCount,
isLiked,
isOwner,
isAlone,
hitCount,
profileImageUrl
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,63 +30,77 @@
@ExtendWith(MockitoExtension.class)
class WithdrawStudyServiceTest {

@Mock
StudyRepository studyRepository;
@Mock
StudyMemberRepository studyMemberRepository;

WithdrawStudyService withdrawStudyService;

@BeforeEach
void setUp() {
withdrawStudyService = new WithdrawStudyService(studyMemberRepository);
withdrawStudyService = new WithdrawStudyService(studyRepository, studyMemberRepository);
}

@Nested
@DisplayName("스터디 탈퇴 (withdrawStudy)")
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;
StudyMember ownerMember = owner(1L, STUDY_ID, ownerId);
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);

// then
assertThat(ownerMember.getStudyMemberStatus()).isEqualTo(StudyMemberStatus.WITHDRAWN);
assertThat(nextOwner.getStudyMemberStatus()).isEqualTo(StudyMemberStatus.OWNER);
assertThat(study.getCurrentMembers()).isEqualTo(initialMemberCount - 1);
}

@Test
Expand All @@ -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(
Expand All @@ -112,18 +132,88 @@ 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);

when(studyMemberRepository.getByMemberIdAndStudyId(ownerId, STUDY_ID))
.thenReturn(ownerMember);
when(studyMemberRepository.findByMemberIdAndStudyId(nextOwnerId, STUDY_ID)).thenReturn(
Optional.empty());
when(studyRepository.getStudyById(STUDY_ID)).thenReturn(study);

// when & then
assertThatThrownBy(
() -> withdrawStudyService.withdrawStudy(STUDY_ID, ownerId, request))
.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);
}
}
}