Skip to content

Commit 0ff4f73

Browse files
committed
feat: 에러응답 변경
1 parent 0ce2711 commit 0ff4f73

5 files changed

Lines changed: 201 additions & 5 deletions

File tree

src/main/java/com/leets/tdd/party/repository/DeliveryPartyRepository.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package com.leets.tdd.party.repository;
22

33
import com.leets.tdd.party.domain.DeliveryParty;
4+
import com.leets.tdd.party.domain.PartyStatus;
45
import com.leets.tdd.settlement.domain.SettlementStatus;
56
import jakarta.persistence.LockModeType;
7+
import java.util.Collection;
68
import java.util.List;
79
import java.util.Optional;
810
import org.springframework.data.jpa.repository.JpaRepository;
@@ -20,4 +22,22 @@ List<DeliveryParty> findAllByCreatorIdAndSettlementStatus(
2022
Long creatorId,
2123
SettlementStatus settlementStatus
2224
);
25+
26+
// 탈퇴 제한: 방장(creator)으로서 진행 중이거나(RECRUITING/CLOSED/ORDERED) 정산이 안 끝난 팟이 있는지 확인
27+
boolean existsByCreatorIdAndStatusIn(Long creatorId, Collection<PartyStatus> statuses);
28+
29+
boolean existsByCreatorIdAndStatusAndSettlementStatusNotIn(
30+
Long creatorId,
31+
PartyStatus status,
32+
Collection<SettlementStatus> settlementStatuses
33+
);
34+
35+
// 탈퇴 제한: 참여자(participant)로서 진행 중이거나 정산이 안 끝난 팟이 있는지 확인
36+
boolean existsByIdInAndStatusIn(Collection<Long> ids, Collection<PartyStatus> statuses);
37+
38+
boolean existsByIdInAndStatusAndSettlementStatusNotIn(
39+
Collection<Long> ids,
40+
PartyStatus status,
41+
Collection<SettlementStatus> settlementStatuses
42+
);
2343
}

src/main/java/com/leets/tdd/party/repository/PartyParticipantRepository.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,7 @@ public interface PartyParticipantRepository extends JpaRepository<PartyParticipa
2020
List<PartyParticipant> findAllByPartyIdIn(Collection<Long> partyIds);
2121

2222
List<PartyParticipant> findAllByUserIdAndPaymentStatusIsNotNull(Long userId);
23+
24+
// 탈퇴 제한: 이 사용자가 참여 중인(취소하지 않은) 팟 목록
25+
List<PartyParticipant> findAllByUserIdAndStatus(Long userId, PartyParticipantStatus status);
2326
}

src/main/java/com/leets/tdd/user/exception/UserErrorCode.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ public enum UserErrorCode {
2121
NEW_PASSWORD_SAME_AS_CURRENT(HttpStatus.BAD_REQUEST, "새 비밀번호가 기존 비밀번호와 동일합니다."),
2222
REGISTRATION_BLOCKED(HttpStatus.BAD_REQUEST, "가입할 수 없는 이메일입니다."),
2323
ALREADY_REGISTERED_EMAIL(HttpStatus.BAD_REQUEST, "이미 가입된 이메일입니다."),
24-
NICKNAME_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "닉네임 자동 생성에 실패했습니다. 잠시 후 다시 시도해주세요.");
24+
NICKNAME_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "닉네임 자동 생성에 실패했습니다. 잠시 후 다시 시도해주세요."),
25+
ACTIVE_POT_EXISTS(HttpStatus.BAD_REQUEST, "진행 중인 배달팟이 있어 탈퇴할 수 없습니다."),
26+
UNSETTLED_POT_EXISTS(HttpStatus.BAD_REQUEST, "정산 중인 배달팟이 있어 탈퇴할 수 없습니다.");
2527

2628
private final HttpStatus httpStatus;
2729
private final String message;

