Skip to content

Commit 62dbab9

Browse files
authored
feat: 회원탈퇴 (#96)
* feat: 회원 탈퇴(회원만) * docs: 회원 탈퇴 * ci: 임시 배포 * feat: 회원 삭제 시 이미지와 태그도 삭제 * test: 회원 삭제 시 이미지와 태그도 삭제 * fix: 북마크 삭제 추가 * fix: docs 수정
1 parent 2a49ac9 commit 62dbab9

File tree

9 files changed

+178
-10
lines changed

9 files changed

+178
-10
lines changed

capturecat-core/src/docs/asciidoc/error-codes.adoc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ include::{snippets}/errorCode/reissue/error-codes.adoc[]
7070
=== 로그아웃
7171
include::{snippets}/errorCode/logout/error-codes.adoc[]
7272

73+
[[회원탈퇴]]
74+
=== 회원탈퇴
75+
include::{snippets}/errorCode/withdraw/error-codes.adoc[]
76+
7377
[[튜토리얼-완료-업데이트]]
7478
=== 튜토리얼 완료 업데이트
7579
include::{snippets}/errorCode/tutorialComplete/error-codes.adoc[]

capturecat-core/src/docs/asciidoc/user.adoc

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,20 @@
44
[[튜토리얼-완료-업데이트]]
55
=== 튜토리얼(시작하기) 완료 업데이트
66
==== 성공
7-
operation::tutorialComplete[snippets='curl-request,http-request,http-response,response-fields']
7+
operation::tutorialComplete[snippets='curl-request,http-request,request-headers,http-response,response-fields']
88

99
==== 실패
1010
튜토리얼(시작하기) 완료 업데이트가 실패했다면 HTTP 상태 코드와 함께 <<에러-객체-형식, 에러 객체>>가 돌아옵니다.
1111

1212
<<error-codes#튜토리얼-완료-업데이트, 튜토리얼 완료 업데이트 API에서 발생할 수 있는 에러>>를 살펴보세요.
13+
14+
15+
[[회원-탈퇴]]
16+
=== 회원 탈퇴
17+
==== 성공
18+
operation::withdraw[snippets='curl-request,http-request,request-headers,http-response,response-fields']
19+
20+
==== 실패
21+
회원 탈퇴가 실패했다면 HTTP 상태 코드와 함께 <<에러-객체-형식, 에러 객체>>가 돌아옵니다.
22+
23+
<<error-codes#회원탈퇴, 회원 탈퇴 API에서 발생할 수 있는 에러>>를 살펴보세요.

capturecat-core/src/main/java/com/capturecat/core/api/user/UserController.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import org.springframework.security.core.annotation.AuthenticationPrincipal;
66
import org.springframework.validation.BindingResult;
7+
import org.springframework.web.bind.annotation.DeleteMapping;
78
import org.springframework.web.bind.annotation.PostMapping;
89
import org.springframework.web.bind.annotation.RequestBody;
910
import org.springframework.web.bind.annotation.RequestMapping;
@@ -35,4 +36,10 @@ public ApiResponse<?> tutorialCompleted(@AuthenticationPrincipal LoginUser login
3536
userService.updateTutorialCompleted(loginUser);
3637
return ApiResponse.success();
3738
}
39+
40+
@DeleteMapping("/withdraw")
41+
public ApiResponse<?> withdraw(@AuthenticationPrincipal LoginUser loginUser) {
42+
userService.withdraw(loginUser);
43+
return ApiResponse.success();
44+
}
3845
}

capturecat-core/src/main/java/com/capturecat/core/domain/bookmark/BookmarkRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,6 @@ public interface BookmarkRepository extends JpaRepository<Bookmark, Long>, Bookm
1414
boolean existsByUserAndImage(User user, Image image);
1515

1616
void deleteByUserAndImage(User user, Image image);
17+
18+
void deleteByUser(User user);
1719
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
package com.capturecat.core.domain.image;
22

3+
import java.util.List;
4+
35
import org.springframework.data.jpa.repository.JpaRepository;
46

7+
import com.capturecat.core.domain.user.User;
8+
59
public interface ImageRepository extends JpaRepository<Image, Long>, ImageCustomRepository {
10+
List<Image> findByUser(User user);
611
}

