From 540b374327edd245b1252ff914283a71d935719d Mon Sep 17 00:00:00 2001 From: "Choi, Minwoo" Date: Tue, 1 Jul 2025 13:04:24 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84(#1?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: BaseTimeEntity, Member, MemberRole, MemberCategory, SocialProvider 구현 * feat: JPA 엔티티의 생성/수정/삭제 시간 기록을 위한 JpaAuditingConfig 추가 * feat: WebClientConfig, KakaoOauthProperties, KakaoOauthClient, KakaoOauthProvider 구현 * feat: JwtTokenProperties, JwtTokenProvider 구현 * feat: KakaoUserInfoResponse, KakaoTokenResponse 구현 * feat: KakaoLoginService, KakaoLoginRequest, KakaoSignupRequest, TokenResponse 구현 * feat: MemberRepository, MemberJpaRepository, MemberRepositoryAdapter 구현 * feat: MemberErrorCode 정의 * feat: MemberFactory 구현 * feat: MemberService 구현 * feat: AuthFacade 구현 * feat: AuthController 구현 * feat: JwtFilter 구현 및 SecurityFilterChain 등록 * test: MemberFixture, KakaoOauthFixture, TokenFixture 생성 * test: MemberTestHelper 생성 * feat: KakaoLoginServiceTest 단위 테스트 추가 * test: MemberServiceTest 단위 테스트 추가 * test: AuthControllerIntegrationTest 통합 테스트 추가 * test: KakaoLoginServiceTest, MemberServiceTest, AuthControllerIntegrationTest @Nested 어노테이션 적용 --- .../auth/application/facade/AuthFacade.java | 39 ++++ .../service/KakaoLoginService.java | 27 +++ .../auth/domain/error/AuthErrorCode.java | 42 ++++ .../auth/infra/client/KakaoOauthClient.java | 58 +++++ .../auth/infra/dto/KakaoAccount.java | 8 + .../auth/infra/dto/KakaoProfile.java | 8 + .../auth/infra/dto/KakaoTokenResponse.java | 14 ++ .../auth/infra/dto/KakaoUserInfoResponse.java | 16 ++ .../auth/infra/filter/JwtFilter.java | 58 +++++ .../infra/provider/KakaoOauthProvider.java | 58 +++++ .../auth/infra/provider/TokenProvider.java | 78 +++++++ .../controller/AuthController.java | 42 ++++ .../dto/request/KakaoLoginRequest.java | 8 + .../dto/request/KakaoSignupRequest.java | 20 ++ .../dto/response/TokenResponse.java | 11 + .../global/common/entity/BaseTimeEntity.java | 24 ++ .../global/config/JpaAuditingConfig.java | 8 + .../global/config/WebClientConfig.java | 22 ++ .../global/config/WebSecurityConfig.java | 12 +- .../properties/KakaoOauthProperties.java | 11 + .../config/properties/TokenProperties.java | 7 + .../global/exception/error/AuthErrorCode.java | 29 --- .../security/CustomAccessDeniedHandler.java | 2 +- .../CustomAuthenticationEntryPoint.java | 2 +- .../application/service/MemberService.java | 68 ++++++ .../member/domain/entity/Member.java | 57 +++++ .../member/domain/entity/MemberCategory.java | 8 + .../member/domain/entity/MemberRole.java | 7 + .../member/domain/entity/SocialProvider.java | 6 + .../member/domain/error/MemberErrorCode.java | 33 +++ .../domain/repository/MemberRepository.java | 14 ++ .../member/factory/MemberFactory.java | 27 +++ .../member/infra/jpa/MemberJpaRepository.java | 11 + .../infra/jpa/MemberRepositoryAdapter.java | 30 +++ .../ject/studytrip/BaseIntegrationTest.java | 36 +++ .../java/com/ject/studytrip/BaseUnitTest.java | 11 + .../service/KakaoLoginServiceTest.java | 111 ++++++++++ .../auth/fixture/KakaoOauthFixture.java | 35 +++ .../studytrip/auth/fixture/TokenFixture.java | 14 ++ .../AuthControllerIntegrationTest.java | 209 ++++++++++++++++++ .../service/MemberServiceTest.java | 186 ++++++++++++++++ .../member/fixture/MemberFixture.java | 33 +++ .../member/helper/MemberTestHelper.java | 22 ++ 43 files changed, 1489 insertions(+), 33 deletions(-) create mode 100644 src/main/java/com/ject/studytrip/auth/application/facade/AuthFacade.java create mode 100644 src/main/java/com/ject/studytrip/auth/application/service/KakaoLoginService.java create mode 100644 src/main/java/com/ject/studytrip/auth/domain/error/AuthErrorCode.java create mode 100644 src/main/java/com/ject/studytrip/auth/infra/client/KakaoOauthClient.java create mode 100644 src/main/java/com/ject/studytrip/auth/infra/dto/KakaoAccount.java create mode 100644 src/main/java/com/ject/studytrip/auth/infra/dto/KakaoProfile.java create mode 100644 src/main/java/com/ject/studytrip/auth/infra/dto/KakaoTokenResponse.java create mode 100644 src/main/java/com/ject/studytrip/auth/infra/dto/KakaoUserInfoResponse.java create mode 100644 src/main/java/com/ject/studytrip/auth/infra/filter/JwtFilter.java create mode 100644 src/main/java/com/ject/studytrip/auth/infra/provider/KakaoOauthProvider.java create mode 100644 src/main/java/com/ject/studytrip/auth/infra/provider/TokenProvider.java create mode 100644 src/main/java/com/ject/studytrip/auth/presentation/controller/AuthController.java create mode 100644 src/main/java/com/ject/studytrip/auth/presentation/dto/request/KakaoLoginRequest.java create mode 100644 src/main/java/com/ject/studytrip/auth/presentation/dto/request/KakaoSignupRequest.java create mode 100644 src/main/java/com/ject/studytrip/auth/presentation/dto/response/TokenResponse.java create mode 100644 src/main/java/com/ject/studytrip/global/common/entity/BaseTimeEntity.java create mode 100644 src/main/java/com/ject/studytrip/global/config/JpaAuditingConfig.java create mode 100644 src/main/java/com/ject/studytrip/global/config/WebClientConfig.java create mode 100644 src/main/java/com/ject/studytrip/global/config/properties/KakaoOauthProperties.java create mode 100644 src/main/java/com/ject/studytrip/global/config/properties/TokenProperties.java delete mode 100644 src/main/java/com/ject/studytrip/global/exception/error/AuthErrorCode.java create mode 100644 src/main/java/com/ject/studytrip/member/application/service/MemberService.java create mode 100644 src/main/java/com/ject/studytrip/member/domain/entity/Member.java create mode 100644 src/main/java/com/ject/studytrip/member/domain/entity/MemberCategory.java create mode 100644 src/main/java/com/ject/studytrip/member/domain/entity/MemberRole.java create mode 100644 src/main/java/com/ject/studytrip/member/domain/entity/SocialProvider.java create mode 100644 src/main/java/com/ject/studytrip/member/domain/error/MemberErrorCode.java create mode 100644 src/main/java/com/ject/studytrip/member/domain/repository/MemberRepository.java create mode 100644 src/main/java/com/ject/studytrip/member/factory/MemberFactory.java create mode 100644 src/main/java/com/ject/studytrip/member/infra/jpa/MemberJpaRepository.java create mode 100644 src/main/java/com/ject/studytrip/member/infra/jpa/MemberRepositoryAdapter.java create mode 100644 src/test/java/com/ject/studytrip/BaseIntegrationTest.java create mode 100644 src/test/java/com/ject/studytrip/BaseUnitTest.java create mode 100644 src/test/java/com/ject/studytrip/auth/application/service/KakaoLoginServiceTest.java create mode 100644 src/test/java/com/ject/studytrip/auth/fixture/KakaoOauthFixture.java create mode 100644 src/test/java/com/ject/studytrip/auth/fixture/TokenFixture.java create mode 100644 src/test/java/com/ject/studytrip/auth/presentation/controller/AuthControllerIntegrationTest.java create mode 100644 src/test/java/com/ject/studytrip/member/application/service/MemberServiceTest.java create mode 100644 src/test/java/com/ject/studytrip/member/fixture/MemberFixture.java create mode 100644 src/test/java/com/ject/studytrip/member/helper/MemberTestHelper.java diff --git a/src/main/java/com/ject/studytrip/auth/application/facade/AuthFacade.java b/src/main/java/com/ject/studytrip/auth/application/facade/AuthFacade.java new file mode 100644 index 0000000..00dfaa2 --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/application/facade/AuthFacade.java @@ -0,0 +1,39 @@ +package com.ject.studytrip.auth.application.facade; + +import com.ject.studytrip.auth.application.service.KakaoLoginService; +import com.ject.studytrip.auth.infra.dto.KakaoUserInfoResponse; +import com.ject.studytrip.auth.presentation.dto.request.KakaoLoginRequest; +import com.ject.studytrip.auth.presentation.dto.request.KakaoSignupRequest; +import com.ject.studytrip.auth.presentation.dto.response.TokenResponse; +import com.ject.studytrip.member.application.service.MemberService; +import com.ject.studytrip.member.domain.entity.Member; +import com.ject.studytrip.member.domain.entity.SocialProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AuthFacade { + private final KakaoLoginService kakaoLoginService; + private final MemberService memberService; + + public TokenResponse kakaoLogin(KakaoLoginRequest request) { + KakaoUserInfoResponse response = kakaoLoginService.getKakaoUserInfo(request.code()); + Member member = + memberService.getMemberBySocialProviderAndSocialId( + SocialProvider.KAKAO, response.kakaoId()); + return kakaoLoginService.getTokens(member.getId().toString(), member.getRole().name()); + } + + public TokenResponse kakaoSignup(KakaoSignupRequest request) { + KakaoUserInfoResponse response = kakaoLoginService.getKakaoUserInfo(request.code()); + Member member = + memberService.createMemberFromKakao( + response.kakaoId(), + response.getEmail(), + response.getProfileImage(), + request.category(), + request.nickname()); + return kakaoLoginService.getTokens(member.getId().toString(), member.getRole().name()); + } +} diff --git a/src/main/java/com/ject/studytrip/auth/application/service/KakaoLoginService.java b/src/main/java/com/ject/studytrip/auth/application/service/KakaoLoginService.java new file mode 100644 index 0000000..3acc564 --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/application/service/KakaoLoginService.java @@ -0,0 +1,27 @@ +package com.ject.studytrip.auth.application.service; + +import com.ject.studytrip.auth.infra.dto.KakaoTokenResponse; +import com.ject.studytrip.auth.infra.dto.KakaoUserInfoResponse; +import com.ject.studytrip.auth.infra.provider.KakaoOauthProvider; +import com.ject.studytrip.auth.infra.provider.TokenProvider; +import com.ject.studytrip.auth.presentation.dto.response.TokenResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class KakaoLoginService { + private final KakaoOauthProvider kakaoOauthProvider; + private final TokenProvider tokenProvider; + + public KakaoUserInfoResponse getKakaoUserInfo(String code) { + KakaoTokenResponse response = kakaoOauthProvider.getKakaoTokens(code); + return kakaoOauthProvider.getKakaoUserInfo(response.accessToken()); + } + + public TokenResponse getTokens(String memberId, String memberRole) { + String accessToken = tokenProvider.createAccessToken(memberId, memberRole); + String refreshToken = tokenProvider.createRefreshToken(memberId, memberRole); + return TokenResponse.of(accessToken, refreshToken); + } +} diff --git a/src/main/java/com/ject/studytrip/auth/domain/error/AuthErrorCode.java b/src/main/java/com/ject/studytrip/auth/domain/error/AuthErrorCode.java new file mode 100644 index 0000000..6f8226a --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/domain/error/AuthErrorCode.java @@ -0,0 +1,42 @@ +package com.ject.studytrip.auth.domain.error; + +import com.ject.studytrip.global.exception.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum AuthErrorCode implements ErrorCode { + UNAUTHENTICATED(HttpStatus.UNAUTHORIZED, "인증되지 않은 요청입니다."), + ACCESS_DENIED(HttpStatus.FORBIDDEN, "접근 권한이 부족합니다."), + + // 카카오 로그인 관련 에러 + KAKAO_TOKEN_FETCH_FAILED(HttpStatus.UNAUTHORIZED, "카카오 토큰을 가져오는 데 실패했습니다."), + KAKAO_USER_INFO_FETCH_FAILED(HttpStatus.UNAUTHORIZED, "카카오 사용자 정보를 가져오는 데 실패했습니다."), + INVALID_KAKAO_TOKEN(HttpStatus.UNAUTHORIZED, "잘못된 카카오 액세스 토큰입니다."), + INVALID_KAKAO_AUTHORIZATION_CODE(HttpStatus.BAD_REQUEST, "잘못된 카카오 인가 코드입니다."), + KAKAO_SERVER_ERROR(HttpStatus.BAD_GATEWAY, "카카오 서버에서 오류가 발생했습니다."), + + // 인증 관련 예외 + INVALID_JWT_TOKEN(HttpStatus.UNAUTHORIZED, "잘못된 JWT 토큰입니다."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "잘못된 리프레시 토큰입니다."), + TOKEN_IS_BLACKLISTED(HttpStatus.UNAUTHORIZED, "블랙리스트된 엑세스 토큰입니다."), + ; + + private final HttpStatus status; + private final String message; + + @Override + public String getName() { + return this.name(); + } + + @Override + public HttpStatus getStatus() { + return this.status; + } + + @Override + public String getMessage() { + return this.message; + } +} diff --git a/src/main/java/com/ject/studytrip/auth/infra/client/KakaoOauthClient.java b/src/main/java/com/ject/studytrip/auth/infra/client/KakaoOauthClient.java new file mode 100644 index 0000000..f1ed81b --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/infra/client/KakaoOauthClient.java @@ -0,0 +1,58 @@ +package com.ject.studytrip.auth.infra.client; + +import com.ject.studytrip.auth.domain.error.AuthErrorCode; +import com.ject.studytrip.auth.infra.dto.KakaoTokenResponse; +import com.ject.studytrip.auth.infra.dto.KakaoUserInfoResponse; +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.global.exception.error.ErrorCode; +import java.util.function.Function; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Component +@RequiredArgsConstructor +public class KakaoOauthClient { + + private final WebClient webClient; + + public Mono fetchKakaoTokens( + String tokenUri, BodyInserters.FormInserter formData) { + return webClient + .post() + .uri(tokenUri) + .body(formData) + .retrieve() + .onStatus( + HttpStatusCode::is4xxClientError, + handleError(AuthErrorCode.KAKAO_TOKEN_FETCH_FAILED)) + .onStatus( + HttpStatusCode::is5xxServerError, + handleError(AuthErrorCode.KAKAO_SERVER_ERROR)) + .bodyToMono(KakaoTokenResponse.class); + } + + public Mono fetchKakaoUserInfo(String userInfoUri, String accessToken) { + return webClient + .get() + .uri(userInfoUri) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .retrieve() + .onStatus( + HttpStatusCode::is4xxClientError, + handleError(AuthErrorCode.KAKAO_USER_INFO_FETCH_FAILED)) + .onStatus( + HttpStatusCode::is5xxServerError, + handleError(AuthErrorCode.KAKAO_SERVER_ERROR)) + .bodyToMono(KakaoUserInfoResponse.class); + } + + private Function> handleError(ErrorCode errorCode) { + return response -> Mono.error(new CustomException(errorCode)); + } +} diff --git a/src/main/java/com/ject/studytrip/auth/infra/dto/KakaoAccount.java b/src/main/java/com/ject/studytrip/auth/infra/dto/KakaoAccount.java new file mode 100644 index 0000000..536c434 --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/infra/dto/KakaoAccount.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.auth.infra.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +public record KakaoAccount( + @Schema(description = "카카오 프로필") @JsonProperty("profile") KakaoProfile profile, + @Schema(description = "카카오 이메일") @JsonProperty("email") String email) {} diff --git a/src/main/java/com/ject/studytrip/auth/infra/dto/KakaoProfile.java b/src/main/java/com/ject/studytrip/auth/infra/dto/KakaoProfile.java new file mode 100644 index 0000000..b1f562b --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/infra/dto/KakaoProfile.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.auth.infra.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +public record KakaoProfile( + @Schema(description = "카카오 프로필 이미지") @JsonProperty("profile_image_url") + String profileImage) {} diff --git a/src/main/java/com/ject/studytrip/auth/infra/dto/KakaoTokenResponse.java b/src/main/java/com/ject/studytrip/auth/infra/dto/KakaoTokenResponse.java new file mode 100644 index 0000000..34ffde6 --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/infra/dto/KakaoTokenResponse.java @@ -0,0 +1,14 @@ +package com.ject.studytrip.auth.infra.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +public record KakaoTokenResponse( + @Schema(description = "카카오 토큰 타입") @JsonProperty("token_type") String tokenType, + @Schema(description = "카카오 엑세스 토큰") @JsonProperty("access_token") String accessToken, + @Schema(description = "카카오 엑세스 토큰 만료 시간") @JsonProperty("expires_in") + Integer AccessExpiresIn, + @Schema(description = "카카오 리프레시 토큰") @JsonProperty("refresh_token") String refreshToken, + @Schema(description = "카카오 리프레시 토큰 만료 시간") @JsonProperty("refresh_token_expires_in") + Integer RefreshExpiresIn, + @Schema(description = "카카오 스코프") @JsonProperty("scope") String scope) {} diff --git a/src/main/java/com/ject/studytrip/auth/infra/dto/KakaoUserInfoResponse.java b/src/main/java/com/ject/studytrip/auth/infra/dto/KakaoUserInfoResponse.java new file mode 100644 index 0000000..fea6140 --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/infra/dto/KakaoUserInfoResponse.java @@ -0,0 +1,16 @@ +package com.ject.studytrip.auth.infra.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +public record KakaoUserInfoResponse( + @Schema(description = "카카오 ID") @JsonProperty("id") String kakaoId, + @Schema(description = "카카오 계정") @JsonProperty("kakao_account") KakaoAccount account) { + public String getProfileImage() { + return account.profile().profileImage(); + } + + public String getEmail() { + return account.email(); + } +} diff --git a/src/main/java/com/ject/studytrip/auth/infra/filter/JwtFilter.java b/src/main/java/com/ject/studytrip/auth/infra/filter/JwtFilter.java new file mode 100644 index 0000000..c10ffda --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/infra/filter/JwtFilter.java @@ -0,0 +1,58 @@ +package com.ject.studytrip.auth.infra.filter; + +import com.ject.studytrip.auth.infra.provider.TokenProvider; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + private final TokenProvider tokenProvider; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) + throws ServletException, IOException { + String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + String token = extractToken(authorizationHeader); + if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) { + setAuthentication(token); + } + filterChain.doFilter(request, response); + } + + private String extractToken(String header) { + return (StringUtils.hasText(header) && header.startsWith("Bearer ")) + ? header.substring(7) + : null; + } + + private void setAuthentication(String token) { + String memberId = tokenProvider.extractMemberIdFromToken(token); + String memberRole = tokenProvider.extractMemberRoleFromToken(token); + UsernamePasswordAuthenticationToken authentication = + getAuthentication(memberId, memberRole); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + private UsernamePasswordAuthenticationToken getAuthentication( + String memberId, String memberRole) { + var authorities = List.of(new SimpleGrantedAuthority(memberRole)); + return new UsernamePasswordAuthenticationToken(memberId, null, authorities); + } +} diff --git a/src/main/java/com/ject/studytrip/auth/infra/provider/KakaoOauthProvider.java b/src/main/java/com/ject/studytrip/auth/infra/provider/KakaoOauthProvider.java new file mode 100644 index 0000000..049370e --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/infra/provider/KakaoOauthProvider.java @@ -0,0 +1,58 @@ +package com.ject.studytrip.auth.infra.provider; + +import static org.springframework.util.StringUtils.hasText; + +import com.ject.studytrip.auth.domain.error.AuthErrorCode; +import com.ject.studytrip.auth.infra.client.KakaoOauthClient; +import com.ject.studytrip.auth.infra.dto.KakaoTokenResponse; +import com.ject.studytrip.auth.infra.dto.KakaoUserInfoResponse; +import com.ject.studytrip.global.config.properties.KakaoOauthProperties; +import com.ject.studytrip.global.exception.CustomException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; + +@Component +@RequiredArgsConstructor +public class KakaoOauthProvider { + private final KakaoOauthClient kakaoOauthClient; + private final KakaoOauthProperties kakaoOauthProperties; + + public KakaoTokenResponse getKakaoTokens(String code) { + validateKakaoAuthorizationCode(code); + return kakaoOauthClient + .fetchKakaoTokens(kakaoOauthProperties.tokenUri(), createFormData(code)) + .block(); + } + + public KakaoUserInfoResponse getKakaoUserInfo(String accessToken) { + validateKakaoToken(accessToken); + return kakaoOauthClient + .fetchKakaoUserInfo(kakaoOauthProperties.userInfoUri(), accessToken) + .block(); + } + + private BodyInserters.FormInserter createFormData(String code) { + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("grant_type", "authorization_code"); + formData.add("client_id", kakaoOauthProperties.clientId()); + formData.add("client_secret", kakaoOauthProperties.clientSecret()); + formData.add("redirect_uri", kakaoOauthProperties.redirectUri()); + formData.add("code", code); + return BodyInserters.fromFormData(formData); + } + + private void validateKakaoAuthorizationCode(String code) { + if (!hasText(code)) { + throw new CustomException(AuthErrorCode.INVALID_KAKAO_AUTHORIZATION_CODE); + } + } + + private void validateKakaoToken(String accessToken) { + if (!hasText(accessToken)) { + throw new CustomException(AuthErrorCode.INVALID_KAKAO_TOKEN); + } + } +} diff --git a/src/main/java/com/ject/studytrip/auth/infra/provider/TokenProvider.java b/src/main/java/com/ject/studytrip/auth/infra/provider/TokenProvider.java new file mode 100644 index 0000000..69c330c --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/infra/provider/TokenProvider.java @@ -0,0 +1,78 @@ +package com.ject.studytrip.auth.infra.provider; + +import com.ject.studytrip.auth.domain.error.AuthErrorCode; +import com.ject.studytrip.global.config.properties.TokenProperties; +import com.ject.studytrip.global.exception.CustomException; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import java.time.Instant; +import java.util.Date; +import java.util.Map; +import javax.crypto.SecretKey; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class TokenProvider { + private final TokenProperties tokenProperties; + + public String createAccessToken(String memberId, String role) { + return createToken(memberId, role, tokenProperties.accessExpirationTime()); + } + + public String createRefreshToken(String memberId, String role) { + return createToken(memberId, role, tokenProperties.refreshExpirationTime()); + } + + public String extractMemberIdFromToken(String token) { + return parseClaims(token).getSubject(); + } + + public String extractMemberRoleFromToken(String token) { + return (String) parseClaims(token).get("role"); + } + + public boolean validateToken(String token) { + try { + parseClaims(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + throw new CustomException(AuthErrorCode.INVALID_JWT_TOKEN); + } + } + + private String createToken(String memberId, String role, long expirationSeconds) { + return Jwts.builder() + .claims(buildClaims(memberId, role, expirationSeconds)) + .signWith(getSecretKey(), Jwts.SIG.HS256) + .compact(); + } + + private Map buildClaims(String memberId, String role, long expirationSeconds) { + Instant now = Instant.now(); + Instant expiry = now.plusSeconds(expirationSeconds); + return Map.of( + "sub", memberId, "role", role, "iat", Date.from(now), "exp", Date.from(expiry)); + } + + private Claims parseClaims(String token) { + try { + return decodeToken(token); + } catch (JwtException | IllegalArgumentException e) { + throw new CustomException(AuthErrorCode.INVALID_JWT_TOKEN); + } + } + + private Claims decodeToken(String token) { + return Jwts.parser() + .verifyWith(getSecretKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + private SecretKey getSecretKey() { + return Keys.hmacShaKeyFor(tokenProperties.secret().getBytes()); + } +} diff --git a/src/main/java/com/ject/studytrip/auth/presentation/controller/AuthController.java b/src/main/java/com/ject/studytrip/auth/presentation/controller/AuthController.java new file mode 100644 index 0000000..4ccd24d --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/presentation/controller/AuthController.java @@ -0,0 +1,42 @@ +package com.ject.studytrip.auth.presentation.controller; + +import com.ject.studytrip.auth.application.facade.AuthFacade; +import com.ject.studytrip.auth.presentation.dto.request.KakaoLoginRequest; +import com.ject.studytrip.auth.presentation.dto.request.KakaoSignupRequest; +import com.ject.studytrip.auth.presentation.dto.response.TokenResponse; +import com.ject.studytrip.global.common.response.StandardResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Auth", description = "인증 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("api/auth") +public class AuthController { + private final AuthFacade authFacade; + + @Operation(summary = "카카오 로그인", description = "카카오 인가 코드를 이용하여, 엑세스 토큰과 리프레시 토큰을 발급합니다.") + @PostMapping("/login/kakao") + public ResponseEntity kakaoLogin( + @Valid @RequestBody KakaoLoginRequest request) { + TokenResponse response = authFacade.kakaoLogin(request); + return ResponseEntity.ok(StandardResponse.success(200, response)); + } + + @Operation( + summary = "카카오 회원가입", + description = "카카오 인가 코드, 카테고리, 닉네임을 이용하여, 엑세스 토큰과 리프레시 토큰을 발급합니다.") + @PostMapping("/signup/kakao") + public ResponseEntity kakaoSignup( + @Valid @RequestBody KakaoSignupRequest request) { + TokenResponse response = authFacade.kakaoSignup(request); + return ResponseEntity.ok(StandardResponse.success(200, response)); + } +} diff --git a/src/main/java/com/ject/studytrip/auth/presentation/dto/request/KakaoLoginRequest.java b/src/main/java/com/ject/studytrip/auth/presentation/dto/request/KakaoLoginRequest.java new file mode 100644 index 0000000..26eb6b3 --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/presentation/dto/request/KakaoLoginRequest.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.auth.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record KakaoLoginRequest( + @Schema(description = "카카오 인가 코드") @NotBlank(message = "카카오 인가 코드를 입력해 주세요.") + String code) {} diff --git a/src/main/java/com/ject/studytrip/auth/presentation/dto/request/KakaoSignupRequest.java b/src/main/java/com/ject/studytrip/auth/presentation/dto/request/KakaoSignupRequest.java new file mode 100644 index 0000000..dbc8362 --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/presentation/dto/request/KakaoSignupRequest.java @@ -0,0 +1,20 @@ +package com.ject.studytrip.auth.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record KakaoSignupRequest( + @Schema(description = "카카오 인가 코드") @NotBlank(message = "카카오 인가 코드를 입력해 주세요.") String code, + @Schema(description = "멤버 카테고리") + @NotBlank(message = "멤버 카테고리를 입력해 주세요.") + @Pattern( + regexp = "^(STUDENT|WORKER|FREELANCER|JOBSEEKER)$", + message = "멤버 카테고리는 STUDENT, WORKER, FREELANCER, JOBSEEKER 중 하나여야 합니다.") + String category, + @Schema(description = "닉네임") + @NotBlank(message = "닉네임을 입력해 주세요.") + @Pattern( + regexp = "^[a-zA-Z0-9가-힣]{2,10}$", + message = "닉네임은 특수문자를 제외하고 2~10자 이내로 입력해주세요.") + String nickname) {} diff --git a/src/main/java/com/ject/studytrip/auth/presentation/dto/response/TokenResponse.java b/src/main/java/com/ject/studytrip/auth/presentation/dto/response/TokenResponse.java new file mode 100644 index 0000000..724c791 --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/presentation/dto/response/TokenResponse.java @@ -0,0 +1,11 @@ +package com.ject.studytrip.auth.presentation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record TokenResponse( + @Schema(description = "엑세스 토큰") String accessToken, + @Schema(description = "리프레시 토큰") String refreshToken) { + public static TokenResponse of(String accessToken, String refreshToken) { + return new TokenResponse(accessToken, refreshToken); + } +} diff --git a/src/main/java/com/ject/studytrip/global/common/entity/BaseTimeEntity.java b/src/main/java/com/ject/studytrip/global/common/entity/BaseTimeEntity.java new file mode 100644 index 0000000..8177afd --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/common/entity/BaseTimeEntity.java @@ -0,0 +1,24 @@ +package com.ject.studytrip.global.common.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@EntityListeners(AuditingEntityListener.class) +@MappedSuperclass +@Getter +public abstract class BaseTimeEntity { + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate private LocalDateTime updatedAt; + + private LocalDateTime deletedAt; +} diff --git a/src/main/java/com/ject/studytrip/global/config/JpaAuditingConfig.java b/src/main/java/com/ject/studytrip/global/config/JpaAuditingConfig.java new file mode 100644 index 0000000..5abd6de --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/config/JpaAuditingConfig.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig {} diff --git a/src/main/java/com/ject/studytrip/global/config/WebClientConfig.java b/src/main/java/com/ject/studytrip/global/config/WebClientConfig.java new file mode 100644 index 0000000..7412466 --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/config/WebClientConfig.java @@ -0,0 +1,22 @@ +package com.ject.studytrip.global.config; + +import com.ject.studytrip.global.config.properties.KakaoOauthProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +@EnableConfigurationProperties(KakaoOauthProperties.class) +public class WebClientConfig { + + @Bean + public WebClient webClient() { + return WebClient.builder() + .defaultHeader( + HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .build(); + } +} diff --git a/src/main/java/com/ject/studytrip/global/config/WebSecurityConfig.java b/src/main/java/com/ject/studytrip/global/config/WebSecurityConfig.java index d6cb160..085f565 100644 --- a/src/main/java/com/ject/studytrip/global/config/WebSecurityConfig.java +++ b/src/main/java/com/ject/studytrip/global/config/WebSecurityConfig.java @@ -1,10 +1,13 @@ package com.ject.studytrip.global.config; +import com.ject.studytrip.auth.infra.filter.JwtFilter; import com.ject.studytrip.global.common.constants.SwaggerUrlConstants; +import com.ject.studytrip.global.config.properties.TokenProperties; import com.ject.studytrip.global.security.CustomAccessDeniedHandler; import com.ject.studytrip.global.security.CustomAuthenticationEntryPoint; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -12,6 +15,7 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -19,8 +23,9 @@ @EnableWebSecurity @Configuration @RequiredArgsConstructor +@EnableConfigurationProperties(TokenProperties.class) public class WebSecurityConfig { - + private final JwtFilter jwtFilter; private final CustomAuthenticationEntryPoint authenticationEntryPoint; private final CustomAccessDeniedHandler accessDeniedHandler; @@ -44,13 +49,16 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { defaultFilterChain(http); + // JWT 필터 등록 : 인증 이전에 동작해야 하므로 UsernamePasswordAuthenticationFilter 앞에 삽입 + http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + // 경로 인가 설정 http.authorizeHttpRequests( authorize -> authorize .requestMatchers(SwaggerUrlConstants.getSwaggerUrls()) .permitAll() // Swagger 경로 - .requestMatchers("/api/sample/**") + .requestMatchers("/api/sample/**", "/api/auth/**") .permitAll() // 샘플 api 경로 .anyRequest() .authenticated()); // 그 외 요청은 모두 인증 수행 diff --git a/src/main/java/com/ject/studytrip/global/config/properties/KakaoOauthProperties.java b/src/main/java/com/ject/studytrip/global/config/properties/KakaoOauthProperties.java new file mode 100644 index 0000000..56e902d --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/config/properties/KakaoOauthProperties.java @@ -0,0 +1,11 @@ +package com.ject.studytrip.global.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "oauth.kakao") +public record KakaoOauthProperties( + String clientId, + String clientSecret, + String redirectUri, + String tokenUri, + String userInfoUri) {} diff --git a/src/main/java/com/ject/studytrip/global/config/properties/TokenProperties.java b/src/main/java/com/ject/studytrip/global/config/properties/TokenProperties.java new file mode 100644 index 0000000..f5dcee3 --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/config/properties/TokenProperties.java @@ -0,0 +1,7 @@ +package com.ject.studytrip.global.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "jwt") +public record TokenProperties( + String secret, long accessExpirationTime, long refreshExpirationTime) {} diff --git a/src/main/java/com/ject/studytrip/global/exception/error/AuthErrorCode.java b/src/main/java/com/ject/studytrip/global/exception/error/AuthErrorCode.java deleted file mode 100644 index 2aa5b44..0000000 --- a/src/main/java/com/ject/studytrip/global/exception/error/AuthErrorCode.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.ject.studytrip.global.exception.error; - -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; - -@RequiredArgsConstructor -public enum AuthErrorCode implements ErrorCode { - UNAUTHENTICATED(HttpStatus.UNAUTHORIZED, "인증되지 않은 요청입니다."), - ACCESS_DENIED(HttpStatus.FORBIDDEN, "접근 권한이 부족합니다."), - ; - - private final HttpStatus status; - private final String message; - - @Override - public String getName() { - return this.name(); - } - - @Override - public HttpStatus getStatus() { - return this.status; - } - - @Override - public String getMessage() { - return this.message; - } -} diff --git a/src/main/java/com/ject/studytrip/global/security/CustomAccessDeniedHandler.java b/src/main/java/com/ject/studytrip/global/security/CustomAccessDeniedHandler.java index 6a1381e..3b04e17 100644 --- a/src/main/java/com/ject/studytrip/global/security/CustomAccessDeniedHandler.java +++ b/src/main/java/com/ject/studytrip/global/security/CustomAccessDeniedHandler.java @@ -1,6 +1,6 @@ package com.ject.studytrip.global.security; -import com.ject.studytrip.global.exception.error.AuthErrorCode; +import com.ject.studytrip.auth.domain.error.AuthErrorCode; import com.ject.studytrip.global.exception.error.ErrorCode; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; diff --git a/src/main/java/com/ject/studytrip/global/security/CustomAuthenticationEntryPoint.java b/src/main/java/com/ject/studytrip/global/security/CustomAuthenticationEntryPoint.java index 41a8d79..35690ec 100644 --- a/src/main/java/com/ject/studytrip/global/security/CustomAuthenticationEntryPoint.java +++ b/src/main/java/com/ject/studytrip/global/security/CustomAuthenticationEntryPoint.java @@ -1,6 +1,6 @@ package com.ject.studytrip.global.security; -import com.ject.studytrip.global.exception.error.AuthErrorCode; +import com.ject.studytrip.auth.domain.error.AuthErrorCode; import com.ject.studytrip.global.exception.error.ErrorCode; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; diff --git a/src/main/java/com/ject/studytrip/member/application/service/MemberService.java b/src/main/java/com/ject/studytrip/member/application/service/MemberService.java new file mode 100644 index 0000000..40e920d --- /dev/null +++ b/src/main/java/com/ject/studytrip/member/application/service/MemberService.java @@ -0,0 +1,68 @@ +package com.ject.studytrip.member.application.service; + +import static io.jsonwebtoken.lang.Strings.hasText; + +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.member.domain.entity.Member; +import com.ject.studytrip.member.domain.entity.MemberCategory; +import com.ject.studytrip.member.domain.entity.SocialProvider; +import com.ject.studytrip.member.domain.error.MemberErrorCode; +import com.ject.studytrip.member.domain.repository.MemberRepository; +import com.ject.studytrip.member.factory.MemberFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MemberService { + private final MemberRepository memberRepository; + + @Transactional(readOnly = true) + public Member getMember(Long memberId) { + return memberRepository + .findById(memberId) + .orElseThrow(() -> new CustomException(MemberErrorCode.MEMBER_NOT_FOUND)); + } + + @Transactional(readOnly = true) + public Member getMemberBySocialProviderAndSocialId( + SocialProvider socialProvider, String socialId) { + return memberRepository + .findBySocialProviderAndSocialId(socialProvider, socialId) + .orElseThrow(() -> new CustomException(MemberErrorCode.MEMBER_NEED_SIGNUP)); + } + + @Transactional + public Member createMemberFromKakao( + String kakaoId, String email, String profileImage, String category, String nickname) { + validateNewMember(kakaoId); + MemberCategory parsedCategory = parseCategory(category); + validateMemberNickname(nickname); + Member member = + MemberFactory.fromKakao(kakaoId, email, profileImage, nickname, parsedCategory); + return memberRepository.save(member); + } + + private MemberCategory parseCategory(String category) { + try { + return MemberCategory.valueOf(category); + } catch (IllegalArgumentException | NullPointerException e) { + throw new CustomException(MemberErrorCode.MEMBER_CATEGORY_REQUIRED); + } + } + + private void validateNewMember(String socialId) { + if (memberRepository + .findBySocialProviderAndSocialId(SocialProvider.KAKAO, socialId) + .isPresent()) { + throw new CustomException(MemberErrorCode.MEMBER_ALREADY_EXISTS); + } + } + + private void validateMemberNickname(String nickname) { + if (!hasText(nickname)) { + throw new CustomException(MemberErrorCode.MEMBER_NICKNAME_REQUIRED); + } + } +} diff --git a/src/main/java/com/ject/studytrip/member/domain/entity/Member.java b/src/main/java/com/ject/studytrip/member/domain/entity/Member.java new file mode 100644 index 0000000..829d4c7 --- /dev/null +++ b/src/main/java/com/ject/studytrip/member/domain/entity/Member.java @@ -0,0 +1,57 @@ +package com.ject.studytrip.member.domain.entity; + +import com.ject.studytrip.global.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class Member extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private SocialProvider socialProvider; + + @Column(nullable = false) + private String socialId; + + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false, unique = true) + private String nickname; + + private String profileImage; + + @Enumerated(EnumType.STRING) + private MemberCategory category; + + @Enumerated(EnumType.STRING) + private MemberRole role; + + public static Member of( + SocialProvider socialProvider, + String socialId, + String email, + String nickname, + String profileImage, + MemberCategory category, + MemberRole role) { + return Member.builder() + .socialProvider(socialProvider) + .socialId(socialId) + .email(email) + .nickname(nickname) + .profileImage(profileImage) + .category(category) + .role(role) + .build(); + } +} diff --git a/src/main/java/com/ject/studytrip/member/domain/entity/MemberCategory.java b/src/main/java/com/ject/studytrip/member/domain/entity/MemberCategory.java new file mode 100644 index 0000000..baad004 --- /dev/null +++ b/src/main/java/com/ject/studytrip/member/domain/entity/MemberCategory.java @@ -0,0 +1,8 @@ +package com.ject.studytrip.member.domain.entity; + +public enum MemberCategory { + STUDENT, + WORKER, + FREELANCER, + JOBSEEKER +} diff --git a/src/main/java/com/ject/studytrip/member/domain/entity/MemberRole.java b/src/main/java/com/ject/studytrip/member/domain/entity/MemberRole.java new file mode 100644 index 0000000..c3aa7a1 --- /dev/null +++ b/src/main/java/com/ject/studytrip/member/domain/entity/MemberRole.java @@ -0,0 +1,7 @@ +package com.ject.studytrip.member.domain.entity; + +public enum MemberRole { + ROLE_USER, + ROLE_ADMIN, + ROLE_OWNER +} diff --git a/src/main/java/com/ject/studytrip/member/domain/entity/SocialProvider.java b/src/main/java/com/ject/studytrip/member/domain/entity/SocialProvider.java new file mode 100644 index 0000000..543a7d7 --- /dev/null +++ b/src/main/java/com/ject/studytrip/member/domain/entity/SocialProvider.java @@ -0,0 +1,6 @@ +package com.ject.studytrip.member.domain.entity; + +public enum SocialProvider { + KAKAO, + GOOGLE +} 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 new file mode 100644 index 0000000..9aa322d --- /dev/null +++ b/src/main/java/com/ject/studytrip/member/domain/error/MemberErrorCode.java @@ -0,0 +1,33 @@ +package com.ject.studytrip.member.domain.error; + +import com.ject.studytrip.global.exception.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum MemberErrorCode implements ErrorCode { + MEMBER_CATEGORY_REQUIRED(HttpStatus.BAD_REQUEST, "멤버 카테고리는 필수입니다."), + MEMBER_NICKNAME_REQUIRED(HttpStatus.BAD_REQUEST, "멤버 닉네임은 필수입니다."), + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "멤버를 찾을 수 없습니다."), + MEMBER_NEED_SIGNUP(HttpStatus.CONFLICT, "회원가입이 필요한 사용자입니다."), + MEMBER_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 가입된 사용자입니다."), + ; + + private final HttpStatus status; + private final String message; + + @Override + public String getName() { + return this.name(); + } + + @Override + public HttpStatus getStatus() { + return this.status; + } + + @Override + public String getMessage() { + return this.message; + } +} diff --git a/src/main/java/com/ject/studytrip/member/domain/repository/MemberRepository.java b/src/main/java/com/ject/studytrip/member/domain/repository/MemberRepository.java new file mode 100644 index 0000000..2bf8f7e --- /dev/null +++ b/src/main/java/com/ject/studytrip/member/domain/repository/MemberRepository.java @@ -0,0 +1,14 @@ +package com.ject.studytrip.member.domain.repository; + +import com.ject.studytrip.member.domain.entity.Member; +import com.ject.studytrip.member.domain.entity.SocialProvider; +import java.util.Optional; + +public interface MemberRepository { + Optional findBySocialProviderAndSocialId( + SocialProvider socialProvider, String socialId); + + Optional findById(Long id); + + Member save(Member member); +} diff --git a/src/main/java/com/ject/studytrip/member/factory/MemberFactory.java b/src/main/java/com/ject/studytrip/member/factory/MemberFactory.java new file mode 100644 index 0000000..af05776 --- /dev/null +++ b/src/main/java/com/ject/studytrip/member/factory/MemberFactory.java @@ -0,0 +1,27 @@ +package com.ject.studytrip.member.factory; + +import com.ject.studytrip.member.domain.entity.Member; +import com.ject.studytrip.member.domain.entity.MemberCategory; +import com.ject.studytrip.member.domain.entity.MemberRole; +import com.ject.studytrip.member.domain.entity.SocialProvider; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class MemberFactory { + public static Member fromKakao( + String kakaoId, + String email, + String profileImage, + String nickname, + MemberCategory category) { + return Member.of( + SocialProvider.KAKAO, + kakaoId, + email, + nickname, + profileImage, + category, + MemberRole.ROLE_USER); + } +} diff --git a/src/main/java/com/ject/studytrip/member/infra/jpa/MemberJpaRepository.java b/src/main/java/com/ject/studytrip/member/infra/jpa/MemberJpaRepository.java new file mode 100644 index 0000000..22e15cd --- /dev/null +++ b/src/main/java/com/ject/studytrip/member/infra/jpa/MemberJpaRepository.java @@ -0,0 +1,11 @@ +package com.ject.studytrip.member.infra.jpa; + +import com.ject.studytrip.member.domain.entity.Member; +import com.ject.studytrip.member.domain.entity.SocialProvider; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MemberJpaRepository extends JpaRepository { + Optional findBySocialProviderAndSocialId( + SocialProvider socialProvider, String socialId); +} diff --git a/src/main/java/com/ject/studytrip/member/infra/jpa/MemberRepositoryAdapter.java b/src/main/java/com/ject/studytrip/member/infra/jpa/MemberRepositoryAdapter.java new file mode 100644 index 0000000..829d72d --- /dev/null +++ b/src/main/java/com/ject/studytrip/member/infra/jpa/MemberRepositoryAdapter.java @@ -0,0 +1,30 @@ +package com.ject.studytrip.member.infra.jpa; + +import com.ject.studytrip.member.domain.entity.Member; +import com.ject.studytrip.member.domain.entity.SocialProvider; +import com.ject.studytrip.member.domain.repository.MemberRepository; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class MemberRepositoryAdapter implements MemberRepository { + private final MemberJpaRepository memberJpaRepository; + + @Override + public Optional findBySocialProviderAndSocialId( + SocialProvider socialProvider, String socialId) { + return memberJpaRepository.findBySocialProviderAndSocialId(socialProvider, socialId); + } + + @Override + public Optional findById(Long id) { + return memberJpaRepository.findById(id); + } + + @Override + public Member save(Member member) { + return memberJpaRepository.save(member); + } +} diff --git a/src/test/java/com/ject/studytrip/BaseIntegrationTest.java b/src/test/java/com/ject/studytrip/BaseIntegrationTest.java new file mode 100644 index 0000000..e7f2e29 --- /dev/null +++ b/src/test/java/com/ject/studytrip/BaseIntegrationTest.java @@ -0,0 +1,36 @@ +package com.ject.studytrip; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest(classes = {StudytripApplication.class}) +@ActiveProfiles("test") +@TestPropertySource(locations = "file:.env") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Transactional +@AutoConfigureMockMvc +public abstract class BaseIntegrationTest { + + @Autowired protected MockMvc mockMvc; + @Autowired protected ObjectMapper objectMapper; + + /** + * 응답 본문(JSON)을 지정한 클래스 타입으로 변환하는 메서드, 테스트 응답 결과를 객체로 파싱해 내용 검증에 활용할 수 있음 + * + * @param result MockMvc 응답 결과 + * @param clazz 변환할 클래스 타입 + * @return 파싱된 응답 객체 + */ + protected T parseResponse(ResultActions result, Class clazz) throws Exception { + String content = result.andReturn().getResponse().getContentAsString(); + return objectMapper.readValue(content, clazz); + } +} diff --git a/src/test/java/com/ject/studytrip/BaseUnitTest.java b/src/test/java/com/ject/studytrip/BaseUnitTest.java new file mode 100644 index 0000000..09c83e6 --- /dev/null +++ b/src/test/java/com/ject/studytrip/BaseUnitTest.java @@ -0,0 +1,11 @@ +package com.ject.studytrip; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public abstract class BaseUnitTest { + + protected final ObjectMapper objectMapper = new ObjectMapper(); +} diff --git a/src/test/java/com/ject/studytrip/auth/application/service/KakaoLoginServiceTest.java b/src/test/java/com/ject/studytrip/auth/application/service/KakaoLoginServiceTest.java new file mode 100644 index 0000000..3c5a2ca --- /dev/null +++ b/src/test/java/com/ject/studytrip/auth/application/service/KakaoLoginServiceTest.java @@ -0,0 +1,111 @@ +package com.ject.studytrip.auth.application.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +import com.ject.studytrip.BaseUnitTest; +import com.ject.studytrip.auth.domain.error.AuthErrorCode; +import com.ject.studytrip.auth.fixture.KakaoOauthFixture; +import com.ject.studytrip.auth.infra.dto.KakaoTokenResponse; +import com.ject.studytrip.auth.infra.dto.KakaoUserInfoResponse; +import com.ject.studytrip.auth.infra.provider.KakaoOauthProvider; +import com.ject.studytrip.auth.infra.provider.TokenProvider; +import com.ject.studytrip.auth.presentation.dto.response.TokenResponse; +import com.ject.studytrip.global.exception.CustomException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +@DisplayName("KakaoLoginService 단위 테스트") +class KakaoLoginServiceTest extends BaseUnitTest { + private static final String KAKAO_ID = "12345"; + private static final String EMAIL = "choi@kakao.com"; + private static final String PROFILE_IMAGE = "https://kakao.com/profile.jpg"; + private static final String VALID_CODE = "valid-code"; + private static final String MEMBER_ID = "123"; + private static final String ROLE = "ROLE_USER"; + + @InjectMocks private KakaoLoginService kakaoLoginService; + + @Mock private KakaoOauthProvider kakaoOauthProvider; + + @Mock private TokenProvider tokenProvider; + + @Nested + @DisplayName("getKakaoUserInfo 메서드는") + class GetKakaoUserInfo { + + @Test + @DisplayName("유효하지 않은 인가 코드를 전달하면 예외가 발생한다.") + void shouldThrowExceptionWhenAuthorizationCodeIsInvalid() { + // given + when(kakaoOauthProvider.getKakaoTokens(" ")) + .thenThrow(new CustomException(AuthErrorCode.INVALID_KAKAO_AUTHORIZATION_CODE)); + + // when & then + assertThatThrownBy(() -> kakaoLoginService.getKakaoUserInfo(" ")) + .isInstanceOf(CustomException.class) + .hasMessage(AuthErrorCode.INVALID_KAKAO_AUTHORIZATION_CODE.getMessage()); + } + + @Test + @DisplayName("카카오 토큰 응답은 왔지만 사용자 정보 조회에 실패하면 예외가 발생한다.") + void shouldThrowExceptionWhenFetchingKakaoUserInfoFails() { + // given + KakaoTokenResponse tokenResponse = KakaoOauthFixture.createTokenResponse(); + when(kakaoOauthProvider.getKakaoTokens(VALID_CODE)).thenReturn(tokenResponse); + when(kakaoOauthProvider.getKakaoUserInfo(tokenResponse.accessToken())) + .thenThrow(new CustomException(AuthErrorCode.KAKAO_USER_INFO_FETCH_FAILED)); + + // when & then + assertThatThrownBy(() -> kakaoLoginService.getKakaoUserInfo(VALID_CODE)) + .isInstanceOf(CustomException.class) + .hasMessage(AuthErrorCode.KAKAO_USER_INFO_FETCH_FAILED.getMessage()); + } + + @Test + @DisplayName("유효한 인가 코드를 전달하면 사용자 정보를 반환한다.") + void shouldReturnKakaoUserInfoResponseWhenCodeIsValid() { + // given + KakaoTokenResponse kakaoTokenResponse = KakaoOauthFixture.createTokenResponse(); + KakaoUserInfoResponse kakaoUserInfoResponse = + KakaoOauthFixture.createKakaoUserInfoResponse(); + when(kakaoOauthProvider.getKakaoTokens(VALID_CODE)).thenReturn(kakaoTokenResponse); + when(kakaoOauthProvider.getKakaoUserInfo("access-token")) + .thenReturn(kakaoUserInfoResponse); + + // when + KakaoUserInfoResponse result = kakaoLoginService.getKakaoUserInfo(VALID_CODE); + + // then + assertThat(result.kakaoId()).isEqualTo(KAKAO_ID); + assertThat(result.getEmail()).isEqualTo(EMAIL); + assertThat(result.getProfileImage()).isEqualTo(PROFILE_IMAGE); + } + } + + @Nested + @DisplayName("getTokens 메서드는") + class GetTokens { + + @Test + @DisplayName("memberId와 memberRole이 주어지면 토큰을 반환한다.") + void shouldReturnTokenResponseWhenMemberIdAndRoleProvided() { + // given + String accessToken = "access.jwt.token"; + String refreshToken = "refresh.jwt.token"; + when(tokenProvider.createAccessToken(MEMBER_ID, ROLE)).thenReturn(accessToken); + when(tokenProvider.createRefreshToken(MEMBER_ID, ROLE)).thenReturn(refreshToken); + + // when + TokenResponse response = kakaoLoginService.getTokens(MEMBER_ID, ROLE); + + // then + assertThat(response.accessToken()).isEqualTo(accessToken); + assertThat(response.refreshToken()).isEqualTo(refreshToken); + } + } +} diff --git a/src/test/java/com/ject/studytrip/auth/fixture/KakaoOauthFixture.java b/src/test/java/com/ject/studytrip/auth/fixture/KakaoOauthFixture.java new file mode 100644 index 0000000..216ead7 --- /dev/null +++ b/src/test/java/com/ject/studytrip/auth/fixture/KakaoOauthFixture.java @@ -0,0 +1,35 @@ +package com.ject.studytrip.auth.fixture; + +import com.ject.studytrip.auth.infra.dto.KakaoAccount; +import com.ject.studytrip.auth.infra.dto.KakaoProfile; +import com.ject.studytrip.auth.infra.dto.KakaoTokenResponse; +import com.ject.studytrip.auth.infra.dto.KakaoUserInfoResponse; +import com.ject.studytrip.auth.presentation.dto.request.KakaoLoginRequest; +import com.ject.studytrip.auth.presentation.dto.request.KakaoSignupRequest; + +public class KakaoOauthFixture { + private static final String KAKAO_ID = "12345"; + private static final String EMAIL = "choi@kakao.com"; + private static final String PROFILE_IMAGE = "https://kakao.com/profile.jpg"; + private static final String VALID_CODE = "valid-code"; + private static final String NICKNAME = "민우"; + private static final String CATEGORY = "STUDENT"; + + public static KakaoUserInfoResponse createKakaoUserInfoResponse() { + return new KakaoUserInfoResponse( + KAKAO_ID, new KakaoAccount(new KakaoProfile(PROFILE_IMAGE), EMAIL)); + } + + public static KakaoLoginRequest createLoginRequest() { + return new KakaoLoginRequest(VALID_CODE); + } + + public static KakaoSignupRequest createSignupRequest() { + return new KakaoSignupRequest(VALID_CODE, CATEGORY, NICKNAME); + } + + public static KakaoTokenResponse createTokenResponse() { + return new KakaoTokenResponse( + "bearer", "access-token", 3600, "refresh-token", 7200, "scope"); + } +} diff --git a/src/test/java/com/ject/studytrip/auth/fixture/TokenFixture.java b/src/test/java/com/ject/studytrip/auth/fixture/TokenFixture.java new file mode 100644 index 0000000..e90baff --- /dev/null +++ b/src/test/java/com/ject/studytrip/auth/fixture/TokenFixture.java @@ -0,0 +1,14 @@ +package com.ject.studytrip.auth.fixture; + +import com.ject.studytrip.global.config.properties.TokenProperties; + +public class TokenFixture { + public static final String TEST_SECRET = + "this-is-a-test-secret-key-which-is-long-enough-1234567890"; + public static final long ACCESS_EXPIRATION_TIME = 7200; + public static final long REFRESH_EXPIRATION_TIME = 604800; + + public static TokenProperties createTokenProperties() { + return new TokenProperties(TEST_SECRET, ACCESS_EXPIRATION_TIME, REFRESH_EXPIRATION_TIME); + } +} diff --git a/src/test/java/com/ject/studytrip/auth/presentation/controller/AuthControllerIntegrationTest.java b/src/test/java/com/ject/studytrip/auth/presentation/controller/AuthControllerIntegrationTest.java new file mode 100644 index 0000000..6df883c --- /dev/null +++ b/src/test/java/com/ject/studytrip/auth/presentation/controller/AuthControllerIntegrationTest.java @@ -0,0 +1,209 @@ +package com.ject.studytrip.auth.presentation.controller; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import com.ject.studytrip.BaseIntegrationTest; +import com.ject.studytrip.auth.fixture.KakaoOauthFixture; +import com.ject.studytrip.auth.infra.provider.KakaoOauthProvider; +import com.ject.studytrip.auth.presentation.dto.request.KakaoLoginRequest; +import com.ject.studytrip.auth.presentation.dto.request.KakaoSignupRequest; +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.member.domain.error.MemberErrorCode; +import com.ject.studytrip.member.helper.MemberTestHelper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.ResultActions; + +@DisplayName("AuthController 통합 테스트") +class AuthControllerIntegrationTest extends BaseIntegrationTest { + + @Autowired private MemberTestHelper memberTestHelper; + + @MockitoBean KakaoOauthProvider kakaoOauthProvider; + + @Nested + @DisplayName("kakaoLogin 메서드는") + class KakaoLogin { + + @Test + @DisplayName("가입되지 않은 사용자 인가 코드로 로그인 시 MEMBER_NEED_SIGNUP 예외가 발생한다") + void shouldThrowExceptionWhenMemberNotSignUp() throws Exception { + // given + KakaoLoginRequest request = KakaoOauthFixture.createLoginRequest(); + given(kakaoOauthProvider.getKakaoTokens(anyString())) + .willReturn(KakaoOauthFixture.createTokenResponse()); + given(kakaoOauthProvider.getKakaoUserInfo(anyString())) + .willThrow(new CustomException(MemberErrorCode.MEMBER_NEED_SIGNUP)); + + // when + ResultActions result = + mockMvc.perform( + post("/api/auth/login/kakao") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isConflict()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(MemberErrorCode.MEMBER_NEED_SIGNUP.getStatus().value())) + .andExpect( + jsonPath("$.data.error") + .value(MemberErrorCode.MEMBER_NEED_SIGNUP.name())) + .andExpect( + jsonPath("$.data.message") + .value(MemberErrorCode.MEMBER_NEED_SIGNUP.getMessage())); + } + + @Test + @DisplayName("가입된 사용자의 인가 코드로 로그인하면 토큰이 발급된다") + void shouldReturnTokenResponseWhenLoginIsSuccessful() throws Exception { + // given + KakaoLoginRequest request = KakaoOauthFixture.createLoginRequest(); + memberTestHelper.saveMember(); + given(kakaoOauthProvider.getKakaoTokens(anyString())) + .willReturn(KakaoOauthFixture.createTokenResponse()); + given(kakaoOauthProvider.getKakaoUserInfo(anyString())) + .willReturn(KakaoOauthFixture.createKakaoUserInfoResponse()); + + // when + ResultActions result = + mockMvc.perform( + post("/api/auth/login/kakao") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) + .andExpect(jsonPath("$.data.refreshToken").isNotEmpty()); + } + } + + @Nested + @DisplayName("kakaoSignup 메서드는") + class KakaoSignup { + + @Test + @DisplayName("이미 가입된 사용자가 회원가입 요청 시 MEMBER_ALREADY_EXISTS 예외가 발생한다") + void shouldThrowExceptionWhenSignupForExistingMember() throws Exception { + // given + KakaoSignupRequest request = KakaoOauthFixture.createSignupRequest(); + memberTestHelper.saveMember(); + given(kakaoOauthProvider.getKakaoTokens(anyString())) + .willReturn(KakaoOauthFixture.createTokenResponse()); + given(kakaoOauthProvider.getKakaoUserInfo(anyString())) + .willReturn(KakaoOauthFixture.createKakaoUserInfoResponse()); + + // when + ResultActions result = + mockMvc.perform( + post("/api/auth/signup/kakao") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isConflict()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + MemberErrorCode.MEMBER_ALREADY_EXISTS + .getStatus() + .value())) + .andExpect( + jsonPath("$.data.message") + .value(MemberErrorCode.MEMBER_ALREADY_EXISTS.getMessage())); + } + + @Test + @DisplayName("회원가입 요청 시 category가 유효하지 않으면 MEMBER_CATEGORY_REQUIRED 예외가 발생한다") + void shouldThrowExceptionWhenCategoryIsInvalid() throws Exception { + // given + KakaoSignupRequest request = new KakaoSignupRequest("valid-code", "", "민우"); + given(kakaoOauthProvider.getKakaoTokens(anyString())) + .willReturn(KakaoOauthFixture.createTokenResponse()); + given(kakaoOauthProvider.getKakaoUserInfo(anyString())) + .willReturn(KakaoOauthFixture.createKakaoUserInfoResponse()); + + // when + ResultActions result = + mockMvc.perform( + post("/api/auth/signup/kakao") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + MemberErrorCode.MEMBER_CATEGORY_REQUIRED + .getStatus() + .value())); + } + + @Test + @DisplayName("회원가입 요청 시 닉네임이 비어있으면 MEMBER_NICKNAME_REQUIRED 예외가 발생한다") + void shouldThrowExceptionWhenSignupNicknameIsBlank() throws Exception { + // given + KakaoSignupRequest request = new KakaoSignupRequest("valid-code", "STUDENT", ""); + given(kakaoOauthProvider.getKakaoTokens(anyString())) + .willReturn(KakaoOauthFixture.createTokenResponse()); + given(kakaoOauthProvider.getKakaoUserInfo(anyString())) + .willReturn(KakaoOauthFixture.createKakaoUserInfoResponse()); + + // when + ResultActions result = + mockMvc.perform( + post("/api/auth/signup/kakao") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + MemberErrorCode.MEMBER_NICKNAME_REQUIRED + .getStatus() + .value())); + } + + @Test + @DisplayName("회원가입 요청 시 유효한 정보라면 토큰이 발급된다") + void shouldReturnTokenResponseWhenSignupIsSuccessful() throws Exception { + // given + KakaoSignupRequest request = KakaoOauthFixture.createSignupRequest(); + given(kakaoOauthProvider.getKakaoTokens(anyString())) + .willReturn(KakaoOauthFixture.createTokenResponse()); + given(kakaoOauthProvider.getKakaoUserInfo(anyString())) + .willReturn(KakaoOauthFixture.createKakaoUserInfoResponse()); + + // when + ResultActions result = + mockMvc.perform( + post("/api/auth/signup/kakao") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) + .andExpect(jsonPath("$.data.refreshToken").isNotEmpty()); + } + } +} diff --git a/src/test/java/com/ject/studytrip/member/application/service/MemberServiceTest.java b/src/test/java/com/ject/studytrip/member/application/service/MemberServiceTest.java new file mode 100644 index 0000000..d777857 --- /dev/null +++ b/src/test/java/com/ject/studytrip/member/application/service/MemberServiceTest.java @@ -0,0 +1,186 @@ +package com.ject.studytrip.member.application.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import com.ject.studytrip.BaseUnitTest; +import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.member.domain.entity.Member; +import com.ject.studytrip.member.domain.entity.SocialProvider; +import com.ject.studytrip.member.domain.error.MemberErrorCode; +import com.ject.studytrip.member.domain.repository.MemberRepository; +import com.ject.studytrip.member.fixture.MemberFixture; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +@DisplayName("MemberService 단위 테스트") +class MemberServiceTest extends BaseUnitTest { + private static final String KAKAO_ID = "12345"; + private static final String EMAIL = "choi@kakao.com"; + private static final String PROFILE_IMAGE = "https://kakao.com/profile.jpg"; + private static final String NICKNAME = "민우"; + private static final String CATEGORY = "STUDENT"; + + @InjectMocks private MemberService memberService; + @Mock private MemberRepository memberRepository; + + private Member member; + private Member memberWithoutProfileImage; + + @BeforeEach + void setUp() { + member = MemberFixture.createMemberFromKakao(); + memberWithoutProfileImage = MemberFixture.createMemberWithoutProfileImageFromKakao(); + } + + @Nested + @DisplayName("getMember 메서드는") + class GetMember { + + @Test + @DisplayName("존재하지 않는 ID로 조회하면 예외가 발생한다.") + void shouldThrowExceptionWhenMemberIdNotFound() { + // given + Long invalidId = -1L; + given(memberRepository.findById(invalidId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> memberService.getMember(invalidId)) + .isInstanceOf(CustomException.class) + .hasMessage(MemberErrorCode.MEMBER_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("ID로 멤버를 조회하면 Member를 반환한다.") + void shouldReturnMemberWhenMemberIdExists() { + // given + given(memberRepository.findById(1L)).willReturn(Optional.of(member)); + + // when + Member result = memberService.getMember(1L); + + // then + assertThat(result).isEqualTo(member); + } + } + + @Nested + @DisplayName("getMemberBySocialProviderAndSocialId 메서드는") + class GetMemberBySocialProviderAndSocialId { + + @Test + @DisplayName("소셜 ID로 조회 시 존재하지 않으면 예외가 발생한다.") + void shouldThrowExceptionWhenSocialIdNotFound() { + // given + given(memberRepository.findBySocialProviderAndSocialId(SocialProvider.KAKAO, KAKAO_ID)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy( + () -> + memberService.getMemberBySocialProviderAndSocialId( + SocialProvider.KAKAO, KAKAO_ID)) + .isInstanceOf(CustomException.class) + .hasMessage(MemberErrorCode.MEMBER_NEED_SIGNUP.getMessage()); + } + + @Test + @DisplayName("소셜 ID로 조회 시 존재하면 Member를 반환한다.") + void shouldReturnMemberWhenSocialIdExists() { + // given + given(memberRepository.findBySocialProviderAndSocialId(SocialProvider.KAKAO, KAKAO_ID)) + .willReturn(Optional.of(member)); + + // when + Member result = + memberService.getMemberBySocialProviderAndSocialId( + SocialProvider.KAKAO, KAKAO_ID); + + // then + assertThat(result).isEqualTo(member); + } + + @Test + @DisplayName("이미 존재하는 멤버라면 예외가 발생한다.") + void shouldThrowExceptionWhenMemberAlreadyExists() { + // given + given(memberRepository.findBySocialProviderAndSocialId(SocialProvider.KAKAO, KAKAO_ID)) + .willReturn(Optional.of(member)); + + // when & then + assertThatThrownBy( + () -> + memberService.createMemberFromKakao( + KAKAO_ID, EMAIL, PROFILE_IMAGE, CATEGORY, NICKNAME)) + .isInstanceOf(CustomException.class) + .hasMessage(MemberErrorCode.MEMBER_ALREADY_EXISTS.getMessage()); + } + } + + @Nested + @DisplayName("createMemberFromKakao 메서드는") + class CreateMemberFromKakao { + + @Test + @DisplayName("카테고리가 유효하지 않으면 예외가 발생한다.") + void shouldThrowExceptionWhenCategoryIsInvalid() { + // when & then + assertThatThrownBy( + () -> + memberService.createMemberFromKakao( + KAKAO_ID, EMAIL, PROFILE_IMAGE, "INVALID", NICKNAME)) + .isInstanceOf(CustomException.class) + .hasMessage(MemberErrorCode.MEMBER_CATEGORY_REQUIRED.getMessage()); + } + + @Test + @DisplayName("닉네임이 비어 있으면 예외가 발생한다.") + void shouldThrowExceptionWhenNicknameIsBlank() { + // when & then + assertThatThrownBy( + () -> + memberService.createMemberFromKakao( + KAKAO_ID, EMAIL, PROFILE_IMAGE, CATEGORY, " ")) + .isInstanceOf(CustomException.class) + .hasMessage(MemberErrorCode.MEMBER_NICKNAME_REQUIRED.getMessage()); + } + + @Test + @DisplayName("모든 정보가 유효하면 Member를 생성하고 반환한다.") + void shouldCreateMemberWhenAllDataIsValid() { + // given + given(memberRepository.findBySocialProviderAndSocialId(SocialProvider.KAKAO, KAKAO_ID)) + .willReturn(Optional.empty()); + given(memberRepository.save(any(Member.class))).willReturn(member); + + // when + Member result = + memberService.createMemberFromKakao( + KAKAO_ID, EMAIL, PROFILE_IMAGE, CATEGORY, NICKNAME); + + // then + assertThat(result).isEqualTo(member); + } + + @Test + @DisplayName("프로필 이미지가 없어도 Member를 생성하고 반환한다.") + void shouldCreateMemberWhenProfileImageIsNull() { + // given + given(memberRepository.findBySocialProviderAndSocialId(SocialProvider.KAKAO, KAKAO_ID)) + .willReturn(Optional.empty()); + given(memberRepository.save(any(Member.class))).willReturn(memberWithoutProfileImage); + + // when + Member result = + memberService.createMemberFromKakao(KAKAO_ID, EMAIL, null, CATEGORY, NICKNAME); + + // then + assertThat(result).isEqualTo(memberWithoutProfileImage); + } + } +} diff --git a/src/test/java/com/ject/studytrip/member/fixture/MemberFixture.java b/src/test/java/com/ject/studytrip/member/fixture/MemberFixture.java new file mode 100644 index 0000000..64a9788 --- /dev/null +++ b/src/test/java/com/ject/studytrip/member/fixture/MemberFixture.java @@ -0,0 +1,33 @@ +package com.ject.studytrip.member.fixture; + +import com.ject.studytrip.member.domain.entity.Member; +import com.ject.studytrip.member.domain.entity.MemberCategory; +import com.ject.studytrip.member.domain.entity.MemberRole; +import com.ject.studytrip.member.domain.entity.SocialProvider; + +public class MemberFixture { + private static final String NICKNAME = "민우"; + private static final MemberCategory MEMBER_CATEGORY = MemberCategory.STUDENT; + + public static Member createMemberFromKakao() { + return Member.of( + SocialProvider.KAKAO, + "12345", + "choi@kakao.com", + NICKNAME, + "https://kakao.com/profile.jpg", + MEMBER_CATEGORY, + MemberRole.ROLE_USER); + } + + public static Member createMemberWithoutProfileImageFromKakao() { + return Member.of( + SocialProvider.KAKAO, + "12345", + "choi@kakao.com", + NICKNAME, + null, + MEMBER_CATEGORY, + MemberRole.ROLE_USER); + } +} diff --git a/src/test/java/com/ject/studytrip/member/helper/MemberTestHelper.java b/src/test/java/com/ject/studytrip/member/helper/MemberTestHelper.java new file mode 100644 index 0000000..0a2b4db --- /dev/null +++ b/src/test/java/com/ject/studytrip/member/helper/MemberTestHelper.java @@ -0,0 +1,22 @@ +package com.ject.studytrip.member.helper; + +import com.ject.studytrip.member.domain.entity.Member; +import com.ject.studytrip.member.domain.repository.MemberRepository; +import com.ject.studytrip.member.fixture.MemberFixture; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class MemberTestHelper { + private final MemberRepository memberRepository; + + @Autowired + public MemberTestHelper(MemberRepository memberRepository) { + this.memberRepository = memberRepository; + } + + public void saveMember() { + Member member = MemberFixture.createMemberFromKakao(); + memberRepository.save(member); + } +}