src/main/java/com/leets/tdd/user/service/UserService.java

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
import com.leets.tdd.global.jwt.JwtProvider;
44
import com.leets.tdd.global.jwt.RefreshTokenHasher;
55
import com.leets.tdd.auth.service.EmailVerificationService;
6+
import com.leets.tdd.party.domain.PartyParticipant;
7+
import com.leets.tdd.party.domain.PartyParticipantStatus;
8+
import com.leets.tdd.party.domain.PartyStatus;
9+
import com.leets.tdd.party.repository.DeliveryPartyRepository;
10+
import com.leets.tdd.party.repository.PartyParticipantRepository;
11+
import com.leets.tdd.settlement.domain.SettlementStatus;
612
import com.leets.tdd.user.domain.Dormitory;
713
import com.leets.tdd.user.domain.User;
814
import com.leets.tdd.user.dto.MyPageResponse;
@@ -24,6 +30,10 @@
2430
import org.springframework.transaction.annotation.Transactional;
2531

2632
import java.time.LocalDateTime;
33+
import java.util.EnumSet;
34+
import java.util.List;
35+
import java.util.Set;
36+
import java.util.stream.Collectors;
2737

2838
/**
2939
* 마이페이지 조회 + 계정등록(회원가입 완료) API.
@@ -44,6 +54,14 @@ public class UserService {
4454
private final EmailVerificationService emailVerificationService;
4555
private final PasswordEncoder passwordEncoder;
4656
private final NicknameGenerator nicknameGenerator;
57+
private final DeliveryPartyRepository deliveryPartyRepository;
58+
private final PartyParticipantRepository partyParticipantRepository;
59+
60+
// 탈퇴 제한: 진행 중인 배달 팟(모집중/마감/주문완료) 또는 완료됐지만 정산이 안 끝난 팟이 있으면 막는다.
61+
private static final Set<PartyStatus> ONGOING_PARTY_STATUSES =
62+
EnumSet.of(PartyStatus.RECRUITING, PartyStatus.CLOSED, PartyStatus.ORDERED);
63+
private static final Set<SettlementStatus> SETTLEMENT_TERMINAL_STATUSES =
64+
EnumSet.of(SettlementStatus.COMPLETED, SettlementStatus.CANCELED);
4765

4866
@Transactional
4967
public MyPageResponse getMyPage(Long userId) {
@@ -171,10 +189,10 @@ public void changePassword(Long userId, ChangePasswordRequest request) {
171189
* suspendedUntil/noShowApprovedCount/mannerTemperature는 softDelete()가 건드리지 않으므로
172190
* 그대로 유지된다(정지 우회 방지 + 재가입 시 이력 복원).
173191
* <p>
174-
* TODO: "진행 중인 배달팟(정산 미완료) 여부 확인" 단계는 의도적으로 뺐다. party 도메인
175-
* 엔티티(DeliveryParty/PartyParticipant) 자체는 이미 있지만, 이 기능을 넣으려면 user 도메인이
176-
* party/settlement 도메인에 의존하게 돼서 이 PR(로그인/로그아웃/탈퇴) 범위를 벗어난다.
177-
* 별도 이슈로 분리해서 처리한다(명세 실패 케이스: "진행 중인 배달팟이 있어 탈퇴할 수 없습니다.").
192+
* 방장/참여자 구분 없이 본인이 속한 팟 중 진행 중(RECRUITING/CLOSED/ORDERED)인 팟이 있으면
193+
* ACTIVE_POT_EXISTS로, COMPLETED인데 정산이 아직 안 끝난(SettlementStatus가 COMPLETED/CANCELED가
194+
* 아닌) 팟이 있으면 UNSETTLED_POT_EXISTS로 탈퇴를 막는다. 방장 탈퇴로 정산 트리거가 사라지는 문제,
195+
* 참여자가 노쇼 신고 전에 탈퇴로 회피하는 문제를 둘 다 막기 위함이다.
178196
*/
179197
@Transactional
180198
public void withdraw(Long userId, WithdrawalRequest request) {
@@ -189,11 +207,53 @@ public void withdraw(Long userId, WithdrawalRequest request) {
189207
throw new UserException(UserErrorCode.PASSWORD_MISMATCH);
190208
}
191209

210+
List<Long> joinedPartyIds = joinedPartyIds(userId);
211+
212+
if (hasOngoingDeliveryParty(userId, joinedPartyIds)) {
213+
throw new UserException(UserErrorCode.ACTIVE_POT_EXISTS);
214+
}
215+
if (hasUnsettledDeliveryParty(userId, joinedPartyIds)) {
216+
throw new UserException(UserErrorCode.UNSETTLED_POT_EXISTS);
217+
}
218+
192219
user.softDelete();
193220
user.clearRefreshToken();
194221
userRepository.save(user);
195222
}
196223