capturecat-core/src/main/java/com/capturecat/core/service/user/UserService.java

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package com.capturecat.core.service.user;
22

3+
import java.util.List;
4+
35
import org.springframework.security.crypto.password.PasswordEncoder;
46
import org.springframework.stereotype.Service;
57
import org.springframework.transaction.annotation.Transactional;
@@ -8,6 +10,10 @@
810

911
import com.capturecat.core.api.user.dto.UserReqDto.JoinReqDto;
1012
import com.capturecat.core.api.user.dto.UserReqDto.JoinRespDto;
13+
import com.capturecat.core.domain.bookmark.BookmarkRepository;
14+
import com.capturecat.core.domain.image.Image;
15+
import com.capturecat.core.domain.image.ImageRepository;
16+
import com.capturecat.core.domain.tag.ImageTagRepository;
1117
import com.capturecat.core.domain.user.User;
1218
import com.capturecat.core.domain.user.UserRepository;
1319
import com.capturecat.core.domain.user.UserRole;
@@ -23,6 +29,10 @@
2329
public class UserService {
2430

2531
private final UserRepository userRepository;
32+
private final ImageRepository imageRepository;
33+
private final ImageTagRepository imageTagRepository;
34+
private final BookmarkRepository bookmarkRepository;
35+
2636
private final PasswordEncoder passwordEncoder;
2737

2838
/**
@@ -43,13 +53,42 @@ public JoinRespDto join(JoinReqDto joinReqDto) {
4353
/**
4454
* 소셜 로그인 시 회원가입 처리
4555
* TODO: PROVIDER와 SUBJECT를 기준으로 '소셜서비스'+'소셜ID' 형태로 찾는다. 각기 다른 소셜로그인으로 로그인했을 때 문제가 될 것 같다.
56+
* => 소셜 로그인 매핑 테이블 만들 것. (구글, 애플, 카카오 세개 전부 가능하다) + 회원 탈퇴 시 cascade 삭제
4657
*/
4758
public LoginUser upsertSocialUser(OidcUserPayload payload) {
4859
User user = userRepository.findByProviderAndSocialId(payload.provider(), payload.sub())
4960
.orElseGet(() -> userRepository.save(buildUser(payload)));
5061
return new LoginUser(user);
5162
}
5263

64+
/**
65+
* 튜토리얼(시작하기) 완료 상태 저장
66+
*/
67+
public void updateTutorialCompleted(LoginUser loginUser) {
68+
User user = userRepository.findByUsername(loginUser.getUsername()) //email
69+
.orElseThrow(() -> new CoreException(ErrorType.USER_NOT_FOUND));
70+
user.tutorialComplete();
71+
}
72+
73+
/**
74+
* 회원 탈퇴
75+
*/
76+
public void withdraw(LoginUser loginUser) {
77+
User user = userRepository.findByUsername(loginUser.getUsername()) //email
78+
.orElseThrow(() -> new CoreException(ErrorType.USER_NOT_FOUND));
79+
80+
//1. 즐겨찾기 삭제
81+
bookmarkRepository.deleteByUser(user);
82+
83+
// 2. 해당 User가 소유한 이미지 모두 삭제
84+
List<Image> byUser = imageRepository.findByUser(user);
85+
byUser.forEach(imageTagRepository::deleteAllByImage);
86+
imageRepository.deleteAll(byUser);
87+
88+
// 2. User 삭제
89+
userRepository.delete(user);
90+
}
91+
5392
private User buildUser(OidcUserPayload payload) {
5493
return User.builder()
5594
.username(payload.email() != null ? payload.email() : payload.provider() + "_" + payload.sub())
@@ -60,10 +99,4 @@ private User buildUser(OidcUserPayload payload) {
6099
.role(UserRole.USER)
61100
.build();
62101
}
63-
64-
public void updateTutorialCompleted(LoginUser loginUser) {
65-
User user = userRepository.findByUsername(loginUser.getUsername())
66-
.orElseThrow(() -> new CoreException(ErrorType.USER_NOT_FOUND));
67-
user.tutorialComplete();
68-
}
69102
}

capturecat-core/src/test/java/com/capturecat/core/api/error/ErrorCodeControllerTest.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@ void setUp() {
138138
generateErrorDocs("errorCode/tutorialComplete", errorCodeDescriptors);
139139
}
140140

141+
@Test
142+
void 회원탈퇴_에러_코드_문서() {
143+
List<ErrorCodeDescriptor> errorCodeDescriptors = generateErrorCodeDescriptors(USER_NOT_FOUND);
144+
generateErrorDocs("errorCode/withdraw", errorCodeDescriptors);
145+
}
146+
141147
private void generateErrorDocs(String identifier, List<ErrorCodeDescriptor> errorCodeDescriptors) {
142148
given().contentType(ContentType.JSON)
143149
.when().get("/v1/error-codes")

capturecat-core/src/test/java/com/capturecat/core/api/user/UserControllerTest.java

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,39 @@
44
import static org.junit.jupiter.api.Assertions.*;
55
import static org.mockito.BDDMockito.*;
66
import static org.mockito.Mockito.*;
7+
import static org.springframework.restdocs.headers.HeaderDocumentation.*;
8+
import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
79
import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.*;
810
import static org.springframework.restdocs.payload.PayloadDocumentation.*;
911
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
1012

1113
import org.junit.jupiter.api.BeforeEach;
1214
import org.junit.jupiter.api.Test;
15+
import org.springframework.http.HttpHeaders;
1316
import org.springframework.http.HttpStatus;
1417
import org.springframework.restdocs.payload.JsonFieldType;
1518

1619
import com.fasterxml.jackson.databind.ObjectMapper;
1720

1821
import io.restassured.http.ContentType;
1922

23+
import com.capturecat.core.config.jwt.JwtUtil;
2024
import com.capturecat.core.service.auth.LoginUser;
2125
import com.capturecat.core.service.user.UserService;
2226
import com.capturecat.test.api.RestDocsTest;
2327

2428
class UserControllerTest extends RestDocsTest {
2529

2630
private static final String URL_PREFIX = "/v1/user";
31+
private static final String ACCESS_TOKEN = "valid-access-token";
2732

2833
private final ObjectMapper om = new ObjectMapper();
2934

3035
private UserController userController;
3136

3237
private UserService userService;
3338

39+
3440
@BeforeEach
3541
void setUp() {
3642
userService = mock(UserService.class);
@@ -44,12 +50,34 @@ void setUp() {
4450
willDoNothing().given(userService).updateTutorialCompleted(any(LoginUser.class));
4551

4652
// when & then
47-
given().contentType(ContentType.JSON).log().all()
53+
given().header(HttpHeaders.AUTHORIZATION, JwtUtil.BEARER_PREFIX + ACCESS_TOKEN)
54+
.contentType(ContentType.JSON)
4855
.when().post(URL_PREFIX + "/tutorialComplete")
4956
.then().status(HttpStatus.OK)
5057
.apply(document("tutorialComplete", requestPreprocessor(), responsePreprocessor(),
58+
requestHeaders(
59+
headerWithName(HttpHeaders.AUTHORIZATION)
60+
.description("유효한 Access 토큰")),
61+
responseFields(
62+
fieldWithPath("result").type(JsonFieldType.STRING).description("요청 결과 (예: SUCCESS)"))));
63+
}
64+
65+
@Test
66+
void 회원_탈퇴() {
67+
// given
68+
willDoNothing().given(userService).withdraw(any(LoginUser.class));
69+
70+
// when & then
71+
given().header(HttpHeaders.AUTHORIZATION, JwtUtil.BEARER_PREFIX + ACCESS_TOKEN)
72+
.contentType(ContentType.JSON)
73+
.when().delete(URL_PREFIX + "/withdraw")
74+
.then().status(HttpStatus.OK)
75+
.apply(document("withdraw", requestPreprocessor(), responsePreprocessor(),
76+
requestHeaders(
77+
headerWithName(HttpHeaders.AUTHORIZATION)
78+
.description("유효한 Access 토큰")),
5179
responseFields(
52-
fieldWithPath("result").type(JsonFieldType.STRING).description("요청 결과"))));
80+
fieldWithPath("result").type(JsonFieldType.STRING).description("요청 결과 (예: SUCCESS)"))));
5381
}
5482

5583
}

