From 488fafa2d2b526ffc0c08ec009410a184b26ddf4 Mon Sep 17 00:00:00 2001 From: Wo-ogie Date: Sun, 18 Feb 2024 16:16:17 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20#25=20=EC=9C=A0=EC=A0=80=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A4=91=20=EC=83=9D=EB=85=84=EC=9B=94=EC=9D=BC?= =?UTF-8?q?=EA=B3=BC=20=EC=84=B1=EB=B3=84=EC=9D=84=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ajou/hertz/domain/user/dto/request/SignUpRequest.java | 5 +---- src/main/java/com/ajou/hertz/domain/user/entity/User.java | 3 +-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/ajou/hertz/domain/user/dto/request/SignUpRequest.java b/src/main/java/com/ajou/hertz/domain/user/dto/request/SignUpRequest.java index 376e06a..e557b9d 100644 --- a/src/main/java/com/ajou/hertz/domain/user/dto/request/SignUpRequest.java +++ b/src/main/java/com/ajou/hertz/domain/user/dto/request/SignUpRequest.java @@ -2,14 +2,13 @@ import java.time.LocalDate; -import com.ajou.hertz.domain.user.constant.Gender; import com.ajou.hertz.common.validator.Password; import com.ajou.hertz.common.validator.PhoneNumber; +import com.ajou.hertz.domain.user.constant.Gender; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; @@ -31,11 +30,9 @@ public class SignUpRequest { private String password; @Schema(description = "생년월일", example = "2024-01-01") - @NotNull private LocalDate birth; @Schema(description = "성별") - @NotNull private Gender gender; @Schema(description = "전화번호", example = "01012345678") diff --git a/src/main/java/com/ajou/hertz/domain/user/entity/User.java b/src/main/java/com/ajou/hertz/domain/user/entity/User.java index 978d56f..8ffff7a 100644 --- a/src/main/java/com/ajou/hertz/domain/user/entity/User.java +++ b/src/main/java/com/ajou/hertz/domain/user/entity/User.java @@ -62,7 +62,6 @@ public class User extends TimeTrackedBaseEntity { private LocalDate birth; - @Column(nullable = false) @Enumerated(EnumType.STRING) private Gender gender; @@ -77,7 +76,7 @@ public static User create( @NonNull String password, @NonNull String profileImageUrl, LocalDate birth, - @NonNull Gender gender, + Gender gender, String phone ) { return new User( From 095004a0b31d11f9af67fc06225ae2b8ddf221c7 Mon Sep 17 00:00:00 2001 From: Wo-ogie Date: Sun, 18 Feb 2024 17:20:01 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20#25=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=9C=A0=EC=A0=80=20=EC=A4=91=EB=B3=B5=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthControllerV1.java | 5 +- .../constant/CustomExceptionType.java | 8 +- .../user/controller/UserControllerV1.java | 8 +- .../UserKakaoUidDuplicationException.java | 10 +++ .../UserNotFoundByKakaoUidException.java | 10 +++ .../UserPhoneDuplicationException.java | 11 +++ .../user/repository/UserRepository.java | 6 ++ .../user/service/UserCommandService.java | 42 +++++++++- .../domain/user/service/UserQueryService.java | 27 +++++++ .../user/service/UserCommandServiceTest.java | 29 ++++++- .../user/service/UserQueryServiceTest.java | 78 +++++++++++++++++-- 11 files changed, 215 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/ajou/hertz/domain/user/exception/UserKakaoUidDuplicationException.java create mode 100644 src/main/java/com/ajou/hertz/domain/user/exception/UserNotFoundByKakaoUidException.java create mode 100644 src/main/java/com/ajou/hertz/domain/user/exception/UserPhoneDuplicationException.java diff --git a/src/main/java/com/ajou/hertz/common/auth/controller/AuthControllerV1.java b/src/main/java/com/ajou/hertz/common/auth/controller/AuthControllerV1.java index cfe424e..3ada0c7 100644 --- a/src/main/java/com/ajou/hertz/common/auth/controller/AuthControllerV1.java +++ b/src/main/java/com/ajou/hertz/common/auth/controller/AuthControllerV1.java @@ -13,6 +13,7 @@ import com.ajou.hertz.common.auth.service.AuthService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; @@ -33,8 +34,8 @@ public class AuthControllerV1 { ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK"), - @ApiResponse(responseCode = "400", description = "[2003] 비밀번호가 일치하지 않는 경우"), - @ApiResponse(responseCode = "404", description = "[2202] 이메일에 해당하는 유저를 찾을 수 없는 경우") + @ApiResponse(responseCode = "400", description = "[2003] 비밀번호가 일치하지 않는 경우", content = @Content), + @ApiResponse(responseCode = "404", description = "[2202] 이메일에 해당하는 유저를 찾을 수 없는 경우", content = @Content) }) @PostMapping(value = "/login", headers = API_MINOR_VERSION_HEADER_NAME + "=" + 1) public JwtTokenInfoResponse loginV1_1(@RequestBody @Valid LoginRequest loginRequest) { diff --git a/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java b/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java index 7884203..e783b74 100644 --- a/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java +++ b/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java @@ -30,9 +30,13 @@ public enum CustomExceptionType { /** * 유저 관련 예외 */ - USER_EMAIL_DUPLICATION(2200, "이미 다른 회원이 사용 중인 이메일입니다."), + USER_EMAIL_DUPLICATION(2200, "이미 사용 중인 이메일입니다."), USER_NOT_FOUND_BY_ID(2201, "일치하는 회원을 찾을 수 없습니다."), - USER_NOT_FOUND_BY_EMAIL(2202, "일치하는 회원을 찾을 수 없습니다."); + USER_NOT_FOUND_BY_EMAIL(2202, "일치하는 회원을 찾을 수 없습니다."), + USER_PHONE_DUPLICATION(2203, "이미 사용 중인 전화번호입니다."), + USER_KAKAO_UID_DUPLICATION(2204, "이미 가입한 계정입니다."), + USER_NOT_FOUND_BY_KAKAO_UID(2205, "일치하는 회원을 찾을 수 없습니다."), + ; private final Integer code; private final String message; diff --git a/src/main/java/com/ajou/hertz/domain/user/controller/UserControllerV1.java b/src/main/java/com/ajou/hertz/domain/user/controller/UserControllerV1.java index e71d63b..08f3a79 100644 --- a/src/main/java/com/ajou/hertz/domain/user/controller/UserControllerV1.java +++ b/src/main/java/com/ajou/hertz/domain/user/controller/UserControllerV1.java @@ -61,7 +61,13 @@ public UserExistenceResponse getExistenceOfUserByEmailV1_1( ) @ApiResponses({ @ApiResponse(responseCode = "200"), - @ApiResponse(responseCode = "409", description = "[2200] 이미 다른 사용자가 사용 중인 이메일로 신규 회원을 등록하려고 하는 경우", content = @Content) + @ApiResponse( + responseCode = "409", content = @Content, + description = """ +

[2200] 이미 다른 사용자가 사용 중인 이메일로 신규 회원을 등록하려고 하는 경우. +