224+
// 참여를 취소하지 않은(JOINED) 팟 id 목록 - 참여자(participant) 기준 체크에 재사용
225+
private List<Long> joinedPartyIds(Long userId) {
226+
return partyParticipantRepository
227+
.findAllByUserIdAndStatus(userId, PartyParticipantStatus.JOINED)
228+
.stream()
229+
.map(PartyParticipant::getPartyId)
230+
.collect(Collectors.toList());
231+
}
232+
233+
// RECRUITING/CLOSED/ORDERED로 진행 중인 팟이 있는지 (방장 + 참여자 기준)
234+
private boolean hasOngoingDeliveryParty(Long userId, List<Long> joinedPartyIds) {
235+
if (deliveryPartyRepository.existsByCreatorIdAndStatusIn(userId, ONGOING_PARTY_STATUSES)) {
236+
return true;
237+
}
238+
if (joinedPartyIds.isEmpty()) {
239+
return false;
240+
}
241+
return deliveryPartyRepository.existsByIdInAndStatusIn(joinedPartyIds, ONGOING_PARTY_STATUSES);
242+
}
243+
244+
// COMPLETED인데 정산이 아직 안 끝난 팟이 있는지 (방장 + 참여자 기준)
245+
private boolean hasUnsettledDeliveryParty(Long userId, List<Long> joinedPartyIds) {
246+
if (deliveryPartyRepository.existsByCreatorIdAndStatusAndSettlementStatusNotIn(
247+
userId, PartyStatus.COMPLETED, SETTLEMENT_TERMINAL_STATUSES)) {
248+
return true;
249+
}
250+
if (joinedPartyIds.isEmpty()) {
251+
return false;
252+
}
253+
return deliveryPartyRepository.existsByIdInAndStatusAndSettlementStatusNotIn(
254+
joinedPartyIds, PartyStatus.COMPLETED, SETTLEMENT_TERMINAL_STATUSES);
255+
}
256+
197257
private record IssuedTokens(String accessToken, String refreshToken, LocalDateTime refreshTokenExpiresAt) {
198258
}
199259

src/test/java/com/leets/tdd/user/service/UserServiceTest.java

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33
import com.leets.tdd.global.jwt.JwtProvider;
44
import com.leets.tdd.global.jwt.RefreshTokenHasher;
55
import com.leets.tdd.auth.service.EmailVerificationService;
6+
import com.leets.tdd.party.domain.PartyParticipant;
7+
import com.leets.tdd.party.domain.PartyParticipantRole;
8+
import com.leets.tdd.party.domain.PartyParticipantStatus;
9+
import com.leets.tdd.party.domain.PartyStatus;
10+
import com.leets.tdd.party.repository.DeliveryPartyRepository;
11+
import com.leets.tdd.party.repository.PartyParticipantRepository;
12+
import com.leets.tdd.settlement.domain.SettlementStatus;
613
import com.leets.tdd.user.domain.DormStatus;
714
import com.leets.tdd.user.domain.Dormitory;
815
import com.leets.tdd.user.domain.User;
@@ -29,12 +36,14 @@
2936