capturecat-core/src/test/java/com/capturecat/core/service/user/UserServiceTest.java

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,33 @@
22

33
import static com.capturecat.core.DummyObject.*;
44
import static com.capturecat.core.api.user.dto.UserReqDto.*;
5+
import static org.assertj.core.api.Assertions.*;
56
import static org.mockito.ArgumentMatchers.*;
6-
import static org.mockito.Mockito.*;
7+
import static org.mockito.BDDMockito.*;
78

9+
import java.util.ArrayList;
10+
import java.util.List;
811
import java.util.Optional;
912

1013
import org.junit.jupiter.api.Assertions;
1114
import org.junit.jupiter.api.Test;
1215
import org.junit.jupiter.api.extension.ExtendWith;
16+
import org.mockito.ArgumentCaptor;
1317
import org.mockito.InjectMocks;
1418
import org.mockito.Mock;
1519
import org.mockito.Spy;
1620
import org.mockito.junit.jupiter.MockitoExtension;
1721
import org.springframework.security.crypto.password.PasswordEncoder;
1822

23+
import com.capturecat.core.domain.bookmark.BookmarkRepository;
24+
import com.capturecat.core.domain.image.Image;
25+
import com.capturecat.core.domain.image.ImageRepository;
26+
import com.capturecat.core.domain.tag.ImageTagRepository;
1927
import com.capturecat.core.domain.user.User;
2028
import com.capturecat.core.domain.user.UserRepository;
2129
import com.capturecat.core.service.auth.LoginUser;
30+
import com.capturecat.core.support.error.CoreException;
31+
import com.capturecat.core.support.error.ErrorType;
2232