[2203] 이미 다른 사용자가 사용 중인 전화번호로 신규 회원을 등록하려고 하는 경우. + """ + ) }) @PostMapping(headers = API_MINOR_VERSION_HEADER_NAME + "=" + 1) public ResponseEntity signUpV1_1( diff --git a/src/main/java/com/ajou/hertz/domain/user/exception/UserKakaoUidDuplicationException.java b/src/main/java/com/ajou/hertz/domain/user/exception/UserKakaoUidDuplicationException.java new file mode 100644 index 0000000..3907d7e --- /dev/null +++ b/src/main/java/com/ajou/hertz/domain/user/exception/UserKakaoUidDuplicationException.java @@ -0,0 +1,10 @@ +package com.ajou.hertz.domain.user.exception; + +import com.ajou.hertz.common.exception.ConflictException; +import com.ajou.hertz.common.exception.constant.CustomExceptionType; + +public class UserKakaoUidDuplicationException extends ConflictException { + public UserKakaoUidDuplicationException(String kakaoUid) { + super(CustomExceptionType.USER_KAKAO_UID_DUPLICATION, "kakaoUid=" + kakaoUid); + } +} diff --git a/src/main/java/com/ajou/hertz/domain/user/exception/UserNotFoundByKakaoUidException.java b/src/main/java/com/ajou/hertz/domain/user/exception/UserNotFoundByKakaoUidException.java new file mode 100644 index 0000000..bf6d2a0 --- /dev/null +++ b/src/main/java/com/ajou/hertz/domain/user/exception/UserNotFoundByKakaoUidException.java @@ -0,0 +1,10 @@ +package com.ajou.hertz.domain.user.exception; + +import com.ajou.hertz.common.exception.NotFoundException; +import com.ajou.hertz.common.exception.constant.CustomExceptionType; + +public class UserNotFoundByKakaoUidException extends NotFoundException { + public UserNotFoundByKakaoUidException(String kakaoUid) { + super(CustomExceptionType.USER_KAKAO_UID_DUPLICATION, "kakaoUid=" + kakaoUid); + } +} diff --git a/src/main/java/com/ajou/hertz/domain/user/exception/UserPhoneDuplicationException.java b/src/main/java/com/ajou/hertz/domain/user/exception/UserPhoneDuplicationException.java new file mode 100644 index 0000000..a879412 --- /dev/null +++ b/src/main/java/com/ajou/hertz/domain/user/exception/UserPhoneDuplicationException.java @@ -0,0 +1,11 @@ +package com.ajou.hertz.domain.user.exception; + +import com.ajou.hertz.common.exception.ConflictException; +import com.ajou.hertz.common.exception.constant.CustomExceptionType; + +public class UserPhoneDuplicationException extends ConflictException { + + public UserPhoneDuplicationException(String phone) { + super(CustomExceptionType.USER_PHONE_DUPLICATION, "phone=" + phone); + } +} diff --git a/src/main/java/com/ajou/hertz/domain/user/repository/UserRepository.java b/src/main/java/com/ajou/hertz/domain/user/repository/UserRepository.java index 9be5a9b..091acfd 100644 --- a/src/main/java/com/ajou/hertz/domain/user/repository/UserRepository.java +++ b/src/main/java/com/ajou/hertz/domain/user/repository/UserRepository.java @@ -10,5 +10,11 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); + Optional findByKakaoUid(String kakaoUid); + boolean existsByEmail(String email); + + boolean existsByKakaoUid(String kakaoUid); + + boolean existsByPhone(String phone); } diff --git a/src/main/java/com/ajou/hertz/domain/user/service/UserCommandService.java b/src/main/java/com/ajou/hertz/domain/user/service/UserCommandService.java index f175ff6..ecc89ce 100644 --- a/src/main/java/com/ajou/hertz/domain/user/service/UserCommandService.java +++ b/src/main/java/com/ajou/hertz/domain/user/service/UserCommandService.java @@ -9,6 +9,8 @@ import com.ajou.hertz.domain.user.dto.request.SignUpRequest; import com.ajou.hertz.domain.user.entity.User; import com.ajou.hertz.domain.user.exception.UserEmailDuplicationException; +import com.ajou.hertz.domain.user.exception.UserKakaoUidDuplicationException; +import com.ajou.hertz.domain.user.exception.UserPhoneDuplicationException; import com.ajou.hertz.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -29,12 +31,12 @@ public class UserCommandService { * @param signUpRequest 회원 등록을 위한 정보가 담긴 dto * @return 새로 등록된 회원 정보가 담긴 dto * @throws UserEmailDuplicationException 전달된 이메일이 중복된 이메일인 경우. 즉, 다른 사용자가 이미 같은 이메일을 사용하고 있는 경우. + * @throws UserPhoneDuplicationException 전달된 전화번호가 중복된 전화번호인 경우. 즉, 다른 사용자가 이미 같은 전화번호를 사용하고 있는 경우. */ public UserDto createNewUser(SignUpRequest signUpRequest) { String email = signUpRequest.getEmail(); - if (userQueryService.existsByEmail(email)) { - throw new UserEmailDuplicationException(email); - } + String phone = signUpRequest.getPhone(); + validateUserNotDuplicated(email, phone); User userSaved = userRepository.save( User.create( @@ -43,9 +45,41 @@ public UserDto createNewUser(SignUpRequest signUpRequest) { hertzProperties.userDefaultProfileImageUrl(), signUpRequest.getBirth(), signUpRequest.getGender(), - signUpRequest.getPhone() + phone ) ); return UserDto.from(userSaved); } + + /** + * 기존 유저와 중복되는 정보가 없음을 검증한다. + * + * @param email email + * @param phone phone number + * @throws UserEmailDuplicationException 전달된 이메일이 중복된 이메일인 경우. 즉, 다른 사용자가 이미 같은 이메일을 사용하고 있는 경우. + * @throws UserPhoneDuplicationException 전달된 전화번호가 중복된 전화번호인 경우. 즉, 다른 사용자가 이미 같은 전화번호를 사용하고 있는 경우. + */ + private void validateUserNotDuplicated(String email, String phone) { + if (userQueryService.existsByEmail(email)) { + throw new UserEmailDuplicationException(email); + } + if (userQueryService.existsByPhone(phone)) { + throw new UserPhoneDuplicationException(phone); + } + } + + /** + * 기존 유저와 중복되는 정보가 없음을 검증한다. + * + * @param email email + * @param phone phone number + * @throws UserEmailDuplicationException 전달된 이메일이 중복된 이메일인 경우. 즉, 다른 사용자가 이미 같은 이메일을 사용하고 있는 경우. + * @throws UserPhoneDuplicationException 전달된 전화번호가 중복된 전화번호인 경우. 즉, 다른 사용자가 이미 같은 전화번호를 사용하고 있는 경우. + */ + private void validateUserNotDuplicated(String email, String phone, String kakaoUid) { + validateUserNotDuplicated(email, phone); + if (userQueryService.existsByKakaoUid(kakaoUid)) { + throw new UserKakaoUidDuplicationException(kakaoUid); + } + } } diff --git a/src/main/java/com/ajou/hertz/domain/user/service/UserQueryService.java b/src/main/java/com/ajou/hertz/domain/user/service/UserQueryService.java index 0d5a4be..17e001c 100644 --- a/src/main/java/com/ajou/hertz/domain/user/service/UserQueryService.java +++ b/src/main/java/com/ajou/hertz/domain/user/service/UserQueryService.java @@ -7,6 +7,7 @@ import com.ajou.hertz.domain.user.entity.User; import com.ajou.hertz.domain.user.exception.UserNotFoundByEmailException; import com.ajou.hertz.domain.user.exception.UserNotFoundByIdException; +import com.ajou.hertz.domain.user.exception.UserNotFoundByKakaoUidException; import com.ajou.hertz.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -62,6 +63,12 @@ public UserDto getDtoByEmail(String email) { return UserDto.from(getByEmail(email)); } + public UserDto getDtoByKakaoUid(String kakaoUid) { + User user = userRepository.findByKakaoUid(kakaoUid) + .orElseThrow(() -> new UserNotFoundByKakaoUidException(kakaoUid)); + return UserDto.from(user); + } + /** * 전달된 email을 사용 중인 회원의 존재 여부를 조회한다. * @@ -71,4 +78,24 @@ public UserDto getDtoByEmail(String email) { public boolean existsByEmail(String email) { return userRepository.existsByEmail(email); } + + /** + * 전달된 kakaoUid를 사용 중인 회원의 존재 여부를 조회한다. + * + * @param kakaoUid kakao user id + * @return 전달된 kakaoUid를 사용 중인 회원의 존재 여부 + */ + public boolean existsByKakaoUid(String kakaoUid) { + return userRepository.existsByKakaoUid(kakaoUid); + } + + /** + * 전달된 phone를 사용 중인 회원의 존재 여부를 조회한다. + * + * @param phone phone number + * @return 전달된 phone를 사용 중인 회원의 존재 여부 + */ + public boolean existsByPhone(String phone) { + return userRepository.existsByPhone(phone); + } } diff --git a/src/test/java/com/ajou/hertz/unit/domain/user/service/UserCommandServiceTest.java b/src/test/java/com/ajou/hertz/unit/domain/user/service/UserCommandServiceTest.java index f3d05df..780cde9 100644 --- a/src/test/java/com/ajou/hertz/unit/domain/user/service/UserCommandServiceTest.java +++ b/src/test/java/com/ajou/hertz/unit/domain/user/service/UserCommandServiceTest.java @@ -23,6 +23,7 @@ import com.ajou.hertz.domain.user.dto.request.SignUpRequest; import com.ajou.hertz.domain.user.entity.User; import com.ajou.hertz.domain.user.exception.UserEmailDuplicationException; +import com.ajou.hertz.domain.user.exception.UserPhoneDuplicationException; import com.ajou.hertz.domain.user.repository.UserRepository; import com.ajou.hertz.domain.user.service.UserCommandService; import com.ajou.hertz.domain.user.service.UserQueryService; @@ -59,6 +60,7 @@ public void setUp() { String passwordEncoded = "$2a$abc123"; User expectedResult = createUser(userId, passwordEncoded); given(userQueryService.existsByEmail(signUpRequest.getEmail())).willReturn(false); + given(userQueryService.existsByPhone(signUpRequest.getPhone())).willReturn(false); given(passwordEncoder.encode(signUpRequest.getPassword())).willReturn(passwordEncoded); given(userRepository.save(any(User.class))).willReturn(expectedResult); @@ -67,6 +69,7 @@ public void setUp() { // then then(userQueryService).should().existsByEmail(signUpRequest.getEmail()); + then(userQueryService).should().existsByPhone(signUpRequest.getPhone()); then(passwordEncoder).should().encode(signUpRequest.getPassword()); then(userRepository).should().save(any(User.class)); verifyEveryMocksShouldHaveNoMoreInteractions(); @@ -77,7 +80,7 @@ public void setUp() { void 주어진_회원_정보로_신규_회원을_등록한다_이미_사용_중인_이메일이라면_예외가_발생한다() throws Exception { // given String email = "test@test.com"; - SignUpRequest signUpRequest = createSignUpRequest(email); + SignUpRequest signUpRequest = createSignUpRequest(email, "01012345678"); given(userQueryService.existsByEmail(email)).willReturn(true); // when @@ -89,6 +92,24 @@ public void setUp() { assertThat(t).isInstanceOf(UserEmailDuplicationException.class); } + @Test + void 주어진_회원_정보로_신규_회원을_등록한다_이미_사용_중인_전화번호라면_예외가_발생한다() throws Exception { + // given + String phone = "01012345678"; + SignUpRequest signUpRequest = createSignUpRequest(phone, phone); + given(userQueryService.existsByEmail(signUpRequest.getEmail())).willReturn(false); + given(userQueryService.existsByPhone(phone)).willReturn(true); + + // when + Throwable t = catchThrowable(() -> sut.createNewUser(signUpRequest)); + + // then + then(userQueryService).should().existsByEmail(signUpRequest.getEmail()); + then(userQueryService).should().existsByPhone(phone); + verifyEveryMocksShouldHaveNoMoreInteractions(); + assertThat(t).isInstanceOf(UserPhoneDuplicationException.class); + } + private void verifyEveryMocksShouldHaveNoMoreInteractions() { then(userQueryService).shouldHaveNoMoreInteractions(); then(userRepository).shouldHaveNoMoreInteractions(); @@ -115,7 +136,7 @@ private User createUser(Long id, String password) throws Exception { ); } - private SignUpRequest createSignUpRequest(String email) throws Exception { + private SignUpRequest createSignUpRequest(String email, String phone) throws Exception { Constructor signUpRequestConstructor = SignUpRequest.class.getDeclaredConstructor( String.class, String.class, LocalDate.class, Gender.class, String.class ); @@ -125,11 +146,11 @@ private SignUpRequest createSignUpRequest(String email) throws Exception { "1q2w3e4r!", LocalDate.of(2024, 1, 1), Gender.ETC, - "01012345678" + phone ); } private SignUpRequest createSignUpRequest() throws Exception { - return createSignUpRequest("test@test.com"); + return createSignUpRequest("test@test.com", "01012345678"); } } \ No newline at end of file diff --git a/src/test/java/com/ajou/hertz/unit/domain/user/service/UserQueryServiceTest.java b/src/test/java/com/ajou/hertz/unit/domain/user/service/UserQueryServiceTest.java index 918408e..05a2915 100644 --- a/src/test/java/com/ajou/hertz/unit/domain/user/service/UserQueryServiceTest.java +++ b/src/test/java/com/ajou/hertz/unit/domain/user/service/UserQueryServiceTest.java @@ -21,6 +21,7 @@ import com.ajou.hertz.domain.user.entity.User; import com.ajou.hertz.domain.user.exception.UserNotFoundByEmailException; import com.ajou.hertz.domain.user.exception.UserNotFoundByIdException; +import com.ajou.hertz.domain.user.exception.UserNotFoundByKakaoUidException; import com.ajou.hertz.domain.user.repository.UserRepository; import com.ajou.hertz.domain.user.service.UserQueryService; @@ -70,7 +71,7 @@ class UserQueryServiceTest { void 이메일이_주어지고_주어진_이메일로_유저를_조회하면_조회된_유저_정보가_반환된다() throws Exception { // given String email = "test@mail.com"; - User expectedResult = createUser(1L, email); + User expectedResult = createUser(1L, email, "1234"); given(userRepository.findByEmail(email)).willReturn(Optional.of(expectedResult)); // when @@ -100,7 +101,40 @@ class UserQueryServiceTest { } @Test - void 전달된_이메일을_사용_중인_회원의_존재_여부를_조회한다() { + void 카카오_유저_ID가_주어지고_주어진_카카오_유저_ID로_유저를_조회하면_조회된_유저_정보가_반환된다() throws Exception { + // given + String kakaoUid = "12345"; + User expectedResult = createUser(1L, "test@mail.com", kakaoUid); + given(userRepository.findByKakaoUid(kakaoUid)).willReturn(Optional.of(expectedResult)); + + // when + UserDto actualResult = sut.getDtoByKakaoUid(kakaoUid); + + // then + then(userRepository).should().findByKakaoUid(kakaoUid); + verifyEveryMocksShouldHaveNoMoreInteractions(); + assertThat(actualResult) + .hasFieldOrPropertyWithValue("id", expectedResult.getId()) + .hasFieldOrPropertyWithValue("kakaoUid", expectedResult.getKakaoUid()); + } + + @Test + void 카카오_유저_ID가_주어지고_주어진_카카오_유저_ID로_유저를_조회한다_만약_일치하는_유저가_없다면_예외가_발생한다() { + // given + String kakaoUid = "12345"; + given(userRepository.findByKakaoUid(kakaoUid)).willReturn(Optional.empty()); + + // when + Throwable t = catchThrowable(() -> sut.getDtoByKakaoUid(kakaoUid)); + + // then + then(userRepository).should().findByKakaoUid(kakaoUid); + verifyEveryMocksShouldHaveNoMoreInteractions(); + assertThat(t).isInstanceOf(UserNotFoundByKakaoUidException.class); + } + + @Test + void 이메일이_주어지고_주어진_이메일을_사용_중인_회원의_존재_여부를_조회한다() { // given String email = "test@test.com"; boolean expectedResult = true; @@ -115,11 +149,43 @@ class UserQueryServiceTest { assertThat(actualResult).isEqualTo(expectedResult); } + @Test + void 전화번호가_주어지고_주어진_전화번호를_사용_중인_회원의_존재_여부를_조회한다() { + // given + String phone = "01012345678"; + boolean expectedResult = true; + given(userRepository.existsByPhone(phone)).willReturn(expectedResult); + + // when + boolean actualResult = sut.existsByPhone(phone); + + // then + then(userRepository).should().existsByPhone(phone); + verifyEveryMocksShouldHaveNoMoreInteractions(); + assertThat(actualResult).isEqualTo(expectedResult); + } + + @Test + void 카카오_유저_ID가_주어지고_주어진_ID를_사용_중인_회원의_존재_여부를_조회한다() { + // given + String kakaoUid = "1234"; + boolean expectedResult = true; + given(userRepository.existsByKakaoUid(kakaoUid)).willReturn(expectedResult); + + // when + boolean actualResult = sut.existsByKakaoUid(kakaoUid); + + // then + then(userRepository).should().existsByKakaoUid(kakaoUid); + verifyEveryMocksShouldHaveNoMoreInteractions(); + assertThat(actualResult).isEqualTo(expectedResult); + } + private void verifyEveryMocksShouldHaveNoMoreInteractions() { then(userRepository).shouldHaveNoMoreInteractions(); } - private User createUser(Long id, String email) throws Exception { + private User createUser(Long id, String email, String kakaoUid) throws Exception { Constructor userConstructor = User.class.getDeclaredConstructor( Long.class, Set.class, String.class, String.class, String.class, String.class, LocalDate.class, Gender.class, String.class, String.class @@ -130,16 +196,16 @@ private User createUser(Long id, String email) throws Exception { Set.of(RoleType.USER), email, "password", - "kakao-user-id", + kakaoUid, "https://user-default-profile-image-url", LocalDate.of(2024, 1, 1), Gender.ETC, - "010-1234-5678", + "01012345678", null ); } private User createUser(Long id) throws Exception { - return createUser(id, "test@mail.com"); + return createUser(id, "test@mail.com", "12345"); } } \ No newline at end of file From c88fe6b7b165e9a54306d0460ee632cc7dc4f155 Mon Sep 17 00:00:00 2001 From: Wo-ogie Date: Mon, 19 Feb 2024 01:52:22 +0900 Subject: [PATCH 3/5] =?UTF-8?q?chore:=20#25=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EC=99=80=EC=9D=98=20API=20communication?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20Spring=20Open=20Feign=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ .../com/ajou/hertz/common/config/feign/FeignConfig.java | 9 +++++++++ 2 files changed, 12 insertions(+) create mode 100644 src/main/java/com/ajou/hertz/common/config/feign/FeignConfig.java diff --git a/build.gradle b/build.gradle index 3cea095..1efd4a9 100644 --- a/build.gradle +++ b/build.gradle @@ -56,6 +56,9 @@ dependencies { // Spring Configuration Processor annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' + // Spring Open Feign (Feign Client) + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign:4.1.0' + // JWT implementation 'io.jsonwebtoken:jjwt-api:0.11.5' implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' diff --git a/src/main/java/com/ajou/hertz/common/config/feign/FeignConfig.java b/src/main/java/com/ajou/hertz/common/config/feign/FeignConfig.java new file mode 100644 index 0000000..04ecdd8 --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/config/feign/FeignConfig.java @@ -0,0 +1,9 @@ +package com.ajou.hertz.common.config.feign; + +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.context.annotation.Configuration; + +@EnableFeignClients(basePackages = "com.ajou.hertz") +@Configuration +public class FeignConfig { +} From f09605c12e72ff1ff49bd9133dc7be7f595c2189 Mon Sep 17 00:00:00 2001 From: Wo-ogie Date: Mon, 19 Feb 2024 01:53:28 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20#25=20=EC=B9=B4=EC=B9=B4=EC=98=A4?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthControllerV1.java | 24 ++ .../auth/dto/request/KakaoLoginRequest.java | 22 ++ .../hertz/common/config/SecurityConfig.java | 3 +- .../common/config/feign/KakaoFeignConfig.java | 24 ++ .../config/feign/KakaoFeignErrorDecoder.java | 35 +++ .../exception/IOProcessingException.java | 18 ++ .../constant/CustomExceptionType.java | 3 + .../common/kakao/client/KakaoAuthClient.java | 23 ++ .../common/kakao/client/KakaoClient.java | 26 +++ .../request/IssueKakaoAccessTokenRequest.java | 18 ++ .../dto/response/KakaoErrorResponse.java | 10 + .../dto/response/KakaoTokenResponse.java | 29 +++ .../dto/response/KakaoUserInfoResponse.java | 88 +++++++ .../kakao/exception/KakaoClientException.java | 23 ++ .../common/kakao/service/KakaoService.java | 75 ++++++ .../common/properties/KakaoProperties.java | 10 + .../ajou/hertz/domain/user/entity/User.java | 16 ++ .../user/service/UserCommandService.java | 47 ++++ .../domain/user/service/UserQueryService.java | 13 +- src/main/resources/application.properties | 3 + .../auth/controller/AuthControllerV1Test.java | 40 ++++ .../kakao/service/KakaoServiceTest.java | 218 ++++++++++++++++++ .../user/service/UserCommandServiceTest.java | 107 ++++++++- .../user/service/UserQueryServiceTest.java | 23 +- 24 files changed, 867 insertions(+), 31 deletions(-) create mode 100644 src/main/java/com/ajou/hertz/common/auth/dto/request/KakaoLoginRequest.java create mode 100644 src/main/java/com/ajou/hertz/common/config/feign/KakaoFeignConfig.java create mode 100644 src/main/java/com/ajou/hertz/common/config/feign/KakaoFeignErrorDecoder.java create mode 100644 src/main/java/com/ajou/hertz/common/exception/IOProcessingException.java create mode 100644 src/main/java/com/ajou/hertz/common/kakao/client/KakaoAuthClient.java create mode 100644 src/main/java/com/ajou/hertz/common/kakao/client/KakaoClient.java create mode 100644 src/main/java/com/ajou/hertz/common/kakao/dto/request/IssueKakaoAccessTokenRequest.java create mode 100644 src/main/java/com/ajou/hertz/common/kakao/dto/response/KakaoErrorResponse.java create mode 100644 src/main/java/com/ajou/hertz/common/kakao/dto/response/KakaoTokenResponse.java create mode 100644 src/main/java/com/ajou/hertz/common/kakao/dto/response/KakaoUserInfoResponse.java create mode 100644 src/main/java/com/ajou/hertz/common/kakao/exception/KakaoClientException.java create mode 100644 src/main/java/com/ajou/hertz/common/kakao/service/KakaoService.java create mode 100644 src/main/java/com/ajou/hertz/common/properties/KakaoProperties.java create mode 100644 src/test/java/com/ajou/hertz/unit/common/kakao/service/KakaoServiceTest.java diff --git a/src/main/java/com/ajou/hertz/common/auth/controller/AuthControllerV1.java b/src/main/java/com/ajou/hertz/common/auth/controller/AuthControllerV1.java index 3ada0c7..f25a6a3 100644 --- a/src/main/java/com/ajou/hertz/common/auth/controller/AuthControllerV1.java +++ b/src/main/java/com/ajou/hertz/common/auth/controller/AuthControllerV1.java @@ -8,9 +8,11 @@ import org.springframework.web.bind.annotation.RestController; import com.ajou.hertz.common.auth.dto.JwtTokenInfoDto; +import com.ajou.hertz.common.auth.dto.request.KakaoLoginRequest; import com.ajou.hertz.common.auth.dto.request.LoginRequest; import com.ajou.hertz.common.auth.dto.response.JwtTokenInfoResponse; import com.ajou.hertz.common.auth.service.AuthService; +import com.ajou.hertz.common.kakao.service.KakaoService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -27,6 +29,7 @@ public class AuthControllerV1 { private final AuthService authService; + private final KakaoService kakaoService; @Operation( summary = "로그인", @@ -42,4 +45,25 @@ public JwtTokenInfoResponse loginV1_1(@RequestBody @Valid LoginRequest loginRequ JwtTokenInfoDto jwtTokenInfoDto = authService.login(loginRequest); return JwtTokenInfoResponse.from(jwtTokenInfoDto); } + + @Operation( + summary = "카카오 로그인", + description = "카카오 로그인을 진행합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200"), + @ApiResponse( + responseCode = "409", content = @Content, + description = """ +