3037
import java.time.Duration;
3138
import java.time.LocalDateTime;
39+
import java.util.List;
3240
import java.util.Optional;
3341

3442
import static org.assertj.core.api.Assertions.assertThat;
3543
import static org.assertj.core.api.Assertions.assertThatThrownBy;
3644
import static org.mockito.ArgumentMatchers.any;
3745
import static org.mockito.ArgumentMatchers.anyString;
46+
import static org.mockito.ArgumentMatchers.eq;
3847
import static org.mockito.Mockito.never;
3948
import static org.mockito.Mockito.times;
4049
import static org.mockito.Mockito.verify;
@@ -64,6 +73,12 @@ class UserServiceTest {
6473
@Mock
6574
private NicknameGenerator nicknameGenerator;
6675

76+
@Mock
77+
private DeliveryPartyRepository deliveryPartyRepository;
78+
79+
@Mock
80+
private PartyParticipantRepository partyParticipantRepository;
81+
6782
@InjectMocks
6883
private UserService userService;
6984

@@ -511,4 +526,100 @@ void withdraw_passwordMismatch() {
511526
assertThat(user.getStatus().name()).isEqualTo("ACTIVE");
512527
verify(userRepository, never()).save(any());
513528
}
529+
530+
@Test
531+
@DisplayName("방장으로 진행 중인(RECRUITING/CLOSED/ORDERED) 팟이 있으면 탈퇴가 거부된다")
532+
void withdraw_ongoingPartyAsCreator_throwsActivePotExists() {
533+
User user = newUser();
534+
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
535+
when(passwordEncoder.matches("raw-pw", "encoded-pw")).thenReturn(true);
536+
when(deliveryPartyRepository.existsByCreatorIdAndStatusIn(eq(1L), any())).thenReturn(true);
537+
538+
assertThatThrownBy(() -> userService.withdraw(1L, new WithdrawalRequest("raw-pw")))
539+
.isInstanceOf(UserException.class)
540+
.hasMessage(UserErrorCode.ACTIVE_POT_EXISTS.getMessage());
541+
542+
verify(userRepository, never()).save(any());
543+
}
544+
545+
@Test
546+
@DisplayName("방장이지만 COMPLETED인데 정산이 안 끝난 팟이 있으면 탈퇴가 거부된다")
547+
void withdraw_completedButUnsettledAsCreator_throwsUnsettledPotExists() {
548+
User user = newUser();
549+
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
550+
when(passwordEncoder.matches("raw-pw", "encoded-pw")).thenReturn(true);
551+
when(deliveryPartyRepository.existsByCreatorIdAndStatusIn(eq(1L), any())).thenReturn(false);
552+
when(deliveryPartyRepository.existsByCreatorIdAndStatusAndSettlementStatusNotIn(
553+
eq(1L), eq(PartyStatus.COMPLETED), any())).thenReturn(true);
554+
555+
assertThatThrownBy(() -> userService.withdraw(1L, new WithdrawalRequest("raw-pw")))
556+
.isInstanceOf(UserException.class)
557+
.hasMessage(UserErrorCode.UNSETTLED_POT_EXISTS.getMessage());
558+
559+
verify(userRepository, never()).save(any());
560+
}
561+
562+
@Test
563+
@DisplayName("참여자로 진행 중인(RECRUITING/CLOSED/ORDERED) 팟이 있으면 탈퇴가 거부된다")
564+
void withdraw_ongoingPartyAsParticipant_throwsActivePotExists() {
565+
User user = newUser();
566+
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
567+
when(passwordEncoder.matches("raw-pw", "encoded-pw")).thenReturn(true);
568+
when(deliveryPartyRepository.existsByCreatorIdAndStatusIn(eq(1L), any())).thenReturn(false);
569+
when(deliveryPartyRepository.existsByCreatorIdAndStatusAndSettlementStatusNotIn(
570+
eq(1L), eq(PartyStatus.COMPLETED), any())).thenReturn(false);
571+
PartyParticipant participation = new PartyParticipant(
572+
10L, 1L, PartyParticipantRole.MEMBER, PartyParticipantStatus.JOINED, LocalDateTime.now());
573+
when(partyParticipantRepository.findAllByUserIdAndStatus(1L, PartyParticipantStatus.JOINED))
574+
.thenReturn(List.of(participation));
575+
when(deliveryPartyRepository.existsByIdInAndStatusIn(eq(List.of(10L)), any())).thenReturn(true);
576+
577+
assertThatThrownBy(() -> userService.withdraw(1L, new WithdrawalRequest("raw-pw")))
578+
.isInstanceOf(UserException.class)
579+
.hasMessage(UserErrorCode.ACTIVE_POT_EXISTS.getMessage());
580+
581+
verify(userRepository, never()).save(any());
582+
}
583+
584+
@Test
585+
@DisplayName("참여자로 속한 팟이 COMPLETED인데 정산이 안 끝났으면 탈퇴가 거부된다")
586+
void withdraw_completedButUnsettledAsParticipant_throwsUnsettledPotExists() {
587+
User user = newUser();
588+
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
589+
when(passwordEncoder.matches("raw-pw", "encoded-pw")).thenReturn(true);
590+
when(deliveryPartyRepository.existsByCreatorIdAndStatusIn(eq(1L), any())).thenReturn(false);
591+
when(deliveryPartyRepository.existsByCreatorIdAndStatusAndSettlementStatusNotIn(
592+
eq(1L), eq(PartyStatus.COMPLETED), any())).thenReturn(false);
593+
PartyParticipant participation = new PartyParticipant(
594+
10L, 1L, PartyParticipantRole.MEMBER, PartyParticipantStatus.JOINED, LocalDateTime.now());
595+
when(partyParticipantRepository.findAllByUserIdAndStatus(1L, PartyParticipantStatus.JOINED))
596+
.thenReturn(List.of(participation));
597+
when(deliveryPartyRepository.existsByIdInAndStatusIn(eq(List.of(10L)), any())).thenReturn(false);
598+
when(deliveryPartyRepository.existsByIdInAndStatusAndSettlementStatusNotIn(
599+
eq(List.of(10L)), eq(PartyStatus.COMPLETED), any())).thenReturn(true);
600+
601+
assertThatThrownBy(() -> userService.withdraw(1L, new WithdrawalRequest("raw-pw")))
602+
.isInstanceOf(UserException.class)
603+
.hasMessage(UserErrorCode.UNSETTLED_POT_EXISTS.getMessage());
604+
605+
verify(userRepository, never()).save(any());
606+
}
607+
608+
@Test
609+
@DisplayName("참여 중인 팟이 없거나 모두 정산 완료/취소 상태면 탈퇴가 성공한다")
610+
void withdraw_noOngoingParty_succeeds() {
611+
User user = newUser();
612+
when(userRepository.findById(1L)).thenReturn(Optional.of(user));
613+
when(passwordEncoder.matches("raw-pw", "encoded-pw")).thenReturn(true);
614+
when(deliveryPartyRepository.existsByCreatorIdAndStatusIn(eq(1L), any())).thenReturn(false);
615+
when(deliveryPartyRepository.existsByCreatorIdAndStatusAndSettlementStatusNotIn(
616+
eq(1L), eq(PartyStatus.COMPLETED), any())).thenReturn(false);
617+
when(partyParticipantRepository.findAllByUserIdAndStatus(1L, PartyParticipantStatus.JOINED))
618+
.thenReturn(List.of());
619+
620+
userService.withdraw(1L, new WithdrawalRequest("raw-pw"));
621+
622+
assertThat(user.getStatus().name()).isEqualTo("DELETED");
623+
verify(userRepository).save(user);
624+
}
514625
}

0 commit comments

Comments
 (0)