2333
@ExtendWith(MockitoExtension.class)
2434
class UserServiceTest {
@@ -29,6 +39,15 @@ class UserServiceTest {
2939
@Mock
3040
private UserRepository userRepository;
3141

42+
@Mock
43+
private ImageRepository imageRepository;
44+
45+
@Mock
46+
private ImageTagRepository imageTagRepository;
47+
48+
@Mock
49+
private BookmarkRepository bookmarkRepository;
50+
3251
@Spy
3352
private PasswordEncoder passwordEncoder;
3453

@@ -67,4 +86,57 @@ class UserServiceTest {
6786
//then
6887
Assertions.assertEquals(true, savedUser.isTutorialCompleted());
6988
}
89+
90+
@Test
91+
void 회원탈퇴_성공() {
92+
//given
93+
//기 회원 만들기
94+
User savedUser = newMockUser(1L);
95+
96+
when(userRepository.findByUsername(savedUser.getUsername())).thenReturn(Optional.of(savedUser));
97+
98+
// User가 소유한 이미지 2개라고 가정
99+
Image image1 = mock(Image.class);
100+
Image image2 = mock(Image.class);
101+
List<Image> userImages = List.of(image1, image2);
102+
103+
when(imageRepository.findByUser(savedUser)).thenReturn(userImages);
104+
105+
// when
106+
userService.withdraw(new LoginUser(savedUser));
107+
108+
// then
109+
verify(bookmarkRepository).deleteByUser(savedUser);
110+
verify(imageRepository).findByUser(savedUser);
111+
112+
// 각 이미지에 대해 imageTagRepository.deleteAllByImage 호출
113+
verify(imageTagRepository).deleteAllByImage(image1);
114+
verify(imageTagRepository).deleteAllByImage(image2);
115+
116+
// 이미지 전체 삭제
117+
verify(imageRepository).deleteAll(userImages);
118+
119+
// 마지막으로 user 삭제
120+
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
121+
verify(userRepository).delete(captor.capture());
122+
assertThat(captor.getValue()).isSameAs(savedUser);
123+
}
124+
125+
@Test
126+
void 존재하지_않는_회원_탈퇴시_예외() {
127+
// given
128+
//given
129+
//기 회원 만들기
130+
User savedUser = newMockUser(1L);
131+
132+
when(userRepository.findByUsername(savedUser.getUsername())).thenReturn(Optional.empty());
133+
134+
// when & then
135+
assertThatThrownBy(() -> userService.withdraw(new LoginUser(savedUser)))
136+
.isInstanceOf(CoreException.class)
137+
.satisfies(e -> {
138+
CoreException ce = (CoreException) e;
139+
assertThat(ce.getErrorType()).isEqualTo(ErrorType.USER_NOT_FOUND);
140+
});
141+
}
70142
}

0 commit comments

Comments
 (0)