From ce62dbd4f5ea3b39492807f01bfa3080f904810f Mon Sep 17 00:00:00 2001 From: "Choi, Minwoo" Date: Sat, 2 Aug 2025 19:46:03 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EC=9E=AC=EB=B0=9C?= =?UTF-8?q?=EA=B8=89,=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: CacheKeyConstants 추가 * feat: RefreshTokenRedisRepository, RefreshTokenRedisRepositoryAdapter 구현 * feat: LogoutTokenRedisRepository, LogoutTokenRedisRepositoryAdapter 구현 * feat: TokenService 구현 * feat: TokenProvider에 getRefreshTokenExpirationTime 메서드 추가 * feat: TokenProvider에 getAccessTokenRemainingTime 메서드 추가 * feat: TokenReissueRequest 추가 * feat: LogoutRequest 추가 * feat: AuthFacade에 reissueToken, logout 메서드 추가 * feat: AuthController에 reissueToken, logout 메서드 추가 * feat: JwtFilter에 로그아웃 토큰 블랙리스트 검증 로직 추가 * feat: MemberRepository, MemberRepositoryAdapter에 deleteById 메서드 추가 * feat: MemberQueryRepository, MemberQueryRepositoryAdapter 구현 * refactor: KakaoLoginService에서 getTokens 메서드 삭제 * refactor: TokenProvider에서 extractMemberRoleFromToken -> extractRoleFromToken 이름 변경 * refactor: MissionController.updateMission()에 @Valid 어노테이션 추가 * refactor: UpdateMissionRequest에서 @JsonInclude(JsonInclude.Include.NON_NULL) 어노테이션 제거 * refactor: UpdateMissionRequest에 @NotBlank 어노테이션 추가 * refactor: MissionFacade, MissionService에서 updateMissionNameAndMemo -> updateMissionNameAndMemoIfPresent 이름 변경 * refactor: TokenProvider.createRefreshToken() 비즈니스 로직 개선 * refactor: JwtFilter를 global.security 패키지로 이동 * refactor: JwtFilter를 JwtAuthenticationFilter로 이름 변경 * test: TokenServiceTest 단위 테스트 추가 * test: TokenReissueRequestFixture 추가 * test: LogoutRequestFixture 추가 * test: AuthControllerIntegrationTest ReissueToken, Logout 통합 테스트 추가 * test: TokenTestHelper에 createRefreshToken 메서드 추가 * test: MemberTestHelper에 deleteMemberById 메서드 추가 * test: MemberServiceTest에 GetRoleByMemberId 단위 테스트 추가 * fix: Redis에 저장되는 memberId가 직렬화되어 깨지는 문제 해결 --- .../auth/application/facade/AuthFacade.java | 19 +- .../service/KakaoLoginService.java | 9 - .../application/service/TokenService.java | 84 ++++++ .../LogoutTokenRedisRepository.java | 7 + .../RefreshTokenRedisRepository.java | 11 + .../auth/infra/filter/JwtFilter.java | 58 ---- .../auth/infra/provider/TokenProvider.java | 27 +- .../LogoutTokenRedisRepositoryAdapter.java | 30 ++ .../RefreshTokenRedisRepositoryAdapter.java | 43 +++ .../controller/AuthController.java | 19 ++ .../dto/request/LogoutRequest.java | 9 + .../dto/request/TokenReissueRequest.java | 8 + .../common/constants/CacheKeyConstants.java | 13 + .../global/config/WebSecurityConfig.java | 6 +- .../security/JwtAuthenticationFilter.java | 43 +++ .../application/service/MemberService.java | 16 +- .../{ => domain}/factory/MemberFactory.java | 2 +- .../repository/MemberQueryRepository.java | 7 + .../domain/repository/MemberRepository.java | 2 + .../infra/jpa/MemberRepositoryAdapter.java | 5 + .../MemberQueryRepositoryAdapter.java | 24 ++ .../application/facade/MissionFacade.java | 4 +- .../application/service/MissionService.java | 2 +- .../controller/MissionController.java | 4 +- .../dto/request/UpdateMissionRequest.java | 6 +- .../service/KakaoLoginServiceTest.java | 28 -- .../application/service/TokenServiceTest.java | 247 +++++++++++++++++ .../auth/fixture/LogoutRequestFixture.java | 22 ++ .../fixture/TokenReissueRequestFixture.java | 16 ++ .../auth/helper/TokenTestHelper.java | 4 + .../AuthControllerIntegrationTest.java | 256 +++++++++++++++++- .../service/MemberServiceTest.java | 51 +++- .../member/fixture/MemberFixture.java | 2 +- .../member/helper/MemberTestHelper.java | 4 + .../service/MissionServiceTest.java | 14 +- 35 files changed, 951 insertions(+), 151 deletions(-) create mode 100644 src/main/java/com/ject/studytrip/auth/application/service/TokenService.java create mode 100644 src/main/java/com/ject/studytrip/auth/domain/repository/LogoutTokenRedisRepository.java create mode 100644 src/main/java/com/ject/studytrip/auth/domain/repository/RefreshTokenRedisRepository.java delete mode 100644 src/main/java/com/ject/studytrip/auth/infra/filter/JwtFilter.java create mode 100644 src/main/java/com/ject/studytrip/auth/infra/repository/redis/LogoutTokenRedisRepositoryAdapter.java create mode 100644 src/main/java/com/ject/studytrip/auth/infra/repository/redis/RefreshTokenRedisRepositoryAdapter.java create mode 100644 src/main/java/com/ject/studytrip/auth/presentation/dto/request/LogoutRequest.java create mode 100644 src/main/java/com/ject/studytrip/auth/presentation/dto/request/TokenReissueRequest.java create mode 100644 src/main/java/com/ject/studytrip/global/common/constants/CacheKeyConstants.java create mode 100644 src/main/java/com/ject/studytrip/global/security/JwtAuthenticationFilter.java rename src/main/java/com/ject/studytrip/member/{ => domain}/factory/MemberFactory.java (94%) create mode 100644 src/main/java/com/ject/studytrip/member/domain/repository/MemberQueryRepository.java create mode 100644 src/main/java/com/ject/studytrip/member/infra/querydsl/MemberQueryRepositoryAdapter.java create mode 100644 src/test/java/com/ject/studytrip/auth/application/service/TokenServiceTest.java create mode 100644 src/test/java/com/ject/studytrip/auth/fixture/LogoutRequestFixture.java create mode 100644 src/test/java/com/ject/studytrip/auth/fixture/TokenReissueRequestFixture.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 index f226f6b..ce8b7b7 100644 --- a/src/main/java/com/ject/studytrip/auth/application/facade/AuthFacade.java +++ b/src/main/java/com/ject/studytrip/auth/application/facade/AuthFacade.java @@ -1,9 +1,12 @@ package com.ject.studytrip.auth.application.facade; import com.ject.studytrip.auth.application.service.KakaoLoginService; +import com.ject.studytrip.auth.application.service.TokenService; 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.request.LogoutRequest; +import com.ject.studytrip.auth.presentation.dto.request.TokenReissueRequest; import com.ject.studytrip.auth.presentation.dto.response.TokenResponse; import com.ject.studytrip.member.application.dto.CreateMemberCommand; import com.ject.studytrip.member.application.service.MemberService; @@ -16,6 +19,7 @@ @RequiredArgsConstructor public class AuthFacade { private final KakaoLoginService kakaoLoginService; + private final TokenService tokenService; private final MemberService memberService; public TokenResponse kakaoLogin(KakaoLoginRequest request) { @@ -25,7 +29,7 @@ public TokenResponse kakaoLogin(KakaoLoginRequest request) { memberService.getMemberBySocialProviderAndSocialId( SocialProvider.KAKAO, response.kakaoId()); - return kakaoLoginService.getTokens(member.getId().toString(), member.getRole().name()); + return tokenService.getTokens(member.getId().toString(), member.getRole().name()); } public TokenResponse kakaoSignup(KakaoSignupRequest request) { @@ -40,6 +44,17 @@ public TokenResponse kakaoSignup(KakaoSignupRequest request) { Member member = memberService.createMemberFromKakao(command); - return kakaoLoginService.getTokens(member.getId().toString(), member.getRole().name()); + return tokenService.getTokens(member.getId().toString(), member.getRole().name()); + } + + public TokenResponse reissueToken(TokenReissueRequest request) { + String memberId = tokenService.getMemberIdByRefreshToken(request.refreshToken()); + String role = memberService.getRoleByMemberId(memberId); + + return tokenService.reissueToken(request.refreshToken(), memberId, role); + } + + public void logout(LogoutRequest request) { + tokenService.logout(request.accessToken(), request.refreshToken()); } } 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 index 3acc564..8488f4d 100644 --- a/src/main/java/com/ject/studytrip/auth/application/service/KakaoLoginService.java +++ b/src/main/java/com/ject/studytrip/auth/application/service/KakaoLoginService.java @@ -3,8 +3,6 @@ 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; @@ -12,16 +10,9 @@ @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/application/service/TokenService.java b/src/main/java/com/ject/studytrip/auth/application/service/TokenService.java new file mode 100644 index 0000000..a305936 --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/application/service/TokenService.java @@ -0,0 +1,84 @@ +package com.ject.studytrip.auth.application.service; + +import com.ject.studytrip.auth.domain.error.AuthErrorCode; +import com.ject.studytrip.auth.domain.repository.LogoutTokenRedisRepository; +import com.ject.studytrip.auth.domain.repository.RefreshTokenRedisRepository; +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 java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class TokenService { + private final TokenProvider tokenProvider; + private final LogoutTokenRedisRepository logoutTokenRedisRepository; + private final RefreshTokenRedisRepository refreshTokenRedisRepository; + + public TokenResponse getTokens(String memberId, String role) { + String accessToken = tokenProvider.createAccessToken(memberId, role); + String refreshToken = tokenProvider.createRefreshToken(); + long refreshTokenExpirationTime = tokenProvider.getRefreshTokenExpirationTime(); + + refreshTokenRedisRepository.saveRefreshToken( + memberId, refreshToken, refreshTokenExpirationTime); + + return TokenResponse.of(accessToken, refreshToken); + } + + public TokenResponse reissueToken(String refreshToken, String memberId, String role) { + long refreshTokenExpirationTime = tokenProvider.getRefreshTokenExpirationTime(); + String newAccessToken = tokenProvider.createAccessToken(memberId, role); + String newRefreshToken = tokenProvider.createRefreshToken(); + + refreshTokenRedisRepository.deleteRefreshToken(refreshToken); + refreshTokenRedisRepository.saveRefreshToken( + memberId, newRefreshToken, refreshTokenExpirationTime); + + return TokenResponse.of(newAccessToken, newRefreshToken); + } + + public void logout(String accessToken, String refreshToken) { + validateRefreshToken(refreshToken); + + long accessTokenRemainingTime = tokenProvider.getAccessTokenRemainingTime(accessToken); + + logoutTokenRedisRepository.saveAccessToken(accessToken, accessTokenRemainingTime); + refreshTokenRedisRepository.deleteRefreshToken(refreshToken); + } + + public String getMemberIdByRefreshToken(String refreshToken) { + validateRefreshToken(refreshToken); + + return refreshTokenRedisRepository.findMemberIdByRefreshToken(refreshToken); + } + + public void setAuthenticationByAccessToken(String accessToken) { + String memberId = tokenProvider.extractMemberIdFromToken(accessToken); + String role = tokenProvider.extractRoleFromToken(accessToken); + var authorities = List.of(new SimpleGrantedAuthority(role)); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(memberId, null, authorities); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + public void validateActiveAccessToken(String accessToken) { + if (!tokenProvider.validateAccessToken(accessToken)) { + throw new CustomException(AuthErrorCode.INVALID_JWT_TOKEN); + } + if (logoutTokenRedisRepository.existsAccessToken(accessToken)) { + throw new CustomException(AuthErrorCode.TOKEN_IS_BLACKLISTED); + } + } + + private void validateRefreshToken(String refreshToken) { + if (!refreshTokenRedisRepository.existsRefreshToken(refreshToken)) { + throw new CustomException(AuthErrorCode.INVALID_REFRESH_TOKEN); + } + } +} diff --git a/src/main/java/com/ject/studytrip/auth/domain/repository/LogoutTokenRedisRepository.java b/src/main/java/com/ject/studytrip/auth/domain/repository/LogoutTokenRedisRepository.java new file mode 100644 index 0000000..94c6f9f --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/domain/repository/LogoutTokenRedisRepository.java @@ -0,0 +1,7 @@ +package com.ject.studytrip.auth.domain.repository; + +public interface LogoutTokenRedisRepository { + void saveAccessToken(String accessToken, long accessTokenExpirationTime); + + boolean existsAccessToken(String accessToken); +} diff --git a/src/main/java/com/ject/studytrip/auth/domain/repository/RefreshTokenRedisRepository.java b/src/main/java/com/ject/studytrip/auth/domain/repository/RefreshTokenRedisRepository.java new file mode 100644 index 0000000..1e1e763 --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/domain/repository/RefreshTokenRedisRepository.java @@ -0,0 +1,11 @@ +package com.ject.studytrip.auth.domain.repository; + +public interface RefreshTokenRedisRepository { + void saveRefreshToken(String memberId, String refreshToken, long refreshTokenExpireTime); + + boolean existsRefreshToken(String refreshToken); + + void deleteRefreshToken(String refreshToken); + + String findMemberIdByRefreshToken(String refreshToken); +} 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 deleted file mode 100644 index c10ffda..0000000 --- a/src/main/java/com/ject/studytrip/auth/infra/filter/JwtFilter.java +++ /dev/null @@ -1,58 +0,0 @@ -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/TokenProvider.java b/src/main/java/com/ject/studytrip/auth/infra/provider/TokenProvider.java index 69c330c..16bf0dc 100644 --- a/src/main/java/com/ject/studytrip/auth/infra/provider/TokenProvider.java +++ b/src/main/java/com/ject/studytrip/auth/infra/provider/TokenProvider.java @@ -8,6 +8,7 @@ import java.time.Instant; import java.util.Date; import java.util.Map; +import java.util.UUID; import javax.crypto.SecretKey; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -21,25 +22,31 @@ 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 createRefreshToken() { + return UUID.randomUUID().toString(); } public String extractMemberIdFromToken(String token) { return parseClaims(token).getSubject(); } - public String extractMemberRoleFromToken(String token) { + public String extractRoleFromToken(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); - } + public boolean validateAccessToken(String accessToken) { + parseClaims(accessToken); // 내부에서 예외 처리 + return true; + } + + public long getRefreshTokenExpirationTime() { + return tokenProperties.refreshExpirationTime(); + } + + public long getAccessTokenRemainingTime(String accessToken) { + Claims claims = parseClaims(accessToken); // 내부에서 예외 처리 + Date expiration = claims.getExpiration(); + return Math.max(expiration.getTime() - System.currentTimeMillis(), 0); // 음수 방지 } private String createToken(String memberId, String role, long expirationSeconds) { diff --git a/src/main/java/com/ject/studytrip/auth/infra/repository/redis/LogoutTokenRedisRepositoryAdapter.java b/src/main/java/com/ject/studytrip/auth/infra/repository/redis/LogoutTokenRedisRepositoryAdapter.java new file mode 100644 index 0000000..fb018d3 --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/infra/repository/redis/LogoutTokenRedisRepositoryAdapter.java @@ -0,0 +1,30 @@ +package com.ject.studytrip.auth.infra.repository.redis; + +import static com.ject.studytrip.global.common.constants.CacheKeyConstants.AUTH_LOGOUT_TOKEN_PREFIX; + +import com.ject.studytrip.auth.domain.repository.LogoutTokenRedisRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class LogoutTokenRedisRepositoryAdapter implements LogoutTokenRedisRepository { + private final RedisTemplate redisTemplate; + + @Override + public void saveAccessToken(String accessToken, long accessTokenExpirationTime) { + redisTemplate + .opsForValue() + .set( + AUTH_LOGOUT_TOKEN_PREFIX.getValue() + accessToken, + "LOGOUT", + accessTokenExpirationTime); + } + + @Override + public boolean existsAccessToken(String accessToken) { + return Boolean.TRUE.equals( + redisTemplate.hasKey(AUTH_LOGOUT_TOKEN_PREFIX.getValue() + accessToken)); + } +} diff --git a/src/main/java/com/ject/studytrip/auth/infra/repository/redis/RefreshTokenRedisRepositoryAdapter.java b/src/main/java/com/ject/studytrip/auth/infra/repository/redis/RefreshTokenRedisRepositoryAdapter.java new file mode 100644 index 0000000..803ca29 --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/infra/repository/redis/RefreshTokenRedisRepositoryAdapter.java @@ -0,0 +1,43 @@ +package com.ject.studytrip.auth.infra.repository.redis; + +import static com.ject.studytrip.global.common.constants.CacheKeyConstants.AUTH_REISSUE_TOKEN_PREFIX; + +import com.ject.studytrip.auth.domain.repository.RefreshTokenRedisRepository; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class RefreshTokenRedisRepositoryAdapter implements RefreshTokenRedisRepository { + private final RedisTemplate redisTemplate; + + @Override + public void saveRefreshToken( + String memberId, String refreshToken, long refreshTokenExpireTime) { + redisTemplate + .opsForValue() + .set( + AUTH_REISSUE_TOKEN_PREFIX.getValue() + refreshToken, + memberId, + refreshTokenExpireTime, + TimeUnit.MILLISECONDS); + } + + @Override + public boolean existsRefreshToken(String refreshToken) { + return Boolean.TRUE.equals( + redisTemplate.hasKey(AUTH_REISSUE_TOKEN_PREFIX.getValue() + refreshToken)); + } + + @Override + public void deleteRefreshToken(String refreshToken) { + redisTemplate.delete(AUTH_REISSUE_TOKEN_PREFIX.getValue() + refreshToken); + } + + @Override + public String findMemberIdByRefreshToken(String refreshToken) { + return redisTemplate.opsForValue().get(AUTH_REISSUE_TOKEN_PREFIX.getValue() + refreshToken); + } +} 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 index 744a8f4..99e3223 100644 --- a/src/main/java/com/ject/studytrip/auth/presentation/controller/AuthController.java +++ b/src/main/java/com/ject/studytrip/auth/presentation/controller/AuthController.java @@ -3,6 +3,8 @@ 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.request.LogoutRequest; +import com.ject.studytrip.auth.presentation.dto.request.TokenReissueRequest; import com.ject.studytrip.auth.presentation.dto.response.TokenResponse; import com.ject.studytrip.global.common.response.StandardResponse; import io.swagger.v3.oas.annotations.Operation; @@ -40,4 +42,21 @@ public ResponseEntity kakaoSignup( TokenResponse response = authFacade.kakaoSignup(request); return ResponseEntity.ok(StandardResponse.success(HttpStatus.OK.value(), response)); } + + @Operation(summary = "토큰 재발급", description = "리프레시 토큰을 이용하여, 엑세스 토큰과 리프레시 토큰을 재발급합니다.") + @PostMapping("/token/reissue") + public ResponseEntity reissueToken( + @Valid @RequestBody TokenReissueRequest request) { + TokenResponse response = authFacade.reissueToken(request); + return ResponseEntity.ok(StandardResponse.success(HttpStatus.OK.value(), response)); + } + + @Operation( + summary = "로그아웃", + description = "엑세스 토큰과 리프레시 토큰을 이용하여, 엑세스 토큰을 블랙리스트에 추가하고, 저장된 리프레시 토큰을 제거합니다.") + @PostMapping("/logout") + public ResponseEntity logout(@Valid @RequestBody LogoutRequest request) { + authFacade.logout(request); + return ResponseEntity.ok(StandardResponse.success(HttpStatus.OK.value(), null)); + } } diff --git a/src/main/java/com/ject/studytrip/auth/presentation/dto/request/LogoutRequest.java b/src/main/java/com/ject/studytrip/auth/presentation/dto/request/LogoutRequest.java new file mode 100644 index 0000000..b1034c5 --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/presentation/dto/request/LogoutRequest.java @@ -0,0 +1,9 @@ +package com.ject.studytrip.auth.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record LogoutRequest( + @Schema(description = "엑세스 토큰") @NotBlank(message = "엑세스 토큰을 입력해 주세요.") String accessToken, + @Schema(description = "리프레시 토큰") @NotBlank(message = "리프레시 토큰을 입력해 주세요.") + String refreshToken) {} diff --git a/src/main/java/com/ject/studytrip/auth/presentation/dto/request/TokenReissueRequest.java b/src/main/java/com/ject/studytrip/auth/presentation/dto/request/TokenReissueRequest.java new file mode 100644 index 0000000..5d7df2e --- /dev/null +++ b/src/main/java/com/ject/studytrip/auth/presentation/dto/request/TokenReissueRequest.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 TokenReissueRequest( + @Schema(description = "리프레시 토큰") @NotBlank(message = "리프레시 토큰을 입력해 주세요.") + String refreshToken) {} diff --git a/src/main/java/com/ject/studytrip/global/common/constants/CacheKeyConstants.java b/src/main/java/com/ject/studytrip/global/common/constants/CacheKeyConstants.java new file mode 100644 index 0000000..95b8d47 --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/common/constants/CacheKeyConstants.java @@ -0,0 +1,13 @@ +package com.ject.studytrip.global.common.constants; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CacheKeyConstants { + AUTH_REISSUE_TOKEN_PREFIX("auth::reissue::token:"), + AUTH_LOGOUT_TOKEN_PREFIX("auth::logout::token:"); + + private final String value; +} 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 19fa2e6..674cd72 100644 --- a/src/main/java/com/ject/studytrip/global/config/WebSecurityConfig.java +++ b/src/main/java/com/ject/studytrip/global/config/WebSecurityConfig.java @@ -1,11 +1,11 @@ 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.common.constants.UrlConstants; import com.ject.studytrip.global.config.properties.TokenProperties; import com.ject.studytrip.global.security.CustomAccessDeniedHandler; import com.ject.studytrip.global.security.CustomAuthenticationEntryPoint; +import com.ject.studytrip.global.security.JwtAuthenticationFilter; import java.util.Arrays; import java.util.List; import lombok.RequiredArgsConstructor; @@ -28,7 +28,7 @@ @RequiredArgsConstructor @EnableConfigurationProperties(TokenProperties.class) public class WebSecurityConfig { - private final JwtFilter jwtFilter; + private final JwtAuthenticationFilter jwtAuthenticationFilter; private final CustomAuthenticationEntryPoint authenticationEntryPoint; private final CustomAccessDeniedHandler accessDeniedHandler; @@ -53,7 +53,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { defaultFilterChain(http); // JWT 필터 등록 : 인증 이전에 동작해야 하므로 UsernamePasswordAuthenticationFilter 앞에 삽입 - http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); // 경로 인가 설정 http.authorizeHttpRequests( diff --git a/src/main/java/com/ject/studytrip/global/security/JwtAuthenticationFilter.java b/src/main/java/com/ject/studytrip/global/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..4951656 --- /dev/null +++ b/src/main/java/com/ject/studytrip/global/security/JwtAuthenticationFilter.java @@ -0,0 +1,43 @@ +package com.ject.studytrip.global.security; + +import com.ject.studytrip.auth.application.service.TokenService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final TokenService tokenService; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) + throws ServletException, IOException { + String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + String accessToken = extractBearerToken(authorizationHeader); + + if (StringUtils.hasText(accessToken)) { + tokenService.validateActiveAccessToken(accessToken); + tokenService.setAuthenticationByAccessToken(accessToken); + } + + filterChain.doFilter(request, response); + } + + private String extractBearerToken(String header) { + return (StringUtils.hasText(header) && header.startsWith("Bearer ")) + ? header.substring(7) + : null; + } +} 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 index 2fb626a..07085a6 100644 --- a/src/main/java/com/ject/studytrip/member/application/service/MemberService.java +++ b/src/main/java/com/ject/studytrip/member/application/service/MemberService.java @@ -5,12 +5,14 @@ import com.ject.studytrip.global.exception.CustomException; import com.ject.studytrip.member.application.dto.CreateMemberCommand; import com.ject.studytrip.member.domain.error.MemberErrorCode; +import com.ject.studytrip.member.domain.factory.MemberFactory; import com.ject.studytrip.member.domain.model.Member; import com.ject.studytrip.member.domain.model.MemberCategory; +import com.ject.studytrip.member.domain.model.MemberRole; import com.ject.studytrip.member.domain.model.SocialProvider; import com.ject.studytrip.member.domain.policy.MemberPolicy; +import com.ject.studytrip.member.domain.repository.MemberQueryRepository; import com.ject.studytrip.member.domain.repository.MemberRepository; -import com.ject.studytrip.member.factory.MemberFactory; import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -20,6 +22,7 @@ @RequiredArgsConstructor public class MemberService { private final MemberRepository memberRepository; + private final MemberQueryRepository memberQueryRepository; @Transactional public Member createMemberFromKakao(CreateMemberCommand command) { @@ -71,6 +74,17 @@ public Member getActiveMemberById(Long memberId) { .orElseThrow(() -> new CustomException(MemberErrorCode.MEMBER_NOT_FOUND)); } + @Transactional(readOnly = true) + public String getRoleByMemberId(String memberId) { + MemberRole memberRole = memberQueryRepository.findMemberRoleById(Long.valueOf(memberId)); + + if (memberRole == null) { + throw new CustomException(MemberErrorCode.MEMBER_NOT_FOUND); + } + + return memberRole.name(); + } + private void validateMemberIsUnique(SocialProvider socialProvider, String socialId) { boolean isMemberDuplicated = memberRepository.existsBySocialProviderAndSocialId(socialProvider, socialId); diff --git a/src/main/java/com/ject/studytrip/member/factory/MemberFactory.java b/src/main/java/com/ject/studytrip/member/domain/factory/MemberFactory.java similarity index 94% rename from src/main/java/com/ject/studytrip/member/factory/MemberFactory.java rename to src/main/java/com/ject/studytrip/member/domain/factory/MemberFactory.java index 0f3aacd..b55b277 100644 --- a/src/main/java/com/ject/studytrip/member/factory/MemberFactory.java +++ b/src/main/java/com/ject/studytrip/member/domain/factory/MemberFactory.java @@ -1,4 +1,4 @@ -package com.ject.studytrip.member.factory; +package com.ject.studytrip.member.domain.factory; import com.ject.studytrip.member.domain.model.Member; import com.ject.studytrip.member.domain.model.MemberCategory; diff --git a/src/main/java/com/ject/studytrip/member/domain/repository/MemberQueryRepository.java b/src/main/java/com/ject/studytrip/member/domain/repository/MemberQueryRepository.java new file mode 100644 index 0000000..220ec0d --- /dev/null +++ b/src/main/java/com/ject/studytrip/member/domain/repository/MemberQueryRepository.java @@ -0,0 +1,7 @@ +package com.ject.studytrip.member.domain.repository; + +import com.ject.studytrip.member.domain.model.MemberRole; + +public interface MemberQueryRepository { + MemberRole findMemberRoleById(Long memberId); +} 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 index ac2991b..ce77777 100644 --- a/src/main/java/com/ject/studytrip/member/domain/repository/MemberRepository.java +++ b/src/main/java/com/ject/studytrip/member/domain/repository/MemberRepository.java @@ -15,4 +15,6 @@ Optional findBySocialProviderAndSocialId( Member save(Member member); Optional findByIdAndDeletedAtIsNull(Long id); + + void deleteById(Long id); } 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 index 3aa73c3..5602b9c 100644 --- a/src/main/java/com/ject/studytrip/member/infra/jpa/MemberRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/member/infra/jpa/MemberRepositoryAdapter.java @@ -38,4 +38,9 @@ public Member save(Member member) { public Optional findByIdAndDeletedAtIsNull(Long id) { return memberJpaRepository.findByIdAndDeletedAtIsNull(id); } + + @Override + public void deleteById(Long id) { + memberJpaRepository.deleteById(id); + } } diff --git a/src/main/java/com/ject/studytrip/member/infra/querydsl/MemberQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/member/infra/querydsl/MemberQueryRepositoryAdapter.java new file mode 100644 index 0000000..b1dbfbe --- /dev/null +++ b/src/main/java/com/ject/studytrip/member/infra/querydsl/MemberQueryRepositoryAdapter.java @@ -0,0 +1,24 @@ +package com.ject.studytrip.member.infra.querydsl; + +import static com.ject.studytrip.member.domain.model.QMember.member; + +import com.ject.studytrip.member.domain.model.MemberRole; +import com.ject.studytrip.member.domain.repository.MemberQueryRepository; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class MemberQueryRepositoryAdapter implements MemberQueryRepository { + private final JPAQueryFactory queryFactory; + + @Override + public MemberRole findMemberRoleById(Long memberId) { + return queryFactory + .select(member.role) + .from(member) + .where(member.id.eq(memberId)) + .fetchOne(); + } +} diff --git a/src/main/java/com/ject/studytrip/mission/application/facade/MissionFacade.java b/src/main/java/com/ject/studytrip/mission/application/facade/MissionFacade.java index ba65ea5..9c0c0a6 100644 --- a/src/main/java/com/ject/studytrip/mission/application/facade/MissionFacade.java +++ b/src/main/java/com/ject/studytrip/mission/application/facade/MissionFacade.java @@ -29,7 +29,7 @@ public MissionInfo createMission( return MissionInfo.from(mission); } - public void updateMissionNameAndMemo( + public void updateMissionNameAndMemoIfPresent( Long memberId, Long tripId, Long stampId, @@ -38,7 +38,7 @@ public void updateMissionNameAndMemo( Stamp stamp = getValidStampFromTripOwnedByMember(memberId, tripId, stampId); Mission mission = missionService.getValidMission(stamp.getId(), missionId); - missionService.updateMissionNameAndMemo(stamp.getId(), mission, request); + missionService.updateMissionNameAndMemoIfPresent(stamp.getId(), mission, request); } public void updateMissionOrders( diff --git a/src/main/java/com/ject/studytrip/mission/application/service/MissionService.java b/src/main/java/com/ject/studytrip/mission/application/service/MissionService.java index bcc5b4f..5d80384 100644 --- a/src/main/java/com/ject/studytrip/mission/application/service/MissionService.java +++ b/src/main/java/com/ject/studytrip/mission/application/service/MissionService.java @@ -40,7 +40,7 @@ public Mission createMission(Stamp stamp, CreateMissionRequest request) { } @Transactional - public void updateMissionNameAndMemo( + public void updateMissionNameAndMemoIfPresent( Long stampId, Mission mission, UpdateMissionRequest request) { validateMissionIsActiveAndBelongsToStamp(stampId, mission); diff --git a/src/main/java/com/ject/studytrip/mission/presentation/controller/MissionController.java b/src/main/java/com/ject/studytrip/mission/presentation/controller/MissionController.java index 6aff1d9..5d716fa 100644 --- a/src/main/java/com/ject/studytrip/mission/presentation/controller/MissionController.java +++ b/src/main/java/com/ject/studytrip/mission/presentation/controller/MissionController.java @@ -51,8 +51,8 @@ public ResponseEntity updateMission( @PathVariable @NotNull(message = "여행 ID는 필수 요청 파라미터입니다.") Long tripId, @PathVariable @NotNull(message = "스탬프 ID는 필수 요청 파라미터입니다.") Long stampId, @PathVariable @NotNull(message = "미션 ID는 필수 요청 파라미터입니다.") Long missionId, - @RequestBody UpdateMissionRequest request) { - missionFacade.updateMissionNameAndMemo( + @RequestBody @Valid UpdateMissionRequest request) { + missionFacade.updateMissionNameAndMemoIfPresent( Long.valueOf(memberId), tripId, stampId, missionId, request); return ResponseEntity.status(HttpStatus.OK) diff --git a/src/main/java/com/ject/studytrip/mission/presentation/dto/request/UpdateMissionRequest.java b/src/main/java/com/ject/studytrip/mission/presentation/dto/request/UpdateMissionRequest.java index 007b881..91a94ec 100644 --- a/src/main/java/com/ject/studytrip/mission/presentation/dto/request/UpdateMissionRequest.java +++ b/src/main/java/com/ject/studytrip/mission/presentation/dto/request/UpdateMissionRequest.java @@ -1,9 +1,9 @@ package com.ject.studytrip.mission.presentation.dto.request; -import com.fasterxml.jackson.annotation.JsonInclude; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; -@JsonInclude(JsonInclude.Include.NON_NULL) public record UpdateMissionRequest( - @Schema(description = "수정할 미션 이름") String name, + @Schema(description = "수정할 미션 이름") @NotBlank(message = "새로운 미션 이름은 필수 요청 값입니다.") + String name, @Schema(description = "수정할 미션 메모") String memo) {} 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 index 8f2fcb1..cc9692e 100644 --- a/src/test/java/com/ject/studytrip/auth/application/service/KakaoLoginServiceTest.java +++ b/src/test/java/com/ject/studytrip/auth/application/service/KakaoLoginServiceTest.java @@ -11,8 +11,6 @@ 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; @@ -26,15 +24,11 @@ class KakaoLoginServiceTest extends BaseUnitTest { 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 { @@ -87,26 +81,4 @@ void shouldReturnKakaoUserInfoResponseWhenCodeIsValid() { 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/application/service/TokenServiceTest.java b/src/test/java/com/ject/studytrip/auth/application/service/TokenServiceTest.java new file mode 100644 index 0000000..2e09f97 --- /dev/null +++ b/src/test/java/com/ject/studytrip/auth/application/service/TokenServiceTest.java @@ -0,0 +1,247 @@ +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.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +import com.ject.studytrip.BaseUnitTest; +import com.ject.studytrip.auth.domain.error.AuthErrorCode; +import com.ject.studytrip.auth.domain.repository.LogoutTokenRedisRepository; +import com.ject.studytrip.auth.domain.repository.RefreshTokenRedisRepository; +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; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +@DisplayName("TokenService 단위 테스트") +class TokenServiceTest extends BaseUnitTest { + private static final String MEMBER_ID = "123"; + private static final String ROLE = "ROLE_USER"; + private static final String ACCESS_TOKEN = "access.jwt.token"; + private static final String REFRESH_TOKEN = "refresh-token"; + private static final String NEW_ACCESS_TOKEN = "newAccess.jwt.token"; + private static final String NEW_REFRESH_TOKEN = "newRefresh-token"; + private static final long REFRESH_TOKEN_EXPIRATION_TIME = 7200L; + private static final long ACCESS_TOKEN_REMAINING_TIME = 300L; + + @InjectMocks private TokenService tokenService; + + @Mock private TokenProvider tokenProvider; + @Mock private RefreshTokenRedisRepository refreshTokenRedisRepository; + @Mock private LogoutTokenRedisRepository logoutTokenRedisRepository; + + @Nested + @DisplayName("getTokens 메서드는") + class GetTokens { + + @Test + @DisplayName("멤버 ID와 Role이 주어지면 엑세스 토큰과 리프레시 토큰을 반환한다.") + void shouldReturnTokenResponseWhenMemberIdAndRoleProvided() { + // given + when(tokenProvider.createAccessToken(MEMBER_ID, ROLE)).thenReturn(ACCESS_TOKEN); + when(tokenProvider.createRefreshToken()).thenReturn(REFRESH_TOKEN); + when(tokenProvider.getRefreshTokenExpirationTime()) + .thenReturn(REFRESH_TOKEN_EXPIRATION_TIME); + + // when + TokenResponse response = tokenService.getTokens(MEMBER_ID, ROLE); + + // then + assertThat(response.accessToken()).isEqualTo(ACCESS_TOKEN); + assertThat(response.refreshToken()).isEqualTo(REFRESH_TOKEN); + verify(refreshTokenRedisRepository) + .saveRefreshToken(MEMBER_ID, REFRESH_TOKEN, REFRESH_TOKEN_EXPIRATION_TIME); + } + } + + @Nested + @DisplayName("reissueToken 메서드는") + class ReissueToken { + + @Test + @DisplayName("유효한 리프레시 토큰이 들어오면, 새로운 엑세스 토큰과 리프레시 토큰을 반환한다.") + void shouldReissueTokenWhenRefreshTokenIsValid() { + // given + given(tokenProvider.getRefreshTokenExpirationTime()) + .willReturn(REFRESH_TOKEN_EXPIRATION_TIME); + given(tokenProvider.createAccessToken(MEMBER_ID, ROLE)).willReturn(NEW_ACCESS_TOKEN); + given(tokenProvider.createRefreshToken()).willReturn(NEW_REFRESH_TOKEN); + + // when + TokenResponse response = tokenService.reissueToken(REFRESH_TOKEN, MEMBER_ID, ROLE); + + // then + assertThat(response.accessToken()).isEqualTo(NEW_ACCESS_TOKEN); + assertThat(response.refreshToken()).isEqualTo(NEW_REFRESH_TOKEN); + verify(refreshTokenRedisRepository).deleteRefreshToken(REFRESH_TOKEN); + verify(refreshTokenRedisRepository) + .saveRefreshToken(MEMBER_ID, NEW_REFRESH_TOKEN, REFRESH_TOKEN_EXPIRATION_TIME); + } + } + + @Nested + @DisplayName("logout 메서드는") + class Logout { + + @Test + @DisplayName("리프레시 토큰이 Redis에 존재하지 않으면 예외가 발생한다.") + void shouldThrowExceptionWhenRefreshTokenDoesNotExistInRedis() { + // given + given(refreshTokenRedisRepository.existsRefreshToken(REFRESH_TOKEN)).willReturn(false); + + // when & then + assertThatThrownBy(() -> tokenService.logout(ACCESS_TOKEN, REFRESH_TOKEN)) + .isInstanceOf(CustomException.class) + .hasMessage(AuthErrorCode.INVALID_REFRESH_TOKEN.getMessage()); + } + + @Test + @DisplayName("유효한 엑세스 토큰과 리프레시 토큰이 들어오면, 엑세스 토큰을 블랙리스트에 저장하고 저장된 리프레시 토큰을 삭제한다.") + void shouldLogoutWhenAccessTokenAndRefreshTokenAreValid() { + // given + given(refreshTokenRedisRepository.existsRefreshToken(REFRESH_TOKEN)).willReturn(true); + given(tokenProvider.getAccessTokenRemainingTime(ACCESS_TOKEN)) + .willReturn(ACCESS_TOKEN_REMAINING_TIME); + + // when + tokenService.logout(ACCESS_TOKEN, REFRESH_TOKEN); + + // then + verify(logoutTokenRedisRepository) + .saveAccessToken(ACCESS_TOKEN, ACCESS_TOKEN_REMAINING_TIME); + verify(refreshTokenRedisRepository).deleteRefreshToken(REFRESH_TOKEN); + } + } + + @Nested + @DisplayName("getMemberIdByRefreshToken 메서드는") + class GetMemberIdByRefreshToken { + + @Test + @DisplayName("리프레시 토큰이 Redis에 존재하지 않으면 예외가 발생한다.") + void shouldThrowExceptionWhenRefreshTokenDoesNotExistInRedis() { + // given + given(refreshTokenRedisRepository.existsRefreshToken(REFRESH_TOKEN)).willReturn(false); + + // when & then + assertThatThrownBy(() -> tokenService.getMemberIdByRefreshToken(REFRESH_TOKEN)) + .isInstanceOf(CustomException.class) + .hasMessage(AuthErrorCode.INVALID_REFRESH_TOKEN.getMessage()); + } + + @Test + @DisplayName("리프레시 토큰이 Redis에 존재하면 멤버 ID를 반환한다.") + void shouldReturnMemberIdWhenRefreshTokenExistsInRedis() { + // given + given(refreshTokenRedisRepository.existsRefreshToken(REFRESH_TOKEN)).willReturn(true); + given(refreshTokenRedisRepository.findMemberIdByRefreshToken(REFRESH_TOKEN)) + .willReturn(MEMBER_ID); + + // when + String result = tokenService.getMemberIdByRefreshToken(REFRESH_TOKEN); + + // then + assertThat(result).isEqualTo(MEMBER_ID); + } + } + + @Nested + @DisplayName("setAuthenticationByAccessToken 메서드는") + class SetAuthenticationByAccessToken { + + @Test + @DisplayName("멤버 ID 추출에 실패하면 예외가 발생한다.") + void shouldThrowExceptionWhenMemberIdExtractionFails() { + // given + when(tokenProvider.extractMemberIdFromToken(ACCESS_TOKEN)) + .thenThrow(new CustomException(AuthErrorCode.INVALID_JWT_TOKEN)); + + // when & then + assertThatThrownBy(() -> tokenService.setAuthenticationByAccessToken(ACCESS_TOKEN)) + .isInstanceOf(CustomException.class) + .hasMessage(AuthErrorCode.INVALID_JWT_TOKEN.getMessage()); + } + + @Test + @DisplayName("Role 추출에 실패하면 예외가 발생한다.") + void shouldThrowExceptionWhenRoleExtractionFails() { + // given + when(tokenProvider.extractMemberIdFromToken(ACCESS_TOKEN)).thenReturn(MEMBER_ID); + when(tokenProvider.extractRoleFromToken(ACCESS_TOKEN)) + .thenThrow(new CustomException(AuthErrorCode.INVALID_JWT_TOKEN)); + + // when & then + assertThatThrownBy(() -> tokenService.setAuthenticationByAccessToken(ACCESS_TOKEN)) + .isInstanceOf(CustomException.class) + .hasMessage(AuthErrorCode.INVALID_JWT_TOKEN.getMessage()); + } + + @Test + @DisplayName("토큰에서 멤버 ID와 Role을 추출해 SecurityContext에 저장한다.") + void shouldSetAuthenticationInSecurityContext() { + // given + when(tokenProvider.extractMemberIdFromToken(ACCESS_TOKEN)).thenReturn(MEMBER_ID); + when(tokenProvider.extractRoleFromToken(ACCESS_TOKEN)).thenReturn(ROLE); + + // when + tokenService.setAuthenticationByAccessToken(ACCESS_TOKEN); + + // then + var authentication = SecurityContextHolder.getContext().getAuthentication(); + assertThat(authentication).isInstanceOf(UsernamePasswordAuthenticationToken.class); + assertThat(authentication.getName()).isEqualTo(MEMBER_ID); + assertThat(authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority)) + .containsExactly(ROLE); + } + } + + @Nested + @DisplayName("validateActiveAccessToken 메서드는") + class ValidateActiveAccessToken { + + @Test + @DisplayName("유효하지 않은 엑세스 토큰이면 예외가 발생한다.") + void shouldThrowExceptionWhenAccessTokenIsInvalid() { + // given + given(tokenProvider.validateAccessToken(ACCESS_TOKEN)).willReturn(false); + + // when & then + assertThatThrownBy(() -> tokenService.validateActiveAccessToken(ACCESS_TOKEN)) + .isInstanceOf(CustomException.class) + .hasMessage(AuthErrorCode.INVALID_JWT_TOKEN.getMessage()); + } + + @Test + @DisplayName("블랙리스트에 포함된 엑세스 토큰이면 예외가 발생한다.") + void shouldThrowExceptionWhenAccessTokenIsBlacklisted() { + // given + given(tokenProvider.validateAccessToken(ACCESS_TOKEN)).willReturn(true); + given(logoutTokenRedisRepository.existsAccessToken(ACCESS_TOKEN)).willReturn(true); + + // when & then + assertThatThrownBy(() -> tokenService.validateActiveAccessToken(ACCESS_TOKEN)) + .isInstanceOf(CustomException.class) + .hasMessage(AuthErrorCode.TOKEN_IS_BLACKLISTED.getMessage()); + } + + @Test + @DisplayName("유효하고 블랙리스트에 포함되지 않은 토큰이면 예외가 발생하지 않는다") + void shouldPassValidationWhenAccessTokenIsValidAndNotBlacklisted() { + // given + given(tokenProvider.validateAccessToken(ACCESS_TOKEN)).willReturn(true); + given(logoutTokenRedisRepository.existsAccessToken(ACCESS_TOKEN)).willReturn(false); + + // when & then + assertDoesNotThrow(() -> tokenService.validateActiveAccessToken(ACCESS_TOKEN)); + } + } +} diff --git a/src/test/java/com/ject/studytrip/auth/fixture/LogoutRequestFixture.java b/src/test/java/com/ject/studytrip/auth/fixture/LogoutRequestFixture.java new file mode 100644 index 0000000..0383e6b --- /dev/null +++ b/src/test/java/com/ject/studytrip/auth/fixture/LogoutRequestFixture.java @@ -0,0 +1,22 @@ +package com.ject.studytrip.auth.fixture; + +import com.ject.studytrip.auth.presentation.dto.request.LogoutRequest; + +public class LogoutRequestFixture { + private String accessToken = null; + private String refreshToken = null; + + public LogoutRequestFixture withAccessToken(String accessToken) { + this.accessToken = accessToken; + return this; + } + + public LogoutRequestFixture withRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + return this; + } + + public LogoutRequest build() { + return new LogoutRequest(accessToken, refreshToken); + } +} diff --git a/src/test/java/com/ject/studytrip/auth/fixture/TokenReissueRequestFixture.java b/src/test/java/com/ject/studytrip/auth/fixture/TokenReissueRequestFixture.java new file mode 100644 index 0000000..62fcdc8 --- /dev/null +++ b/src/test/java/com/ject/studytrip/auth/fixture/TokenReissueRequestFixture.java @@ -0,0 +1,16 @@ +package com.ject.studytrip.auth.fixture; + +import com.ject.studytrip.auth.presentation.dto.request.TokenReissueRequest; + +public class TokenReissueRequestFixture { + private String refreshToken = null; + + public TokenReissueRequestFixture withRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + return this; + } + + public TokenReissueRequest build() { + return new TokenReissueRequest(refreshToken); + } +} diff --git a/src/test/java/com/ject/studytrip/auth/helper/TokenTestHelper.java b/src/test/java/com/ject/studytrip/auth/helper/TokenTestHelper.java index 2704fad..0e372c4 100644 --- a/src/test/java/com/ject/studytrip/auth/helper/TokenTestHelper.java +++ b/src/test/java/com/ject/studytrip/auth/helper/TokenTestHelper.java @@ -17,4 +17,8 @@ public TokenTestHelper(TokenProvider tokenProvider) { public String createAccessToken(String memberId, String role) { return tokenProvider.createAccessToken(memberId, role); } + + public String createRefreshToken() { + return tokenProvider.createRefreshToken(); + } } 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 index b057651..627b98f 100644 --- a/src/test/java/com/ject/studytrip/auth/presentation/controller/AuthControllerIntegrationTest.java +++ b/src/test/java/com/ject/studytrip/auth/presentation/controller/AuthControllerIntegrationTest.java @@ -6,15 +6,25 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import com.ject.studytrip.BaseIntegrationTest; +import com.ject.studytrip.auth.domain.error.AuthErrorCode; +import com.ject.studytrip.auth.domain.repository.RefreshTokenRedisRepository; import com.ject.studytrip.auth.fixture.*; +import com.ject.studytrip.auth.fixture.TokenReissueRequestFixture; +import com.ject.studytrip.auth.helper.TokenTestHelper; 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.presentation.dto.request.KakaoLoginRequest; import com.ject.studytrip.auth.presentation.dto.request.KakaoSignupRequest; +import com.ject.studytrip.auth.presentation.dto.request.LogoutRequest; +import com.ject.studytrip.auth.presentation.dto.request.TokenReissueRequest; import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.global.exception.error.CommonErrorCode; import com.ject.studytrip.member.domain.error.MemberErrorCode; +import com.ject.studytrip.member.domain.model.Member; import com.ject.studytrip.member.helper.MemberTestHelper; +import java.time.Duration; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -26,11 +36,30 @@ @DisplayName("AuthController 통합 테스트") class AuthControllerIntegrationTest extends BaseIntegrationTest { + private static final String BASE_AUTH_URL = "/api/auth"; @Autowired private MemberTestHelper memberTestHelper; + @Autowired private TokenTestHelper tokenTestHelper; + @Autowired private RefreshTokenRedisRepository refreshTokenRedisRepository; @MockitoBean KakaoOauthProvider kakaoOauthProvider; + private Member member; + private String accessToken; + private String refreshToken; + + @BeforeEach + void setUp() { + member = memberTestHelper.saveMember(); + accessToken = + tokenTestHelper.createAccessToken( + member.getId().toString(), member.getRole().name()); + refreshToken = tokenTestHelper.createRefreshToken(); + long refreshTokenExpirationTime = Duration.ofSeconds(30).getSeconds(); + refreshTokenRedisRepository.saveRefreshToken( + member.getId().toString(), refreshToken, refreshTokenExpirationTime); + } + @Nested @DisplayName("카카오 로그인 API") class KakaoLogin { @@ -43,7 +72,7 @@ class KakaoLogin { private ResultActions getResultActions(KakaoLoginRequest request) throws Exception { return mockMvc.perform( - post("/api/auth/login/kakao") + post(BASE_AUTH_URL + "/login/kakao") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))); } @@ -52,6 +81,7 @@ private ResultActions getResultActions(KakaoLoginRequest request) throws Excepti @DisplayName("가입되지 않은 사용자 인가 코드로 로그인 시 409 Conflict를 반환한다.") void shouldReturnConflictWhenMemberNotSignUp() throws Exception { // given + member.updateDeletedAt(); KakaoLoginRequest request = kakaoLoginRequestFixture.build(); KakaoTokenResponse kakaoTokenResponse = kakaoTokenResponseFixture.build(); @@ -85,7 +115,6 @@ void shouldReturnTokenResponseWhenLoginIsSuccessful() throws Exception { KakaoTokenResponse kakaoTokenResponse = kakaoTokenResponseFixture.build(); KakaoUserInfoResponse kakaoUserInfoResponse = kakaoUserInfoResponseFixture.build(); - memberTestHelper.saveMember(); given(kakaoOauthProvider.getKakaoTokens(anyString())).willReturn(kakaoTokenResponse); given(kakaoOauthProvider.getKakaoUserInfo(anyString())) .willReturn(kakaoUserInfoResponse); @@ -115,7 +144,7 @@ class KakaoSignup { private ResultActions getResultActions(KakaoSignupRequest request) throws Exception { return mockMvc.perform( - post("/api/auth/signup/kakao") + post(BASE_AUTH_URL + "/signup/kakao") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))); } @@ -128,20 +157,16 @@ void shouldThrowExceptionWhenSignupForExistingMember() throws Exception { KakaoTokenResponse kakaoTokenResponse = kakaoTokenResponseFixture.build(); KakaoUserInfoResponse kakaoUserInfoResponse = kakaoUserInfoResponseFixture.build(); - memberTestHelper.saveMember(); given(kakaoOauthProvider.getKakaoTokens(anyString())).willReturn(kakaoTokenResponse); given(kakaoOauthProvider.getKakaoUserInfo(anyString())) .willReturn(kakaoUserInfoResponse); // when - ResultActions result = - mockMvc.perform( - post("/api/auth/signup/kakao") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); + ResultActions resultActions = getResultActions(request); // then - result.andExpect(status().isConflict()) + resultActions + .andExpect(status().isConflict()) .andExpect(jsonPath("$.success").value(false)) .andExpect( jsonPath("$.status") @@ -158,6 +183,7 @@ void shouldThrowExceptionWhenSignupForExistingMember() throws Exception { @DisplayName("회원가입 요청 시 유효한 정보라면 토큰이 발급된다.") void shouldReturnTokenResponseWhenSignupIsSuccessful() throws Exception { // given + memberTestHelper.deleteMemberById(member.getId()); KakaoSignupRequest request = kakaoSignupRequestFixture.build(); KakaoTokenResponse kakaoTokenResponse = kakaoTokenResponseFixture.build(); KakaoUserInfoResponse kakaoUserInfoResponse = kakaoUserInfoResponseFixture.build(); @@ -178,4 +204,214 @@ void shouldReturnTokenResponseWhenSignupIsSuccessful() throws Exception { .andExpect(jsonPath("$.data.refreshToken").isNotEmpty()); } } + + @Nested + @DisplayName("토큰 재발급 API") + class ReissueToken { + private final TokenReissueRequestFixture tokenReissueRequestFixture = + new TokenReissueRequestFixture(); + + private ResultActions getResultActions(TokenReissueRequest request) throws Exception { + return mockMvc.perform( + post(BASE_AUTH_URL + "/token/reissue") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + } + + @Test + @DisplayName("리프레시 토큰이 null 이면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenRefreshTokenIsNull() throws Exception { + // given + TokenReissueRequest request = tokenReissueRequestFixture.build(); + + // when + ResultActions resultActions = getResultActions(request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_NOT_VALID + .getStatus() + .value())) + .andExpect( + jsonPath("$.data.message") + .value(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.getMessage())); + } + + @Test + @DisplayName("리프레시 토큰이 존재하지 않거나 위조된 경우 401 UNAUTHORIZED를 반환한다.") + void shouldReturnUnauthorizedWhenRefreshTokenIsInvalid() throws Exception { + // given + TokenReissueRequest request = + tokenReissueRequestFixture.withRefreshToken("invalid.refresh.token").build(); + + // when + ResultActions resultActions = getResultActions(request); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(AuthErrorCode.INVALID_REFRESH_TOKEN.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(AuthErrorCode.INVALID_REFRESH_TOKEN.getMessage())); + } + + @Test + @DisplayName("유효한 요청이 들어오면 새로운 엑세스 토큰과 리프레시 토큰을 재발급한다.") + void shouldReissueTokenWhenRequestIsValid() throws Exception { + // given + TokenReissueRequest request = + tokenReissueRequestFixture.withRefreshToken(refreshToken).build(); + + // when + ResultActions resultActions = getResultActions(request); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) + .andExpect(jsonPath("$.data.refreshToken").isNotEmpty()); + } + } + + @Nested + @DisplayName("로그아웃 API") + class Logout { + private final LogoutRequestFixture logoutRequestFixture = new LogoutRequestFixture(); + + private ResultActions getResultActions(LogoutRequest request) throws Exception { + return mockMvc.perform( + post(BASE_AUTH_URL + "/logout") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + } + + @Test + @DisplayName("엑세스 토큰이 null 이면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenAccessTokenIsNull() throws Exception { + // given + LogoutRequest request = logoutRequestFixture.withRefreshToken(refreshToken).build(); + + // when + ResultActions resultActions = getResultActions(request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_NOT_VALID + .getStatus() + .value())) + .andExpect( + jsonPath("$.data.message") + .value(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.getMessage())); + } + + @Test + @DisplayName("리프레시 토큰이 null 이면 400 Bad Request를 반환한다.") + void shouldReturnBadRequestWhenRefreshTokenIsNull() throws Exception { + // given + LogoutRequest request = logoutRequestFixture.withAccessToken(accessToken).build(); + + // when + ResultActions resultActions = getResultActions(request); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value( + CommonErrorCode.METHOD_ARGUMENT_NOT_VALID + .getStatus() + .value())) + .andExpect( + jsonPath("$.data.message") + .value(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.getMessage())); + } + + @Test + @DisplayName("엑세스 토큰이 존재하지 않거나 위조된 경우 401 UNAUTHORIZED를 반환한다.") + void shouldReturnUnauthorizedWhenAccessTokenIsInvalid() throws Exception { + // given + LogoutRequest request = + logoutRequestFixture + .withAccessToken("invalid.access.token") + .withRefreshToken(refreshToken) + .build(); + + // when + ResultActions resultActions = getResultActions(request); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(AuthErrorCode.INVALID_JWT_TOKEN.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(AuthErrorCode.INVALID_JWT_TOKEN.getMessage())); + } + + @Test + @DisplayName("리프레시 토큰이 존재하지 않거나 위조된 경우 401 UNAUTHORIZED를 반환한다.") + void shouldReturnUnauthorizedWhenRefreshTokenIsInvalid() throws Exception { + // given + LogoutRequest request = + logoutRequestFixture + .withAccessToken(accessToken) + .withRefreshToken("invalid.refresh.token") + .build(); + + // when + ResultActions resultActions = getResultActions(request); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(AuthErrorCode.INVALID_REFRESH_TOKEN.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(AuthErrorCode.INVALID_REFRESH_TOKEN.getMessage())); + } + + @Test + @DisplayName("유효한 요청이 들어오면, 엑세스 토큰을 블랙리스트에 추가하고, 저장된 리프레시 토큰을 제거합니다.") + void shouldLogoutWhenRequestIsValid() throws Exception { + // given + LogoutRequest request = + logoutRequestFixture + .withAccessToken(accessToken) + .withRefreshToken(refreshToken) + .build(); + + // when + ResultActions resultActions = getResultActions(request); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())); + } + } } 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 index 3081708..57c4ba8 100644 --- a/src/test/java/com/ject/studytrip/member/application/service/MemberServiceTest.java +++ b/src/test/java/com/ject/studytrip/member/application/service/MemberServiceTest.java @@ -10,7 +10,9 @@ import com.ject.studytrip.member.application.dto.CreateMemberCommand; import com.ject.studytrip.member.domain.error.MemberErrorCode; import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.member.domain.model.MemberRole; import com.ject.studytrip.member.domain.model.SocialProvider; +import com.ject.studytrip.member.domain.repository.MemberQueryRepository; import com.ject.studytrip.member.domain.repository.MemberRepository; import com.ject.studytrip.member.fixture.CreateMemberCommandFixture; import com.ject.studytrip.member.fixture.MemberFixture; @@ -28,11 +30,14 @@ @DisplayName("MemberService 단위 테스트") class MemberServiceTest extends BaseUnitTest { + private static final String MEMBER_ID = "123"; + private static final MemberRole MEMBER_ROLE = MemberRole.ROLE_USER; private static final String NEW_MEMBER_NICKNAME = "팬텀"; private static final String NEW_MEMBER_CATEGORY = "WORKER"; @InjectMocks private MemberService memberService; @Mock private MemberRepository memberRepository; + @Mock private MemberQueryRepository memberQueryRepository; private Member member; private Member memberWithoutProfileImage; @@ -193,18 +198,6 @@ void shouldUpdateMemberNicknameAndCategory() { @DisplayName("deleteMember 메서드는") class DeleteMember { - // @Test - // @DisplayName("멤버가 이미 삭제된 경우 예외가 발생한다.") - // void shouldThrowExceptionWhenMemberAlreadyDeleted() { - // // given - // ReflectionTestUtils.setField(member, "deletedAt", LocalDateTime.now()); - // - // // when & then - // assertThatThrownBy(() -> memberService.deleteMember(member)) - // .isInstanceOf(CustomException.class) - // .hasMessage(MemberErrorCode.MEMBER_ALREADY_DELETED.getMessage()); - // } - @Test @DisplayName("멤버를 삭제하면 deletedAt 필드에 현재 시각이 설정된다.") void shouldDeleteMember() { @@ -321,7 +314,7 @@ void shouldThrowExceptionWhenMemberAlreadyDeleted() { } @Test - @DisplayName("유효한 ID가 주어지면 Member를 반환한다.") + @DisplayName("유효한 멤버 ID가 주어지면 Member를 반환한다.") void shouldReturnMemberWhenIdIsValid() { // given Long memberId = member.getId(); @@ -336,4 +329,36 @@ void shouldReturnMemberWhenIdIsValid() { assertThat(result.getDeletedAt()).isNull(); } } + + @Nested + @DisplayName("getRoleByMemberId 메서드는") + class GetRoleByMemberId { + + @Test + @DisplayName("존재하지 않는 멤버 ID로 조회하면 예외가 발생한다.") + void shouldThrowExceptionWhenMemberIdNotFound() { + // given + given(memberQueryRepository.findMemberRoleById(Long.valueOf(MEMBER_ID))) + .willReturn(null); + + // when & then + assertThatThrownBy(() -> memberService.getRoleByMemberId(MEMBER_ID)) + .isInstanceOf(CustomException.class) + .hasMessage(MemberErrorCode.MEMBER_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("유효한 멤버 ID가 주어지면 Role을 반환한다.") + void shouldReturnRoleNameWhenMemberIdIsValid() { + // given + given(memberQueryRepository.findMemberRoleById(Long.valueOf(MEMBER_ID))) + .willReturn(MEMBER_ROLE); + + // when + String result = memberService.getRoleByMemberId(MEMBER_ID); + + // then + assertThat(result).isEqualTo(MEMBER_ROLE.name()); + } + } } diff --git a/src/test/java/com/ject/studytrip/member/fixture/MemberFixture.java b/src/test/java/com/ject/studytrip/member/fixture/MemberFixture.java index d5c256b..9f8801b 100644 --- a/src/test/java/com/ject/studytrip/member/fixture/MemberFixture.java +++ b/src/test/java/com/ject/studytrip/member/fixture/MemberFixture.java @@ -1,8 +1,8 @@ package com.ject.studytrip.member.fixture; +import com.ject.studytrip.member.domain.factory.MemberFactory; import com.ject.studytrip.member.domain.model.Member; import com.ject.studytrip.member.domain.model.MemberCategory; -import com.ject.studytrip.member.factory.MemberFactory; import org.springframework.test.util.ReflectionTestUtils; public class MemberFixture { diff --git a/src/test/java/com/ject/studytrip/member/helper/MemberTestHelper.java b/src/test/java/com/ject/studytrip/member/helper/MemberTestHelper.java index 4f3648c..0187543 100644 --- a/src/test/java/com/ject/studytrip/member/helper/MemberTestHelper.java +++ b/src/test/java/com/ject/studytrip/member/helper/MemberTestHelper.java @@ -24,4 +24,8 @@ public Member saveMember(String email, String nickname) { Member member = MemberFixture.createMemberFromKakao(email, nickname); return memberRepository.save(member); } + + public void deleteMemberById(Long memberId) { + memberRepository.deleteById(memberId); + } } diff --git a/src/test/java/com/ject/studytrip/mission/application/service/MissionServiceTest.java b/src/test/java/com/ject/studytrip/mission/application/service/MissionServiceTest.java index 4059464..e7cca70 100644 --- a/src/test/java/com/ject/studytrip/mission/application/service/MissionServiceTest.java +++ b/src/test/java/com/ject/studytrip/mission/application/service/MissionServiceTest.java @@ -136,8 +136,8 @@ void shouldReturnMissionForExploreStampWithoutMemo() { } @Nested - @DisplayName("updateMissionNameAndMemo 메서드는") - class UpdateMissionNameAndMemo { + @DisplayName("updateMissionNameAndMemoIfPresent 메서드는") + class UpdateMissionNameAndMemoIfPresent { @Test @DisplayName("미션이 다른 스탬프에 속하면 예외가 발생한다.") @@ -150,7 +150,7 @@ void shouldThrowExceptionWhenMissionNotBelongToStamp() { // when & then assertThatThrownBy( () -> - missionService.updateMissionNameAndMemo( + missionService.updateMissionNameAndMemoIfPresent( invalidStampId, courseMission, request)) .isInstanceOf(CustomException.class) .hasMessage(MissionErrorCode.MISSION_NOT_BELONGS_TO_STAMP.getMessage()); @@ -168,7 +168,7 @@ void shouldThrowExceptionWhenMissionIsDeleted() { // when & then assertThatThrownBy( () -> - missionService.updateMissionNameAndMemo( + missionService.updateMissionNameAndMemoIfPresent( stampId, courseMission, request)) .isInstanceOf(CustomException.class) .hasMessage(MissionErrorCode.MISSION_ALREADY_DELETED.getMessage()); @@ -183,7 +183,7 @@ void shouldUpdateMissionName() { new UpdateMissionRequestFixture().withName(NEW_MISSION_NAME).build(); // when - missionService.updateMissionNameAndMemo(stampId, courseMission, request); + missionService.updateMissionNameAndMemoIfPresent(stampId, courseMission, request); // then assertThat(courseMission.getName()).isEqualTo(NEW_MISSION_NAME); @@ -198,7 +198,7 @@ void shouldUpdateMissionMemo() { new UpdateMissionRequestFixture().withMemo(NEW_MISSION_MEMO).build(); // when - missionService.updateMissionNameAndMemo(stampId, courseMission, request); + missionService.updateMissionNameAndMemoIfPresent(stampId, courseMission, request); // then assertThat(courseMission.getMemo()).isEqualTo(NEW_MISSION_MEMO); @@ -216,7 +216,7 @@ void shouldUpdateMissionNameAndMemo() { .build(); // when - missionService.updateMissionNameAndMemo(stampId, courseMission, request); + missionService.updateMissionNameAndMemoIfPresent(stampId, courseMission, request); // then assertThat(courseMission.getName()).isEqualTo(NEW_MISSION_NAME);