[2200] 이미 다른 사용자가 사용 중인 이메일로 신규 회원을 등록하려고 하는 경우 +

[2203] 이미 다른 사용자가 사용 중인 전화번호로 신규 회원을 등록하려고 하는 경우 + """ + ), + @ApiResponse(responseCode = "Any", description = "[10000] 카카오 서버와의 통신 중 오류가 발생한 경우. Http status code는 kakao에서 응답받은 것과 동일하게 설정하여 응답한다.", content = @Content) + }) + @PostMapping(value = "/login/kakao", headers = API_MINOR_VERSION_HEADER_NAME + "=" + 1) + public JwtTokenInfoResponse kakaoLoginV1_1(@RequestBody @Valid KakaoLoginRequest kakaoLoginRequest) { + JwtTokenInfoDto jwtTokenInfoDto = kakaoService.login(kakaoLoginRequest); + return JwtTokenInfoResponse.from(jwtTokenInfoDto); + } } diff --git a/src/main/java/com/ajou/hertz/common/auth/dto/request/KakaoLoginRequest.java b/src/main/java/com/ajou/hertz/common/auth/dto/request/KakaoLoginRequest.java new file mode 100644 index 0000000..efff2ce --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/auth/dto/request/KakaoLoginRequest.java @@ -0,0 +1,22 @@ +package com.ajou.hertz.common.auth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public class KakaoLoginRequest { + + @Schema(description = "카카오 '인가 코드 받기'를 통해 얻은 인가 코드", example = "v9rZPkB2nyKTFX26E8ByNzUojqi6w9bQRyfBQCFDzRFhxcoUUSVpI_3HgPIKlwzSAAABjbztS6bkNSpXBP-m7E") + @NotBlank + private String authorizationCode; + + @Schema(description = "인가 코드가 리다이렉트된 uri", example = "https://hertz.com/kakao-login-redirection") + @NotBlank + private String redirectUri; +} diff --git a/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java b/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java index 1d7708c..92975f2 100644 --- a/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java +++ b/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java @@ -42,7 +42,8 @@ public class SecurityConfig { private static final Map AUTH_WHITE_LIST = Map.of( "/v*/users", POST, "/v*/users/existence", GET, - "/v*/auth/login", POST + "/v*/auth/login", POST, + "/v*/auth/login/kakao", POST ); @Bean diff --git a/src/main/java/com/ajou/hertz/common/config/feign/KakaoFeignConfig.java b/src/main/java/com/ajou/hertz/common/config/feign/KakaoFeignConfig.java new file mode 100644 index 0000000..450cb95 --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/config/feign/KakaoFeignConfig.java @@ -0,0 +1,24 @@ +package com.ajou.hertz.common.config.feign; + +import org.springframework.beans.factory.ObjectFactory; +import org.springframework.boot.autoconfigure.http.HttpMessageConverters; +import org.springframework.cloud.openfeign.support.SpringEncoder; +import org.springframework.context.annotation.Bean; + +import feign.Logger; +import feign.codec.Encoder; +import feign.codec.ErrorDecoder; +import feign.form.spring.SpringFormEncoder; + +public class KakaoFeignConfig { + + @Bean + public Encoder encoder(ObjectFactory converters) { + return new SpringFormEncoder(new SpringEncoder(converters)); + } + + @Bean + public ErrorDecoder errorDecoder() { + return new KakaoFeignErrorDecoder(); + } +} diff --git a/src/main/java/com/ajou/hertz/common/config/feign/KakaoFeignErrorDecoder.java b/src/main/java/com/ajou/hertz/common/config/feign/KakaoFeignErrorDecoder.java new file mode 100644 index 0000000..d4e08a7 --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/config/feign/KakaoFeignErrorDecoder.java @@ -0,0 +1,35 @@ +package com.ajou.hertz.common.config.feign; + +import java.io.IOException; +import java.io.InputStream; + +import org.springframework.http.HttpStatus; + +import com.ajou.hertz.common.exception.IOProcessingException; +import com.ajou.hertz.common.kakao.dto.response.KakaoErrorResponse; +import com.ajou.hertz.common.kakao.exception.KakaoClientException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import feign.Response; +import feign.codec.ErrorDecoder; + +public class KakaoFeignErrorDecoder implements ErrorDecoder { + + private final ObjectMapper mapper; + + public KakaoFeignErrorDecoder() { + this.mapper = new ObjectMapper(); + } + + @Override + public Exception decode(String methodKey, Response response) { + try (InputStream responseBody = response.body().asInputStream()) { + return new KakaoClientException( + HttpStatus.valueOf(response.status()), + mapper.readValue(responseBody, KakaoErrorResponse.class) + ); + } catch (IOException e) { + return new IOProcessingException(e); + } + } +} diff --git a/src/main/java/com/ajou/hertz/common/exception/IOProcessingException.java b/src/main/java/com/ajou/hertz/common/exception/IOProcessingException.java new file mode 100644 index 0000000..2a6dbb0 --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/exception/IOProcessingException.java @@ -0,0 +1,18 @@ +package com.ajou.hertz.common.exception; + +import java.io.IOException; + +import com.ajou.hertz.common.exception.constant.CustomExceptionType; + +/** + *

I/O 작업에서 문제가 발생했을 때 사용하는 일반적인 exception class. + *

Checked exception을 {@link IOException}을 감싸 처리하는 용도로도 사용한다. + * + * @see IOException + */ +public class IOProcessingException extends InternalServerException { + + public IOProcessingException(Throwable cause) { + super(CustomExceptionType.IO_PROCESSING, cause); + } +} diff --git a/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java b/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java index e783b74..b0eaef1 100644 --- a/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java +++ b/src/main/java/com/ajou/hertz/common/exception/constant/CustomExceptionType.java @@ -18,6 +18,7 @@ public enum CustomExceptionType { MULTIPART_FILE_NOT_READABLE(1000, "파일을 읽을 수 없습니다. 올바른 파일인지 다시 확인 후 요청해주세요."), + IO_PROCESSING(1001, "입출력 처리 중 오류가 발생했습니다."), /** * 로그인, 인증 관련 예외 @@ -36,6 +37,8 @@ public enum CustomExceptionType { USER_PHONE_DUPLICATION(2203, "이미 사용 중인 전화번호입니다."), USER_KAKAO_UID_DUPLICATION(2204, "이미 가입한 계정입니다."), USER_NOT_FOUND_BY_KAKAO_UID(2205, "일치하는 회원을 찾을 수 없습니다."), + + KAKAO_CLIENT(10000, "카카오 서버와의 통신 중 오류가 발생했습니다."), ; private final Integer code; diff --git a/src/main/java/com/ajou/hertz/common/kakao/client/KakaoAuthClient.java b/src/main/java/com/ajou/hertz/common/kakao/client/KakaoAuthClient.java new file mode 100644 index 0000000..3096607 --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/kakao/client/KakaoAuthClient.java @@ -0,0 +1,23 @@ +package com.ajou.hertz.common.kakao.client; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import com.ajou.hertz.common.config.feign.KakaoFeignConfig; +import com.ajou.hertz.common.kakao.dto.request.IssueKakaoAccessTokenRequest; +import com.ajou.hertz.common.kakao.dto.response.KakaoTokenResponse; + +@FeignClient( + name = "kakaoAuthClient", + url = "https://kauth.kakao.com", + configuration = KakaoFeignConfig.class +) +public interface KakaoAuthClient { + + @PostMapping( + value = "/oauth/token", + headers = "Content-type=application/x-www-form-urlencoded;charset=utf-8" + ) + KakaoTokenResponse issueAccessToken(@RequestBody IssueKakaoAccessTokenRequest issueTokenRequest); +} diff --git a/src/main/java/com/ajou/hertz/common/kakao/client/KakaoClient.java b/src/main/java/com/ajou/hertz/common/kakao/client/KakaoClient.java new file mode 100644 index 0000000..7d14fc9 --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/kakao/client/KakaoClient.java @@ -0,0 +1,26 @@ +package com.ajou.hertz.common.kakao.client; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +import com.ajou.hertz.common.config.feign.KakaoFeignConfig; +import com.ajou.hertz.common.kakao.dto.response.KakaoUserInfoResponse; + +@FeignClient( + name = "kakaoClient", + url = "https://kapi.kakao.com", + configuration = KakaoFeignConfig.class +) +public interface KakaoClient { + + @GetMapping( + value = "/v2/user/me", + headers = "Content-type=application/x-www-form-urlencoded;charset=utf-8" + ) + KakaoUserInfoResponse getUserInfo( + @RequestHeader("Authorization") String authorizationHeader, + @RequestParam("secure_resource") Boolean secureResource + ); +} diff --git a/src/main/java/com/ajou/hertz/common/kakao/dto/request/IssueKakaoAccessTokenRequest.java b/src/main/java/com/ajou/hertz/common/kakao/dto/request/IssueKakaoAccessTokenRequest.java new file mode 100644 index 0000000..a715797 --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/kakao/dto/request/IssueKakaoAccessTokenRequest.java @@ -0,0 +1,18 @@ +package com.ajou.hertz.common.kakao.dto.request; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public class IssueKakaoAccessTokenRequest { + + private String grant_type; + private String client_id; + private String redirect_uri; + private String code; + private String client_secret; +} diff --git a/src/main/java/com/ajou/hertz/common/kakao/dto/response/KakaoErrorResponse.java b/src/main/java/com/ajou/hertz/common/kakao/dto/response/KakaoErrorResponse.java new file mode 100644 index 0000000..b231b1b --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/kakao/dto/response/KakaoErrorResponse.java @@ -0,0 +1,10 @@ +package com.ajou.hertz.common.kakao.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record KakaoErrorResponse( + String error, + @JsonProperty("error_description") String errorDescription, + @JsonProperty("error_code") String errorCode +) { +} diff --git a/src/main/java/com/ajou/hertz/common/kakao/dto/response/KakaoTokenResponse.java b/src/main/java/com/ajou/hertz/common/kakao/dto/response/KakaoTokenResponse.java new file mode 100644 index 0000000..3040885 --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/kakao/dto/response/KakaoTokenResponse.java @@ -0,0 +1,29 @@ +package com.ajou.hertz.common.kakao.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public class KakaoTokenResponse { + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("expires_in") + private Integer expiresIn; + + @JsonProperty("refresh_token") + private String refreshToken; + + @JsonProperty("refresh_token_expires_in") + private Integer refreshTokenExpiresIn; +} diff --git a/src/main/java/com/ajou/hertz/common/kakao/dto/response/KakaoUserInfoResponse.java b/src/main/java/com/ajou/hertz/common/kakao/dto/response/KakaoUserInfoResponse.java new file mode 100644 index 0000000..37a5e7f --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/kakao/dto/response/KakaoUserInfoResponse.java @@ -0,0 +1,88 @@ +package com.ajou.hertz.common.kakao.dto.response; + +import java.time.LocalDate; + +import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record KakaoUserInfoResponse( + String id, + @JsonProperty("kakao_account") KakaoAccount kakaoAccount +) { + + public record KakaoAccount( + @JsonProperty("profile_image_needs_agreement") Boolean profileImageNeedsAgreement, + Profile profile, + + @JsonProperty("has_email") Boolean hasEmail, + @JsonProperty("email_needs_agreement") Boolean emailNeedsAgreement, + @JsonProperty("is_email_valid") Boolean isEmailValid, + @JsonProperty("is_email_verified") Boolean isEmailVerified, + String email, + + @JsonProperty("has_phone_number") Boolean hasPhoneNumber, + @JsonProperty("phone_number_needs_agreement") Boolean phoneNumberNeedsAgreement, + @JsonProperty("phone_number") String phoneNumber, + + @JsonProperty("has_birthyear") Boolean hasBirthyear, + @JsonProperty("birthyear_needs_agreement") Boolean birthyearNeedsAgreement, + String birthyear, + + @JsonProperty("has_birthday") Boolean hasBirthday, + String birthday, + + @JsonProperty("has_gender") Boolean hasGender, + @JsonProperty("gender_needs_agreement") Boolean genderNeedsAgreement, + String gender + ) { + + public record Profile( + @JsonProperty("profile_image_url") String profileImageUrl, + @JsonProperty("thumbnail_image_url") String thumbnailImageUrl, + @JsonProperty("is_default_image") Boolean isDefaultImage + ) { + } + } + + public String profileImageUrl() { + return kakaoAccount.profile.profileImageUrl(); + } + + public String email() { + return kakaoAccount.email(); + } + + public String gender() { + return kakaoAccount.gender(); + } + + @Nullable + public LocalDate birth() { + String birthyear = kakaoAccount.birthyear(); + String birthday = kakaoAccount.birthday(); + if (!StringUtils.hasText(birthyear) || !StringUtils.hasText(birthday)) { + return null; + } + return LocalDate.of( + Integer.parseInt(birthyear), + Integer.parseInt(birthday.substring(0, 2)), + Integer.parseInt(birthday.substring(2, 4)) + ); + } + + /** + *

[한국 전화번호 형식 기준] + *

"+82 10-1234-5678" 형태의 전화번호를 "01012345678" 형태로 변환하여 반환한다. + * + * @return 변환된 전화번호 + */ + public String getKoreanFormatPhoneNumber() { + return kakaoAccount.phoneNumber() + .replace("+82 ", "0") + .replace("-", "") + .trim(); + } +} + diff --git a/src/main/java/com/ajou/hertz/common/kakao/exception/KakaoClientException.java b/src/main/java/com/ajou/hertz/common/kakao/exception/KakaoClientException.java new file mode 100644 index 0000000..b0d89a2 --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/kakao/exception/KakaoClientException.java @@ -0,0 +1,23 @@ +package com.ajou.hertz.common.kakao.exception; + +import org.springframework.http.HttpStatus; + +import com.ajou.hertz.common.exception.CustomException; +import com.ajou.hertz.common.exception.constant.CustomExceptionType; +import com.ajou.hertz.common.kakao.dto.response.KakaoErrorResponse; + +public class KakaoClientException extends CustomException { + + public KakaoClientException(HttpStatus httpStatus, KakaoErrorResponse kakaoErrorResponse) { + super(httpStatus, CustomExceptionType.KAKAO_CLIENT, createErrorMessage(kakaoErrorResponse)); + } + + private static String createErrorMessage(KakaoErrorResponse kakaoErrorResponse) { + return String.format( + "ErrorInfo=[errorCode=%s, error=%s, errorMessage=%s]", + kakaoErrorResponse.errorCode(), + kakaoErrorResponse.error(), + kakaoErrorResponse.errorDescription() + ); + } +} diff --git a/src/main/java/com/ajou/hertz/common/kakao/service/KakaoService.java b/src/main/java/com/ajou/hertz/common/kakao/service/KakaoService.java new file mode 100644 index 0000000..873359a --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/kakao/service/KakaoService.java @@ -0,0 +1,75 @@ +package com.ajou.hertz.common.kakao.service; + +import org.springframework.stereotype.Service; + +import com.ajou.hertz.common.auth.JwtTokenProvider; +import com.ajou.hertz.common.auth.dto.JwtTokenInfoDto; +import com.ajou.hertz.common.auth.dto.request.KakaoLoginRequest; +import com.ajou.hertz.common.kakao.client.KakaoAuthClient; +import com.ajou.hertz.common.kakao.client.KakaoClient; +import com.ajou.hertz.common.kakao.dto.request.IssueKakaoAccessTokenRequest; +import com.ajou.hertz.common.kakao.dto.response.KakaoTokenResponse; +import com.ajou.hertz.common.kakao.dto.response.KakaoUserInfoResponse; +import com.ajou.hertz.common.kakao.exception.KakaoClientException; +import com.ajou.hertz.common.properties.KakaoProperties; +import com.ajou.hertz.domain.user.dto.UserDto; +import com.ajou.hertz.domain.user.exception.UserEmailDuplicationException; +import com.ajou.hertz.domain.user.exception.UserPhoneDuplicationException; +import com.ajou.hertz.domain.user.service.UserCommandService; +import com.ajou.hertz.domain.user.service.UserQueryService; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Service +public class KakaoService { + + private final UserQueryService userQueryService; + private final UserCommandService userCommandService; + private final KakaoClient kakaoClient; + private final KakaoAuthClient kakaoAuthClient; + private final KakaoProperties kakaoProperties; + private final JwtTokenProvider jwtTokenProvider; + + /** + *

카카오에서 발급 받은 인가 코드로 유저 정보를 조회한 후, 로그인한다. + *

만약 신규 회원이라면 신규 회원을 등록한다. + * + * @param kakaoLoginRequest 카카오 로그인을 위한 정보(인가코드 등) + * @return 로그인 성공 시 발급한 access token 정보 + * @throws UserEmailDuplicationException 전달된 이메일이 중복된 이메일인 경우. 즉, 다른 사용자가 이미 같은 이메일을 사용하고 있는 경우. + * @throws UserPhoneDuplicationException 전달된 전화번호가 중복된 전화번호인 경우. 즉, 다른 사용자가 이미 같은 전화번호를 사용하고 있는 경우. + * @throws KakaoClientException 카카오 서버와의 통신 중 오류가 발생한 경우. + */ + public JwtTokenInfoDto login(KakaoLoginRequest kakaoLoginRequest) { + String kakaoAccessToken = issueAccessToken(kakaoLoginRequest); + KakaoUserInfoResponse userInfo = kakaoClient.getUserInfo("Bearer " + kakaoAccessToken, true); + + // 기존 존재하는 회원이라면 조회, 신규 회원이라면 신규 회원 등록 로직 수행 + UserDto user = userQueryService + .findDtoByKakaoUid(userInfo.id()) + .orElseGet(() -> userCommandService.createNewUserWithKakao(userInfo)); + + return jwtTokenProvider.createAccessToken(user); + } + + /** + * 카카오에서 발급 받은 인가 코드로 access token을 발행한다. + * + * @param kakaoLoginRequest access token을 발급받기 위한 정보(인가코드 등) + * @return access token + * @throws KakaoClientException 카카오 서버와의 통신 중 오류가 발생한 경우. + */ + private String issueAccessToken(KakaoLoginRequest kakaoLoginRequest) { + KakaoTokenResponse accessTokenResponse = kakaoAuthClient.issueAccessToken( + new IssueKakaoAccessTokenRequest( + "authorization_code", + kakaoProperties.restApiKey(), + kakaoLoginRequest.getRedirectUri(), + kakaoLoginRequest.getAuthorizationCode(), + kakaoProperties.clientSecret() + ) + ); + return accessTokenResponse.getAccessToken(); + } +} diff --git a/src/main/java/com/ajou/hertz/common/properties/KakaoProperties.java b/src/main/java/com/ajou/hertz/common/properties/KakaoProperties.java new file mode 100644 index 0000000..ae9fd00 --- /dev/null +++ b/src/main/java/com/ajou/hertz/common/properties/KakaoProperties.java @@ -0,0 +1,10 @@ +package com.ajou.hertz.common.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("kakao") +public record KakaoProperties( + String restApiKey, + String clientSecret +) { +} diff --git a/src/main/java/com/ajou/hertz/domain/user/entity/User.java b/src/main/java/com/ajou/hertz/domain/user/entity/User.java index 8ffff7a..f900e55 100644 --- a/src/main/java/com/ajou/hertz/domain/user/entity/User.java +++ b/src/main/java/com/ajou/hertz/domain/user/entity/User.java @@ -84,4 +84,20 @@ public static User create( profileImageUrl, birth, gender, phone, null ); } + + @NonNull + public static User create( + @NonNull String email, + @NonNull String password, + @NonNull String kakaoUid, + @NonNull String profileImageUrl, + LocalDate birth, + Gender gender, + String phone + ) { + return new User( + null, Set.of(RoleType.USER), email, password, kakaoUid, + profileImageUrl, birth, gender, phone, null + ); + } } diff --git a/src/main/java/com/ajou/hertz/domain/user/service/UserCommandService.java b/src/main/java/com/ajou/hertz/domain/user/service/UserCommandService.java index ecc89ce..59bb57d 100644 --- a/src/main/java/com/ajou/hertz/domain/user/service/UserCommandService.java +++ b/src/main/java/com/ajou/hertz/domain/user/service/UserCommandService.java @@ -1,10 +1,15 @@ package com.ajou.hertz.domain.user.service; +import java.util.UUID; + import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import com.ajou.hertz.common.kakao.dto.response.KakaoUserInfoResponse; import com.ajou.hertz.common.properties.HertzProperties; +import com.ajou.hertz.domain.user.constant.Gender; import com.ajou.hertz.domain.user.dto.UserDto; import com.ajou.hertz.domain.user.dto.request.SignUpRequest; import com.ajou.hertz.domain.user.entity.User; @@ -51,6 +56,36 @@ public UserDto createNewUser(SignUpRequest signUpRequest) { return UserDto.from(userSaved); } + /** + * 카카오 소셜 로그인과 연동하여, 신규 회원을 등록한다. + * + * @param userInfo 카카오에서 응답받은 유저 정보 + * @return 새로 등록된 회원 정보 + * @throws UserEmailDuplicationException 전달된 이메일이 중복된 이메일인 경우. 즉, 다른 사용자가 이미 같은 이메일을 사용하고 있는 경우. + * @throws UserPhoneDuplicationException 전달된 전화번호가 중복된 전화번호인 경우. 즉, 다른 사용자가 이미 같은 전화번호를 사용하고 있는 경우. + * @throws UserKakaoUidDuplicationException 이미 가입된 카카오 계정인 경우. + */ + public UserDto createNewUserWithKakao(KakaoUserInfoResponse userInfo) { + String gender = userInfo.gender(); + String email = userInfo.email(); + String phone = userInfo.getKoreanFormatPhoneNumber(); + String kakaoUid = userInfo.id(); + validateUserNotDuplicated(email, phone, kakaoUid); + + User userSaved = userRepository.save( + User.create( + email, + passwordEncoder.encode(generateRandom16CharString()), + kakaoUid, + userInfo.profileImageUrl(), + userInfo.birth(), + StringUtils.hasText(gender) ? Gender.valueOf(gender.toUpperCase()) : null, + phone + ) + ); + return UserDto.from(userSaved); + } + /** * 기존 유저와 중복되는 정보가 없음을 검증한다. * @@ -82,4 +117,16 @@ private void validateUserNotDuplicated(String email, String phone, String kakaoU throw new UserKakaoUidDuplicationException(kakaoUid); } } + + /** + * 16글자의 랜덤한 문자열을 생성한다. + * + * @return 생성된 랜덤 문자열 + */ + private String generateRandom16CharString() { + return UUID + .randomUUID() + .toString() + .substring(0, 16); + } } diff --git a/src/main/java/com/ajou/hertz/domain/user/service/UserQueryService.java b/src/main/java/com/ajou/hertz/domain/user/service/UserQueryService.java index 17e001c..62d635a 100644 --- a/src/main/java/com/ajou/hertz/domain/user/service/UserQueryService.java +++ b/src/main/java/com/ajou/hertz/domain/user/service/UserQueryService.java @@ -1,5 +1,7 @@ package com.ajou.hertz.domain.user.service; +import java.util.Optional; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -7,7 +9,6 @@ import com.ajou.hertz.domain.user.entity.User; import com.ajou.hertz.domain.user.exception.UserNotFoundByEmailException; import com.ajou.hertz.domain.user.exception.UserNotFoundByIdException; -import com.ajou.hertz.domain.user.exception.UserNotFoundByKakaoUidException; import com.ajou.hertz.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -41,6 +42,10 @@ private User getByEmail(String email) { return userRepository.findByEmail(email).orElseThrow(() -> new UserNotFoundByEmailException(email)); } + public Optional findDtoByKakaoUid(String kakaoUid) { + return userRepository.findByKakaoUid(kakaoUid).map(UserDto::from); + } + /** * 유저 id로 유저를 조회한다. * @@ -63,12 +68,6 @@ public UserDto getDtoByEmail(String email) { return UserDto.from(getByEmail(email)); } - public UserDto getDtoByKakaoUid(String kakaoUid) { - User user = userRepository.findByKakaoUid(kakaoUid) - .orElseThrow(() -> new UserNotFoundByKakaoUidException(kakaoUid)); - return UserDto.from(user); - } - /** * 전달된 email을 사용 중인 회원의 존재 여부를 조회한다. * diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index c86d7f4..888a534 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -34,3 +34,6 @@ aws.s3.bucket-name=${AWS_S3_BUCKET_NAME} aws.s3.access-key=${AWS_S3_IAM_ACCESS_KEY} aws.s3.secret-key=${AWS_S3_IAM_SECRET_KEY} aws.cloud-front.base-url=${AWS_CLOUD_FRONT_BASE_URL} + +kakao.rest-api-key=${KAKAO_REST_API_KEY} +kakao.client-secret=${KAKAO_CLIENT_SECRET} diff --git a/src/test/java/com/ajou/hertz/unit/common/auth/controller/AuthControllerV1Test.java b/src/test/java/com/ajou/hertz/unit/common/auth/controller/AuthControllerV1Test.java index c23fa65..d20f80f 100644 --- a/src/test/java/com/ajou/hertz/unit/common/auth/controller/AuthControllerV1Test.java +++ b/src/test/java/com/ajou/hertz/unit/common/auth/controller/AuthControllerV1Test.java @@ -19,8 +19,10 @@ import com.ajou.hertz.common.auth.controller.AuthControllerV1; import com.ajou.hertz.common.auth.dto.JwtTokenInfoDto; +import com.ajou.hertz.common.auth.dto.request.KakaoLoginRequest; import com.ajou.hertz.common.auth.dto.request.LoginRequest; import com.ajou.hertz.common.auth.service.AuthService; +import com.ajou.hertz.common.kakao.service.KakaoService; import com.ajou.hertz.config.ControllerTestConfig; import com.fasterxml.jackson.databind.ObjectMapper; @@ -32,6 +34,9 @@ class AuthControllerV1Test { @MockBean private AuthService authService; + @MockBean + private KakaoService kakaoService; + private final MockMvc mvc; private final ObjectMapper objectMapper; @@ -59,7 +64,32 @@ public AuthControllerV1Test(MockMvc mvc, ObjectMapper objectMapper) { .andExpect(status().isOk()) .andExpect(jsonPath("$.token").value(expectedResult.token())); then(authService).should().login(any(LoginRequest.class)); + verifyEveryMocksShouldHaveNoMoreInteractions(); + } + + @Test + void 카카오_로그인을_위한_정보가_주어지고_카카오_로그인을_진행한다() throws Exception { + // given + KakaoLoginRequest kakaoLoginRequest = createKakaoLoginRequest(); + JwtTokenInfoDto expectedResult = createJwtTokenInfoDto(); + given(kakaoService.login(any(KakaoLoginRequest.class))).willReturn(expectedResult); + + // when & then + mvc.perform( + post("/v1/auth/login/kakao") + .header(API_MINOR_VERSION_HEADER_NAME, 1) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(kakaoLoginRequest)) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.token").value(expectedResult.token())); + then(kakaoService).should().login(any(KakaoLoginRequest.class)); + verifyEveryMocksShouldHaveNoMoreInteractions(); + } + + private void verifyEveryMocksShouldHaveNoMoreInteractions() { then(authService).shouldHaveNoMoreInteractions(); + then(kakaoService).shouldHaveNoMoreInteractions(); } private LoginRequest createLoginRequest() throws Exception { @@ -72,6 +102,16 @@ private LoginRequest createLoginRequest() throws Exception { ); } + private static KakaoLoginRequest createKakaoLoginRequest() throws Exception { + Constructor kakaoLoginRequestConstructor = + KakaoLoginRequest.class.getDeclaredConstructor(String.class, String.class); + kakaoLoginRequestConstructor.setAccessible(true); + return kakaoLoginRequestConstructor.newInstance( + "authorization-code", + "https://redirect-uri" + ); + } + private JwtTokenInfoDto createJwtTokenInfoDto() { return new JwtTokenInfoDto( "access-token", diff --git a/src/test/java/com/ajou/hertz/unit/common/kakao/service/KakaoServiceTest.java b/src/test/java/com/ajou/hertz/unit/common/kakao/service/KakaoServiceTest.java new file mode 100644 index 0000000..be7c0f1 --- /dev/null +++ b/src/test/java/com/ajou/hertz/unit/common/kakao/service/KakaoServiceTest.java @@ -0,0 +1,218 @@ +package com.ajou.hertz.unit.common.kakao.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.lang.reflect.Constructor; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.context.event.annotation.BeforeTestMethod; + +import com.ajou.hertz.common.auth.JwtTokenProvider; +import com.ajou.hertz.common.auth.dto.JwtTokenInfoDto; +import com.ajou.hertz.common.auth.dto.request.KakaoLoginRequest; +import com.ajou.hertz.common.kakao.client.KakaoAuthClient; +import com.ajou.hertz.common.kakao.client.KakaoClient; +import com.ajou.hertz.common.kakao.dto.request.IssueKakaoAccessTokenRequest; +import com.ajou.hertz.common.kakao.dto.response.KakaoTokenResponse; +import com.ajou.hertz.common.kakao.dto.response.KakaoUserInfoResponse; +import com.ajou.hertz.common.kakao.service.KakaoService; +import com.ajou.hertz.common.properties.KakaoProperties; +import com.ajou.hertz.domain.user.constant.Gender; +import com.ajou.hertz.domain.user.constant.RoleType; +import com.ajou.hertz.domain.user.dto.UserDto; +import com.ajou.hertz.domain.user.service.UserCommandService; +import com.ajou.hertz.domain.user.service.UserQueryService; + +@DisplayName("[Unit] Service - Kakao") +@ExtendWith(MockitoExtension.class) +class KakaoServiceTest { + + @InjectMocks + private KakaoService sut; + + @Mock + private UserQueryService userQueryService; + + @Mock + private UserCommandService userCommandService; + + @Mock + private KakaoClient kakaoClient; + + @Mock + private KakaoAuthClient kakaoAuthClient; + + @Mock + private KakaoProperties kakaoProperties; + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @BeforeTestMethod + public void setUp() { + given(kakaoProperties.restApiKey()).willReturn("kakao-rest-api-key"); + given(kakaoProperties.clientSecret()).willReturn("kakao-client-secret"); + } + + @Test + void 인가_코드와_리다이렉트_주소가_주어지고_주어진_정보로_카카오_로그인을_진행한다() throws Exception { + // given + KakaoLoginRequest kakaoLoginRequest = createKakaoLoginRequest(); + KakaoTokenResponse kakaoTokenResponse = createKakaoTokenResponse(); + String authorizationHeaderToGettingKakaoUserInfo = "Bearer " + kakaoTokenResponse.getAccessToken(); + KakaoUserInfoResponse kakaoUserInfoResponse = createKakaoUserInfoResponse(); + UserDto userDto = createUserDto(); + JwtTokenInfoDto expectedResult = createJwtTokenInfoDto(); + given(kakaoAuthClient.issueAccessToken(any(IssueKakaoAccessTokenRequest.class))).willReturn(kakaoTokenResponse); + given( + kakaoClient.getUserInfo(authorizationHeaderToGettingKakaoUserInfo, true) + ).willReturn(kakaoUserInfoResponse); + given(userQueryService.findDtoByKakaoUid(kakaoUserInfoResponse.id())).willReturn(Optional.of(userDto)); + given(jwtTokenProvider.createAccessToken(userDto)).willReturn(expectedResult); + + // when + JwtTokenInfoDto actualResult = sut.login(kakaoLoginRequest); + + // then + then(kakaoProperties).should().restApiKey(); + then(kakaoProperties).should().clientSecret(); + then(kakaoAuthClient).should().issueAccessToken(any(IssueKakaoAccessTokenRequest.class)); + then(kakaoClient).should().getUserInfo(authorizationHeaderToGettingKakaoUserInfo, true); + then(userQueryService).should().findDtoByKakaoUid(kakaoUserInfoResponse.id()); + then(jwtTokenProvider).should().createAccessToken(userDto); + verifyEveryMocksShouldHaveNoMoreInteractions(); + assertThat(actualResult) + .hasFieldOrPropertyWithValue("token", expectedResult.token()); + } + + @Test + void 인가_코드와_리다이렉트_주소가_주어지고_주어진_정보로_카카오_로그인을_진행한다_만약_신규_회원이라면_회원가입한다() throws Exception { + // given + KakaoLoginRequest kakaoLoginRequest = createKakaoLoginRequest(); + KakaoTokenResponse kakaoTokenResponse = createKakaoTokenResponse(); + String authorizationHeaderToGettingKakaoUserInfo = "Bearer " + kakaoTokenResponse.getAccessToken(); + KakaoUserInfoResponse kakaoUserInfoResponse = createKakaoUserInfoResponse(); + UserDto userDto = createUserDto(); + JwtTokenInfoDto expectedResult = createJwtTokenInfoDto(); + given(kakaoAuthClient.issueAccessToken(any(IssueKakaoAccessTokenRequest.class))).willReturn(kakaoTokenResponse); + given( + kakaoClient.getUserInfo(authorizationHeaderToGettingKakaoUserInfo, true) + ).willReturn(kakaoUserInfoResponse); + given(userQueryService.findDtoByKakaoUid(kakaoUserInfoResponse.id())).willReturn(Optional.empty()); + given(userCommandService.createNewUserWithKakao(kakaoUserInfoResponse)).willReturn(userDto); + given(jwtTokenProvider.createAccessToken(userDto)).willReturn(expectedResult); + + // when + JwtTokenInfoDto actualResult = sut.login(kakaoLoginRequest); + + // then + then(kakaoProperties).should().restApiKey(); + then(kakaoProperties).should().clientSecret(); + then(kakaoAuthClient).should().issueAccessToken(any(IssueKakaoAccessTokenRequest.class)); + then(kakaoClient).should().getUserInfo(authorizationHeaderToGettingKakaoUserInfo, true); + then(userQueryService).should().findDtoByKakaoUid(kakaoUserInfoResponse.id()); + then(userCommandService).should().createNewUserWithKakao(kakaoUserInfoResponse); + then(jwtTokenProvider).should().createAccessToken(userDto); + verifyEveryMocksShouldHaveNoMoreInteractions(); + assertThat(actualResult) + .hasFieldOrPropertyWithValue("token", expectedResult.token()); + } + + private void verifyEveryMocksShouldHaveNoMoreInteractions() { + then(userQueryService).shouldHaveNoMoreInteractions(); + then(userCommandService).shouldHaveNoMoreInteractions(); + then(kakaoClient).shouldHaveNoMoreInteractions(); + then(kakaoAuthClient).shouldHaveNoMoreInteractions(); + then(kakaoProperties).shouldHaveNoMoreInteractions(); + then(jwtTokenProvider).shouldHaveNoMoreInteractions(); + } + + private UserDto createUserDto(long id) throws Exception { + Constructor userResponseConstructor = UserDto.class.getDeclaredConstructor( + Long.class, Set.class, String.class, String.class, String.class, + String.class, LocalDate.class, Gender.class, String.class, String.class + ); + userResponseConstructor.setAccessible(true); + return userResponseConstructor.newInstance( + id, + Set.of(RoleType.USER), + "test@mail.com", + "$2a$abc123", + "kakao-user-id", + "https://user-default-profile-image", + LocalDate.of(2024, 1, 1), + Gender.ETC, + "01012345678", + "https://contack-link" + ); + } + + private UserDto createUserDto() throws Exception { + return createUserDto(1L); + } + + private static KakaoLoginRequest createKakaoLoginRequest() throws Exception { + Constructor kakaoLoginRequestConstructor = + KakaoLoginRequest.class.getDeclaredConstructor(String.class, String.class); + kakaoLoginRequestConstructor.setAccessible(true); + return kakaoLoginRequestConstructor.newInstance( + "authorization-code", + "https://redirect-uri" + ); + } + + private static KakaoTokenResponse createKakaoTokenResponse() throws Exception { + Constructor kakaoTokenResponseConstructor = KakaoTokenResponse.class.getDeclaredConstructor( + String.class, String.class, Integer.class, String.class, Integer.class + ); + kakaoTokenResponseConstructor.setAccessible(true); + return kakaoTokenResponseConstructor.newInstance("bearer", "access-token", 43199, "refresh-token", 5184000); + } + + private static KakaoUserInfoResponse createKakaoUserInfoResponse() { + return new KakaoUserInfoResponse( + "12345", + new KakaoUserInfoResponse.KakaoAccount( + true, + new KakaoUserInfoResponse.KakaoAccount.Profile( + "https://profile-image-url", + "https://thumbnail-image-url", + true + ), + true, + true, + true, + true, + "test@mail.com", + true, + true, + "01012345678", + true, + true, + null, + true, + null, + true, + true, + "male" + ) + ); + } + + private static JwtTokenInfoDto createJwtTokenInfoDto() { + return new JwtTokenInfoDto( + "access-token", + LocalDateTime.of(2024, 1, 1, 0, 0) + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/ajou/hertz/unit/domain/user/service/UserCommandServiceTest.java b/src/test/java/com/ajou/hertz/unit/domain/user/service/UserCommandServiceTest.java index 780cde9..a0a62da 100644 --- a/src/test/java/com/ajou/hertz/unit/domain/user/service/UserCommandServiceTest.java +++ b/src/test/java/com/ajou/hertz/unit/domain/user/service/UserCommandServiceTest.java @@ -1,21 +1,27 @@ package com.ajou.hertz.unit.domain.user.service; import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.params.provider.Arguments.*; import static org.mockito.BDDMockito.*; import java.lang.reflect.Constructor; import java.time.LocalDate; import java.util.Set; +import java.util.stream.Stream; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.event.annotation.BeforeTestMethod; +import com.ajou.hertz.common.kakao.dto.response.KakaoUserInfoResponse; import com.ajou.hertz.common.properties.HertzProperties; import com.ajou.hertz.domain.user.constant.Gender; import com.ajou.hertz.domain.user.constant.RoleType; @@ -23,6 +29,7 @@ import com.ajou.hertz.domain.user.dto.request.SignUpRequest; import com.ajou.hertz.domain.user.entity.User; import com.ajou.hertz.domain.user.exception.UserEmailDuplicationException; +import com.ajou.hertz.domain.user.exception.UserKakaoUidDuplicationException; import com.ajou.hertz.domain.user.exception.UserPhoneDuplicationException; import com.ajou.hertz.domain.user.repository.UserRepository; import com.ajou.hertz.domain.user.service.UserCommandService; @@ -58,7 +65,7 @@ public void setUp() { long userId = 1L; SignUpRequest signUpRequest = createSignUpRequest(); String passwordEncoded = "$2a$abc123"; - User expectedResult = createUser(userId, passwordEncoded); + User expectedResult = createUser(userId, passwordEncoded, "12345"); given(userQueryService.existsByEmail(signUpRequest.getEmail())).willReturn(false); given(userQueryService.existsByPhone(signUpRequest.getPhone())).willReturn(false); given(passwordEncoder.encode(signUpRequest.getPassword())).willReturn(passwordEncoded); @@ -110,13 +117,67 @@ public void setUp() { assertThat(t).isInstanceOf(UserPhoneDuplicationException.class); } + @MethodSource("testDataForCreateNewUserWithKakao") + @ParameterizedTest + void 주어진_카카오_유저_정보로_신규_회원을_등록한다(KakaoUserInfoResponse kakaoUserInfo, User expectedResult) throws Exception { + // given + given(userQueryService.existsByEmail(kakaoUserInfo.email())).willReturn(false); + given(userQueryService.existsByPhone(kakaoUserInfo.getKoreanFormatPhoneNumber())).willReturn(false); + given(userQueryService.existsByKakaoUid(kakaoUserInfo.id())).willReturn(false); + given(passwordEncoder.encode(anyString())).willReturn(expectedResult.getPassword()); + given(userRepository.save(any(User.class))).willReturn(expectedResult); + + // when + UserDto actualResult = sut.createNewUserWithKakao(kakaoUserInfo); + + // then + then(userQueryService).should().existsByEmail(kakaoUserInfo.email()); + then(userQueryService).should().existsByPhone(kakaoUserInfo.getKoreanFormatPhoneNumber()); + then(userQueryService).should().existsByKakaoUid(kakaoUserInfo.id()); + then(passwordEncoder).should().encode(anyString()); + then(userRepository).should().save(any(User.class)); + verifyEveryMocksShouldHaveNoMoreInteractions(); + assertThat(actualResult) + .hasFieldOrPropertyWithValue("id", expectedResult.getId()) + .hasFieldOrPropertyWithValue("kakaoUid", expectedResult.getKakaoUid()) + .hasFieldOrPropertyWithValue("password", expectedResult.getPassword()); + } + + static Stream testDataForCreateNewUserWithKakao() throws Exception { + return Stream.of( + arguments(createKakaoUserInfoResponse("male"), createUser(1L, "$2a$abc123", "12345", Gender.MALE)), + arguments(createKakaoUserInfoResponse("female"), createUser(1L, "$2a$abc123", "12345", Gender.FEMALE)), + arguments(createKakaoUserInfoResponse(""), createUser(1L, "$2a$abc123", "12345", null)), + arguments(createKakaoUserInfoResponse(null), createUser(1L, "$2a$abc123", "12345", null)) + ); + } + + @Test + void 주어진_카카오_유저_정보로_신규_회원을_등록한다_이미_가입한_카카오_계정이라면_예외가_발생한다() throws Exception { + // given + KakaoUserInfoResponse kakaoUserInfo = createKakaoUserInfoResponse(); + given(userQueryService.existsByEmail(kakaoUserInfo.email())).willReturn(false); + given(userQueryService.existsByPhone(kakaoUserInfo.getKoreanFormatPhoneNumber())).willReturn(false); + given(userQueryService.existsByKakaoUid(kakaoUserInfo.id())).willReturn(true); + + // when + Throwable t = catchThrowable(() -> sut.createNewUserWithKakao(kakaoUserInfo)); + + // then + then(userQueryService).should().existsByEmail(kakaoUserInfo.email()); + then(userQueryService).should().existsByPhone(kakaoUserInfo.getKoreanFormatPhoneNumber()); + then(userQueryService).should().existsByKakaoUid(kakaoUserInfo.id()); + verifyEveryMocksShouldHaveNoMoreInteractions(); + assertThat(t).isInstanceOf(UserKakaoUidDuplicationException.class); + } + private void verifyEveryMocksShouldHaveNoMoreInteractions() { then(userQueryService).shouldHaveNoMoreInteractions(); then(userRepository).shouldHaveNoMoreInteractions(); then(passwordEncoder).shouldHaveNoMoreInteractions(); } - private User createUser(Long id, String password) throws Exception { + private static User createUser(Long id, String password, String kakaoUid, Gender gender) throws Exception { Constructor userConstructor = User.class.getDeclaredConstructor( Long.class, Set.class, String.class, String.class, String.class, String.class, LocalDate.class, Gender.class, String.class, String.class @@ -127,15 +188,19 @@ private User createUser(Long id, String password) throws Exception { Set.of(RoleType.USER), "test@test.com", password, - "kakao-user-id", + kakaoUid, "https://user-default-profile-image-url", LocalDate.of(2024, 1, 1), - Gender.ETC, + gender, "010-1234-5678", null ); } + private static User createUser(Long id, String password, String kakaoUid) throws Exception { + return createUser(id, password, kakaoUid, Gender.ETC); + } + private SignUpRequest createSignUpRequest(String email, String phone) throws Exception { Constructor signUpRequestConstructor = SignUpRequest.class.getDeclaredConstructor( String.class, String.class, LocalDate.class, Gender.class, String.class @@ -153,4 +218,38 @@ private SignUpRequest createSignUpRequest(String email, String phone) throws Exc private SignUpRequest createSignUpRequest() throws Exception { return createSignUpRequest("test@test.com", "01012345678"); } + + private static KakaoUserInfoResponse createKakaoUserInfoResponse(String gender) { + return new KakaoUserInfoResponse( + "12345", + new KakaoUserInfoResponse.KakaoAccount( + true, + new KakaoUserInfoResponse.KakaoAccount.Profile( + "https://profile-image-url", + "https://thumbnail-image-url", + true + ), + true, + true, + true, + true, + "test@mail.com", + true, + true, + "01012345678", + true, + true, + null, + true, + null, + true, + true, + gender + ) + ); + } + + private static KakaoUserInfoResponse createKakaoUserInfoResponse() { + return createKakaoUserInfoResponse("male"); + } } \ No newline at end of file diff --git a/src/test/java/com/ajou/hertz/unit/domain/user/service/UserQueryServiceTest.java b/src/test/java/com/ajou/hertz/unit/domain/user/service/UserQueryServiceTest.java index 05a2915..f4e4bb6 100644 --- a/src/test/java/com/ajou/hertz/unit/domain/user/service/UserQueryServiceTest.java +++ b/src/test/java/com/ajou/hertz/unit/domain/user/service/UserQueryServiceTest.java @@ -21,7 +21,6 @@ import com.ajou.hertz.domain.user.entity.User; import com.ajou.hertz.domain.user.exception.UserNotFoundByEmailException; import com.ajou.hertz.domain.user.exception.UserNotFoundByIdException; -import com.ajou.hertz.domain.user.exception.UserNotFoundByKakaoUidException; import com.ajou.hertz.domain.user.repository.UserRepository; import com.ajou.hertz.domain.user.service.UserQueryService; @@ -101,38 +100,24 @@ class UserQueryServiceTest { } @Test - void 카카오_유저_ID가_주어지고_주어진_카카오_유저_ID로_유저를_조회하면_조회된_유저_정보가_반환된다() throws Exception { + void 카카오_유저_ID가_주어지고_주어진_카카오_유저_ID로_유저를_조회하면_조회된_Optional_유저_정보가_반환된다() throws Exception { // given String kakaoUid = "12345"; User expectedResult = createUser(1L, "test@mail.com", kakaoUid); given(userRepository.findByKakaoUid(kakaoUid)).willReturn(Optional.of(expectedResult)); // when - UserDto actualResult = sut.getDtoByKakaoUid(kakaoUid); + Optional actualResult = sut.findDtoByKakaoUid(kakaoUid); // then then(userRepository).should().findByKakaoUid(kakaoUid); verifyEveryMocksShouldHaveNoMoreInteractions(); - assertThat(actualResult) + assertThat(actualResult).isNotEmpty(); + assertThat(actualResult.get()) .hasFieldOrPropertyWithValue("id", expectedResult.getId()) .hasFieldOrPropertyWithValue("kakaoUid", expectedResult.getKakaoUid()); } - @Test - void 카카오_유저_ID가_주어지고_주어진_카카오_유저_ID로_유저를_조회한다_만약_일치하는_유저가_없다면_예외가_발생한다() { - // given - String kakaoUid = "12345"; - given(userRepository.findByKakaoUid(kakaoUid)).willReturn(Optional.empty()); - - // when - Throwable t = catchThrowable(() -> sut.getDtoByKakaoUid(kakaoUid)); - - // then - then(userRepository).should().findByKakaoUid(kakaoUid); - verifyEveryMocksShouldHaveNoMoreInteractions(); - assertThat(t).isInstanceOf(UserNotFoundByKakaoUidException.class); - } - @Test void 이메일이_주어지고_주어진_이메일을_사용_중인_회원의_존재_여부를_조회한다() { // given From 7e7762672918885fd34a4c27dffa6a9b81ed9048 Mon Sep 17 00:00:00 2001 From: Wo-ogie Date: Mon, 19 Feb 2024 15:55:38 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20#25=20=EC=B9=B4=EC=B9=B4=EC=98=A4?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20API=20endpoint=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ajou/hertz/common/auth/controller/AuthControllerV1.java | 2 +- src/main/java/com/ajou/hertz/common/config/SecurityConfig.java | 2 +- .../hertz/unit/common/auth/controller/AuthControllerV1Test.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/ajou/hertz/common/auth/controller/AuthControllerV1.java b/src/main/java/com/ajou/hertz/common/auth/controller/AuthControllerV1.java index f25a6a3..6d4d262 100644 --- a/src/main/java/com/ajou/hertz/common/auth/controller/AuthControllerV1.java +++ b/src/main/java/com/ajou/hertz/common/auth/controller/AuthControllerV1.java @@ -61,7 +61,7 @@ public JwtTokenInfoResponse loginV1_1(@RequestBody @Valid LoginRequest loginRequ ), @ApiResponse(responseCode = "Any", description = "[10000] 카카오 서버와의 통신 중 오류가 발생한 경우. Http status code는 kakao에서 응답받은 것과 동일하게 설정하여 응답한다.", content = @Content) }) - @PostMapping(value = "/login/kakao", headers = API_MINOR_VERSION_HEADER_NAME + "=" + 1) + @PostMapping(value = "/kakao/login", headers = API_MINOR_VERSION_HEADER_NAME + "=" + 1) public JwtTokenInfoResponse kakaoLoginV1_1(@RequestBody @Valid KakaoLoginRequest kakaoLoginRequest) { JwtTokenInfoDto jwtTokenInfoDto = kakaoService.login(kakaoLoginRequest); return JwtTokenInfoResponse.from(jwtTokenInfoDto); diff --git a/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java b/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java index 92975f2..005b694 100644 --- a/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java +++ b/src/main/java/com/ajou/hertz/common/config/SecurityConfig.java @@ -43,7 +43,7 @@ public class SecurityConfig { "/v*/users", POST, "/v*/users/existence", GET, "/v*/auth/login", POST, - "/v*/auth/login/kakao", POST + "/v*/auth/kakao/login", POST ); @Bean diff --git a/src/test/java/com/ajou/hertz/unit/common/auth/controller/AuthControllerV1Test.java b/src/test/java/com/ajou/hertz/unit/common/auth/controller/AuthControllerV1Test.java index d20f80f..c459705 100644 --- a/src/test/java/com/ajou/hertz/unit/common/auth/controller/AuthControllerV1Test.java +++ b/src/test/java/com/ajou/hertz/unit/common/auth/controller/AuthControllerV1Test.java @@ -76,7 +76,7 @@ public AuthControllerV1Test(MockMvc mvc, ObjectMapper objectMapper) { // when & then mvc.perform( - post("/v1/auth/login/kakao") + post("/v1/auth/kakao/login") .header(API_MINOR_VERSION_HEADER_NAME, 1) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(kakaoLoginRequest))