diff --git a/src/main/java/com/ject/studytrip/global/common/constants/UrlConstants.java b/src/main/java/com/ject/studytrip/global/common/constants/UrlConstants.java index caea19d..871e70e 100644 --- a/src/main/java/com/ject/studytrip/global/common/constants/UrlConstants.java +++ b/src/main/java/com/ject/studytrip/global/common/constants/UrlConstants.java @@ -30,5 +30,7 @@ private UrlConstants() {} public static final String[] ORIGIN_EXTRACT_PATHS = {"/api/auth/login/kakao"}; // 인증이 필요없는 API 경로 - public static final String[] PERMIT_ALL_API_PATHS = {"/api/auth/**", "/api/trips/categories"}; + public static final String[] PERMIT_ALL_API_PATHS = { + "/api/auth/**", "/api/trips/categories", "/api/members/me/restore/**" + }; } diff --git a/src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java b/src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java index 976c3c8..ff60bf6 100644 --- a/src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java +++ b/src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java @@ -132,6 +132,13 @@ public void hardDeleteMemberCascade(Long memberId) { imageService.publishCleanupBatchEvent(imageUrls); } + @Transactional + public void restoreMember(Long memberId) { + Member member = memberQueryService.getDeletedMember(memberId); + + memberCommandService.restoreMember(member); + } + private List collectImageUrlsForMember(Member member) { List imageUrls = new ArrayList<>(); diff --git a/src/main/java/com/ject/studytrip/member/application/service/MemberCommandService.java b/src/main/java/com/ject/studytrip/member/application/service/MemberCommandService.java index 3e75124..5e20038 100644 --- a/src/main/java/com/ject/studytrip/member/application/service/MemberCommandService.java +++ b/src/main/java/com/ject/studytrip/member/application/service/MemberCommandService.java @@ -51,6 +51,10 @@ public void deleteMember(Member member) { member.updateDeletedAt(); } + public void restoreMember(Member member) { + member.restoreDeletedAt(); + } + public long hardDeleteMembers() { return memberCommandRepository.deleteAllByDeletedAtIsNotNull(); } diff --git a/src/main/java/com/ject/studytrip/member/application/service/MemberQueryService.java b/src/main/java/com/ject/studytrip/member/application/service/MemberQueryService.java index 18b40b9..0abbff3 100644 --- a/src/main/java/com/ject/studytrip/member/application/service/MemberQueryService.java +++ b/src/main/java/com/ject/studytrip/member/application/service/MemberQueryService.java @@ -41,6 +41,17 @@ public Member getValidMember(Long memberId) { .orElseThrow(() -> new CustomException(MemberErrorCode.MEMBER_NOT_FOUND)); } + public Member getDeletedMember(Long memberId) { + Member member = + memberRepository + .findById(memberId) + .orElseThrow(() -> new CustomException(MemberErrorCode.MEMBER_NOT_FOUND)); + + MemberPolicy.validateDeleted(member); + + return member; + } + public String getRoleByMemberId(String memberId) { MemberRole memberRole = memberQueryRepository diff --git a/src/main/java/com/ject/studytrip/member/domain/error/MemberErrorCode.java b/src/main/java/com/ject/studytrip/member/domain/error/MemberErrorCode.java index b81eeed..0142b8b 100644 --- a/src/main/java/com/ject/studytrip/member/domain/error/MemberErrorCode.java +++ b/src/main/java/com/ject/studytrip/member/domain/error/MemberErrorCode.java @@ -10,6 +10,7 @@ public enum MemberErrorCode implements ErrorCode { INVALID_MEMBER_CATEGORY(HttpStatus.BAD_REQUEST, "유효하지 않은 멤버 카테고리입니다."), MEMBER_NICKNAME_DUPLICATED(HttpStatus.BAD_REQUEST, "이미 사용 중인 닉네임입니다."), MEMBER_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "해당 멤버는 이미 삭제되었습니다."), + MEMBER_NOT_DELETED(HttpStatus.BAD_REQUEST, "해당 멤버는 삭제되지 않았습니다."), // 404 MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "멤버를 찾을 수 없습니다."), diff --git a/src/main/java/com/ject/studytrip/member/domain/model/Member.java b/src/main/java/com/ject/studytrip/member/domain/model/Member.java index 51ac19f..9e89f54 100644 --- a/src/main/java/com/ject/studytrip/member/domain/model/Member.java +++ b/src/main/java/com/ject/studytrip/member/domain/model/Member.java @@ -74,6 +74,10 @@ public void updateDeletedAt() { this.deletedAt = LocalDateTime.now(); } + public void restoreDeletedAt() { + this.deletedAt = null; + } + public MemberCategory getCategory() { return category; } diff --git a/src/main/java/com/ject/studytrip/member/domain/policy/MemberPolicy.java b/src/main/java/com/ject/studytrip/member/domain/policy/MemberPolicy.java index 1633648..2b20663 100644 --- a/src/main/java/com/ject/studytrip/member/domain/policy/MemberPolicy.java +++ b/src/main/java/com/ject/studytrip/member/domain/policy/MemberPolicy.java @@ -19,4 +19,10 @@ public static void validateNotDeleted(Member member) { throw new CustomException(MemberErrorCode.MEMBER_ALREADY_DELETED); } } + + public static void validateDeleted(Member member) { + if (member.getDeletedAt() == null) { + throw new CustomException(MemberErrorCode.MEMBER_NOT_DELETED); + } + } } diff --git a/src/main/java/com/ject/studytrip/member/presentation/controller/MemberController.java b/src/main/java/com/ject/studytrip/member/presentation/controller/MemberController.java index 4bc1924..618c1c7 100644 --- a/src/main/java/com/ject/studytrip/member/presentation/controller/MemberController.java +++ b/src/main/java/com/ject/studytrip/member/presentation/controller/MemberController.java @@ -12,6 +12,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -144,4 +145,13 @@ public ResponseEntity deleteMemberHardDelete( return ResponseEntity.ok().body(StandardResponse.success(HttpStatus.OK.value(), null)); } + + @Operation(summary = "멤버 복구", description = "삭제된 멤버를 복구합니다.") + @PatchMapping("/me/restore/{memberId}") + public ResponseEntity restoreMember( + @PathVariable @NotNull(message = "멤버 ID는 필수 요청 파라미터입니다.") Long memberId) { + memberFacade.restoreMember(memberId); + + return ResponseEntity.ok().body(StandardResponse.success(HttpStatus.OK.value(), null)); + } } diff --git a/src/test/java/com/ject/studytrip/member/application/service/MemberCommandServiceTest.java b/src/test/java/com/ject/studytrip/member/application/service/MemberCommandServiceTest.java index 9b29153..034a7ad 100644 --- a/src/test/java/com/ject/studytrip/member/application/service/MemberCommandServiceTest.java +++ b/src/test/java/com/ject/studytrip/member/application/service/MemberCommandServiceTest.java @@ -248,6 +248,24 @@ void shouldDeleteMember() { } } + @Nested + @DisplayName("restoreMember 메서드는") + class RestoreMember { + + @Test + @DisplayName("삭제된 멤버가 복구될 때 deletedAt 필드를 null로 업데이트한다.") + void shouldRestoreDeletedAtWhenDeletedMemberIsRestored() { + // given + member.updateDeletedAt(); + + // when + memberCommandService.restoreMember(member); + + // then + assertThat(member.getDeletedAt()).isNull(); + } + } + @Nested @DisplayName("hardDeleteMembers 메서드는") class HardDeleteMembers { diff --git a/src/test/java/com/ject/studytrip/member/application/service/MemberQueryServiceTest.java b/src/test/java/com/ject/studytrip/member/application/service/MemberQueryServiceTest.java index 9c30773..d93a69b 100644 --- a/src/test/java/com/ject/studytrip/member/application/service/MemberQueryServiceTest.java +++ b/src/test/java/com/ject/studytrip/member/application/service/MemberQueryServiceTest.java @@ -156,6 +156,52 @@ void shouldReturnMemberWhenMemberIdIsValid() { } } + @Nested + @DisplayName("getDeletedMember 메서드는") + class GetDeletedMember { + + @Test + @DisplayName("멤버가 존재하지 않으면 예외가 발생한다.") + void shouldThrowExceptionWhenMemberDoesNotExist() { + // given + Long memberId = -1L; + given(memberRepository.findById(memberId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> memberQueryService.getDeletedMember(memberId)) + .isInstanceOf(CustomException.class) + .hasMessage(MemberErrorCode.MEMBER_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("멤버가 삭제되지 않았다면 예외가 발생한다.") + void shouldThrowExceptionWhenMemberIsNotDeleted() { + // given + Long memberId = member.getId(); + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + + // when & then + assertThatThrownBy(() -> memberQueryService.getDeletedMember(memberId)) + .isInstanceOf(CustomException.class) + .hasMessage(MemberErrorCode.MEMBER_NOT_DELETED.getMessage()); + } + + @Test + @DisplayName("멤버가 이미 삭제되었다면 삭제된 멤버를 반환한다.") + void shouldReturnMemberWhenMemberAlreadyDeleted() { + // given + Long memberId = member.getId(); + member.updateDeletedAt(); + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); + + // when + Member result = memberQueryService.getDeletedMember(memberId); + + // then + assertThat(result).isEqualTo(member); + } + } + @Nested @DisplayName("getRoleByMemberId 메서드는") class GetRoleByMemberId { diff --git a/src/test/java/com/ject/studytrip/member/presentation/controller/MemberControllerIntegrationTest.java b/src/test/java/com/ject/studytrip/member/presentation/controller/MemberControllerIntegrationTest.java index daffbd5..e0e3683 100644 --- a/src/test/java/com/ject/studytrip/member/presentation/controller/MemberControllerIntegrationTest.java +++ b/src/test/java/com/ject/studytrip/member/presentation/controller/MemberControllerIntegrationTest.java @@ -14,6 +14,7 @@ import com.ject.studytrip.auth.domain.error.AuthErrorCode; import com.ject.studytrip.auth.fixture.TokenFixture; import com.ject.studytrip.auth.helper.TokenTestHelper; +import com.ject.studytrip.global.exception.error.CommonErrorCode; import com.ject.studytrip.image.domain.error.ImageErrorCode; import com.ject.studytrip.image.infra.s3.provider.S3ImageStorageProvider; import com.ject.studytrip.member.domain.error.MemberErrorCode; @@ -524,4 +525,99 @@ void shouldHardDeleteMemberAndAllRelatedDataWhenMemberIdIsValid() throws Excepti .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())); } } + + @Nested + @DisplayName("멤버 복구 API") + class RestoreMember { + private ResultActions getResultActions(Object memberId) throws Exception { + return mockMvc.perform( + patch(BASE_MEMBER_URL + "/me/restore/{memberId}", memberId) + .contentType(MediaType.APPLICATION_JSON)); + } + + @Test + @DisplayName("PathVariable 멤버 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenMemberIdTypeMismatch() throws Exception { + // given + String memberId = "abc"; + + // when + ResultActions resultActions = getResultActions(memberId); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getStatus() + .value())) + .andExpect( + jsonPath("$.data.message") + .value( + CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH + .getMessage())); + } + + @Test + @DisplayName("멤버가 존재하지 않으면 404 Not Found를 반환한다.") + void shouldReturnNotFoundWhenMemberDoesNotExist() throws Exception { + // given + Long memberId = -1L; + + // when + ResultActions resultActions = getResultActions(memberId); + + // then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(MemberErrorCode.MEMBER_NOT_FOUND.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(MemberErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("멤버가 삭제되지 않았다면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenMemberIsNotDeleted() throws Exception { + // given + Long memberId = member.getId(); + + // when + ResultActions resultActions = getResultActions(memberId); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(MemberErrorCode.MEMBER_NOT_DELETED.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(MemberErrorCode.MEMBER_NOT_DELETED.getMessage())); + } + + @Test + @DisplayName("유효한 멤버 ID가 들어오면 삭제된 멤버를 복구한다.") + void shouldRestoreMemberWhenMemberIdIsValid() throws Exception { + // given + Long memberId = member.getId(); + member.updateDeletedAt(); + + // when + ResultActions resultActions = getResultActions(memberId); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())); + } + } }