diff --git a/build.gradle b/build.gradle index dd9f0b4..e6c5bf7 100644 --- a/build.gradle +++ b/build.gradle @@ -82,9 +82,14 @@ dependencies { // Kotlin implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8" implementation "org.jetbrains.kotlin:kotlin-reflect" - implementation "com.fasterxml.jackson.module:jackson-module-kotlin" + implementation 'io.projectreactor.kotlin:reactor-kotlin-extensions' testImplementation "org.jetbrains.kotlin:kotlin-test" + // Jackson + implementation "com.fasterxml.jackson.module:jackson-module-kotlin" + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + + // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.batch:spring-batch-test' diff --git a/src/main/java/com/ject/studytrip/auth/application/dto/OAuthLoginOutcome.java b/src/main/java/com/ject/studytrip/auth/application/dto/OAuthLoginOutcome.java deleted file mode 100644 index 40943ec..0000000 --- a/src/main/java/com/ject/studytrip/auth/application/dto/OAuthLoginOutcome.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.ject.studytrip.auth.application.dto; - -public record OAuthLoginOutcome(Outcome outcome, TokenInfo tokenInfo, String signupKey) { - enum Outcome { - SUCCESS, - SIGNUP_REQUIRED - } - - public static OAuthLoginOutcome success( - String accessToken, String refreshToken, long refreshTokenExpiresIn) { - return new OAuthLoginOutcome( - Outcome.SUCCESS, - new TokenInfo(accessToken, refreshToken, refreshTokenExpiresIn), - null); - } - - public static OAuthLoginOutcome signupRequired(String signupKey) { - return new OAuthLoginOutcome(Outcome.SIGNUP_REQUIRED, null, signupKey); - } - - public boolean isSignupRequired() { - return this.outcome == Outcome.SIGNUP_REQUIRED; - } -} diff --git a/src/main/java/com/ject/studytrip/auth/application/dto/TokenInfo.java b/src/main/java/com/ject/studytrip/auth/application/dto/TokenInfo.java deleted file mode 100644 index d9b500d..0000000 --- a/src/main/java/com/ject/studytrip/auth/application/dto/TokenInfo.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.ject.studytrip.auth.application.dto; - -public record TokenInfo(String accessToken, String refreshToken, long refreshTokenExpiresIn) { - public static TokenInfo of( - String accessToken, String refreshToken, long refreshTokenExpiresIn) { - return new TokenInfo(accessToken, refreshToken, refreshTokenExpiresIn); - } -} 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 deleted file mode 100644 index c622420..0000000 --- a/src/main/java/com/ject/studytrip/auth/application/facade/AuthFacade.java +++ /dev/null @@ -1,87 +0,0 @@ -package com.ject.studytrip.auth.application.facade; - -import com.ject.studytrip.auth.application.dto.OAuthLoginOutcome; -import com.ject.studytrip.auth.application.dto.TokenInfo; -import com.ject.studytrip.auth.application.service.KakaoLoginService; -import com.ject.studytrip.auth.application.service.KakaoSignupProfileService; -import com.ject.studytrip.auth.application.service.TokenService; -import com.ject.studytrip.auth.domain.model.KakaoSignupProfile; -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.member.application.dto.CreateMemberCommand; -import com.ject.studytrip.member.application.service.MemberCommandService; -import com.ject.studytrip.member.application.service.MemberQueryService; -import com.ject.studytrip.member.domain.model.Member; -import com.ject.studytrip.member.domain.model.SocialProvider; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class AuthFacade { - private final KakaoLoginService kakaoLoginService; - private final KakaoSignupProfileService kakaoSignupProfileService; - private final TokenService tokenService; - private final MemberQueryService memberQueryService; - private final MemberCommandService memberCommandService; - - public OAuthLoginOutcome kakaoLogin(KakaoLoginRequest request, String origin) { - KakaoUserInfoResponse info = kakaoLoginService.getKakaoUserInfo(request.code(), origin); - - return memberQueryService - .getMemberBySocialProviderAndSocialId(SocialProvider.KAKAO, info.kakaoId()) - // 가입되어 있는 사용자인 경우 토큰 발급 - .map( - member -> - createLoginOutcomeWithIssuedTokens( - member.getId(), member.getRole().name())) - // 가입이 필요할 경우 가입 키 발급 - .orElseGet(() -> createSignupRequiredOutcomeWithIssuedSignupKey(info)); - } - - public TokenInfo kakaoSignup(String signupKey, KakaoSignupRequest request) { - KakaoSignupProfile profile = kakaoSignupProfileService.getSignupProfileByKey(signupKey); - CreateMemberCommand command = - CreateMemberCommand.of( - profile.socialId(), - profile.email(), - profile.profileImageUrl(), - request.nickname(), - request.category()); - - Member member = memberCommandService.createMemberFromKakao(command); - - kakaoSignupProfileService.deleteBySignupKey(signupKey); - - return tokenService.getTokens(member.getId().toString(), member.getRole().name()); - } - - public TokenInfo reissueToken(String refreshToken) { - String memberId = tokenService.getMemberIdByRefreshToken(refreshToken); - String role = memberQueryService.getRoleByMemberId(memberId); - - return tokenService.reissueToken(refreshToken, memberId, role); - } - - public void logout(LogoutRequest request, String refreshToken) { - tokenService.logout(request.accessToken(), refreshToken); - } - - private OAuthLoginOutcome createLoginOutcomeWithIssuedTokens(Long memberId, String roleName) { - // 토큰 발급 - TokenInfo tokens = tokenService.getTokens(memberId.toString(), roleName); - return OAuthLoginOutcome.success( - tokens.accessToken(), tokens.refreshToken(), tokens.refreshTokenExpiresIn()); - } - - private OAuthLoginOutcome createSignupRequiredOutcomeWithIssuedSignupKey( - KakaoUserInfoResponse info) { - // 카카오 가입 프로필 임시 저장 및 키 발급 - String signupKey = - kakaoSignupProfileService.saveAndIssueSignupKey( - info.kakaoId(), info.getEmail(), info.getProfileImage()); - return OAuthLoginOutcome.signupRequired(signupKey); - } -} 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 deleted file mode 100644 index 7109a46..0000000 --- a/src/main/java/com/ject/studytrip/auth/application/service/KakaoLoginService.java +++ /dev/null @@ -1,18 +0,0 @@ -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 lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class KakaoLoginService { - private final KakaoOauthProvider kakaoOauthProvider; - - public KakaoUserInfoResponse getKakaoUserInfo(String code, String origin) { - KakaoTokenResponse response = kakaoOauthProvider.getKakaoTokens(code, origin); - return kakaoOauthProvider.getKakaoUserInfo(response.accessToken()); - } -} diff --git a/src/main/java/com/ject/studytrip/auth/application/service/KakaoSignupProfileService.java b/src/main/java/com/ject/studytrip/auth/application/service/KakaoSignupProfileService.java deleted file mode 100644 index 17c4ede..0000000 --- a/src/main/java/com/ject/studytrip/auth/application/service/KakaoSignupProfileService.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.ject.studytrip.auth.application.service; - -import com.ject.studytrip.auth.domain.error.AuthErrorCode; -import com.ject.studytrip.auth.domain.model.KakaoSignupProfile; -import com.ject.studytrip.auth.domain.repository.KakaoSignupProfileRedisRepository; -import com.ject.studytrip.global.exception.CustomException; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class KakaoSignupProfileService { - private final KakaoSignupProfileRedisRepository kakaoSignupProfileRedisRepository; - - public String saveAndIssueSignupKey(String socialId, String email, String profileImageUrl) { - return kakaoSignupProfileRedisRepository.saveAndIssueSignupKey( - socialId, email, profileImageUrl); - } - - public KakaoSignupProfile getSignupProfileByKey(String signupKey) { - if (signupKey == null || signupKey.isBlank()) { - throw new CustomException(AuthErrorCode.MISSING_KAKAO_SIGNUP_KEY); - } - - return kakaoSignupProfileRedisRepository - .findBySignupKey(signupKey) - .orElseThrow(() -> new CustomException(AuthErrorCode.INVALID_KAKAO_SIGNUP_KEY)); - } - - public void deleteBySignupKey(String signupKey) { - kakaoSignupProfileRedisRepository.deleteBySignupKey(signupKey); - } -} 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 deleted file mode 100644 index fe76f25..0000000 --- a/src/main/java/com/ject/studytrip/auth/application/service/TokenService.java +++ /dev/null @@ -1,88 +0,0 @@ -package com.ject.studytrip.auth.application.service; - -import com.ject.studytrip.auth.application.dto.TokenInfo; -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.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 TokenInfo 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 TokenInfo.of(accessToken, refreshToken, refreshTokenExpirationTime); - } - - public TokenInfo 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 TokenInfo.of(newAccessToken, newRefreshToken, refreshTokenExpirationTime); - } - - 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 (refreshToken == null || refreshToken.isBlank()) { - throw new CustomException(AuthErrorCode.MISSING_REFRESH_TOKEN); - } - - if (!refreshTokenRedisRepository.existsRefreshToken(refreshToken)) { - throw new CustomException(AuthErrorCode.INVALID_REFRESH_TOKEN); - } - } -} diff --git a/src/main/java/com/ject/studytrip/auth/domain/model/KakaoSignupProfile.java b/src/main/java/com/ject/studytrip/auth/domain/model/KakaoSignupProfile.java deleted file mode 100644 index 6d29cb7..0000000 --- a/src/main/java/com/ject/studytrip/auth/domain/model/KakaoSignupProfile.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.ject.studytrip.auth.domain.model; - -public record KakaoSignupProfile( - String socialId, String socialProvider, String email, String profileImageUrl) { - public static KakaoSignupProfile of( - String socialId, String socialProvider, String email, String profileImageUrl) { - return new KakaoSignupProfile(socialId, socialProvider, email, profileImageUrl); - } -} diff --git a/src/main/java/com/ject/studytrip/auth/domain/repository/KakaoSignupProfileRedisRepository.java b/src/main/java/com/ject/studytrip/auth/domain/repository/KakaoSignupProfileRedisRepository.java deleted file mode 100644 index 47d6ca0..0000000 --- a/src/main/java/com/ject/studytrip/auth/domain/repository/KakaoSignupProfileRedisRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.ject.studytrip.auth.domain.repository; - -import com.ject.studytrip.auth.domain.model.KakaoSignupProfile; -import java.util.Optional; - -public interface KakaoSignupProfileRedisRepository { - String saveAndIssueSignupKey(String socialId, String email, String profileImageUrl); - - Optional findBySignupKey(String signupKey); - - void deleteBySignupKey(String signupKey); -} 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 deleted file mode 100644 index 94c6f9f..0000000 --- a/src/main/java/com/ject/studytrip/auth/domain/repository/LogoutTokenRedisRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index 1e1e763..0000000 --- a/src/main/java/com/ject/studytrip/auth/domain/repository/RefreshTokenRedisRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -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/client/KakaoOauthClient.java b/src/main/java/com/ject/studytrip/auth/infra/client/KakaoOauthClient.java deleted file mode 100644 index f1ed81b..0000000 --- a/src/main/java/com/ject/studytrip/auth/infra/client/KakaoOauthClient.java +++ /dev/null @@ -1,58 +0,0 @@ -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 deleted file mode 100644 index 536c434..0000000 --- a/src/main/java/com/ject/studytrip/auth/infra/dto/KakaoAccount.java +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index b1f562b..0000000 --- a/src/main/java/com/ject/studytrip/auth/infra/dto/KakaoProfile.java +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 34ffde6..0000000 --- a/src/main/java/com/ject/studytrip/auth/infra/dto/KakaoTokenResponse.java +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index fea6140..0000000 --- a/src/main/java/com/ject/studytrip/auth/infra/dto/KakaoUserInfoResponse.java +++ /dev/null @@ -1,16 +0,0 @@ -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/provider/KakaoOauthProvider.java b/src/main/java/com/ject/studytrip/auth/infra/provider/KakaoOauthProvider.java deleted file mode 100644 index 6d5873e..0000000 --- a/src/main/java/com/ject/studytrip/auth/infra/provider/KakaoOauthProvider.java +++ /dev/null @@ -1,58 +0,0 @@ -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, String origin) { - validateKakaoAuthorizationCode(code); - return kakaoOauthClient - .fetchKakaoTokens(kakaoOauthProperties.tokenUri(), createFormData(code, origin)) - .block(); - } - - public KakaoUserInfoResponse getKakaoUserInfo(String accessToken) { - validateKakaoToken(accessToken); - return kakaoOauthClient - .fetchKakaoUserInfo(kakaoOauthProperties.userInfoUri(), accessToken) - .block(); - } - - private BodyInserters.FormInserter createFormData(String code, String origin) { - 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", origin + 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 deleted file mode 100644 index 16bf0dc..0000000 --- a/src/main/java/com/ject/studytrip/auth/infra/provider/TokenProvider.java +++ /dev/null @@ -1,85 +0,0 @@ -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 java.util.UUID; -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() { - return UUID.randomUUID().toString(); - } - - public String extractMemberIdFromToken(String token) { - return parseClaims(token).getSubject(); - } - - public String extractRoleFromToken(String token) { - return (String) parseClaims(token).get("role"); - } - - 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) { - 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/infra/repository/redis/KakaoSignupProfileRedisRepositoryAdapter.java b/src/main/java/com/ject/studytrip/auth/infra/repository/redis/KakaoSignupProfileRedisRepositoryAdapter.java deleted file mode 100644 index 34a1f87..0000000 --- a/src/main/java/com/ject/studytrip/auth/infra/repository/redis/KakaoSignupProfileRedisRepositoryAdapter.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.ject.studytrip.auth.infra.repository.redis; - -import static com.ject.studytrip.global.common.constants.CacheKeyConstants.*; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.ject.studytrip.auth.domain.model.KakaoSignupProfile; -import com.ject.studytrip.auth.domain.repository.KakaoSignupProfileRedisRepository; -import com.ject.studytrip.member.domain.model.SocialProvider; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.TimeUnit; -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Repository; - -@Repository -@RequiredArgsConstructor -public class KakaoSignupProfileRedisRepositoryAdapter implements KakaoSignupProfileRedisRepository { - private static final long KAKAO_SIGNUP_PROFILE_TTL_MILLIS = 900000; - - private final RedisTemplate redisTemplate; - private final ObjectMapper objectMapper; - - @Override - public String saveAndIssueSignupKey(String socialId, String email, String profileImageUrl) { - String socialProvider = SocialProvider.KAKAO.name().toLowerCase(); - String key = issueKey(socialProvider); - KakaoSignupProfile signupProfile = - KakaoSignupProfile.of(socialId, socialProvider, email, profileImageUrl); - - redisTemplate - .opsForValue() - .set(key, signupProfile, KAKAO_SIGNUP_PROFILE_TTL_MILLIS, TimeUnit.MILLISECONDS); - - return key; - } - - @Override - public Optional findBySignupKey(String signupKey) { - return Optional.ofNullable(redisTemplate.opsForValue().get(signupKey)) - .map(value -> objectMapper.convertValue(value, KakaoSignupProfile.class)); - } - - @Override - public void deleteBySignupKey(String signupKey) { - redisTemplate.delete(signupKey); - } - - private String issueKey(String socialProvider) { - return OAUTH_SIGNUP_PROFILE_PREFIX.formatted(socialProvider) + UUID.randomUUID(); - } -} 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 deleted file mode 100644 index c06f13b..0000000 --- a/src/main/java/com/ject/studytrip/auth/infra/repository/redis/LogoutTokenRedisRepositoryAdapter.java +++ /dev/null @@ -1,26 +0,0 @@ -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 + accessToken, "LOGOUT", accessTokenExpirationTime); - } - - @Override - public boolean existsAccessToken(String accessToken) { - return Boolean.TRUE.equals(redisTemplate.hasKey(AUTH_LOGOUT_TOKEN_PREFIX + 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 deleted file mode 100644 index ce14b6c..0000000 --- a/src/main/java/com/ject/studytrip/auth/infra/repository/redis/RefreshTokenRedisRepositoryAdapter.java +++ /dev/null @@ -1,42 +0,0 @@ -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 + refreshToken, - memberId, - refreshTokenExpireTime, - TimeUnit.MILLISECONDS); - } - - @Override - public boolean existsRefreshToken(String refreshToken) { - return Boolean.TRUE.equals(redisTemplate.hasKey(AUTH_REISSUE_TOKEN_PREFIX + refreshToken)); - } - - @Override - public void deleteRefreshToken(String refreshToken) { - redisTemplate.delete(AUTH_REISSUE_TOKEN_PREFIX + refreshToken); - } - - @Override - public String findMemberIdByRefreshToken(String refreshToken) { - return redisTemplate.opsForValue().get(AUTH_REISSUE_TOKEN_PREFIX + 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 deleted file mode 100644 index 0bfa018..0000000 --- a/src/main/java/com/ject/studytrip/auth/presentation/controller/AuthController.java +++ /dev/null @@ -1,137 +0,0 @@ -package com.ject.studytrip.auth.presentation.controller; - -import static com.ject.studytrip.global.common.constants.CookieConstants.*; - -import com.ject.studytrip.auth.application.dto.OAuthLoginOutcome; -import com.ject.studytrip.auth.application.dto.TokenInfo; -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.response.LoginResponse; -import com.ject.studytrip.auth.presentation.dto.response.ReissueTokenResponse; -import com.ject.studytrip.auth.presentation.helper.AuthCookieHelper; -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.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseCookie; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@Tag(name = "Auth", description = "인증 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/auth") -public class AuthController { - private final AuthFacade authFacade; - - @Operation( - summary = "카카오 로그인", - description = - """ - 카카오 인가 코드를 이용하여 로그인을 수행합니다. - 가입된 회원이라면 엑세스 토큰(Response Body)과 리프레시 토큰(HttpOnly Secure 쿠키 - 'auth_refresh')을 반환합니다. - 가입되지 않은 회원이라면 OAuth 가입키('oauth_signup_key')를 HttpOnly Secure 쿠키에 담아 응답합니다. - """) - @PostMapping("/login/kakao") - public ResponseEntity kakaoLogin( - @RequestAttribute(value = "origin") String origin, - @Valid @RequestBody KakaoLoginRequest request) { - OAuthLoginOutcome response = authFacade.kakaoLogin(request, origin); - - // 회원가입이 필요할 경우 - // 카카오 유저 프로필 키 저장 (쿠키) - if (response.isSignupRequired()) { - ResponseCookie pendingCookie = - AuthCookieHelper.setOAuthSignupProfileCookie(response.signupKey()); - return ResponseEntity.status(HttpStatus.OK) - .header(HttpHeaders.SET_COOKIE, pendingCookie.toString()) - .body( - StandardResponse.success( - HttpStatus.OK.value(), LoginResponse.requiredSignup())); - } - - // 리프레시 토큰 저장 (쿠키) - ResponseCookie refreshCookie = - AuthCookieHelper.setRefreshTokenCookie( - response.tokenInfo().refreshToken(), - response.tokenInfo().refreshTokenExpiresIn()); - return ResponseEntity.status(HttpStatus.OK) - .header(HttpHeaders.SET_COOKIE, refreshCookie.toString()) - .body( - StandardResponse.success( - HttpStatus.OK.value(), - LoginResponse.success(response.tokenInfo().accessToken()))); - } - - @Operation( - summary = "카카오 회원가입", - description = - """ - 닉네임, 카테고리를 입력받고, OAuth 가입키 쿠키로 회원가입을 수행합니다. - 회원가입에 성공하면 로그인처리되며, 엑세스 토큰(Response Body)과 리프레시 토큰(HttpOnly Secure 쿠키 - 'auth_refresh')을 반환합니다. - """) - @PostMapping("/signup/kakao") - public ResponseEntity kakaoSignup( - @CookieValue(name = OAUTH_SIGNUP_KEY, required = false) String pendingKey, - @Valid @RequestBody KakaoSignupRequest request) { - TokenInfo response = authFacade.kakaoSignup(pendingKey, request); - - // 리프레시 토큰 쿠키 생성 - ResponseCookie refreshCookie = - AuthCookieHelper.setRefreshTokenCookie( - response.refreshToken(), response.refreshTokenExpiresIn()); - - // 카카오 가입 쿠키 삭제 - ResponseCookie clearSignupCookie = AuthCookieHelper.clearCookie(OAUTH_SIGNUP_KEY); - - return ResponseEntity.status(HttpStatus.OK) - .header(HttpHeaders.SET_COOKIE, refreshCookie.toString()) - .header(HttpHeaders.SET_COOKIE, clearSignupCookie.toString()) - .body( - StandardResponse.success( - HttpStatus.OK.value(), - LoginResponse.success(response.accessToken()))); - } - - @Operation( - summary = "토큰 재발급", - description = - "리프레시 토큰을 이용하여, 엑세스 토큰과 리프레시 토큰을 재발급합니다. 리프레시 토큰은 'auth_refresh' HttpOnly Secure 쿠키에 담아 응답합니다.") - @PostMapping("/token/reissue") - public ResponseEntity reissueToken( - @CookieValue(name = AUTH_REFRESH_TOKEN, required = false) String refreshToken) { - TokenInfo response = authFacade.reissueToken(refreshToken); - - // 리프레시 토큰 덮어쓰기 - ResponseCookie refreshCookie = - AuthCookieHelper.setRefreshTokenCookie( - response.refreshToken(), response.refreshTokenExpiresIn()); - return ResponseEntity.status(HttpStatus.OK) - .header(HttpHeaders.SET_COOKIE, refreshCookie.toString()) - .body( - StandardResponse.success( - HttpStatus.OK.value(), - ReissueTokenResponse.of(response.accessToken()))); - } - - @Operation( - summary = "로그아웃", - description = "엑세스 토큰과 리프레시 토큰을 이용하여, 엑세스 토큰을 블랙리스트에 추가하고, 저장된 리프레시 토큰을 제거합니다.") - @PostMapping("/logout") - public ResponseEntity logout( - @CookieValue(name = AUTH_REFRESH_TOKEN, required = false) String refreshToken, - @Valid @RequestBody LogoutRequest request) { - authFacade.logout(request, refreshToken); - - // 기존 리프레시 쿠키 삭제 - ResponseCookie clearCookie = AuthCookieHelper.clearCookie(AUTH_REFRESH_TOKEN); - return ResponseEntity.status(HttpStatus.OK) - .header(HttpHeaders.SET_COOKIE, clearCookie.toString()) - .body(StandardResponse.success(HttpStatus.OK.value(), null)); - } -} 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 deleted file mode 100644 index 26eb6b3..0000000 --- a/src/main/java/com/ject/studytrip/auth/presentation/dto/request/KakaoLoginRequest.java +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 1a2be14..0000000 --- a/src/main/java/com/ject/studytrip/auth/presentation/dto/request/KakaoSignupRequest.java +++ /dev/null @@ -1,19 +0,0 @@ -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 = "멤버 카테고리를 입력해 주세요.") - @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/request/LogoutRequest.java b/src/main/java/com/ject/studytrip/auth/presentation/dto/request/LogoutRequest.java deleted file mode 100644 index 13f2f1c..0000000 --- a/src/main/java/com/ject/studytrip/auth/presentation/dto/request/LogoutRequest.java +++ /dev/null @@ -1,8 +0,0 @@ -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) {} 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 deleted file mode 100644 index 5d7df2e..0000000 --- a/src/main/java/com/ject/studytrip/auth/presentation/dto/request/TokenReissueRequest.java +++ /dev/null @@ -1,8 +0,0 @@ -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/auth/presentation/dto/response/LoginResponse.java b/src/main/java/com/ject/studytrip/auth/presentation/dto/response/LoginResponse.java deleted file mode 100644 index bb8db2f..0000000 --- a/src/main/java/com/ject/studytrip/auth/presentation/dto/response/LoginResponse.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.ject.studytrip.auth.presentation.dto.response; - -import com.fasterxml.jackson.annotation.JsonInclude; -import io.swagger.v3.oas.annotations.media.Schema; - -@JsonInclude(JsonInclude.Include.NON_NULL) -public record LoginResponse( - @Schema(description = "회원가입 필요 여부") boolean signupRequired, - @Schema(description = "엑세스 토큰(가입된 회원인 경우)") String accessToken) { - public static LoginResponse success(String accessToken) { - return new LoginResponse(false, accessToken); - } - - public static LoginResponse requiredSignup() { - return new LoginResponse(true, null); - } -} diff --git a/src/main/java/com/ject/studytrip/auth/presentation/dto/response/ReissueTokenResponse.java b/src/main/java/com/ject/studytrip/auth/presentation/dto/response/ReissueTokenResponse.java deleted file mode 100644 index 0e4bc73..0000000 --- a/src/main/java/com/ject/studytrip/auth/presentation/dto/response/ReissueTokenResponse.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.ject.studytrip.auth.presentation.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; - -public record ReissueTokenResponse(@Schema(description = "새로 발급된 엑세스 토큰") String accessToken) { - public static ReissueTokenResponse of(String accessToken) { - return new ReissueTokenResponse(accessToken); - } -} diff --git a/src/main/java/com/ject/studytrip/auth/presentation/helper/AuthCookieHelper.java b/src/main/java/com/ject/studytrip/auth/presentation/helper/AuthCookieHelper.java deleted file mode 100644 index 89e0d7c..0000000 --- a/src/main/java/com/ject/studytrip/auth/presentation/helper/AuthCookieHelper.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.ject.studytrip.auth.presentation.helper; - -import static com.ject.studytrip.global.common.constants.CookieConstants.*; - -import java.time.Duration; -import org.springframework.http.ResponseCookie; - -public final class AuthCookieHelper { - public static ResponseCookie setOAuthSignupProfileCookie(String value) { - return setResponseCookie( - OAUTH_SIGNUP_KEY, value, Duration.ofMillis(OAUTH_SIGNUP_COOKIE_TTL_MILLIS)); - } - - public static ResponseCookie setRefreshTokenCookie(String value, long maxAgeInSeconds) { - return setResponseCookie(AUTH_REFRESH_TOKEN, value, Duration.ofSeconds(maxAgeInSeconds)); - } - - private static ResponseCookie setResponseCookie(String name, String value, Duration maxAge) { - return ResponseCookie.from(name, value) - .httpOnly(true) - .secure(true) - .sameSite("None") - .maxAge(maxAge) - .path("/") - .build(); - } - - public static ResponseCookie clearCookie(String name) { - return ResponseCookie.from(name, "") - .httpOnly(true) - .secure(true) - .sameSite("None") - .path("/") - .maxAge(0) - .build(); - } -} diff --git a/src/main/java/com/ject/studytrip/cleanup/application/executor/HardDeleteExecutor.java b/src/main/java/com/ject/studytrip/cleanup/application/executor/HardDeleteExecutor.java deleted file mode 100644 index a338fe5..0000000 --- a/src/main/java/com/ject/studytrip/cleanup/application/executor/HardDeleteExecutor.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.ject.studytrip.cleanup.application.executor; - -import java.util.function.LongSupplier; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -@Slf4j -@Component -public class HardDeleteExecutor { - - public long run(String phase, LongSupplier supplier) { - try { - long deleted = supplier.getAsLong(); - log.info("[HardDelete] phase={}, deleted={}", phase, deleted); - return deleted; - } catch (Exception e) { - log.error("[HardDelete] phase={} failed: {}", phase, e.getMessage(), e); - return -1L; // 실패 표식 - } - } -} diff --git a/src/main/java/com/ject/studytrip/cleanup/application/facade/HardDeleteFacade.java b/src/main/java/com/ject/studytrip/cleanup/application/facade/HardDeleteFacade.java deleted file mode 100644 index 322df98..0000000 --- a/src/main/java/com/ject/studytrip/cleanup/application/facade/HardDeleteFacade.java +++ /dev/null @@ -1,212 +0,0 @@ -package com.ject.studytrip.cleanup.application.facade; - -import com.ject.studytrip.cleanup.application.executor.HardDeleteExecutor; -import com.ject.studytrip.member.application.service.MemberCommandService; -import com.ject.studytrip.mission.application.service.DailyMissionCommandService; -import com.ject.studytrip.mission.application.service.MissionCommandService; -import com.ject.studytrip.pomodoro.application.service.PomodoroCommandService; -import com.ject.studytrip.stamp.application.service.StampCommandService; -import com.ject.studytrip.studylog.application.service.StudyLogCommandService; -import com.ject.studytrip.studylog.application.service.StudyLogDailyMissionCommandService; -import com.ject.studytrip.trip.application.service.DailyGoalCommandService; -import com.ject.studytrip.trip.application.service.TripCommandService; -import com.ject.studytrip.trip.application.service.TripReportCommandService; -import com.ject.studytrip.trip.application.service.TripReportStudyLogCommandService; -import java.util.LinkedHashMap; -import java.util.Map; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; - -@Component -@RequiredArgsConstructor -public class HardDeleteFacade { - private final MemberCommandService memberCommandService; - private final TripCommandService tripCommandService; - private final StampCommandService stampCommandService; - private final MissionCommandService missionCommandService; - private final StudyLogCommandService studyLogCommandService; - private final DailyMissionCommandService dailyMissionCommandService; - private final StudyLogDailyMissionCommandService studyLogDailyMissionCommandService; - private final DailyGoalCommandService dailyGoalCommandService; - private final PomodoroCommandService pomodoroCommandService; - private final TripReportCommandService tripReportCommandService; - private final TripReportStudyLogCommandService tripReportStudyLogCommandService; - - private final HardDeleteExecutor executor; - - private static final String POMODOROS_OWNED_BY_DELETED_DAILY_GOAL = - "pomodorosOwnedByDeletedDailyGoal"; - private static final String STUDY_LOG_DAILY_MISSIONS_OWNED_BY_DELETED_DAILY_MISSION = - "studyLogDailyMissionsOwnedByDeletedDailyMission"; - private static final String STUDY_LOG_DAILY_MISSIONS_OWNED_BY_DELETED_STUDY_LOG = - "studyLogDailyMissionsOwnedByDeletedStudyLog"; - private static final String STUDY_LOGS_OWNED_BY_DELETED_MEMBER = - "studyLogsOwnedByDeletedMember"; - private static final String STUDY_LOGS_OWNED_BY_DELETED_DAILY_GOAL = - "studyLogsOwnedByDeletedDailyGoal"; - private static final String DAILY_MISSIONS_OWNED_BY_DELETED_MISSION = - "dailyMissionsOwnedByDeletedMission"; - private static final String DAILY_MISSIONS_OWNED_BY_DELETED_DAILY_GOAL = - "dailyMissionsOwnedByDeletedDailyGoal"; - private static final String TRIP_REPORT_STUDY_LOGS_OWNED_BY_DELETED_MEMBER = - "tripReportStudyLogsOwnedByDeletedMember"; - private static final String TRIP_REPORTS_OWNED_BY_DELETED_MEMBER = - "tripReportsOwnedByDeletedMember"; - - private static final String POMODOROS = "pomodoros"; - private static final String STUDY_LOG_DAILY_MISSIONS = "studyLogDailyMissions"; - private static final String STUDY_LOGS = "studyLogs"; - private static final String DAILY_MISSIONS = "dailyMissions"; - private static final String MISSIONS_OWNED_BY_DELETED_STAMP = "missionsOwnedByDeletedStamp"; - private static final String MISSIONS = "missions"; - private static final String STAMPS_OWNED_BY_DELETED_TRIP = "stampsOwnedByDeletedTrip"; - private static final String STAMPS = "stamps"; - private static final String TRIPS_OWNED_BY_DELETED_MEMBER = "tripsOwnedByDeletedMember"; - private static final String TRIPS = "trips"; - private static final String DAILY_GOALS_OWNED_BY_DELETED_TRIP = "dailyGoalsOwnedByDeletedTrip"; - private static final String DAILY_GOALS = "dailyGoals"; - private static final String TRIP_REPORTS = "tripReports"; - private static final String MEMBERS = "members"; - - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void hardDeleteAll() { - Map phases = new LinkedHashMap<>(); - - deletePomodoros(phases); // 뽀모도로 삭제 - deleteStudyLogDailyMissions(phases); // StudyLogDailyMission 삭제 - deleteDailyMissions(phases); // 데일리 미션 삭제 - deleteTripReportStudyLogs(phases); // TripReportStudyLog 삭제 - deleteTripReports(phases); // 여행 리포트 삭제 - deleteStudyLogs(phases); // 학습 로그 삭제 - deleteDailyGoals(phases); // 데일리 목표 삭제 - deleteMissions(phases); // 미션 삭제 - deleteStamps(phases); // 스탬프 삭제 - deleteTrips(phases); // 여행 삭제 - deleteMembers(phases); // 멤버 삭제 - } - - private void deletePomodoros(Map phases) { - phases.put( - POMODOROS_OWNED_BY_DELETED_DAILY_GOAL, - executor.run( - POMODOROS_OWNED_BY_DELETED_DAILY_GOAL, - pomodoroCommandService::hardDeletePomodorosOwnedByDeletedDailyGoal)); - phases.put(POMODOROS, executor.run(POMODOROS, pomodoroCommandService::hardDeletePomodoros)); - } - - private void deleteStudyLogDailyMissions(Map phases) { - phases.put( - STUDY_LOG_DAILY_MISSIONS_OWNED_BY_DELETED_DAILY_MISSION, - executor.run( - STUDY_LOG_DAILY_MISSIONS_OWNED_BY_DELETED_DAILY_MISSION, - studyLogDailyMissionCommandService - ::hardDeleteStudyLogDailyMissionsOwnedByDeletedDailyMission)); - phases.put( - STUDY_LOG_DAILY_MISSIONS_OWNED_BY_DELETED_STUDY_LOG, - executor.run( - STUDY_LOG_DAILY_MISSIONS_OWNED_BY_DELETED_STUDY_LOG, - studyLogDailyMissionCommandService - ::hardDeleteStudyLogDailyMissionsOwnedByDeletedStudyLog)); - phases.put( - STUDY_LOG_DAILY_MISSIONS, - executor.run( - STUDY_LOG_DAILY_MISSIONS, - studyLogDailyMissionCommandService::hardDeleteStudyLogDailyMissions)); - } - - private void deleteDailyMissions(Map phases) { - phases.put( - DAILY_MISSIONS_OWNED_BY_DELETED_MISSION, - executor.run( - DAILY_MISSIONS_OWNED_BY_DELETED_MISSION, - dailyMissionCommandService::hardDeleteDailyMissionsOwnedByDeletedMission)); - phases.put( - DAILY_MISSIONS_OWNED_BY_DELETED_DAILY_GOAL, - executor.run( - DAILY_MISSIONS_OWNED_BY_DELETED_DAILY_GOAL, - dailyMissionCommandService - ::hardDeleteDailyMissionsOwnedByDeletedDailyGoal)); - phases.put( - DAILY_MISSIONS, - executor.run(DAILY_MISSIONS, dailyMissionCommandService::hardDeleteDailyMissions)); - } - - private void deleteTripReportStudyLogs(Map phases) { - phases.put( - TRIP_REPORT_STUDY_LOGS_OWNED_BY_DELETED_MEMBER, - executor.run( - TRIP_REPORT_STUDY_LOGS_OWNED_BY_DELETED_MEMBER, - tripReportStudyLogCommandService - ::hardDeleteTripReportStudyLogsOwnedByDeletedMember)); - } - - private void deleteTripReports(Map phases) { - phases.put( - TRIP_REPORTS_OWNED_BY_DELETED_MEMBER, - executor.run( - TRIP_REPORTS_OWNED_BY_DELETED_MEMBER, - tripReportCommandService::hardDeleteTripReportsOwnedByDeletedMember)); - phases.put( - TRIP_REPORTS, - executor.run(TRIP_REPORTS, tripReportCommandService::hardDeleteTripReports)); - } - - private void deleteStudyLogs(Map phases) { - phases.put( - STUDY_LOGS_OWNED_BY_DELETED_MEMBER, - executor.run( - STUDY_LOGS_OWNED_BY_DELETED_MEMBER, - studyLogCommandService::hardDeleteStudyLogsOwnedByDeletedMember)); - phases.put( - STUDY_LOGS_OWNED_BY_DELETED_DAILY_GOAL, - executor.run( - STUDY_LOGS_OWNED_BY_DELETED_DAILY_GOAL, - studyLogCommandService::hardDeleteStudyLogsOwnedByDeletedDailyGoal)); - phases.put( - STUDY_LOGS, executor.run(STUDY_LOGS, studyLogCommandService::hardDeleteStudyLogs)); - } - - private void deleteDailyGoals(Map phases) { - phases.put( - DAILY_GOALS_OWNED_BY_DELETED_TRIP, - executor.run( - DAILY_GOALS_OWNED_BY_DELETED_TRIP, - dailyGoalCommandService::hardDeleteDailyGoalsOwnedByDeletedTrip)); - phases.put( - DAILY_GOALS, - executor.run(DAILY_GOALS, dailyGoalCommandService::hardDeleteDailyGoals)); - } - - private void deleteMissions(Map phases) { - phases.put( - MISSIONS_OWNED_BY_DELETED_STAMP, - executor.run( - MISSIONS_OWNED_BY_DELETED_STAMP, - missionCommandService::hardDeleteMissionsOwnedByDeletedStamp)); - phases.put(MISSIONS, executor.run(MISSIONS, missionCommandService::hardDeleteMissions)); - } - - private void deleteStamps(Map phases) { - phases.put( - STAMPS_OWNED_BY_DELETED_TRIP, - executor.run( - STAMPS_OWNED_BY_DELETED_TRIP, - stampCommandService::hardDeleteStampsOwnedByDeletedTrip)); - phases.put(STAMPS, executor.run(STAMPS, stampCommandService::hardDeleteStamps)); - } - - private void deleteTrips(Map phases) { - phases.put( - TRIPS_OWNED_BY_DELETED_MEMBER, - executor.run( - TRIPS_OWNED_BY_DELETED_MEMBER, - tripCommandService::hardDeleteTripsOwnedByDeletedMember)); - phases.put(TRIPS, executor.run(TRIPS, tripCommandService::hardDeleteTrips)); - } - - private void deleteMembers(Map phases) { - phases.put(MEMBERS, executor.run(MEMBERS, memberCommandService::hardDeleteMembers)); - } -} diff --git a/src/main/java/com/ject/studytrip/global/config/RedisConfig.java b/src/main/java/com/ject/studytrip/global/config/RedisConfig.java index c24f709..55c889b 100644 --- a/src/main/java/com/ject/studytrip/global/config/RedisConfig.java +++ b/src/main/java/com/ject/studytrip/global/config/RedisConfig.java @@ -1,5 +1,6 @@ package com.ject.studytrip.global.config; +import com.fasterxml.jackson.databind.ObjectMapper; import com.ject.studytrip.global.config.properties.RedisProperties; import lombok.RequiredArgsConstructor; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -24,17 +25,18 @@ public RedisConnectionFactory redisConnectionFactory() { } @Bean - public RedisTemplate redisTemplate(RedisConnectionFactory factory) { + public RedisTemplate redisTemplate( + RedisConnectionFactory factory, ObjectMapper objectMapper) { RedisTemplate template = new RedisTemplate<>(); template.setConnectionFactory(factory); // 단순 Key-Value 직렬화 template.setKeySerializer(new StringRedisSerializer()); - template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper)); // 해시 Key-Value 직렬화 template.setHashKeySerializer(new StringRedisSerializer()); - template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer(objectMapper)); template.afterPropertiesSet(); return template; diff --git a/src/main/java/com/ject/studytrip/image/application/dto/CleanupImagesResult.java b/src/main/java/com/ject/studytrip/image/application/dto/CleanupImagesResult.java deleted file mode 100644 index 7a3baec..0000000 --- a/src/main/java/com/ject/studytrip/image/application/dto/CleanupImagesResult.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.ject.studytrip.image.application.dto; - -import java.util.List; - -public record CleanupImagesResult(int success, List failedKeys) { - public static CleanupImagesResult of(int success, List failedKeys) { - return new CleanupImagesResult(success, failedKeys); - } -} diff --git a/src/main/java/com/ject/studytrip/image/application/dto/PresignedImageInfo.java b/src/main/java/com/ject/studytrip/image/application/dto/PresignedImageInfo.java deleted file mode 100644 index ae369b6..0000000 --- a/src/main/java/com/ject/studytrip/image/application/dto/PresignedImageInfo.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.ject.studytrip.image.application.dto; - -public record PresignedImageInfo(String tmpKey, String presignedUrl) { - public static PresignedImageInfo of(String tmpKey, String presignedUrl) { - return new PresignedImageInfo(tmpKey, presignedUrl); - } -} diff --git a/src/main/java/com/ject/studytrip/image/application/event/ImageCleanupBatchEvent.java b/src/main/java/com/ject/studytrip/image/application/event/ImageCleanupBatchEvent.java deleted file mode 100644 index 855607f..0000000 --- a/src/main/java/com/ject/studytrip/image/application/event/ImageCleanupBatchEvent.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.ject.studytrip.image.application.event; - -import java.util.List; - -public record ImageCleanupBatchEvent(List imageUrls) { - public static ImageCleanupBatchEvent of(List imageUrls) { - return new ImageCleanupBatchEvent(imageUrls); - } -} diff --git a/src/main/java/com/ject/studytrip/image/application/event/ImageEventListener.java b/src/main/java/com/ject/studytrip/image/application/event/ImageEventListener.java deleted file mode 100644 index 81d7efd..0000000 --- a/src/main/java/com/ject/studytrip/image/application/event/ImageEventListener.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.ject.studytrip.image.application.event; - -import com.ject.studytrip.image.application.service.ImageService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -@Component -@RequiredArgsConstructor -public class ImageEventListener { - - private final ImageService imageService; - - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handleCleanupBatch(ImageCleanupBatchEvent event) { - imageService.cleanupBatch(event.imageUrls()); - } -} diff --git a/src/main/java/com/ject/studytrip/image/application/event/ImageEventPublisher.java b/src/main/java/com/ject/studytrip/image/application/event/ImageEventPublisher.java deleted file mode 100644 index cf57610..0000000 --- a/src/main/java/com/ject/studytrip/image/application/event/ImageEventPublisher.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.ject.studytrip.image.application.event; - -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class ImageEventPublisher { - - private final ApplicationEventPublisher publisher; - - public void publishCleanupBatch(List imageUrls) { - if (imageUrls == null || imageUrls.isEmpty()) return; - - ImageCleanupBatchEvent event = ImageCleanupBatchEvent.of(imageUrls); - publisher.publishEvent(event); - } -} diff --git a/src/main/java/com/ject/studytrip/image/application/service/ImageService.java b/src/main/java/com/ject/studytrip/image/application/service/ImageService.java deleted file mode 100644 index 4b30481..0000000 --- a/src/main/java/com/ject/studytrip/image/application/service/ImageService.java +++ /dev/null @@ -1,179 +0,0 @@ -package com.ject.studytrip.image.application.service; - -import com.ject.studytrip.global.config.properties.CdnProperties; -import com.ject.studytrip.global.exception.CustomException; -import com.ject.studytrip.global.util.FilenameUtil; -import com.ject.studytrip.image.application.dto.CleanupImagesResult; -import com.ject.studytrip.image.application.dto.PresignedImageInfo; -import com.ject.studytrip.image.application.event.ImageEventPublisher; -import com.ject.studytrip.image.domain.constants.ImageConstants; -import com.ject.studytrip.image.domain.factory.ImageKeyFactory; -import com.ject.studytrip.image.domain.policy.ImagePolicy; -import com.ject.studytrip.image.domain.util.ImageUrlUtil; -import com.ject.studytrip.image.infra.s3.dto.ImageHeadInfo; -import com.ject.studytrip.image.infra.s3.provider.S3ImageStorageProvider; -import com.ject.studytrip.image.infra.tika.provider.TikaImageProbeProvider; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -@Slf4j -public class ImageService { - - private static final int MAX_BATCH = 1000; - - private final S3ImageStorageProvider s3Provider; - private final TikaImageProbeProvider tikaProvider; - - private final ImageEventPublisher publisher; - - private final CdnProperties cdnProps; - - // Presigned URL 발급 - public PresignedImageInfo presign(String keyPrefix, String id, String originFilename) { - // 키 prefix 검증 - ImagePolicy.validateKeyPrefix(keyPrefix); - - // 확장자 추출, 검증 - String ext = FilenameUtil.extractExtension(originFilename); - ImagePolicy.validateExtension(ext); - - // 새로운 파일명 생성 - String filename = FilenameUtil.createNewFilename(ext); - - // 임시 키 생성 - String tmpKey = ImageKeyFactory.createTmpKey(keyPrefix, id, filename); - - // Presigned URL 생성 - String presignedUrl = s3Provider.issuePresignedUrl(tmpKey); - - return PresignedImageInfo.of(tmpKey, presignedUrl); - } - - // 업로드된 이미지 확정 - // S3 자체 에러 시에는 cleanup 실행 X - // 이미지 파일 크기, MIME 등 도메인 정책 검증에 실패하면 cleanup 실행 - public String confirm(String tmpKey) { - // 임시 이미지 키 검증 - ImagePolicy.validateKey(tmpKey); - - // 업로드된 이미지 HEAD 조회 - ImageHeadInfo head = s3Provider.getHeadByKey(tmpKey); - - // 이미지 크기 검증, 검증 실패 시 이미지 삭제 - validateSizeWithCleanup(tmpKey, head.contentLength()); - - // MIME 추출 및 판별, 검증 실패 시 이미지 삭제 - validateMimeWithCleanup(tmpKey, head.contentLength()); - - // 임시 -> 최종 이미지 복사 및 경로 반환 - return moveToFinalLocation(tmpKey); - } - - // 업로드 취소 - public void cancel(List uploadedKeys) { - s3Provider.deleteByKeys(uploadedKeys); - } - - // 이미지 삭제 - public void cleanup(String imageUrl) { - ImageUrlUtil.extractKey(cdnProps.domain(), imageUrl).ifPresent(s3Provider::deleteByKey); - } - - // 이미지 배치 삭제 - public void cleanupBatch(List imageUrls) { - List keys = extractKeysFromUrls(imageUrls); - if (keys.isEmpty()) return; - - int attempted = 0; - int succeeded = 0; - List failed = new ArrayList<>(); - - // 배치 삭제 (S3 DeleteObjects 최대 개수: 1000개) - for (int i = 0; i < keys.size(); i += MAX_BATCH) { - List batch = - new ArrayList<>(keys.subList(i, Math.min(keys.size(), i + MAX_BATCH))); - attempted += batch.size(); - - CleanupImagesResult result = s3Provider.deleteByKeys(batch); - succeeded += result.success(); - - if (!result.failedKeys().isEmpty()) failed.addAll(result.failedKeys()); - } - - log.info( - "Image Cleanup Batch attempted={}, succeeded={}, failed={}", - attempted, - succeeded, - failed.size()); - - if (!failed.isEmpty()) { - // 우선 로깅 처리 - // 추후 Outbox 패턴으로 확장 가능 - log.debug( - "Image Cleanup Batch Failed. failedCount={}, failedKeys={}", - failed.size(), - failed); - } - } - - public void publishCleanupBatchEvent(List imageUrls) { - publisher.publishCleanupBatch(imageUrls); - } - - // 이미지 사이즈 검증, 실패 시 삭제 - private void validateSizeWithCleanup(String tmpKey, long contentLength) { - try { - ImagePolicy.validateSize(contentLength); - } catch (CustomException e) { - cleanupAndThrow(tmpKey, e); - } - } - - // 이미지 MIME 추출 및 검증, 실패 시 삭제 - private void validateMimeWithCleanup(String tmpKey, long len) { - int maxBytes = (int) Math.min(len, ImageConstants.PROBE_BYTES); - byte[] prefix = s3Provider.readPrefix(tmpKey, maxBytes); - String mime = tikaProvider.detectMime(prefix); - - try { - ImagePolicy.validateMime(mime); - } catch (CustomException e) { - cleanupAndThrow(tmpKey, e); - } - } - - // 최종 경로에 이미지 복사 및 반환 - private String moveToFinalLocation(String tmpKey) { - String finalKey = ImageKeyFactory.toFinalKey(tmpKey); - ImagePolicy.validateKey(finalKey); - s3Provider.copyByKey(tmpKey, finalKey); - - return ImageUrlUtil.build(cdnProps.domain(), finalKey); - } - - // 삭제 및 예외 처리 - private void cleanupAndThrow(String tmpKey, CustomException exception) { - s3Provider.deleteByKey(tmpKey); - throw exception; - } - - // 중복, 빈 값 제거 후 키 목록 추출 - private List extractKeysFromUrls(List urls) { - if (urls == null || urls.isEmpty()) return List.of(); - return urls.stream() - .filter(Objects::nonNull) - .map(url -> ImageUrlUtil.extractKey(cdnProps.domain(), url)) - .flatMap(Optional::stream) - .map(String::trim) - .filter(s -> !s.isEmpty()) - .distinct() - .toList(); - } -} diff --git a/src/main/java/com/ject/studytrip/image/domain/constants/ImageConstants.java b/src/main/java/com/ject/studytrip/image/domain/constants/ImageConstants.java deleted file mode 100644 index a708f0c..0000000 --- a/src/main/java/com/ject/studytrip/image/domain/constants/ImageConstants.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.ject.studytrip.image.domain.constants; - -import java.util.Set; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; -import org.springframework.util.unit.DataSize; - -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public final class ImageConstants { - - public static final Set ALLOWED_MIME_TYPES = - Set.of("image/jpeg", "image/png", "image/webp"); - - public static final long MAX_IMAGE_SIZE_BYTES = DataSize.ofMegabytes(5).toBytes(); - public static final long MIN_IMAGE_SIZE_BYTES = 1L; - - public static final int PROBE_BYTES = 32 * 1024; // 32KB - - public static final String KEY_PATTERN = "%s/%s/%s"; - public static final String TMP_PREFIX = "tmp/"; - public static final Set ALLOWED_OBJECT_KEY_PREFIXES = - Set.of("members", "study-logs", "trip-reports"); - - public static final Set ALLOWED_EXTENSIONS = Set.of("jpg", "jpeg", "png", "webp"); -} diff --git a/src/main/java/com/ject/studytrip/image/domain/factory/ImageKeyFactory.java b/src/main/java/com/ject/studytrip/image/domain/factory/ImageKeyFactory.java deleted file mode 100644 index 8093591..0000000 --- a/src/main/java/com/ject/studytrip/image/domain/factory/ImageKeyFactory.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.ject.studytrip.image.domain.factory; - -import static com.ject.studytrip.image.domain.constants.ImageConstants.*; - -import lombok.AccessLevel; -import lombok.NoArgsConstructor; - -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public final class ImageKeyFactory { - public static String createTmpKey(String keyPrefix, String id, String filename) { - return TMP_PREFIX + KEY_PATTERN.formatted(keyPrefix, id, filename); - } - - public static String toFinalKey(String tmpKey) { - if (tmpKey == null || tmpKey.isBlank() || !tmpKey.startsWith(TMP_PREFIX)) { - return null; - } - return tmpKey.substring(TMP_PREFIX.length()); - } -} diff --git a/src/main/java/com/ject/studytrip/image/domain/policy/ImagePolicy.java b/src/main/java/com/ject/studytrip/image/domain/policy/ImagePolicy.java deleted file mode 100644 index a67116b..0000000 --- a/src/main/java/com/ject/studytrip/image/domain/policy/ImagePolicy.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.ject.studytrip.image.domain.policy; - -import static com.ject.studytrip.image.domain.constants.ImageConstants.*; -import static org.springframework.util.StringUtils.hasText; - -import com.ject.studytrip.global.exception.CustomException; -import com.ject.studytrip.image.domain.error.ImageErrorCode; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; - -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public class ImagePolicy { - - public static void validateExtension(String ext) { - if (!hasText(ext) || !ALLOWED_EXTENSIONS.contains(ext)) { - throw new CustomException(ImageErrorCode.INVALID_IMAGE_EXTENSION); - } - } - - public static void validateMime(String mime) { - if (!hasText(mime) || !ALLOWED_MIME_TYPES.contains(mime)) { - throw new CustomException(ImageErrorCode.INVALID_IMAGE_MIME); - } - } - - public static void validateSize(long contentLength) { - if (contentLength < MIN_IMAGE_SIZE_BYTES) { - throw new CustomException(ImageErrorCode.EMPTY_IMAGE); - } - - if (contentLength > MAX_IMAGE_SIZE_BYTES) { - throw new CustomException(ImageErrorCode.IMAGE_SIZE_EXCEEDED); - } - } - - public static void validateKeyPrefix(String keyPrefix) { - if (!hasText(keyPrefix) || !ALLOWED_OBJECT_KEY_PREFIXES.contains(keyPrefix)) { - throw new CustomException(ImageErrorCode.INVALID_IMAGE_KEY_PREFIX); - } - } - - public static void validateKey(String key) { - if (!hasText(key)) { - throw new CustomException(ImageErrorCode.INVALID_IMAGE_KEY); - } - } -} diff --git a/src/main/java/com/ject/studytrip/image/domain/util/ImageUrlUtil.java b/src/main/java/com/ject/studytrip/image/domain/util/ImageUrlUtil.java deleted file mode 100644 index accbb8c..0000000 --- a/src/main/java/com/ject/studytrip/image/domain/util/ImageUrlUtil.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.ject.studytrip.image.domain.util; - -import static org.springframework.util.StringUtils.hasText; - -import java.net.URI; -import java.util.Optional; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; - -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public final class ImageUrlUtil { - // 이미지 최종 경로 생성 - public static String build(String baseUrl, String key) { - if (!hasText(baseUrl) || !hasText(key)) { - return null; - } - - String normalizedDomain = - baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; - - return normalizedDomain + "/" + key.trim(); - } - - // 이미지 최종 경로에서 이미지 키 추출 - public static Optional extractKey(String baseUrl, String url) { - if (url == null || url.isBlank()) { - return Optional.empty(); - } - - if (baseUrl == null || baseUrl.isBlank()) { - return Optional.empty(); - } - - // URI 파싱 - URI uri; - try { - uri = URI.create(url); - } catch (IllegalArgumentException e) { - return Optional.empty(); - } - - String host = uri.getHost(); - if (host == null || !host.equals(extractHostName(baseUrl))) { - return Optional.empty(); - } - - String path = uri.getPath(); - if (path == null || path.length() <= 1) { - return Optional.empty(); - } - - // 맨 앞의 '/' 제거 - String key = path.startsWith("/") ? path.substring(1) : path; - return key.isBlank() ? Optional.empty() : Optional.of(key); - } - - // 경로에서 Host 추출 - private static String extractHostName(String baseUrl) { - if (baseUrl.startsWith("https://")) { - return baseUrl.substring(8).replaceAll("/$", ""); - } - if (baseUrl.startsWith("http://")) { - return baseUrl.substring(7).replaceAll("/$", ""); - } - return baseUrl.replaceAll("/$", ""); - } -} diff --git a/src/main/java/com/ject/studytrip/image/infra/s3/client/S3ImageStorageClient.java b/src/main/java/com/ject/studytrip/image/infra/s3/client/S3ImageStorageClient.java deleted file mode 100644 index 4d2d2cb..0000000 --- a/src/main/java/com/ject/studytrip/image/infra/s3/client/S3ImageStorageClient.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.ject.studytrip.image.infra.s3.client; - -import com.ject.studytrip.global.config.properties.S3Properties; -import com.ject.studytrip.image.infra.s3.error.S3ExceptionTranslator; -import java.time.Duration; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import software.amazon.awssdk.core.ResponseBytes; -import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.*; -import software.amazon.awssdk.services.s3.presigner.S3Presigner; -import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; -import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; - -@Component -@RequiredArgsConstructor -@Slf4j -public class S3ImageStorageClient { - private final S3Properties props; - private final S3Presigner presigner; - private final S3Client client; - - public PresignedPutObjectRequest presignPut(String key) { - return S3ExceptionTranslator.executeWithExceptionTranslation( - () -> { - PutObjectRequest put = - PutObjectRequest.builder().bucket(props.bucket()).key(key).build(); - - Duration ttl = Duration.ofMinutes(props.presignExpiresInMinutes()); - PutObjectPresignRequest req = - PutObjectPresignRequest.builder() - .signatureDuration(ttl) - .putObjectRequest(put) - .build(); - - return presigner.presignPutObject(req); - }); - } - - public HeadObjectResponse getHeadObject(String key) { - return S3ExceptionTranslator.executeWithExceptionTranslation( - () -> client.headObject(builder -> builder.bucket(props.bucket()).key(key))); - } - - public ResponseBytes getObjectAsBytes(String key, String range) { - return S3ExceptionTranslator.executeWithExceptionTranslation( - () -> - client.getObjectAsBytes( - builder -> builder.bucket(props.bucket()).key(key).range(range))); - } - - public void deleteObject(String key) { - S3ExceptionTranslator.executeWithExceptionTranslation( - () -> client.deleteObject(builder -> builder.bucket(props.bucket()).key(key))); - } - - public DeleteObjectsResponse deleteObjects(List objects) { - return client.deleteObjects( - builder -> - builder.bucket(props.bucket()) - .delete(d -> d.quiet(false).objects(objects))); - } - - public void copyObject(String tmpKey, String finalKey) { - S3ExceptionTranslator.executeWithExceptionTranslation( - () -> - client.copyObject( - builder -> - builder.sourceBucket(props.bucket()) - .sourceKey(tmpKey) - .destinationBucket(props.bucket()) - .destinationKey(finalKey))); - } -} diff --git a/src/main/java/com/ject/studytrip/image/infra/s3/dto/ImageHeadInfo.java b/src/main/java/com/ject/studytrip/image/infra/s3/dto/ImageHeadInfo.java deleted file mode 100644 index 830607b..0000000 --- a/src/main/java/com/ject/studytrip/image/infra/s3/dto/ImageHeadInfo.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.ject.studytrip.image.infra.s3.dto; - -public record ImageHeadInfo(long contentLength) { - public static ImageHeadInfo of(long contentLength) { - return new ImageHeadInfo(contentLength); - } -} diff --git a/src/main/java/com/ject/studytrip/image/infra/s3/error/S3ErrorCode.java b/src/main/java/com/ject/studytrip/image/infra/s3/error/S3ErrorCode.java deleted file mode 100644 index 7c04b60..0000000 --- a/src/main/java/com/ject/studytrip/image/infra/s3/error/S3ErrorCode.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.ject.studytrip.image.infra.s3.error; - -import com.ject.studytrip.global.exception.error.ErrorCode; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; - -@RequiredArgsConstructor -public enum S3ErrorCode implements ErrorCode { - S3_STORAGE_SERVER_ERROR(HttpStatus.BAD_GATEWAY, "Storage 서버 에러가 발생했습니다."); - - 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/image/infra/s3/error/S3ExceptionTranslator.java b/src/main/java/com/ject/studytrip/image/infra/s3/error/S3ExceptionTranslator.java deleted file mode 100644 index ffceab7..0000000 --- a/src/main/java/com/ject/studytrip/image/infra/s3/error/S3ExceptionTranslator.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.ject.studytrip.image.infra.s3.error; - -import com.ject.studytrip.global.exception.CustomException; -import lombok.extern.slf4j.Slf4j; -import software.amazon.awssdk.core.exception.SdkClientException; -import software.amazon.awssdk.core.exception.SdkException; -import software.amazon.awssdk.services.s3.model.S3Exception; - -@Slf4j -public class S3ExceptionTranslator { - - public static T executeWithExceptionTranslation(S3Operation operation) { - try { - return operation.execute(); - } catch (S3Exception e) { - log.error("S3 service error: {}", e.getMessage(), e); - } catch (SdkClientException e) { - log.error("S3 client error: {}", e.getMessage(), e); - } catch (SdkException e) { - log.error("AWS SDK error: {}", e.getMessage(), e); - } catch (Exception e) { - log.error("Exception: {}", e.getMessage(), e); - } - - throw new CustomException(S3ErrorCode.S3_STORAGE_SERVER_ERROR); - } - - @FunctionalInterface - public interface S3Operation { - T execute() throws Exception; - } -} diff --git a/src/main/java/com/ject/studytrip/image/infra/s3/provider/S3ImageStorageProvider.java b/src/main/java/com/ject/studytrip/image/infra/s3/provider/S3ImageStorageProvider.java deleted file mode 100644 index 16031ba..0000000 --- a/src/main/java/com/ject/studytrip/image/infra/s3/provider/S3ImageStorageProvider.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.ject.studytrip.image.infra.s3.provider; - -import com.ject.studytrip.image.application.dto.CleanupImagesResult; -import com.ject.studytrip.image.infra.s3.client.S3ImageStorageClient; -import com.ject.studytrip.image.infra.s3.dto.ImageHeadInfo; -import java.util.List; -import java.util.Objects; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import software.amazon.awssdk.core.exception.SdkClientException; -import software.amazon.awssdk.services.s3.model.*; -import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; - -@Component -@RequiredArgsConstructor -@Slf4j -public class S3ImageStorageProvider { - private final S3ImageStorageClient s3Client; - - public String issuePresignedUrl(String key) { - PresignedPutObjectRequest presignPut = s3Client.presignPut(key); - return presignPut.url().toString(); - } - - public ImageHeadInfo getHeadByKey(String key) { - HeadObjectResponse head = s3Client.getHeadObject(key); - return ImageHeadInfo.of(head.contentLength()); - } - - public byte[] readPrefix(String key, int maxBytes) { - String range = "bytes=0-" + (maxBytes - 1); - return s3Client.getObjectAsBytes(key, range).asByteArray(); - } - - public void deleteByKey(String key) { - s3Client.deleteObject(key); - } - - public CleanupImagesResult deleteByKeys(List keys) { - List objects = - keys.stream().map(key -> ObjectIdentifier.builder().key(key).build()).toList(); - int attempts = objects.size(); - - try { - DeleteObjectsResponse response = s3Client.deleteObjects(objects); - List failedKeys = - response.errors() == null - ? List.of() - : response.errors().stream() - .map(S3Error::key) - .filter(Objects::nonNull) - .filter(key -> !key.isBlank()) - .distinct() - .toList(); - int success = attempts - failedKeys.size(); - - return CleanupImagesResult.of(success, failedKeys); - } catch (S3Exception | SdkClientException e) { - // S3 삭제는 멱등이기 때문에 키가 존재하지 않거나, 중복이여도 에러가 발생하지 않음 - // 삭제 시 발생하는 에러는 보통 S3 내부 서버 문제(IO/네트워크) 혹은 인증/자격, 권한, 정책, 상태 등으로 발생 - // 따라서 요청 레벨 실패로 간주하고 배치를 전체 실패로 처리 - log.warn("S3 deleteObjects request failure: {}", e.getMessage(), e); - return CleanupImagesResult.of(0, keys); - } - } - - public void copyByKey(String tmpKey, String finalKey) { - s3Client.copyObject(tmpKey, finalKey); - s3Client.deleteObject(tmpKey); - } -} diff --git a/src/main/java/com/ject/studytrip/image/infra/tika/error/TikaErrorCode.java b/src/main/java/com/ject/studytrip/image/infra/tika/error/TikaErrorCode.java deleted file mode 100644 index adad9fd..0000000 --- a/src/main/java/com/ject/studytrip/image/infra/tika/error/TikaErrorCode.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.ject.studytrip.image.infra.tika.error; - -import com.ject.studytrip.global.exception.error.ErrorCode; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; - -@RequiredArgsConstructor -public enum TikaErrorCode implements ErrorCode { - TIKA_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Tika 서버 에러가 발생했습니다."), - ; - - 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/image/infra/tika/provider/TikaImageProbeProvider.java b/src/main/java/com/ject/studytrip/image/infra/tika/provider/TikaImageProbeProvider.java deleted file mode 100644 index 85fce9a..0000000 --- a/src/main/java/com/ject/studytrip/image/infra/tika/provider/TikaImageProbeProvider.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.ject.studytrip.image.infra.tika.provider; - -import com.ject.studytrip.global.exception.CustomException; -import com.ject.studytrip.image.infra.tika.error.TikaErrorCode; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.apache.tika.Tika; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -@Slf4j -public class TikaImageProbeProvider { - private final Tika tika; - - public String detectMime(byte[] headBytes) { - try { - return tika.detect(headBytes); - } catch (Exception e) { - log.error("Tika Exception: {}", e.getMessage(), e); - throw new CustomException(TikaErrorCode.TIKA_SERVER_ERROR); - } - } -} diff --git a/src/main/java/com/ject/studytrip/member/application/dto/CreateMemberCommand.java b/src/main/java/com/ject/studytrip/member/application/dto/CreateMemberCommand.java deleted file mode 100644 index 051d8a8..0000000 --- a/src/main/java/com/ject/studytrip/member/application/dto/CreateMemberCommand.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.ject.studytrip.member.application.dto; - -public record CreateMemberCommand( - String socialId, String email, String profileImage, String nickname, String category) { - public static CreateMemberCommand of( - String socialId, String email, String profileImage, String nickname, String category) { - return new CreateMemberCommand(socialId, email, profileImage, nickname, category); - } -} diff --git a/src/main/java/com/ject/studytrip/member/application/dto/MemberDetail.java b/src/main/java/com/ject/studytrip/member/application/dto/MemberDetail.java deleted file mode 100644 index a8ce875..0000000 --- a/src/main/java/com/ject/studytrip/member/application/dto/MemberDetail.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.ject.studytrip.member.application.dto; - -import com.ject.studytrip.trip.application.dto.TripCount; - -public record MemberDetail(MemberInfo memberInfo, TripCount tripCount, long studyLogCount) { - public static MemberDetail from( - MemberInfo memberInfo, TripCount tripCount, long studyLogCount) { - return new MemberDetail(memberInfo, tripCount, studyLogCount); - } -} diff --git a/src/main/java/com/ject/studytrip/member/application/dto/MemberInfo.java b/src/main/java/com/ject/studytrip/member/application/dto/MemberInfo.java deleted file mode 100644 index 373ea57..0000000 --- a/src/main/java/com/ject/studytrip/member/application/dto/MemberInfo.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.ject.studytrip.member.application.dto; - -import com.ject.studytrip.global.util.DateUtil; -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; - -public record MemberInfo( - Long memberId, - SocialProvider socialProvider, - String socialId, - String email, - String nickname, - String profileImage, - MemberCategory category, - MemberRole role, - String createdAt, - String updatedAt, - String deletedAt) { - public static MemberInfo from(Member member) { - return new MemberInfo( - member.getId(), - member.getSocialProvider(), - member.getSocialId(), - member.getEmail(), - member.getNickname(), - member.getProfileImage(), - member.getCategory(), - member.getRole(), - DateUtil.formatDateTime(member.getCreatedAt()), - DateUtil.formatDateTime(member.getUpdatedAt()), - DateUtil.formatDateTime(member.getDeletedAt())); - } -} diff --git a/src/main/java/com/ject/studytrip/member/application/dto/PresignedProfileImageInfo.java b/src/main/java/com/ject/studytrip/member/application/dto/PresignedProfileImageInfo.java deleted file mode 100644 index 46d6538..0000000 --- a/src/main/java/com/ject/studytrip/member/application/dto/PresignedProfileImageInfo.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.ject.studytrip.member.application.dto; - -public record PresignedProfileImageInfo(Long memberId, String tmpKey, String presignedUrl) { - public static PresignedProfileImageInfo of(Long memberId, String tmpKey, String presignedUrl) { - return new PresignedProfileImageInfo(memberId, tmpKey, presignedUrl); - } -} diff --git a/src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java b/src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java deleted file mode 100644 index fedd1cc..0000000 --- a/src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java +++ /dev/null @@ -1,173 +0,0 @@ -package com.ject.studytrip.member.application.facade; - -import static com.ject.studytrip.global.common.constants.CacheNameConstants.*; - -import com.ject.studytrip.image.application.dto.PresignedImageInfo; -import com.ject.studytrip.image.application.service.ImageService; -import com.ject.studytrip.member.application.dto.MemberDetail; -import com.ject.studytrip.member.application.dto.MemberInfo; -import com.ject.studytrip.member.application.dto.PresignedProfileImageInfo; -import com.ject.studytrip.member.application.service.MemberCommandService; -import com.ject.studytrip.member.application.service.MemberQueryService; -import com.ject.studytrip.member.domain.model.Member; -import com.ject.studytrip.member.presentation.dto.request.ConfirmProfileImageRequest; -import com.ject.studytrip.member.presentation.dto.request.PresignProfileImageRequest; -import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest; -import com.ject.studytrip.mission.application.service.DailyMissionCommandService; -import com.ject.studytrip.mission.application.service.MissionCommandService; -import com.ject.studytrip.pomodoro.application.service.PomodoroCommandService; -import com.ject.studytrip.stamp.application.service.StampCommandService; -import com.ject.studytrip.studylog.application.service.StudyLogCommandService; -import com.ject.studytrip.studylog.application.service.StudyLogDailyMissionCommandService; -import com.ject.studytrip.studylog.application.service.StudyLogQueryService; -import com.ject.studytrip.trip.application.dto.TripCount; -import com.ject.studytrip.trip.application.service.*; -import java.util.ArrayList; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@Component -@RequiredArgsConstructor -public class MemberFacade { - private static final String MEMBER_PROFILE_IMAGE_KEY_PREFIX = "members"; - - private final MemberQueryService memberQueryService; - private final TripQueryService tripQueryService; - private final StudyLogQueryService studyLogQueryService; - private final TripReportQueryService tripReportQueryService; - - private final MemberCommandService memberCommandService; - private final TripCommandService tripCommandService; - private final StampCommandService stampCommandService; - private final MissionCommandService missionCommandService; - private final DailyGoalCommandService dailyGoalCommandService; - private final PomodoroCommandService pomodoroCommandService; - private final DailyMissionCommandService dailyMissionCommandService; - private final StudyLogCommandService studyLogCommandService; - private final StudyLogDailyMissionCommandService studyLogDailyMissionCommandService; - private final TripReportCommandService tripReportCommandService; - private final TripReportStudyLogCommandService tripReportStudyLogCommandService; - - private final ImageService imageService; - - @CacheEvict( - cacheNames = MEMBER, - key = "T(com.ject.studytrip.global.common.factory.CacheKeyFactory).member(#memberId)") - @Transactional - public void updateNicknameAndCategoryIfPresent(Long memberId, UpdateMemberRequest request) { - Member member = memberQueryService.getValidMember(memberId); - - memberCommandService.updateNicknameAndCategoryIfPresent(member, request); - } - - @CacheEvict( - cacheNames = MEMBER, - key = "T(com.ject.studytrip.global.common.factory.CacheKeyFactory).member(#memberId)") - @Transactional - public void deleteMember(Long memberId) { - Member member = memberQueryService.getValidMember(memberId); - - memberCommandService.deleteMember(member); - imageService.cleanup(member.getProfileImage()); - } - - @Cacheable( - cacheNames = MEMBER, - key = "T(com.ject.studytrip.global.common.factory.CacheKeyFactory).member(#memberId)") - @Transactional(readOnly = true) - public MemberDetail getMemberDetail(Long memberId) { - Member member = memberQueryService.getValidMember(memberId); - TripCount tripCount = tripQueryService.getActiveTripCountByMemberId(memberId); - long studyLogCount = studyLogQueryService.getActiveStudyLogCountByMemberId(memberId); - - MemberInfo memberInfo = MemberInfo.from(member); - - return MemberDetail.from(memberInfo, tripCount, studyLogCount); - } - - @Transactional(readOnly = true) - public PresignedProfileImageInfo issuePresignedUrl( - Long memberId, PresignProfileImageRequest request) { - Member member = memberQueryService.getValidMember(memberId); - PresignedImageInfo info = - imageService.presign( - MEMBER_PROFILE_IMAGE_KEY_PREFIX, - member.getId().toString(), - request.originFilename()); - - return PresignedProfileImageInfo.of(member.getId(), info.tmpKey(), info.presignedUrl()); - } - - @CacheEvict( - cacheNames = MEMBER, - key = "T(com.ject.studytrip.global.common.factory.CacheKeyFactory).member(#memberId)") - @Transactional - public void confirmImage(Long memberId, ConfirmProfileImageRequest request) { - Member member = memberQueryService.getValidMember(memberId); - String finalKey = imageService.confirm(request.tmpKey()); - - // 기존 이미지 삭제 (이미지가 존재하지 않아도 예외발생 X) - imageService.cleanup(member.getProfileImage()); - - // 새로운 이미지 업데이트 - memberCommandService.updateProfileImage(member, finalKey); - } - - @Transactional - public void hardDeleteMemberCascade(Long memberId) { - Member member = memberQueryService.getValidMember(memberId); - - // 삭제할 이미지 목록 - List imageUrls = collectImageUrlsForMember(member); - - // 멤버의 모든 데이터 즉시 삭제 - cascadeHardDeleteByMemberId(member.getId()); - - // 이미지 삭제 이벤트 발행 - // 트랜잭션 커밋 이후 이미지 삭제 처리 - imageService.publishCleanupBatchEvent(imageUrls); - } - - @Transactional - public void restoreMember(Long memberId) { - Member member = memberQueryService.getDeletedMember(memberId); - - memberCommandService.restoreMember(member); - } - - private List collectImageUrlsForMember(Member member) { - List imageUrls = new ArrayList<>(); - - // TripReport 이미지 목록 조회 - imageUrls.addAll(tripReportQueryService.getTripReportImageUrlsByMemberId(member.getId())); - - // StudyLog 이미지 목록 조회 - imageUrls.addAll(studyLogQueryService.getStudyLogImageUrlsByMemberId(member.getId())); - - if (member.getProfileImage() != null && !member.getProfileImage().isBlank()) { - imageUrls.add(member.getProfileImage()); - } - return imageUrls; - } - - private void cascadeHardDeleteByMemberId(Long memberId) { - // 자식 -> 부모 순으로 삭제 진행 - tripReportStudyLogCommandService.hardDeleteTripReportStudyLogsOwnedByMember(memberId); - tripReportCommandService.hardDeleteTripReportsOwnedByMember(memberId); - - studyLogDailyMissionCommandService.hardDeleteStudyLogDailyMissionsOwnedByMember(memberId); - pomodoroCommandService.hardDeletePomodorosOwnedByMember(memberId); - studyLogCommandService.hardDeleteStudyLogsOwnedByMember(memberId); - dailyMissionCommandService.hardDeleteDailyMissionsOwnedByMember(memberId); - dailyGoalCommandService.hardDeleteDailyGoalsOwnedByMember(memberId); - - missionCommandService.hardDeleteMissionsOwnedByMember(memberId); - stampCommandService.hardDeleteStampsOwnedByMember(memberId); - tripCommandService.hardDeleteTripsOwnedByMember(memberId); - memberCommandService.hardDeleteMemberById(memberId); - } -} diff --git a/src/main/java/com/ject/studytrip/member/application/service/MemberCommandService.java b/src/main/java/com/ject/studytrip/member/application/service/MemberCommandService.java deleted file mode 100644 index 5e20038..0000000 --- a/src/main/java/com/ject/studytrip/member/application/service/MemberCommandService.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.ject.studytrip.member.application.service; - -import static io.jsonwebtoken.lang.Strings.hasText; - -import com.ject.studytrip.member.application.dto.CreateMemberCommand; -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.SocialProvider; -import com.ject.studytrip.member.domain.policy.MemberPolicy; -import com.ject.studytrip.member.domain.repository.MemberCommandRepository; -import com.ject.studytrip.member.domain.repository.MemberRepository; -import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class MemberCommandService { - private final MemberRepository memberRepository; - private final MemberCommandRepository memberCommandRepository; - - public Member createMemberFromKakao(CreateMemberCommand command) { - validateMemberIsUnique(SocialProvider.KAKAO, command.socialId()); - - MemberCategory memberCategory = convertToMemberCategory(command.category()); - Member member = - MemberFactory.createFromKakao( - command.socialId(), - command.email(), - command.profileImage(), - command.nickname(), - memberCategory); - - return memberRepository.save(member); - } - - public void updateNicknameAndCategoryIfPresent(Member member, UpdateMemberRequest request) { - MemberCategory memberCategory = convertToMemberCategory(request.category()); - - member.update(request.nickname(), memberCategory); - } - - public void updateProfileImage(Member member, String profileImage) { - MemberPolicy.validateNotDeleted(member); - - member.updateProfileImage(profileImage); - } - - public void deleteMember(Member member) { - member.updateDeletedAt(); - } - - public void restoreMember(Member member) { - member.restoreDeletedAt(); - } - - public long hardDeleteMembers() { - return memberCommandRepository.deleteAllByDeletedAtIsNotNull(); - } - - public void hardDeleteMemberById(Long memberId) { - memberRepository.deleteById(memberId); - } - - private void validateMemberIsUnique(SocialProvider socialProvider, String socialId) { - boolean isMemberDuplicated = - memberRepository.existsBySocialProviderAndSocialId(socialProvider, socialId); - MemberPolicy.validateNotDuplicated(isMemberDuplicated); - } - - private MemberCategory convertToMemberCategory(String categoryName) { - return hasText(categoryName) ? MemberCategory.from(categoryName) : null; - } -} diff --git a/src/main/java/com/ject/studytrip/member/application/service/MemberQueryService.java b/src/main/java/com/ject/studytrip/member/application/service/MemberQueryService.java deleted file mode 100644 index 0abbff3..0000000 --- a/src/main/java/com/ject/studytrip/member/application/service/MemberQueryService.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.ject.studytrip.member.application.service; - -import com.ject.studytrip.global.exception.CustomException; -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.policy.MemberPolicy; -import com.ject.studytrip.member.domain.repository.MemberQueryRepository; -import com.ject.studytrip.member.domain.repository.MemberRepository; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class MemberQueryService { - private final MemberRepository memberRepository; - private final MemberQueryRepository memberQueryRepository; - - public Optional getMemberBySocialProviderAndSocialId( - SocialProvider socialProvider, String socialId) { - return memberRepository - .findBySocialProviderAndSocialId(socialProvider, socialId) - .map( - member -> { - MemberPolicy.validateNotDeleted(member); - return member; - }); - } - - public Member getMember(Long memberId) { - return memberRepository - .findById(memberId) - .orElseThrow(() -> new CustomException(MemberErrorCode.MEMBER_NOT_FOUND)); - } - - public Member getValidMember(Long memberId) { - return memberRepository - .findByIdAndDeletedAtIsNull(memberId) - .orElseThrow(() -> new CustomException(MemberErrorCode.MEMBER_NOT_FOUND)); - } - - public Member getDeletedMember(Long memberId) { - Member member = - memberRepository - .findById(memberId) - .orElseThrow(() -> new CustomException(MemberErrorCode.MEMBER_NOT_FOUND)); - - MemberPolicy.validateDeleted(member); - - return member; - } - - public String getRoleByMemberId(String memberId) { - MemberRole memberRole = - memberQueryRepository - .findMemberRoleById(Long.valueOf(memberId)) - .orElseThrow(() -> new CustomException(MemberErrorCode.MEMBER_NOT_FOUND)); - - return memberRole.name(); - } -} 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 deleted file mode 100644 index 0142b8b..0000000 --- a/src/main/java/com/ject/studytrip/member/domain/error/MemberErrorCode.java +++ /dev/null @@ -1,40 +0,0 @@ -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 { - // 400 - INVALID_MEMBER_CATEGORY(HttpStatus.BAD_REQUEST, "유효하지 않은 멤버 카테고리입니다."), - MEMBER_NICKNAME_DUPLICATED(HttpStatus.BAD_REQUEST, "이미 사용 중인 닉네임입니다."), - MEMBER_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "해당 멤버는 이미 삭제되었습니다."), - MEMBER_NOT_DELETED(HttpStatus.BAD_REQUEST, "해당 멤버는 삭제되지 않았습니다."), - - // 404 - MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "멤버를 찾을 수 없습니다."), - - // 409 - 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/factory/MemberFactory.java b/src/main/java/com/ject/studytrip/member/domain/factory/MemberFactory.java deleted file mode 100644 index b55b277..0000000 --- a/src/main/java/com/ject/studytrip/member/domain/factory/MemberFactory.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.ject.studytrip.member.domain.factory; - -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 lombok.AccessLevel; -import lombok.NoArgsConstructor; - -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public class MemberFactory { - public static Member createFromKakao( - 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/domain/policy/MemberPolicy.java b/src/main/java/com/ject/studytrip/member/domain/policy/MemberPolicy.java deleted file mode 100644 index 2b20663..0000000 --- a/src/main/java/com/ject/studytrip/member/domain/policy/MemberPolicy.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.ject.studytrip.member.domain.policy; - -import com.ject.studytrip.global.exception.CustomException; -import com.ject.studytrip.member.domain.error.MemberErrorCode; -import com.ject.studytrip.member.domain.model.Member; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; - -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public class MemberPolicy { - public static void validateNotDuplicated(boolean exists) { - if (exists) { - throw new CustomException(MemberErrorCode.MEMBER_ALREADY_EXISTS); - } - } - - public static void validateNotDeleted(Member member) { - if (member.getDeletedAt() != null) { - throw new CustomException(MemberErrorCode.MEMBER_ALREADY_DELETED); - } - } - - public static void validateDeleted(Member member) { - if (member.getDeletedAt() == null) { - throw new CustomException(MemberErrorCode.MEMBER_NOT_DELETED); - } - } -} diff --git a/src/main/java/com/ject/studytrip/member/domain/repository/MemberRepository.java b/src/main/java/com/ject/studytrip/member/domain/repository/MemberRepository.java deleted file mode 100644 index ce77777..0000000 --- a/src/main/java/com/ject/studytrip/member/domain/repository/MemberRepository.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.ject.studytrip.member.domain.repository; - -import com.ject.studytrip.member.domain.model.Member; -import com.ject.studytrip.member.domain.model.SocialProvider; -import java.util.Optional; - -public interface MemberRepository { - Optional findBySocialProviderAndSocialId( - SocialProvider socialProvider, String socialId); - - boolean existsBySocialProviderAndSocialId(SocialProvider socialProvider, String socialId); - - Optional findById(Long id); - - Member save(Member member); - - Optional findByIdAndDeletedAtIsNull(Long id); - - void deleteById(Long id); -} 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 deleted file mode 100644 index e075b0e..0000000 --- a/src/main/java/com/ject/studytrip/member/infra/jpa/MemberJpaRepository.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.ject.studytrip.member.infra.jpa; - -import com.ject.studytrip.member.domain.model.Member; -import com.ject.studytrip.member.domain.model.SocialProvider; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface MemberJpaRepository extends JpaRepository { - Optional findBySocialProviderAndSocialId( - SocialProvider socialProvider, String socialId); - - boolean existsBySocialProviderAndSocialId(SocialProvider socialProvider, String socialId); - - Optional findByIdAndDeletedAtIsNull(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 deleted file mode 100644 index 5602b9c..0000000 --- a/src/main/java/com/ject/studytrip/member/infra/jpa/MemberRepositoryAdapter.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.ject.studytrip.member.infra.jpa; - -import com.ject.studytrip.member.domain.model.Member; -import com.ject.studytrip.member.domain.model.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 boolean existsBySocialProviderAndSocialId( - SocialProvider socialProvider, String socialId) { - return memberJpaRepository.existsBySocialProviderAndSocialId(socialProvider, socialId); - } - - @Override - public Optional findById(Long id) { - return memberJpaRepository.findById(id); - } - - @Override - public Member save(Member member) { - return memberJpaRepository.save(member); - } - - @Override - 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/presentation/controller/MemberController.java b/src/main/java/com/ject/studytrip/member/presentation/controller/MemberController.java deleted file mode 100644 index 618c1c7..0000000 --- a/src/main/java/com/ject/studytrip/member/presentation/controller/MemberController.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.ject.studytrip.member.presentation.controller; - -import com.ject.studytrip.global.common.response.StandardResponse; -import com.ject.studytrip.member.application.dto.MemberDetail; -import com.ject.studytrip.member.application.dto.PresignedProfileImageInfo; -import com.ject.studytrip.member.application.facade.MemberFacade; -import com.ject.studytrip.member.presentation.dto.request.ConfirmProfileImageRequest; -import com.ject.studytrip.member.presentation.dto.request.PresignProfileImageRequest; -import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest; -import com.ject.studytrip.member.presentation.dto.response.LoadMemberDetailResponse; -import com.ject.studytrip.member.presentation.dto.response.PresignProfileImageResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import jakarta.validation.constraints.NotNull; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; - -@Tag(name = "Member", description = "멤버 API") -@RequestMapping("/api/members") -@RestController -@RequiredArgsConstructor -@Validated -public class MemberController { - private final MemberFacade memberFacade; - - @Operation(summary = "멤버 수정", description = "멤버의 이름 또는 카테고리를 수정합니다.") - @PatchMapping("/me") - public ResponseEntity updateMember( - @AuthenticationPrincipal String memberId, @RequestBody UpdateMemberRequest request) { - memberFacade.updateNicknameAndCategoryIfPresent(Long.valueOf(memberId), request); - - return ResponseEntity.status(HttpStatus.OK) - .body(StandardResponse.success(HttpStatus.OK.value(), null)); - } - - @Operation(summary = "멤버 삭제", description = "멤버를 삭제합니다. (회원탈퇴)") - @DeleteMapping("/me") - public ResponseEntity deleteMember(@AuthenticationPrincipal String memberId) { - memberFacade.deleteMember(Long.valueOf(memberId)); - - return ResponseEntity.status(HttpStatus.OK) - .body(StandardResponse.success(HttpStatus.OK.value(), null)); - } - - @Operation(summary = "멤버 상세 조회", description = "멤버를 상세 조회합니다.") - @GetMapping("/me") - public ResponseEntity loadMemberDetail( - @AuthenticationPrincipal String memberId) { - MemberDetail result = memberFacade.getMemberDetail(Long.valueOf(memberId)); - - return ResponseEntity.status(HttpStatus.OK) - .body( - StandardResponse.success( - HttpStatus.OK.value(), - LoadMemberDetailResponse.of( - result.memberInfo(), - result.tripCount(), - result.studyLogCount()))); - } - - @Operation( - summary = "멤버 프로필 이미지 업로드용 Presigned URL 발급", - description = - """ - 멤버 프로필 이미지를 S3 Storage에 업로드하기 위한 Presigned URL을 발급합니다. - - [흐름] - 1) 멤버 수정 화면에서 수정하기 버튼을 클릭합니다. - 2) 이때 만약 프로필 이미지를 변경했다면 해당 파일이름과 함께 본 API를 호출합니다.(예: abc.jpeg 형태) - 2-1) 프로필 이미지를 변경하지 않았으면 멤버 수정 API를 호출합니다. (닉네임 또는 카테고리를 수정했을 경우) - 3) 서버는 업로드에 사용할 Presigned PUT URL과 임시 키(tmpKey)를 반환합니다. - 4) 클라이언트는 반환된 Presigned URL로 이미지를 S3에 업로드합니다. - 5) 업로드가 정상 완료되면 즉시 프로필 이미지 Confirm API를 호출하여 이미지를 검증 및 확정하고 멤버 프로필에 적용합니다. - 6) 이후 다른 수정 사항이 있다면 멤버 수정 API를 호출합니다. (닉네임 또는 카테고리도 수정했을 경우) - - [주의] - - Presigned URL 유효시간은 짧습니다(예: 10분). 만료되면 재발급해야 합니다. - - 요청 값의 originFilename은 꼭 파일 확장자를 포함한 파일명으로 요청해야합니다. - """) - @PostMapping("/profile-images/presigned") - public ResponseEntity presigned( - @AuthenticationPrincipal String memberId, - @RequestBody @Valid PresignProfileImageRequest request) { - PresignedProfileImageInfo info = - memberFacade.issuePresignedUrl(Long.valueOf(memberId), request); - return ResponseEntity.ok() - .body( - StandardResponse.success( - HttpStatus.OK.value(), - PresignProfileImageResponse.of( - info.memberId(), info.tmpKey(), info.presignedUrl()))); - } - - @Operation( - summary = "업로드된 멤버 프로필 이미지 검증/확정", - description = - """ - Presigned URL을 통해 S3에 업로드된 프로필 이미지를 서버에서 검증하고 확정(Confirm)합니다. - - [흐름] - 1) 클라이언트는 발급받은 Presigned PUT URL로 이미지를 업로드합니다. - 2) 업로드 완료 후, Presigned URL 발급 API에서 응답받은 임시키(tmpKey)를 포함해 해당 API를 호출합니다. - 임시키(tmpKey)는 Presigned URL의 전체 경로 중, 버킷 호스트명을 제외한 S3 객체 경로(ObjectKey) 입니다. - (예: https://bucket.s3.ap-northeast-2.amazonaws.com/tmp/members/1/abc.jpg -> tmp/members/1/abc.jpg) - - 3) 서버는 이미지 존재 여부, 크기, MIME 타입 등을 검증한 뒤 최종 경로로 이동시키고 회원 프로필 이미지 정보를 갱신합니다. - 만약 S3 Storage 기술 자체 에러가 발생하면 임시 경로에 저장된 이미지를 즉시 삭제하지 않아 컨펌 재시도가 가능하지만, - 유효하지 않은 이미지 크기/확장자 등 도메인 정책을 위반해 실패할 경우 임시 경로에 저장된 이미지가 즉시 삭제되며 다시 업로드부터 수행해야합니다. - - S3 Storage 기술 자체 예외 예시 - { - "status": 502 (BAD_GATEWAY), - "message": "Storage 서버 에러가 발생했습니다." - } - - 이미지 도메인 정책 위반 예외 예시 - { - "status": 400 (BAD_REQUEST), - "message": "유효하지 않은 이미지 확장자 입니다." , "유효하지 않은 이미지 MIME 입니다." 등 - } - - [주의] - - 이미지 타입(MIME/Content-Type)은 JPG, JPEG, PNG, WEBP만 허용합니다. 그 외 타입은 도메인 정책 위반으로 예외가 발생합니다. - - 이미지 최대 크기는 5MB로 설정되어있으며, 크기가 0 이하이거나 최대 크기를 벗어날 경우 도메인 정책 위반으로 예외가 발생합니다. - - 업로드는 되었지만 그 이후 문제가 발생하더라고 tmp/ 경로의 객체는 라이프사이클 정책에 따라 자동 정리되기 때문에 따로 삭제 요청 API는 호출하지 않아도 됩니다. - """) - @PostMapping("/profile-images/confirm") - public ResponseEntity confirm( - @AuthenticationPrincipal String memberId, - @RequestBody @Valid ConfirmProfileImageRequest request) { - memberFacade.confirmImage(Long.valueOf(memberId), request); - return ResponseEntity.ok().body(StandardResponse.success(HttpStatus.OK.value(), null)); - } - - @Operation(summary = "멤버 즉시 삭제", description = "멤버를 즉시 삭제하고 관련된 모든 데이터를 삭제합니다. (CASCADE)") - @DeleteMapping("/me/hard-delete") - public ResponseEntity deleteMemberHardDelete( - @AuthenticationPrincipal String memberId) { - memberFacade.hardDeleteMemberCascade(Long.valueOf(memberId)); - - return ResponseEntity.ok().body(StandardResponse.success(HttpStatus.OK.value(), null)); - } - - @Operation(summary = "멤버 복구", description = "삭제된 멤버를 복구합니다.") - @PatchMapping("/me/restore/{memberId}") - public ResponseEntity restoreMember( - @PathVariable @NotNull(message = "멤버 ID는 필수 요청 파라미터입니다.") Long memberId) { - memberFacade.restoreMember(memberId); - - return ResponseEntity.ok().body(StandardResponse.success(HttpStatus.OK.value(), null)); - } -} diff --git a/src/main/java/com/ject/studytrip/member/presentation/dto/request/ConfirmProfileImageRequest.java b/src/main/java/com/ject/studytrip/member/presentation/dto/request/ConfirmProfileImageRequest.java deleted file mode 100644 index 0a1f8b8..0000000 --- a/src/main/java/com/ject/studytrip/member/presentation/dto/request/ConfirmProfileImageRequest.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.ject.studytrip.member.presentation.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotEmpty; - -public record ConfirmProfileImageRequest( - @Schema(description = "업로드된 이미지 임시키") @NotEmpty(message = "업로드된 이미지 임시키는 필수 요청 값입니다.") - String tmpKey) {} diff --git a/src/main/java/com/ject/studytrip/member/presentation/dto/request/PresignProfileImageRequest.java b/src/main/java/com/ject/studytrip/member/presentation/dto/request/PresignProfileImageRequest.java deleted file mode 100644 index 48b4d62..0000000 --- a/src/main/java/com/ject/studytrip/member/presentation/dto/request/PresignProfileImageRequest.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.ject.studytrip.member.presentation.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotEmpty; - -public record PresignProfileImageRequest( - @Schema(description = "업로드할 원본 이미지 파일명") @NotEmpty(message = "원본 이미지 파일명은 필수 요청 값입니다.") - String originFilename) {} diff --git a/src/main/java/com/ject/studytrip/member/presentation/dto/request/UpdateMemberRequest.java b/src/main/java/com/ject/studytrip/member/presentation/dto/request/UpdateMemberRequest.java deleted file mode 100644 index 0c0fdfc..0000000 --- a/src/main/java/com/ject/studytrip/member/presentation/dto/request/UpdateMemberRequest.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.ject.studytrip.member.presentation.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Pattern; - -public record UpdateMemberRequest( - @Schema(description = "수정할 멤버 닉네임") - @Pattern( - regexp = "^[a-zA-Z0-9가-힣]{2,10}$", - message = "닉네임은 특수문자를 제외하고 2~10자 이내로 입력해주세요.") - String nickname, - @Schema(description = "수정할 멤버 카테고리") - @Pattern( - regexp = "^(STUDENT|WORKER|FREELANCER|JOBSEEKER)$", - message = "멤버 카테고리는 STUDENT, WORKER, FREELANCER, JOBSEEKER 중 하나여야 합니다.") - String category) {} diff --git a/src/main/java/com/ject/studytrip/member/presentation/dto/response/LoadMemberDetailResponse.java b/src/main/java/com/ject/studytrip/member/presentation/dto/response/LoadMemberDetailResponse.java deleted file mode 100644 index 8490a0a..0000000 --- a/src/main/java/com/ject/studytrip/member/presentation/dto/response/LoadMemberDetailResponse.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.ject.studytrip.member.presentation.dto.response; - -import com.ject.studytrip.member.application.dto.MemberInfo; -import com.ject.studytrip.member.domain.model.MemberCategory; -import com.ject.studytrip.trip.application.dto.TripCount; -import io.swagger.v3.oas.annotations.media.Schema; - -public record LoadMemberDetailResponse( - @Schema(description = "멤버 ID") Long memberId, - @Schema(description = "이메일") String email, - @Schema(description = "닉네임") String nickname, - @Schema(description = "프로필 이미지") String profileImage, - @Schema(description = "멤버 카테고리") MemberCategory category, - @Schema(description = "코스형 여행 개수") long courseTripCount, - @Schema(description = "탐험형 여행 개수") long exploreTripCount, - @Schema(description = "학습 기록 개수") long studyLogCount) { - public static LoadMemberDetailResponse of( - MemberInfo memberInfo, TripCount tripCount, long studyLogCount) { - return new LoadMemberDetailResponse( - memberInfo.memberId(), - memberInfo.email(), - memberInfo.nickname(), - memberInfo.profileImage(), - memberInfo.category(), - tripCount.getCourse(), - tripCount.getExplore(), - studyLogCount); - } -} diff --git a/src/main/java/com/ject/studytrip/member/presentation/dto/response/PresignProfileImageResponse.java b/src/main/java/com/ject/studytrip/member/presentation/dto/response/PresignProfileImageResponse.java deleted file mode 100644 index 8e8dc04..0000000 --- a/src/main/java/com/ject/studytrip/member/presentation/dto/response/PresignProfileImageResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.ject.studytrip.member.presentation.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; - -public record PresignProfileImageResponse( - @Schema(description = "멤버 ID") Long memberId, - @Schema(description = "멤버 프로필 이미지 임시키") String tmpKey, - @Schema(description = "멤버 프로필 이미지 업로드용 Presigned URL") String presignedUrl) { - public static PresignProfileImageResponse of( - Long memberId, String tmpKey, String presignedUrl) { - return new PresignProfileImageResponse(memberId, tmpKey, presignedUrl); - } -} diff --git a/src/main/kotlin/com/ject/studytrip/auth/application/dto/KakaoSignupProfile.kt b/src/main/kotlin/com/ject/studytrip/auth/application/dto/KakaoSignupProfile.kt new file mode 100644 index 0000000..88056dd --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/application/dto/KakaoSignupProfile.kt @@ -0,0 +1,8 @@ +package com.ject.studytrip.auth.application.dto + +data class KakaoSignupProfile( + val socialId: String, + val socialProvider: String, + val email: String, + val profileImageUrl: String?, +) diff --git a/src/main/kotlin/com/ject/studytrip/auth/application/dto/OAuthLoginOutcome.kt b/src/main/kotlin/com/ject/studytrip/auth/application/dto/OAuthLoginOutcome.kt new file mode 100644 index 0000000..ead8532 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/application/dto/OAuthLoginOutcome.kt @@ -0,0 +1,23 @@ +package com.ject.studytrip.auth.application.dto + +sealed class OAuthLoginOutcome { + data class Success( + val tokenInfo: TokenInfo, + ) : OAuthLoginOutcome() + + data class SignupRequired( + val signupKey: String, + ) : OAuthLoginOutcome() + + companion object { + @JvmStatic + fun success( + accessToken: String, + refreshToken: String, + refreshTokenExpiresIn: Long, + ): OAuthLoginOutcome = Success(TokenInfo(accessToken, refreshToken, refreshTokenExpiresIn)) + + @JvmStatic + fun signupRequired(signupKey: String): OAuthLoginOutcome = SignupRequired(signupKey) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/auth/application/dto/TokenInfo.kt b/src/main/kotlin/com/ject/studytrip/auth/application/dto/TokenInfo.kt new file mode 100644 index 0000000..7b6b50d --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/application/dto/TokenInfo.kt @@ -0,0 +1,7 @@ +package com.ject.studytrip.auth.application.dto + +data class TokenInfo( + val accessToken: String, + val refreshToken: String, + val refreshTokenExpiresIn: Long, +) diff --git a/src/main/kotlin/com/ject/studytrip/auth/application/facade/AuthFacade.kt b/src/main/kotlin/com/ject/studytrip/auth/application/facade/AuthFacade.kt new file mode 100644 index 0000000..ef42c95 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/application/facade/AuthFacade.kt @@ -0,0 +1,80 @@ +package com.ject.studytrip.auth.application.facade + +import com.ject.studytrip.auth.application.dto.OAuthLoginOutcome +import com.ject.studytrip.auth.application.dto.TokenInfo +import com.ject.studytrip.auth.application.service.KakaoLoginService +import com.ject.studytrip.auth.application.service.KakaoSignupProfileService +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.member.application.dto.CreateMemberCommand +import com.ject.studytrip.member.application.service.MemberCommandService +import com.ject.studytrip.member.application.service.MemberQueryService +import com.ject.studytrip.member.domain.model.SocialProvider +import org.springframework.stereotype.Component + +@Component +class AuthFacade( + private val kakaoSignupProfileService: KakaoSignupProfileService, + private val kakaoLoginService: KakaoLoginService, + private val tokenService: TokenService, + private val memberQueryService: MemberQueryService, + private val memberCommandService: MemberCommandService, +) { + fun kakaoSignup( + signupKey: String?, + request: KakaoSignupRequest, + ): TokenInfo { + val profile = kakaoSignupProfileService.getSignupProfileByKey(signupKey) + val command = CreateMemberCommand.of(profile.socialId, profile.email, profile.profileImageUrl, request.nickname, request.category) + + val member = memberCommandService.createMemberFromKakao(command) + kakaoSignupProfileService.deleteBySignupKey(signupKey) + + return tokenService.getTokens(member.id.toString(), member.role.name) + } + + fun kakaoLogin( + request: KakaoLoginRequest, + origin: String, + ): OAuthLoginOutcome { + val info = kakaoLoginService.getKakaoUserInfo(request.code, origin) + + return memberQueryService + .getMemberBySocialProviderAndSocialId(SocialProvider.KAKAO, info.kakaoId) + .orElse(null) + ?.let { member -> createLoginOutcomeWithIssuedTokens(member.id, member.role.name) } // 가입되어 있는 사용자인 경우 토큰 발급 + ?: createSignupRequiredOutcomeWithIssuedSignupKey(info) // 가입이 필요할 경우 가입 키 발급 + } + + fun reissueToken(refreshToken: String?): TokenInfo { + val memberId = tokenService.getMemberIdByRefreshToken(refreshToken) + val memberRole = memberQueryService.getMemberRoleByMemberId(memberId.toLong()) + + return tokenService.reissueToken(refreshToken, memberId, memberRole.name) + } + + fun logout( + request: LogoutRequest, + refreshToken: String?, + ) = tokenService.logout(request.accessToken, refreshToken) + + private fun createLoginOutcomeWithIssuedTokens( + memberId: Long, + roleName: String, + ): OAuthLoginOutcome { + // 토큰 발급 + val tokens = tokenService.getTokens(memberId.toString(), roleName) + + return OAuthLoginOutcome.success(tokens.accessToken, tokens.refreshToken, tokens.refreshTokenExpiresIn) + } + + private fun createSignupRequiredOutcomeWithIssuedSignupKey(info: KakaoUserInfoResponse): OAuthLoginOutcome { + // 카카오 가입 프로필 임시 저장 및 키 발급 + val signupKey = kakaoSignupProfileService.saveAndIssueSignupKey(info.kakaoId, info.email, info.profileImage) + + return OAuthLoginOutcome.signupRequired(signupKey) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/auth/application/service/KakaoLoginService.kt b/src/main/kotlin/com/ject/studytrip/auth/application/service/KakaoLoginService.kt new file mode 100644 index 0000000..5d18d18 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/application/service/KakaoLoginService.kt @@ -0,0 +1,19 @@ +package com.ject.studytrip.auth.application.service + +import com.ject.studytrip.auth.infra.dto.KakaoUserInfoResponse +import com.ject.studytrip.auth.infra.provider.KakaoOauthProvider +import org.springframework.stereotype.Service + +@Service +class KakaoLoginService( + private val kakaoOauthProvider: KakaoOauthProvider, +) { + fun getKakaoUserInfo( + code: String, + origin: String, + ): KakaoUserInfoResponse { + val response = kakaoOauthProvider.getKakaoTokens(code, origin) + + return kakaoOauthProvider.getKakaoUserInfo(response.accessToken) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/auth/application/service/KakaoSignupProfileService.kt b/src/main/kotlin/com/ject/studytrip/auth/application/service/KakaoSignupProfileService.kt new file mode 100644 index 0000000..186fa31 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/application/service/KakaoSignupProfileService.kt @@ -0,0 +1,34 @@ +package com.ject.studytrip.auth.application.service + +import com.ject.studytrip.auth.application.dto.KakaoSignupProfile +import com.ject.studytrip.auth.domain.error.AuthErrorCode +import com.ject.studytrip.auth.domain.repository.KakaoSignupProfileRedisRepository +import com.ject.studytrip.global.exception.CustomException +import org.springframework.stereotype.Service + +@Service +class KakaoSignupProfileService( + private val kakaoSignupProfileRedisRepository: KakaoSignupProfileRedisRepository, +) { + fun saveAndIssueSignupKey( + socialId: String, + email: String, + profileImageUrl: String, + ): String = kakaoSignupProfileRedisRepository.saveAndIssueSignupKey(socialId, email, profileImageUrl) + + fun getSignupProfileByKey(signupKey: String?): KakaoSignupProfile { + if (signupKey.isNullOrBlank()) { + throw CustomException(AuthErrorCode.MISSING_KAKAO_SIGNUP_KEY) + } + + return kakaoSignupProfileRedisRepository + .findBySignupKey(signupKey) + .orElseThrow { CustomException(AuthErrorCode.INVALID_KAKAO_SIGNUP_KEY) } + } + + fun deleteBySignupKey(signupKey: String?) { + if (signupKey.isNullOrBlank()) return + + kakaoSignupProfileRedisRepository.deleteBySignupKey(signupKey) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/auth/application/service/TokenService.kt b/src/main/kotlin/com/ject/studytrip/auth/application/service/TokenService.kt new file mode 100644 index 0000000..7334cd7 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/application/service/TokenService.kt @@ -0,0 +1,95 @@ +package com.ject.studytrip.auth.application.service + +import com.ject.studytrip.auth.application.dto.TokenInfo +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.global.exception.CustomException +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 +class TokenService( + private val tokenProvider: TokenProvider, + private val logoutTokenRedisRepository: LogoutTokenRedisRepository, + private val refreshTokenRedisRepository: RefreshTokenRedisRepository, +) { + fun getTokens( + memberId: String, + role: String, + ): TokenInfo { + val accessToken = tokenProvider.createAccessToken(memberId, role) + val refreshToken = tokenProvider.createRefreshToken() + val refreshTokenExpirationTime = tokenProvider.getRefreshTokenExpirationTime() + + refreshTokenRedisRepository.saveRefreshToken(memberId, refreshToken, refreshTokenExpirationTime) + + return TokenInfo(accessToken, refreshToken, refreshTokenExpirationTime) + } + + fun reissueToken( + refreshToken: String?, + memberId: String, + role: String, + ): TokenInfo { + val newAccessToken = tokenProvider.createAccessToken(memberId, role) + val newRefreshToken = tokenProvider.createRefreshToken() + val refreshTokenExpirationTime = tokenProvider.getRefreshTokenExpirationTime() + + refreshTokenRedisRepository.deleteRefreshToken(refreshToken) + refreshTokenRedisRepository.saveRefreshToken(memberId, newRefreshToken, refreshTokenExpirationTime) + + return TokenInfo(newAccessToken, newRefreshToken, refreshTokenExpirationTime) + } + + fun logout( + accessToken: String, + refreshToken: String?, + ) { + validateRefreshToken(refreshToken) + + val accessTokenRemainingTime = tokenProvider.getAccessTokenRemainingTime(accessToken) + + logoutTokenRedisRepository.saveAccessToken(accessToken, accessTokenRemainingTime) + refreshTokenRedisRepository.deleteRefreshToken(refreshToken) + } + + fun getMemberIdByRefreshToken(refreshToken: String?): String { + validateRefreshToken(refreshToken) + + return refreshTokenRedisRepository + .findMemberIdByRefreshToken(refreshToken) + ?: throw CustomException(AuthErrorCode.INVALID_REFRESH_TOKEN) + } + + fun setAuthenticationByAccessToken(accessToken: String) { + val memberId = tokenProvider.extractMemberIdFromToken(accessToken) + val role = tokenProvider.extractRoleFromToken(accessToken) + val authorities = listOf(SimpleGrantedAuthority(role)) + val authentication = UsernamePasswordAuthenticationToken(memberId, null, authorities) + SecurityContextHolder.getContext().authentication = authentication + } + + fun validateActiveAccessToken(accessToken: String) { + if (!tokenProvider.validateAccessToken(accessToken)) { + throw CustomException(AuthErrorCode.INVALID_JWT_TOKEN) + } + + if (logoutTokenRedisRepository.existsAccessToken(accessToken)) { + throw CustomException(AuthErrorCode.TOKEN_IS_BLACKLISTED) + } + } + + private fun validateRefreshToken(refreshToken: String?) { + if (refreshToken.isNullOrBlank()) { + throw CustomException(AuthErrorCode.MISSING_REFRESH_TOKEN) + } + + if (!refreshTokenRedisRepository.existsRefreshToken(refreshToken)) { + throw CustomException(AuthErrorCode.INVALID_REFRESH_TOKEN) + } + } +} diff --git a/src/main/java/com/ject/studytrip/auth/domain/error/AuthErrorCode.java b/src/main/kotlin/com/ject/studytrip/auth/domain/error/AuthErrorCode.kt similarity index 60% rename from src/main/java/com/ject/studytrip/auth/domain/error/AuthErrorCode.java rename to src/main/kotlin/com/ject/studytrip/auth/domain/error/AuthErrorCode.kt index 29b7a50..9f5b7ba 100644 --- a/src/main/java/com/ject/studytrip/auth/domain/error/AuthErrorCode.java +++ b/src/main/kotlin/com/ject/studytrip/auth/domain/error/AuthErrorCode.kt @@ -1,46 +1,37 @@ -package com.ject.studytrip.auth.domain.error; +package com.ject.studytrip.auth.domain.error -import com.ject.studytrip.global.exception.error.ErrorCode; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; +import com.ject.studytrip.global.exception.error.ErrorCode +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, "잘못된 카카오 액세스 토큰입니다."), +enum class AuthErrorCode( + private val status: HttpStatus, + private val message: String, +) : ErrorCode { + // 400 INVALID_KAKAO_AUTHORIZATION_CODE(HttpStatus.BAD_REQUEST, "잘못된 카카오 인가 코드입니다."), - KAKAO_SERVER_ERROR(HttpStatus.BAD_GATEWAY, "카카오 서버에서 오류가 발생했습니다."), + INVALID_KAKAO_SIGNUP_KEY(HttpStatus.BAD_REQUEST, "요청한 카카오 가입 키(signupKey)의 정보가 존재하지 않거나 만료되었습니다."), MISSING_KAKAO_SIGNUP_KEY(HttpStatus.BAD_REQUEST, "카카오 가입 키(signupKey)가 누락되었습니다."), - INVALID_KAKAO_SIGNUP_KEY( - HttpStatus.BAD_REQUEST, "요청한 카카오 가입 키(signupKey)의 정보가 존재하지 않거나 만료되었습니다."), + MISSING_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "리프레시 토큰이 누락되었습니다."), - // 인증 관련 예외 + // 401 + UNAUTHENTICATED(HttpStatus.UNAUTHORIZED, "인증되지 않은 요청입니다."), + TOKEN_IS_BLACKLISTED(HttpStatus.UNAUTHORIZED, "블랙리스트된 엑세스 토큰입니다."), + INVALID_KAKAO_TOKEN(HttpStatus.UNAUTHORIZED, "잘못된 카카오 액세스 토큰입니다."), INVALID_JWT_TOKEN(HttpStatus.UNAUTHORIZED, "잘못된 JWT 토큰입니다."), - MISSING_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "리프레시 토큰이 누락되었습니다."), INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "잘못된 리프레시 토큰입니다."), - TOKEN_IS_BLACKLISTED(HttpStatus.UNAUTHORIZED, "블랙리스트된 엑세스 토큰입니다."), - ; + KAKAO_TOKEN_FETCH_FAILED(HttpStatus.UNAUTHORIZED, "카카오 토큰을 가져오는 데 실패했습니다."), + KAKAO_USER_INFO_FETCH_FAILED(HttpStatus.UNAUTHORIZED, "카카오 사용자 정보를 가져오는 데 실패했습니다."), + + // 403 + ACCESS_DENIED(HttpStatus.FORBIDDEN, "접근 권한이 부족합니다."), - private final HttpStatus status; - private final String message; + // 502 + KAKAO_SERVER_ERROR(HttpStatus.BAD_GATEWAY, "카카오 서버에서 오류가 발생했습니다."), + ; - @Override - public String getName() { - return this.name(); - } + override fun getName(): String = name - @Override - public HttpStatus getStatus() { - return this.status; - } + override fun getStatus(): HttpStatus = status - @Override - public String getMessage() { - return this.message; - } + override fun getMessage(): String = message } diff --git a/src/main/kotlin/com/ject/studytrip/auth/domain/repository/KakaoSignupProfileRedisRepository.kt b/src/main/kotlin/com/ject/studytrip/auth/domain/repository/KakaoSignupProfileRedisRepository.kt new file mode 100644 index 0000000..2cdde5a --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/domain/repository/KakaoSignupProfileRedisRepository.kt @@ -0,0 +1,16 @@ +package com.ject.studytrip.auth.domain.repository + +import com.ject.studytrip.auth.application.dto.KakaoSignupProfile +import java.util.Optional + +interface KakaoSignupProfileRedisRepository { + fun saveAndIssueSignupKey( + socialId: String, + email: String, + profileImageUrl: String, + ): String + + fun findBySignupKey(signupKey: String): Optional + + fun deleteBySignupKey(signupKey: String) +} diff --git a/src/main/kotlin/com/ject/studytrip/auth/domain/repository/LogoutTokenRedisRepository.kt b/src/main/kotlin/com/ject/studytrip/auth/domain/repository/LogoutTokenRedisRepository.kt new file mode 100644 index 0000000..dc7f20b --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/domain/repository/LogoutTokenRedisRepository.kt @@ -0,0 +1,10 @@ +package com.ject.studytrip.auth.domain.repository + +interface LogoutTokenRedisRepository { + fun saveAccessToken( + accessToken: String, + accessTokenExpirationTime: Long, + ) + + fun existsAccessToken(accessToken: String): Boolean +} diff --git a/src/main/kotlin/com/ject/studytrip/auth/domain/repository/RefreshTokenRedisRepository.kt b/src/main/kotlin/com/ject/studytrip/auth/domain/repository/RefreshTokenRedisRepository.kt new file mode 100644 index 0000000..55c40e7 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/domain/repository/RefreshTokenRedisRepository.kt @@ -0,0 +1,15 @@ +package com.ject.studytrip.auth.domain.repository + +interface RefreshTokenRedisRepository { + fun saveRefreshToken( + memberId: String, + refreshToken: String, + refreshTokenExpireTime: Long, + ) + + fun existsRefreshToken(refreshToken: String?): Boolean + + fun deleteRefreshToken(refreshToken: String?) + + fun findMemberIdByRefreshToken(refreshToken: String?): String? +} diff --git a/src/main/kotlin/com/ject/studytrip/auth/infra/client/KakaoOauthClient.kt b/src/main/kotlin/com/ject/studytrip/auth/infra/client/KakaoOauthClient.kt new file mode 100644 index 0000000..9d6bc16 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/infra/client/KakaoOauthClient.kt @@ -0,0 +1,52 @@ +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 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 org.springframework.web.reactive.function.client.bodyToMono +import reactor.core.publisher.Mono +import java.util.function.Function + +@Component +class KakaoOauthClient( + private val webClient: WebClient, +) { + fun fetchKakaoTokens( + tokenUri: String, + formData: BodyInserters.FormInserter, + ): Mono = + 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() + + fun fetchKakaoUserInfo( + userInfoUri: String, + accessToken: String, + ): Mono = + 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() + + private fun handleError(errorCode: ErrorCode): Function> = + Function { + Mono.error(CustomException(errorCode)) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/auth/infra/dto/KakaoAccount.kt b/src/main/kotlin/com/ject/studytrip/auth/infra/dto/KakaoAccount.kt new file mode 100644 index 0000000..c5af4ba --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/infra/dto/KakaoAccount.kt @@ -0,0 +1,13 @@ +package com.ject.studytrip.auth.infra.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema + +data class KakaoAccount( + @field:Schema(description = "카카오 프로필") + @field:JsonProperty("profile") + val profile: KakaoProfile, + @field:Schema(description = "카카오 이메일") + @field:JsonProperty("email") + val email: String, +) diff --git a/src/main/kotlin/com/ject/studytrip/auth/infra/dto/KakaoProfile.kt b/src/main/kotlin/com/ject/studytrip/auth/infra/dto/KakaoProfile.kt new file mode 100644 index 0000000..f4a4edc --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/infra/dto/KakaoProfile.kt @@ -0,0 +1,10 @@ +package com.ject.studytrip.auth.infra.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema + +data class KakaoProfile( + @field:Schema(description = "카카오 프로필 이미지") + @field:JsonProperty("profile_image_url") + val profileImage: String, +) diff --git a/src/main/kotlin/com/ject/studytrip/auth/infra/dto/KakaoTokenResponse.kt b/src/main/kotlin/com/ject/studytrip/auth/infra/dto/KakaoTokenResponse.kt new file mode 100644 index 0000000..b227de2 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/infra/dto/KakaoTokenResponse.kt @@ -0,0 +1,25 @@ +package com.ject.studytrip.auth.infra.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema + +data class KakaoTokenResponse( + @field:Schema(description = "카카오 토큰 타입") + @field:JsonProperty("token_type") + val tokenType: String, + @field:Schema(description = "카카오 엑세스 토큰") + @field:JsonProperty("access_token") + val accessToken: String, + @field:Schema(description = "카카오 엑세스 토큰 만료 시간") + @field:JsonProperty("expires_in") + val accessExpiresIn: Int, + @field:Schema(description = "카카오 리프레시 토큰") + @field:JsonProperty("refresh_token") + val refreshToken: String, + @field:Schema(description = "카카오 리프레시 토큰 만료 시간") + @field:JsonProperty("refresh_token_expires_in") + val refreshExpiresIn: Int, + @field:Schema(description = "카카오 스코프") + @field:JsonProperty("scope") + val scope: String, +) diff --git a/src/main/kotlin/com/ject/studytrip/auth/infra/dto/KakaoUserInfoResponse.kt b/src/main/kotlin/com/ject/studytrip/auth/infra/dto/KakaoUserInfoResponse.kt new file mode 100644 index 0000000..dcff8fd --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/infra/dto/KakaoUserInfoResponse.kt @@ -0,0 +1,19 @@ +package com.ject.studytrip.auth.infra.dto + +import com.fasterxml.jackson.annotation.JsonProperty +import io.swagger.v3.oas.annotations.media.Schema + +data class KakaoUserInfoResponse( + @field:Schema(description = "카카오 ID") + @field:JsonProperty("id") + val kakaoId: String, + @field:Schema(description = "카카오 계정") + @field:JsonProperty("kakao_account") + val account: KakaoAccount, +) { + val profileImage: String + get() = account.profile.profileImage + + val email: String + get() = account.email +} diff --git a/src/main/kotlin/com/ject/studytrip/auth/infra/provider/KakaoOauthProvider.kt b/src/main/kotlin/com/ject/studytrip/auth/infra/provider/KakaoOauthProvider.kt new file mode 100644 index 0000000..68e9e82 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/infra/provider/KakaoOauthProvider.kt @@ -0,0 +1,64 @@ +package com.ject.studytrip.auth.infra.provider + +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 org.springframework.stereotype.Component +import org.springframework.util.LinkedMultiValueMap +import org.springframework.util.StringUtils.hasText +import org.springframework.web.reactive.function.BodyInserters + +@Component +class KakaoOauthProvider( + private val kakaoOauthClient: KakaoOauthClient, + private val kakaoOauthProperties: KakaoOauthProperties, +) { + fun getKakaoTokens( + code: String, + origin: String, + ): KakaoTokenResponse { + validateKakaoAuthorizationCode(code) + + return kakaoOauthClient + .fetchKakaoTokens(kakaoOauthProperties.tokenUri, createFormData(code, origin)) + .block() + ?: throw CustomException(AuthErrorCode.INVALID_KAKAO_AUTHORIZATION_CODE) + } + + fun getKakaoUserInfo(accessToken: String): KakaoUserInfoResponse { + validateKakaoToken(accessToken) + + return kakaoOauthClient + .fetchKakaoUserInfo(kakaoOauthProperties.userInfoUri, accessToken) + .block() + ?: throw CustomException(AuthErrorCode.INVALID_KAKAO_TOKEN) + } + + private fun createFormData( + code: String, + origin: String, + ) = BodyInserters.fromFormData( + LinkedMultiValueMap().apply { + add("grant_type", "authorization_code") + add("client_id", kakaoOauthProperties.clientId) + add("client_secret", kakaoOauthProperties.clientSecret) + add("redirect_uri", origin + kakaoOauthProperties.redirectUri) + add("code", code) + }, + ) + + private fun validateKakaoAuthorizationCode(code: String) { + if (!hasText(code)) { + throw CustomException(AuthErrorCode.INVALID_KAKAO_AUTHORIZATION_CODE) + } + } + + private fun validateKakaoToken(accessToken: String) { + if (!hasText(accessToken)) { + throw CustomException(AuthErrorCode.INVALID_KAKAO_TOKEN) + } + } +} diff --git a/src/main/kotlin/com/ject/studytrip/auth/infra/provider/TokenProvider.kt b/src/main/kotlin/com/ject/studytrip/auth/infra/provider/TokenProvider.kt new file mode 100644 index 0000000..96a31ae --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/infra/provider/TokenProvider.kt @@ -0,0 +1,74 @@ +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.Claims +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import org.springframework.stereotype.Component +import java.time.Instant +import java.util.Date +import java.util.UUID +import javax.crypto.SecretKey + +@Component +class TokenProvider( + private val tokenProperties: TokenProperties, +) { + fun createAccessToken( + memberId: String, + role: String, + ): String = createToken(memberId, role, tokenProperties.accessExpirationTime) + + fun createRefreshToken(): String = UUID.randomUUID().toString() + + fun extractMemberIdFromToken(token: String): String = parseClaims(token).subject + + fun extractRoleFromToken(token: String): String = parseClaims(token)["role"] as String + + fun validateAccessToken(accessToken: String): Boolean = runCatching { parseClaims(accessToken) }.isSuccess + + fun getAccessTokenRemainingTime(accessToken: String): Long { + val expirationTime = parseClaims(accessToken).expiration.time + val remaining = expirationTime - System.currentTimeMillis() + + return maxOf(remaining, 0) + } + + fun getRefreshTokenExpirationTime(): Long = tokenProperties.refreshExpirationTime + + private fun createToken( + memberId: String, + role: String, + expirationSeconds: Long, + ): String { + val now = Instant.now() + val expiry = now.plusSeconds(expirationSeconds) + + return Jwts + .builder() + .subject(memberId) + .claim("role", role) + .issuedAt(Date.from(now)) + .expiration(Date.from(expiry)) + .signWith(secretKey, Jwts.SIG.HS256) + .compact() + } + + private fun parseClaims(token: String): Claims = + runCatching { + Jwts + .parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .payload + }.getOrElse { + throw CustomException(AuthErrorCode.INVALID_JWT_TOKEN) + } + + private val secretKey: SecretKey by lazy { + Keys.hmacShaKeyFor(tokenProperties.secret.toByteArray()) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/auth/infra/repository/redis/KakaoSignupProfileRedisRepositoryAdapter.kt b/src/main/kotlin/com/ject/studytrip/auth/infra/repository/redis/KakaoSignupProfileRedisRepositoryAdapter.kt new file mode 100644 index 0000000..e4ec992 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/infra/repository/redis/KakaoSignupProfileRedisRepositoryAdapter.kt @@ -0,0 +1,49 @@ +package com.ject.studytrip.auth.infra.repository.redis + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ject.studytrip.auth.application.dto.KakaoSignupProfile +import com.ject.studytrip.auth.domain.repository.KakaoSignupProfileRedisRepository +import com.ject.studytrip.global.common.constants.CacheKeyConstants.OAUTH_SIGNUP_PROFILE_PREFIX +import com.ject.studytrip.member.domain.model.SocialProvider +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.stereotype.Repository +import java.util.Optional +import java.util.UUID +import java.util.concurrent.TimeUnit + +@Repository +class KakaoSignupProfileRedisRepositoryAdapter( + private val redisTemplate: RedisTemplate, + private val objectMapper: ObjectMapper, +) : KakaoSignupProfileRedisRepository { + companion object { + private const val KAKAO_SIGNUP_PROFILE_TTL_MILLIS = 900000L + } + + override fun saveAndIssueSignupKey( + socialId: String, + email: String, + profileImageUrl: String, + ): String { + val socialProvider = SocialProvider.KAKAO.name.lowercase() + val key = issueKey(socialProvider) + val signupProfile = KakaoSignupProfile(socialId, socialProvider, email, profileImageUrl) + + redisTemplate.opsForValue().set(key, signupProfile, KAKAO_SIGNUP_PROFILE_TTL_MILLIS, TimeUnit.MILLISECONDS) + + return key + } + + override fun findBySignupKey(signupKey: String): Optional { + val value = redisTemplate.opsForValue().get(signupKey) ?: return Optional.empty() + val converted = objectMapper.convertValue(value, KakaoSignupProfile::class.java) + + return Optional.of(converted) + } + + override fun deleteBySignupKey(signupKey: String) { + redisTemplate.delete(signupKey) + } + + private fun issueKey(socialProvider: String): String = OAUTH_SIGNUP_PROFILE_PREFIX.format(socialProvider) + UUID.randomUUID() +} diff --git a/src/main/kotlin/com/ject/studytrip/auth/infra/repository/redis/LogoutTokenRedisRepositoryAdapter.kt b/src/main/kotlin/com/ject/studytrip/auth/infra/repository/redis/LogoutTokenRedisRepositoryAdapter.kt new file mode 100644 index 0000000..733d226 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/infra/repository/redis/LogoutTokenRedisRepositoryAdapter.kt @@ -0,0 +1,27 @@ +package com.ject.studytrip.auth.infra.repository.redis + +import com.ject.studytrip.auth.domain.repository.LogoutTokenRedisRepository +import com.ject.studytrip.global.common.constants.CacheKeyConstants.AUTH_LOGOUT_TOKEN_PREFIX +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.stereotype.Repository +import java.util.concurrent.TimeUnit + +@Repository +class LogoutTokenRedisRepositoryAdapter( + private val redisTemplate: RedisTemplate, +) : LogoutTokenRedisRepository { + companion object { + private const val LOGOUT_MARKER = "LOGOUT" + } + + override fun saveAccessToken( + accessToken: String, + accessTokenExpirationTime: Long, + ) { + redisTemplate.opsForValue().set(buildKey(accessToken), LOGOUT_MARKER, accessTokenExpirationTime, TimeUnit.MILLISECONDS) + } + + override fun existsAccessToken(accessToken: String): Boolean = redisTemplate.hasKey(buildKey(accessToken)) == true + + private fun buildKey(accessToken: String): String = AUTH_LOGOUT_TOKEN_PREFIX + accessToken +} diff --git a/src/main/kotlin/com/ject/studytrip/auth/infra/repository/redis/RefreshTokenRedisRepositoryAdapter.kt b/src/main/kotlin/com/ject/studytrip/auth/infra/repository/redis/RefreshTokenRedisRepositoryAdapter.kt new file mode 100644 index 0000000..9cea638 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/infra/repository/redis/RefreshTokenRedisRepositoryAdapter.kt @@ -0,0 +1,30 @@ +package com.ject.studytrip.auth.infra.repository.redis + +import com.ject.studytrip.auth.domain.repository.RefreshTokenRedisRepository +import com.ject.studytrip.global.common.constants.CacheKeyConstants.AUTH_REISSUE_TOKEN_PREFIX +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.stereotype.Repository +import java.util.concurrent.TimeUnit + +@Repository +class RefreshTokenRedisRepositoryAdapter( + private val redisTemplate: RedisTemplate, +) : RefreshTokenRedisRepository { + override fun saveRefreshToken( + memberId: String, + refreshToken: String, + refreshTokenExpireTime: Long, + ) { + redisTemplate.opsForValue().set(buildKey(refreshToken), memberId, refreshTokenExpireTime, TimeUnit.MILLISECONDS) + } + + override fun existsRefreshToken(refreshToken: String?): Boolean = redisTemplate.hasKey(buildKey(refreshToken)) == true + + override fun deleteRefreshToken(refreshToken: String?) { + redisTemplate.delete(buildKey(refreshToken)) + } + + override fun findMemberIdByRefreshToken(refreshToken: String?): String? = redisTemplate.opsForValue().get(buildKey(refreshToken)) + + private fun buildKey(refreshToken: String?): String = AUTH_REISSUE_TOKEN_PREFIX + refreshToken +} diff --git a/src/main/kotlin/com/ject/studytrip/auth/presentation/controller/AuthController.kt b/src/main/kotlin/com/ject/studytrip/auth/presentation/controller/AuthController.kt new file mode 100644 index 0000000..d09cf46 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/presentation/controller/AuthController.kt @@ -0,0 +1,134 @@ +package com.ject.studytrip.auth.presentation.controller + +import com.ject.studytrip.auth.application.dto.OAuthLoginOutcome +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.response.LoginResponse +import com.ject.studytrip.auth.presentation.dto.response.ReissueTokenResponse +import com.ject.studytrip.auth.presentation.helper.AuthCookieHelper +import com.ject.studytrip.global.common.constants.CookieConstants.AUTH_REFRESH_TOKEN +import com.ject.studytrip.global.common.constants.CookieConstants.OAUTH_SIGNUP_KEY +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 org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.CookieValue +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestAttribute +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 +@RequestMapping("/api/auth") +@Validated +class AuthController( + private val authFacade: AuthFacade, +) { + @Operation( + summary = "카카오 회원가입", + description = """ + 닉네임, 카테고리를 입력받고, OAuth 가입키 쿠키로 회원가입을 수행합니다. + 회원가입에 성공하면 로그인처리되며, 엑세스 토큰(Response Body)과 리프레시 토큰(HttpOnly Secure 쿠키 - 'auth_refresh')을 반환합니다. + """, + ) + @PostMapping("/signup/kakao") + fun kakaoSignup( + @CookieValue(name = OAUTH_SIGNUP_KEY, required = false) pendingKey: String?, + @RequestBody @Valid request: KakaoSignupRequest, + ): ResponseEntity { + val response = authFacade.kakaoSignup(pendingKey, request) + + // 리프레시 토큰 쿠키 생성 + val refreshCookie = AuthCookieHelper.setRefreshTokenCookie(response.refreshToken, response.refreshTokenExpiresIn) + + // 카카오 가입 쿠키 삭제 + val clearSignupCookie = AuthCookieHelper.clearCookie(OAUTH_SIGNUP_KEY) + + return ResponseEntity + .status(HttpStatus.OK) + .header(HttpHeaders.SET_COOKIE, refreshCookie.toString()) + .header(HttpHeaders.SET_COOKIE, clearSignupCookie.toString()) + .body(StandardResponse.success(HttpStatus.OK.value(), LoginResponse.success(response.accessToken))) + } + + @Operation( + summary = "카카오 로그인", + description = """ + 카카오 인가 코드를 이용하여 로그인을 수행합니다. + 가입된 회원이라면 엑세스 토큰(Response Body)과 리프레시 토큰(HttpOnly Secure 쿠키 - 'auth_refresh')을 반환합니다. + 가입되지 않은 회원이라면 OAuth 가입키('oauth_signup_key')를 HttpOnly Secure 쿠키에 담아 응답합니다. + """, + ) + @PostMapping("/login/kakao") + fun kakaoLogin( + @RequestAttribute(value = "origin") origin: String, + @RequestBody @Valid request: KakaoLoginRequest, + ): ResponseEntity = + when (val response = authFacade.kakaoLogin(request, origin)) { + is OAuthLoginOutcome.SignupRequired -> { + val cookie = AuthCookieHelper.setOAuthSignupProfileCookie(response.signupKey) + + ResponseEntity + .status(HttpStatus.OK) + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .body(StandardResponse.success(HttpStatus.OK.value(), LoginResponse.requiredSignup())) + } + + is OAuthLoginOutcome.Success -> { + val tokenInfo = response.tokenInfo + val cookie = AuthCookieHelper.setRefreshTokenCookie(tokenInfo.refreshToken, tokenInfo.refreshTokenExpiresIn) + + ResponseEntity + .status(HttpStatus.OK) + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .body(StandardResponse.success(HttpStatus.OK.value(), LoginResponse.success(tokenInfo.accessToken))) + } + } + + @Operation( + summary = "토큰 재발급", + description = """ + 리프레시 토큰을 이용하여, 엑세스 토큰과 리프레시 토큰을 재발급합니다. + 리프레시 토큰은 'auth_refresh' HttpOnly Secure 쿠키에 담아 응답합니다. + """, + ) + @PostMapping("/token/reissue") + fun reissueToken( + @CookieValue(name = AUTH_REFRESH_TOKEN, required = false) refreshToken: String?, + ): ResponseEntity { + val response = authFacade.reissueToken(refreshToken) + + // 리프레시 토큰 덮어쓰기 + val refreshCookie = AuthCookieHelper.setRefreshTokenCookie(response.refreshToken, response.refreshTokenExpiresIn) + + return ResponseEntity + .status(HttpStatus.OK) + .header(HttpHeaders.SET_COOKIE, refreshCookie.toString()) + .body(StandardResponse.success(HttpStatus.OK.value(), ReissueTokenResponse(response.accessToken))) + } + + @Operation(summary = "로그아웃", description = "엑세스 토큰과 리프레시 토큰을 이용하여, 엑세스 토큰을 블랙리스트에 추가하고, Redis에 저장된 리프레시 토큰을 제거합니다.") + @PostMapping("/logout") + fun logout( + @CookieValue(name = AUTH_REFRESH_TOKEN, required = false) refreshToken: String?, + @RequestBody @Valid request: LogoutRequest, + ): ResponseEntity { + authFacade.logout(request, refreshToken) + + // 기존 리프레시 쿠키 삭제 + val clearCookie = AuthCookieHelper.clearCookie(AUTH_REFRESH_TOKEN) + + return ResponseEntity + .status(HttpStatus.OK) + .header(HttpHeaders.SET_COOKIE, clearCookie.toString()) + .body(StandardResponse.success(HttpStatus.OK.value(), null)) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/auth/presentation/dto/request/KakaoLoginRequest.kt b/src/main/kotlin/com/ject/studytrip/auth/presentation/dto/request/KakaoLoginRequest.kt new file mode 100644 index 0000000..f60a2a7 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/presentation/dto/request/KakaoLoginRequest.kt @@ -0,0 +1,10 @@ +package com.ject.studytrip.auth.presentation.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank + +data class KakaoLoginRequest( + @field:Schema(description = "카카오 인가 코드") + @field:NotBlank(message = "카카오 인가 코드를 입력해 주세요.") + val code: String, +) diff --git a/src/main/kotlin/com/ject/studytrip/auth/presentation/dto/request/KakaoSignupRequest.kt b/src/main/kotlin/com/ject/studytrip/auth/presentation/dto/request/KakaoSignupRequest.kt new file mode 100644 index 0000000..4755287 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/presentation/dto/request/KakaoSignupRequest.kt @@ -0,0 +1,22 @@ +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 + +data class KakaoSignupRequest( + @field:Schema(description = "멤버 카테고리") + @field:NotBlank(message = "멤버 카테고리를 입력해 주세요.") + @field:Pattern( + regexp = "^(STUDENT|WORKER|FREELANCER|JOBSEEKER)$", + message = "멤버 카테고리는 STUDENT, WORKER, FREELANCER, JOBSEEKER 중 하나여야 합니다.", + ) + val category: String, + @field:Schema(description = "멤버 닉네임") + @field:NotBlank(message = "멤버 닉네임을 입력해 주세요.") + @field:Pattern( + regexp = "^[a-zA-Z0-9가-힣]{2,10}$", + message = "닉네임은 특수문자를 제외하고 2~10자 이내로 입력해주세요.", + ) + val nickname: String, +) diff --git a/src/main/kotlin/com/ject/studytrip/auth/presentation/dto/request/LogoutRequest.kt b/src/main/kotlin/com/ject/studytrip/auth/presentation/dto/request/LogoutRequest.kt new file mode 100644 index 0000000..00f5343 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/presentation/dto/request/LogoutRequest.kt @@ -0,0 +1,10 @@ +package com.ject.studytrip.auth.presentation.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank + +data class LogoutRequest( + @field:Schema(description = "엑세스 토큰") + @field:NotBlank(message = "엑세스 토큰을 입력해 주세요.") + val accessToken: String, +) diff --git a/src/main/kotlin/com/ject/studytrip/auth/presentation/dto/response/LoginResponse.kt b/src/main/kotlin/com/ject/studytrip/auth/presentation/dto/response/LoginResponse.kt new file mode 100644 index 0000000..9ea2b80 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/presentation/dto/response/LoginResponse.kt @@ -0,0 +1,18 @@ +package com.ject.studytrip.auth.presentation.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class LoginResponse( + @field:Schema(description = "회원가입 필요 여부") + val signupRequired: Boolean, + @field:Schema(description = "엑세스 토큰 (가입된 회원인 경우)") + val accessToken: String?, +) { + companion object { + @JvmStatic + fun success(accessToken: String): LoginResponse = LoginResponse(false, accessToken) + + @JvmStatic + fun requiredSignup(): LoginResponse = LoginResponse(true, null) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/auth/presentation/dto/response/ReissueTokenResponse.kt b/src/main/kotlin/com/ject/studytrip/auth/presentation/dto/response/ReissueTokenResponse.kt new file mode 100644 index 0000000..41145ec --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/presentation/dto/response/ReissueTokenResponse.kt @@ -0,0 +1,8 @@ +package com.ject.studytrip.auth.presentation.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class ReissueTokenResponse( + @field:Schema(description = "새로 발급된 엑세스 토큰") + val accessToken: String, +) diff --git a/src/main/kotlin/com/ject/studytrip/auth/presentation/helper/AuthCookieHelper.kt b/src/main/kotlin/com/ject/studytrip/auth/presentation/helper/AuthCookieHelper.kt new file mode 100644 index 0000000..ce22fed --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/auth/presentation/helper/AuthCookieHelper.kt @@ -0,0 +1,41 @@ +package com.ject.studytrip.auth.presentation.helper + +import com.ject.studytrip.global.common.constants.CookieConstants.AUTH_REFRESH_TOKEN +import com.ject.studytrip.global.common.constants.CookieConstants.OAUTH_SIGNUP_COOKIE_TTL_MILLIS +import com.ject.studytrip.global.common.constants.CookieConstants.OAUTH_SIGNUP_KEY +import org.springframework.http.ResponseCookie +import java.time.Duration + +object AuthCookieHelper { + fun setOAuthSignupProfileCookie(value: String): ResponseCookie = + setResponseCookie(OAUTH_SIGNUP_KEY, value, Duration.ofMillis(OAUTH_SIGNUP_COOKIE_TTL_MILLIS)) + + fun setRefreshTokenCookie( + value: String, + maxAgeInSeconds: Long, + ): ResponseCookie = setResponseCookie(AUTH_REFRESH_TOKEN, value, Duration.ofSeconds(maxAgeInSeconds)) + + fun clearCookie(name: String): ResponseCookie = + ResponseCookie + .from(name, "") + .httpOnly(true) + .secure(true) + .sameSite("None") + .path("/") + .maxAge(Duration.ZERO) + .build() + + private fun setResponseCookie( + name: String, + value: String, + maxAge: Duration, + ): ResponseCookie = + ResponseCookie + .from(name, value) + .httpOnly(true) + .secure(true) + .sameSite("None") + .maxAge(maxAge) + .path("/") + .build() +} diff --git a/src/main/kotlin/com/ject/studytrip/cleanup/application/executor/HardDeleteExecutor.kt b/src/main/kotlin/com/ject/studytrip/cleanup/application/executor/HardDeleteExecutor.kt new file mode 100644 index 0000000..530fb13 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/cleanup/application/executor/HardDeleteExecutor.kt @@ -0,0 +1,24 @@ +package com.ject.studytrip.cleanup.application.executor + +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component + +@Component +class HardDeleteExecutor { + companion object { + private val log = LoggerFactory.getLogger(HardDeleteExecutor::class.java) + } + + fun run( + phase: String, + supplier: () -> Long, + ): Long = + try { + val deleted = supplier() + log.info("[HardDelete] phase={}, deleted={}", phase, deleted) + deleted + } catch (e: Exception) { + log.error("[HardDelete] phase={} failed: {}", phase, e.message, e) + -1L // 실패 표식 + } +} diff --git a/src/main/kotlin/com/ject/studytrip/cleanup/application/facade/HardDeleteFacade.kt b/src/main/kotlin/com/ject/studytrip/cleanup/application/facade/HardDeleteFacade.kt new file mode 100644 index 0000000..5cfde3e --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/cleanup/application/facade/HardDeleteFacade.kt @@ -0,0 +1,214 @@ +package com.ject.studytrip.cleanup.application.facade + +import com.ject.studytrip.cleanup.application.executor.HardDeleteExecutor +import com.ject.studytrip.member.application.service.MemberCommandService +import com.ject.studytrip.mission.application.service.DailyMissionCommandService +import com.ject.studytrip.mission.application.service.MissionCommandService +import com.ject.studytrip.pomodoro.application.service.PomodoroCommandService +import com.ject.studytrip.stamp.application.service.StampCommandService +import com.ject.studytrip.studylog.application.service.StudyLogCommandService +import com.ject.studytrip.studylog.application.service.StudyLogDailyMissionCommandService +import com.ject.studytrip.trip.application.service.DailyGoalCommandService +import com.ject.studytrip.trip.application.service.TripCommandService +import com.ject.studytrip.trip.application.service.TripReportCommandService +import com.ject.studytrip.trip.application.service.TripReportStudyLogCommandService +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import java.util.LinkedHashMap + +@Component +class HardDeleteFacade( + private val memberCommandService: MemberCommandService, + private val tripCommandService: TripCommandService, + private val stampCommandService: StampCommandService, + private val missionCommandService: MissionCommandService, + private val studyLogCommandService: StudyLogCommandService, + private val dailyMissionCommandService: DailyMissionCommandService, + private val studyLogDailyMissionCommandService: StudyLogDailyMissionCommandService, + private val dailyGoalCommandService: DailyGoalCommandService, + private val pomodoroCommandService: PomodoroCommandService, + private val tripReportCommandService: TripReportCommandService, + private val tripReportStudyLogCommandService: TripReportStudyLogCommandService, + private val executor: HardDeleteExecutor, +) { + companion object { + private const val POMODOROS_OWNED_BY_DELETED_DAILY_GOAL = "pomodorosOwnedByDeletedDailyGoal" + private const val STUDY_LOG_DAILY_MISSIONS_OWNED_BY_DELETED_DAILY_MISSION = "studyLogDailyMissionsOwnedByDeletedDailyMission" + private const val STUDY_LOG_DAILY_MISSIONS_OWNED_BY_DELETED_STUDY_LOG = "studyLogDailyMissionsOwnedByDeletedStudyLog" + private const val STUDY_LOGS_OWNED_BY_DELETED_MEMBER = "studyLogsOwnedByDeletedMember" + private const val STUDY_LOGS_OWNED_BY_DELETED_DAILY_GOAL = "studyLogsOwnedByDeletedDailyGoal" + private const val DAILY_MISSIONS_OWNED_BY_DELETED_MISSION = "dailyMissionsOwnedByDeletedMission" + private const val DAILY_MISSIONS_OWNED_BY_DELETED_DAILY_GOAL = "dailyMissionsOwnedByDeletedDailyGoal" + private const val TRIP_REPORT_STUDY_LOGS_OWNED_BY_DELETED_MEMBER = "tripReportStudyLogsOwnedByDeletedMember" + private const val TRIP_REPORTS_OWNED_BY_DELETED_MEMBER = "tripReportsOwnedByDeletedMember" + + private const val POMODOROS = "pomodoros" + private const val STUDY_LOG_DAILY_MISSIONS = "studyLogDailyMissions" + private const val STUDY_LOGS = "studyLogs" + private const val DAILY_MISSIONS = "dailyMissions" + private const val MISSIONS_OWNED_BY_DELETED_STAMP = "missionsOwnedByDeletedStamp" + private const val MISSIONS = "missions" + private const val STAMPS_OWNED_BY_DELETED_TRIP = "stampsOwnedByDeletedTrip" + private const val STAMPS = "stamps" + private const val TRIPS_OWNED_BY_DELETED_MEMBER = "tripsOwnedByDeletedMember" + private const val TRIPS = "trips" + private const val DAILY_GOALS_OWNED_BY_DELETED_TRIP = "dailyGoalsOwnedByDeletedTrip" + private const val DAILY_GOALS = "dailyGoals" + private const val TRIP_REPORTS = "tripReports" + private const val MEMBERS = "members" + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + fun hardDeleteAll() { + val phases = LinkedHashMap() + + deletePomodoros(phases) // 뽀모도로 삭제 + deleteStudyLogDailyMissions(phases) // StudyLogDailyMission 삭제 + deleteDailyMissions(phases) // 데일리 미션 삭제 + deleteTripReportStudyLogs(phases) // TripReportStudyLog 삭제 + deleteTripReports(phases) // 여행 리포트 삭제 + deleteStudyLogs(phases) // 학습 로그 삭제 + deleteDailyGoals(phases) // 데일리 목표 삭제 + deleteMissions(phases) // 미션 삭제 + deleteStamps(phases) // 스탬프 삭제 + deleteTrips(phases) // 여행 삭제 + deleteMembers(phases) // 멤버 삭제 + } + + private fun deletePomodoros(phases: MutableMap) { + phases[POMODOROS_OWNED_BY_DELETED_DAILY_GOAL] = + executor.run(POMODOROS_OWNED_BY_DELETED_DAILY_GOAL) { + pomodoroCommandService.hardDeletePomodorosOwnedByDeletedDailyGoal() + } + phases[POMODOROS] = + executor.run(POMODOROS) { + pomodoroCommandService.hardDeletePomodoros() + } + } + + private fun deleteStudyLogDailyMissions(phases: MutableMap) { + phases[STUDY_LOG_DAILY_MISSIONS_OWNED_BY_DELETED_DAILY_MISSION] = + executor.run(STUDY_LOG_DAILY_MISSIONS_OWNED_BY_DELETED_DAILY_MISSION) { + studyLogDailyMissionCommandService.hardDeleteStudyLogDailyMissionsOwnedByDeletedDailyMission() + } + + phases[STUDY_LOG_DAILY_MISSIONS_OWNED_BY_DELETED_STUDY_LOG] = + executor.run(STUDY_LOG_DAILY_MISSIONS_OWNED_BY_DELETED_STUDY_LOG) { + studyLogDailyMissionCommandService.hardDeleteStudyLogDailyMissionsOwnedByDeletedStudyLog() + } + + phases[STUDY_LOG_DAILY_MISSIONS] = + executor.run(STUDY_LOG_DAILY_MISSIONS) { + studyLogDailyMissionCommandService.hardDeleteStudyLogDailyMissions() + } + } + + private fun deleteDailyMissions(phases: MutableMap) { + phases[DAILY_MISSIONS_OWNED_BY_DELETED_MISSION] = + executor.run(DAILY_MISSIONS_OWNED_BY_DELETED_MISSION) { + dailyMissionCommandService.hardDeleteDailyMissionsOwnedByDeletedMission() + } + + phases[DAILY_MISSIONS_OWNED_BY_DELETED_DAILY_GOAL] = + executor.run(DAILY_MISSIONS_OWNED_BY_DELETED_DAILY_GOAL) { + dailyMissionCommandService.hardDeleteDailyMissionsOwnedByDeletedDailyGoal() + } + + phases[DAILY_MISSIONS] = + executor.run(DAILY_MISSIONS) { + dailyMissionCommandService.hardDeleteDailyMissions() + } + } + + private fun deleteTripReportStudyLogs(phases: MutableMap) { + phases[TRIP_REPORT_STUDY_LOGS_OWNED_BY_DELETED_MEMBER] = + executor.run(TRIP_REPORT_STUDY_LOGS_OWNED_BY_DELETED_MEMBER) { + tripReportStudyLogCommandService.hardDeleteTripReportStudyLogsOwnedByDeletedMember() + } + } + + private fun deleteTripReports(phases: MutableMap) { + phases[TRIP_REPORTS_OWNED_BY_DELETED_MEMBER] = + executor.run(TRIP_REPORTS_OWNED_BY_DELETED_MEMBER) { + tripReportCommandService.hardDeleteTripReportsOwnedByDeletedMember() + } + + phases[TRIP_REPORTS] = + executor.run(TRIP_REPORTS) { + tripReportCommandService.hardDeleteTripReports() + } + } + + private fun deleteStudyLogs(phases: MutableMap) { + phases[STUDY_LOGS_OWNED_BY_DELETED_MEMBER] = + executor.run(STUDY_LOGS_OWNED_BY_DELETED_MEMBER) { + studyLogCommandService.hardDeleteStudyLogsOwnedByDeletedMember() + } + + phases[STUDY_LOGS_OWNED_BY_DELETED_DAILY_GOAL] = + executor.run(STUDY_LOGS_OWNED_BY_DELETED_DAILY_GOAL) { + studyLogCommandService.hardDeleteStudyLogsOwnedByDeletedDailyGoal() + } + + phases[STUDY_LOGS] = + executor.run(STUDY_LOGS) { + studyLogCommandService.hardDeleteStudyLogs() + } + } + + private fun deleteDailyGoals(phases: MutableMap) { + phases[DAILY_GOALS_OWNED_BY_DELETED_TRIP] = + executor.run(DAILY_GOALS_OWNED_BY_DELETED_TRIP) { + dailyGoalCommandService.hardDeleteDailyGoalsOwnedByDeletedTrip() + } + + phases[DAILY_GOALS] = + executor.run(DAILY_GOALS) { + dailyGoalCommandService.hardDeleteDailyGoals() + } + } + + private fun deleteMissions(phases: MutableMap) { + phases[MISSIONS_OWNED_BY_DELETED_STAMP] = + executor.run(MISSIONS_OWNED_BY_DELETED_STAMP) { + missionCommandService.hardDeleteMissionsOwnedByDeletedStamp() + } + + phases[MISSIONS] = + executor.run(MISSIONS) { + missionCommandService.hardDeleteMissions() + } + } + + private fun deleteStamps(phases: MutableMap) { + phases[STAMPS_OWNED_BY_DELETED_TRIP] = + executor.run(STAMPS_OWNED_BY_DELETED_TRIP) { + stampCommandService.hardDeleteStampsOwnedByDeletedTrip() + } + + phases[STAMPS] = + executor.run(STAMPS) { + stampCommandService.hardDeleteStamps() + } + } + + private fun deleteTrips(phases: MutableMap) { + phases[TRIPS_OWNED_BY_DELETED_MEMBER] = + executor.run(TRIPS_OWNED_BY_DELETED_MEMBER) { + tripCommandService.hardDeleteTripsOwnedByDeletedMember() + } + + phases[TRIPS] = + executor.run(TRIPS) { + tripCommandService.hardDeleteTrips() + } + } + + private fun deleteMembers(phases: MutableMap) { + phases[MEMBERS] = + executor.run(MEMBERS) { + memberCommandService.hardDeleteMembers() + } + } +} diff --git a/src/main/kotlin/com/ject/studytrip/image/application/dto/CleanupImagesResult.kt b/src/main/kotlin/com/ject/studytrip/image/application/dto/CleanupImagesResult.kt new file mode 100644 index 0000000..ce75018 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/image/application/dto/CleanupImagesResult.kt @@ -0,0 +1,6 @@ +package com.ject.studytrip.image.application.dto + +data class CleanupImagesResult( + val success: Int, + val failedKeys: List, +) diff --git a/src/main/kotlin/com/ject/studytrip/image/application/dto/PresignedImageInfo.kt b/src/main/kotlin/com/ject/studytrip/image/application/dto/PresignedImageInfo.kt new file mode 100644 index 0000000..d65a196 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/image/application/dto/PresignedImageInfo.kt @@ -0,0 +1,6 @@ +package com.ject.studytrip.image.application.dto + +data class PresignedImageInfo( + val tmpKey: String, + val presignedUrl: String, +) diff --git a/src/main/kotlin/com/ject/studytrip/image/application/event/ImageCleanupBatchEvent.kt b/src/main/kotlin/com/ject/studytrip/image/application/event/ImageCleanupBatchEvent.kt new file mode 100644 index 0000000..17e358f --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/image/application/event/ImageCleanupBatchEvent.kt @@ -0,0 +1,5 @@ +package com.ject.studytrip.image.application.event + +data class ImageCleanupBatchEvent( + val imageUrls: List, +) diff --git a/src/main/kotlin/com/ject/studytrip/image/application/event/ImageEventListener.kt b/src/main/kotlin/com/ject/studytrip/image/application/event/ImageEventListener.kt new file mode 100644 index 0000000..c60c292 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/image/application/event/ImageEventListener.kt @@ -0,0 +1,14 @@ +package com.ject.studytrip.image.application.event + +import com.ject.studytrip.image.application.service.ImageService +import org.springframework.stereotype.Component +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener + +@Component +class ImageEventListener( + private val imageService: ImageService, +) { + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun handleCleanupBatch(event: ImageCleanupBatchEvent) = imageService.cleanupBatch(event.imageUrls) +} diff --git a/src/main/kotlin/com/ject/studytrip/image/application/event/ImageEventPublisher.kt b/src/main/kotlin/com/ject/studytrip/image/application/event/ImageEventPublisher.kt new file mode 100644 index 0000000..32394a0 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/image/application/event/ImageEventPublisher.kt @@ -0,0 +1,15 @@ +package com.ject.studytrip.image.application.event + +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Component + +@Component +class ImageEventPublisher( + private val publisher: ApplicationEventPublisher, +) { + fun publishCleanupBatch(imageUrls: List?) { + if (imageUrls.isNullOrEmpty()) return + + publisher.publishEvent(ImageCleanupBatchEvent(imageUrls)) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/image/application/service/ImageService.kt b/src/main/kotlin/com/ject/studytrip/image/application/service/ImageService.kt new file mode 100644 index 0000000..790e778 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/image/application/service/ImageService.kt @@ -0,0 +1,175 @@ +package com.ject.studytrip.image.application.service + +import com.ject.studytrip.global.config.properties.CdnProperties +import com.ject.studytrip.global.exception.CustomException +import com.ject.studytrip.global.util.FilenameUtil +import com.ject.studytrip.image.application.dto.CleanupImagesResult +import com.ject.studytrip.image.application.dto.PresignedImageInfo +import com.ject.studytrip.image.application.event.ImageEventPublisher +import com.ject.studytrip.image.domain.constants.ImageConstants +import com.ject.studytrip.image.domain.factory.ImageKeyFactory +import com.ject.studytrip.image.domain.policy.ImagePolicy +import com.ject.studytrip.image.domain.util.ImageUrlUtil +import com.ject.studytrip.image.infra.s3.provider.S3ImageStorageProvider +import com.ject.studytrip.image.infra.tika.provider.TikaImageProbeProvider +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import kotlin.math.min + +@Service +class ImageService( + private val s3Provider: S3ImageStorageProvider, + private val tikaProvider: TikaImageProbeProvider, + private val publishers: ImageEventPublisher, + private val cdnProperties: CdnProperties, +) { + companion object { + private const val MAX_BATCH = 1000 + private val log = LoggerFactory.getLogger(ImageService::class.java) + } + + // Presigned URL 발급 + fun presign( + keyPrefix: String?, + id: String, + originFilename: String, + ): PresignedImageInfo { + // 키 prefix 검증 + ImagePolicy.validateKeyPrefix(keyPrefix) + + // 확장자 추출, 검증 + val ext = FilenameUtil.extractExtension(originFilename) + ImagePolicy.validateExtension(ext) + + // 새로운 파일명 생성 + val filename = FilenameUtil.createNewFilename(ext) + + // 임시 키 생성 + val tmpKey = ImageKeyFactory.createTmpKey(keyPrefix, id, filename) + + // Presigned URL 생성 + val presignedUrl = s3Provider.issuePresignedUrl(tmpKey) + + return PresignedImageInfo(tmpKey, presignedUrl) + } + + // 업로드된 이미지 확정 + // S3 자체 에러 시에는 cleanup 실행 X + // 이미지 파일 크기, MIME 등 도메인 정책 검증에 실패하면 cleanup 실행 + fun confirm(tmpKey: String): String { + // 임시 이미지 키 검증 + ImagePolicy.validateKey(tmpKey) + + // 업로드된 이미지 HEAD 조회 + val head = s3Provider.getHeadByKey(tmpKey) + + // 이미지 크기 검증, 검증 실패 시 이미지 삭제 + validateSizeWithCleanup(tmpKey, head.contentLength) + + // MIME 추출 및 판별, 검증 실패 시 이미지 삭제 + validateMimeWithCleanup(tmpKey, head.contentLength) + + // 임시 -> 최종 이미지 복사 및 경로 반환 + return moveToFinalLocation(tmpKey) + } + + // 업로드 취소 + fun cancel(uploadKeys: List) { + s3Provider.deleteByKeys(uploadKeys) + } + + // 이미지 삭제 + fun cleanup(imageUrl: String?) { + ImageUrlUtil.extractKey(cdnProperties.domain, imageUrl)?.let { s3Provider.deleteByKey(it) } + } + + // 이미지 배치 삭제 + fun cleanupBatch(imageUrls: List) { + val keys = extractKeysFromUrls(imageUrls) + if (keys.isEmpty()) return + + var attempted = 0 + var succeeded = 0 + val failed = mutableListOf() + + keys.chunked(MAX_BATCH).forEach { batch -> + attempted += batch.size + val result: CleanupImagesResult = s3Provider.deleteByKeys(batch) + succeeded += result.success + + if (result.failedKeys.isNotEmpty()) { + failed += result.failedKeys + } + } + + log.info("Image Cleanup Batch attempted={}, succeeded={}, failed={}", attempted, succeeded, failed.size) + + if (failed.isNotEmpty()) { + log.debug("Image Cleanup Batch Failed. failedCount={}, failedKeys={}", failed.size, failed) + } + } + + fun publishCleanupBatchEvent(imageUrls: List?) { + publishers.publishCleanupBatch(imageUrls) + } + + // 이미지 사이즈 검증, 실패 시 삭제 + private fun validateSizeWithCleanup( + tmpKey: String?, + contentLength: Long, + ) { + try { + ImagePolicy.validateSize(contentLength) + } catch (e: CustomException) { + cleanupAndThrow(tmpKey, e) + } + } + + // 이미지 MIME 추출 및 검증, 실패 시 삭제 + private fun validateMimeWithCleanup( + tmpKey: String?, + len: Long, + ) { + val maxBytes = min(len, ImageConstants.PROBE_BYTES.toLong()).toInt() + val prefix = s3Provider.readPrefix(tmpKey, maxBytes) + val mime = tikaProvider.detectMime(prefix) + + try { + ImagePolicy.validateMime(mime) + } catch (e: CustomException) { + cleanupAndThrow(tmpKey, e) + } + } + + // 삭제 및 예외 처리 + private fun cleanupAndThrow( + tmpKey: String?, + exception: CustomException, + ) { + s3Provider.deleteByKey(tmpKey) + throw exception + } + + // 최종 경로에 이미지 복사 및 반환 + private fun moveToFinalLocation(tmpKey: String): String { + val finalKey = ImageKeyFactory.toFinalKey(tmpKey) + ImagePolicy.validateKey(finalKey) + + s3Provider.copyByKey(tmpKey, finalKey) + + return ImageUrlUtil.build(cdnProperties.domain, finalKey) + } + + // 중복, 빈 값 제거 후 키 목록 추출 + private fun extractKeysFromUrls(urls: List?): List { + if (urls.isNullOrEmpty()) return emptyList() + + return urls + .asSequence() + .mapNotNull { ImageUrlUtil.extractKey(cdnProperties.domain, it) } + .map { it.trim() } + .filter { it.isNotEmpty() } + .distinct() + .toList() + } +} diff --git a/src/main/kotlin/com/ject/studytrip/image/domain/constants/ImageConstants.kt b/src/main/kotlin/com/ject/studytrip/image/domain/constants/ImageConstants.kt new file mode 100644 index 0000000..5b5e548 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/image/domain/constants/ImageConstants.kt @@ -0,0 +1,21 @@ +package com.ject.studytrip.image.domain.constants + +import org.springframework.util.unit.DataSize + +object ImageConstants { + val ALLOWED_MIME_TYPES: Set = setOf("image/jpeg", "image/png", "image/webp") + + val ALLOWED_OBJECT_KEY_PREFIXES: Set = setOf("members", "study-logs", "trip-reports") + + val ALLOWED_EXTENSIONS: Set = setOf("jpg", "jpeg", "png", "webp") + + val MAX_IMAGE_SIZE_BYTES: Long = DataSize.ofMegabytes(5).toBytes() + + const val MIN_IMAGE_SIZE_BYTES: Long = 1L + + const val PROBE_BYTES: Int = 32 * 1024 // 32KB + + const val KEY_PATTERN: String = "%s/%s/%s" + + const val TMP_PREFIX: String = "tmp/" +} diff --git a/src/main/java/com/ject/studytrip/image/domain/error/ImageErrorCode.java b/src/main/kotlin/com/ject/studytrip/image/domain/error/ImageErrorCode.kt similarity index 52% rename from src/main/java/com/ject/studytrip/image/domain/error/ImageErrorCode.java rename to src/main/kotlin/com/ject/studytrip/image/domain/error/ImageErrorCode.kt index 1711111..91e641b 100644 --- a/src/main/java/com/ject/studytrip/image/domain/error/ImageErrorCode.java +++ b/src/main/kotlin/com/ject/studytrip/image/domain/error/ImageErrorCode.kt @@ -1,34 +1,24 @@ -package com.ject.studytrip.image.domain.error; +package com.ject.studytrip.image.domain.error -import com.ject.studytrip.global.exception.error.ErrorCode; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; +import com.ject.studytrip.global.exception.error.ErrorCode +import org.springframework.http.HttpStatus -@RequiredArgsConstructor -public enum ImageErrorCode implements ErrorCode { +enum class ImageErrorCode( + private val status: HttpStatus, + private val message: String, +) : ErrorCode { + // 400 INVALID_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST, "유효하지 않은 이미지 확장자 입니다."), INVALID_IMAGE_MIME(HttpStatus.BAD_REQUEST, "유효하지 않은 이미지 MIME 입니다."), - EMPTY_IMAGE(HttpStatus.BAD_REQUEST, "이미지 파일이 비어 있습니다."), - IMAGE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "이미지 파일 크기가 허용된 최대 크기(5MB)를 초과했습니다."), INVALID_IMAGE_KEY_PREFIX(HttpStatus.BAD_REQUEST, "유효하지 않은 이미지 키 PREFIX 입니다."), INVALID_IMAGE_KEY(HttpStatus.BAD_REQUEST, "유효하지 않은 이미지 키 입니다."), + IMAGE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "이미지 파일 크기가 허용된 최대 크기(5MB)를 초과했습니다."), + EMPTY_IMAGE(HttpStatus.BAD_REQUEST, "이미지 파일이 비어 있습니다."), ; - private final HttpStatus status; - private final String message; - - @Override - public String getName() { - return this.name(); - } + override fun getName(): String = name - @Override - public HttpStatus getStatus() { - return this.status; - } + override fun getStatus(): HttpStatus = status - @Override - public String getMessage() { - return this.message; - } + override fun getMessage(): String = message } diff --git a/src/main/kotlin/com/ject/studytrip/image/domain/factory/ImageKeyFactory.kt b/src/main/kotlin/com/ject/studytrip/image/domain/factory/ImageKeyFactory.kt new file mode 100644 index 0000000..3292617 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/image/domain/factory/ImageKeyFactory.kt @@ -0,0 +1,20 @@ +package com.ject.studytrip.image.domain.factory + +import com.ject.studytrip.global.exception.CustomException +import com.ject.studytrip.image.domain.constants.ImageConstants +import com.ject.studytrip.image.domain.error.ImageErrorCode + +object ImageKeyFactory { + fun createTmpKey( + keyPrefix: String?, + id: String, + filename: String, + ): String = ImageConstants.TMP_PREFIX + ImageConstants.KEY_PATTERN.format(keyPrefix, id, filename) + + fun toFinalKey(tmpKey: String): String { + if (!tmpKey.startsWith(ImageConstants.TMP_PREFIX)) { + throw CustomException(ImageErrorCode.INVALID_IMAGE_KEY) + } + return tmpKey.removePrefix(ImageConstants.TMP_PREFIX) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/image/domain/policy/ImagePolicy.kt b/src/main/kotlin/com/ject/studytrip/image/domain/policy/ImagePolicy.kt new file mode 100644 index 0000000..3e3d0a2 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/image/domain/policy/ImagePolicy.kt @@ -0,0 +1,42 @@ +package com.ject.studytrip.image.domain.policy + +import com.ject.studytrip.global.exception.CustomException +import com.ject.studytrip.image.domain.constants.ImageConstants +import com.ject.studytrip.image.domain.error.ImageErrorCode +import org.springframework.util.StringUtils.hasText + +object ImagePolicy { + fun validateExtension(ext: String?) { + if (!hasText(ext) || ext !in ImageConstants.ALLOWED_EXTENSIONS) { + throw CustomException(ImageErrorCode.INVALID_IMAGE_EXTENSION) + } + } + + fun validateMime(mime: String?) { + if (!hasText(mime) || mime !in ImageConstants.ALLOWED_MIME_TYPES) { + throw CustomException(ImageErrorCode.INVALID_IMAGE_MIME) + } + } + + fun validateKeyPrefix(keyPrefix: String?) { + if (!hasText(keyPrefix) || keyPrefix !in ImageConstants.ALLOWED_OBJECT_KEY_PREFIXES) { + throw CustomException(ImageErrorCode.INVALID_IMAGE_KEY_PREFIX) + } + } + + fun validateKey(key: String?) { + if (!hasText(key)) { + throw CustomException(ImageErrorCode.INVALID_IMAGE_KEY) + } + } + + fun validateSize(contentLength: Long) { + if (contentLength < ImageConstants.MIN_IMAGE_SIZE_BYTES) { + throw CustomException(ImageErrorCode.EMPTY_IMAGE) + } + + if (contentLength > ImageConstants.MAX_IMAGE_SIZE_BYTES) { + throw CustomException(ImageErrorCode.IMAGE_SIZE_EXCEEDED) + } + } +} diff --git a/src/main/kotlin/com/ject/studytrip/image/domain/util/ImageUrlUtil.kt b/src/main/kotlin/com/ject/studytrip/image/domain/util/ImageUrlUtil.kt new file mode 100644 index 0000000..7d62111 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/image/domain/util/ImageUrlUtil.kt @@ -0,0 +1,56 @@ +package com.ject.studytrip.image.domain.util + +import java.net.URI + +object ImageUrlUtil { + // 이미지 최종 경로 생성 + fun build( + baseUrl: String, + key: String, + ): String { + require(baseUrl.isNotBlank()) + require(key.isNotBlank()) + + val normalizedDomain = baseUrl.removeSuffix("/") + return "$normalizedDomain/${key.trim()}" + } + + // 이미지 URL에서 이미지 key 추출 + fun extractKey( + baseUrl: String?, + url: String?, + ): String? { + if (baseUrl.isNullOrBlank() || url.isNullOrBlank()) { + return null + } + + val uri = + try { + URI.create(url) + } catch (_: IllegalArgumentException) { + return null + } + + val baseHost = extractHostName(baseUrl) ?: return null + if (uri.host != baseHost) { + return null + } + + val path = uri.path ?: return null + if (path.length <= 1) { + return null + } + + return path + .removePrefix("/") + .takeIf { it.isNotBlank() } + } + + // baseUrl에서 host 추출 + private fun extractHostName(baseUrl: String): String? = + try { + URI.create(baseUrl).host + } catch (_: IllegalArgumentException) { + null + } +} diff --git a/src/main/kotlin/com/ject/studytrip/image/infra/s3/client/S3ImageStorageClient.kt b/src/main/kotlin/com/ject/studytrip/image/infra/s3/client/S3ImageStorageClient.kt new file mode 100644 index 0000000..0e549f5 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/image/infra/s3/client/S3ImageStorageClient.kt @@ -0,0 +1,81 @@ +package com.ject.studytrip.image.infra.s3.client + +import com.ject.studytrip.global.config.properties.S3Properties +import com.ject.studytrip.image.infra.s3.error.S3ExceptionTranslator +import org.springframework.stereotype.Component +import software.amazon.awssdk.core.ResponseBytes +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse +import software.amazon.awssdk.services.s3.model.GetObjectResponse +import software.amazon.awssdk.services.s3.model.HeadObjectResponse +import software.amazon.awssdk.services.s3.model.ObjectIdentifier +import software.amazon.awssdk.services.s3.model.PutObjectRequest +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest +import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest +import java.time.Duration + +@Component +class S3ImageStorageClient( + private val properties: S3Properties, + private val presigner: S3Presigner, + private val client: S3Client, +) { + fun presignPut(key: String): PresignedPutObjectRequest = + S3ExceptionTranslator.executeWithExceptionTranslation { + val put = + PutObjectRequest + .builder() + .bucket(properties.bucket) + .key(key) + .build() + + val ttl = Duration.ofMinutes(properties.presignExpiresInMinutes) + + val req = + PutObjectPresignRequest + .builder() + .signatureDuration(ttl) + .putObjectRequest(put) + .build() + + presigner.presignPutObject(req) + } + + fun getHeadObject(key: String?): HeadObjectResponse = + S3ExceptionTranslator.executeWithExceptionTranslation { + client.headObject { it.bucket(properties.bucket).key(key) } + } + + fun getObjectAsBytes( + key: String?, + range: String, + ): ResponseBytes = + S3ExceptionTranslator.executeWithExceptionTranslation { + client.getObjectAsBytes { it.bucket(properties.bucket).key(key).range(range) } + } + + fun deleteObject(key: String?) { + S3ExceptionTranslator.executeWithExceptionTranslation { + client.deleteObject { it.bucket(properties.bucket).key(key) } + } + } + + fun deleteObjects(objects: List): DeleteObjectsResponse = + client.deleteObjects { it.bucket(properties.bucket).delete { d -> d.quiet(false).objects(objects) } } + + fun copyObject( + tmpKey: String?, + finalKey: String?, + ) { + S3ExceptionTranslator.executeWithExceptionTranslation { + client.copyObject { + it + .sourceBucket(properties.bucket) + .sourceKey(tmpKey) + .destinationBucket(properties.bucket) + .destinationKey(finalKey) + } + } + } +} diff --git a/src/main/kotlin/com/ject/studytrip/image/infra/s3/dto/ImageHeadInfo.kt b/src/main/kotlin/com/ject/studytrip/image/infra/s3/dto/ImageHeadInfo.kt new file mode 100644 index 0000000..aebdecd --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/image/infra/s3/dto/ImageHeadInfo.kt @@ -0,0 +1,5 @@ +package com.ject.studytrip.image.infra.s3.dto + +data class ImageHeadInfo( + val contentLength: Long, +) diff --git a/src/main/kotlin/com/ject/studytrip/image/infra/s3/error/S3ErrorCode.kt b/src/main/kotlin/com/ject/studytrip/image/infra/s3/error/S3ErrorCode.kt new file mode 100644 index 0000000..f470e65 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/image/infra/s3/error/S3ErrorCode.kt @@ -0,0 +1,19 @@ +package com.ject.studytrip.image.infra.s3.error + +import com.ject.studytrip.global.exception.error.ErrorCode +import org.springframework.http.HttpStatus + +enum class S3ErrorCode( + private val status: HttpStatus, + private val message: String, +) : ErrorCode { + // 502 + S3_STORAGE_SERVER_ERROR(HttpStatus.BAD_GATEWAY, "Storage 서버 에러가 발생했습니다."), + ; + + override fun getName(): String = name + + override fun getStatus(): HttpStatus = status + + override fun getMessage(): String = message +} diff --git a/src/main/kotlin/com/ject/studytrip/image/infra/s3/error/S3ExceptionTranslator.kt b/src/main/kotlin/com/ject/studytrip/image/infra/s3/error/S3ExceptionTranslator.kt new file mode 100644 index 0000000..867c3ca --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/image/infra/s3/error/S3ExceptionTranslator.kt @@ -0,0 +1,26 @@ +package com.ject.studytrip.image.infra.s3.error + +import com.ject.studytrip.global.exception.CustomException +import org.slf4j.LoggerFactory +import software.amazon.awssdk.core.exception.SdkClientException +import software.amazon.awssdk.core.exception.SdkException +import software.amazon.awssdk.services.s3.model.S3Exception + +object S3ExceptionTranslator { + private val log = LoggerFactory.getLogger(S3ExceptionTranslator::class.java) + + fun executeWithExceptionTranslation(operation: () -> T): T { + try { + return operation() + } catch (e: S3Exception) { + log.error("S3 service error: {}", e.message, e) + } catch (e: SdkClientException) { + log.error("S3 client error: {}", e.message, e) + } catch (e: SdkException) { + log.error("AWS SDK error: {}", e.message, e) + } catch (e: Exception) { + log.error("Exception: {}", e.message, e) + } + throw CustomException(S3ErrorCode.S3_STORAGE_SERVER_ERROR) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/image/infra/s3/provider/S3ImageStorageProvider.kt b/src/main/kotlin/com/ject/studytrip/image/infra/s3/provider/S3ImageStorageProvider.kt new file mode 100644 index 0000000..4dba995 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/image/infra/s3/provider/S3ImageStorageProvider.kt @@ -0,0 +1,89 @@ +package com.ject.studytrip.image.infra.s3.provider + +import com.ject.studytrip.image.application.dto.CleanupImagesResult +import com.ject.studytrip.image.application.service.ImageService +import com.ject.studytrip.image.infra.s3.client.S3ImageStorageClient +import com.ject.studytrip.image.infra.s3.dto.ImageHeadInfo +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import software.amazon.awssdk.core.exception.SdkClientException +import software.amazon.awssdk.services.s3.model.DeleteObjectsResponse +import software.amazon.awssdk.services.s3.model.ObjectIdentifier +import software.amazon.awssdk.services.s3.model.S3Exception + +@Component +class S3ImageStorageProvider( + private val s3Client: S3ImageStorageClient, +) { + companion object { + private val log = LoggerFactory.getLogger(ImageService::class.java) + } + + fun issuePresignedUrl(key: String): String { + val presignPut = s3Client.presignPut(key) + + return presignPut.url().toString() + } + + fun getHeadByKey(key: String?): ImageHeadInfo { + val head = s3Client.getHeadObject(key) + + return ImageHeadInfo(head.contentLength()) + } + + fun readPrefix( + key: String?, + maxBytes: Int, + ): ByteArray { + val range = "bytes=0-${maxBytes - 1}" + + return s3Client.getObjectAsBytes(key, range).asByteArray() + } + + fun deleteByKey(key: String?) { + s3Client.deleteObject(key) + } + + fun deleteByKeys(keys: List): CleanupImagesResult { + val objects = keys.map { ObjectIdentifier.builder().key(it).build() } + + return try { + val response = s3Client.deleteObjects(objects) + val failedKeys = extractFailedKeys(response) + val success = objects.size - failedKeys.size + CleanupImagesResult(success, failedKeys) + } catch (e: Exception) { + // S3 삭제는 멱등이기 때문에 키가 존재하지 않거나, 중복이여도 에러가 발생하지 않음 + // 삭제 시 발생하는 에러는 보통 S3 내부 서버 문제(IO/네트워크) 혹은 인증/자격, 권한, 정책, 상태 등으로 발생 + // 따라서 요청 레벨 실패로 간주하고 배치를 전체 실패로 처리 + when (e) { + is S3Exception, + is SdkClientException, + -> { + log.warn("S3 deleteObjects request failure: {}", e.message, e) + CleanupImagesResult(0, keys) + } + + else -> { + throw e + } + } + } + } + + fun copyByKey( + tmpKey: String?, + finalKey: String?, + ) { + s3Client.copyObject(tmpKey, finalKey) + s3Client.deleteObject(tmpKey) + } + + private fun extractFailedKeys(response: DeleteObjectsResponse): List = + response + .errors() + ?.mapNotNull { it.key() } + ?.filter(String::isNotBlank) + ?.distinct() + ?: emptyList() +} diff --git a/src/main/kotlin/com/ject/studytrip/image/infra/tika/error/TikaErrorCode.kt b/src/main/kotlin/com/ject/studytrip/image/infra/tika/error/TikaErrorCode.kt new file mode 100644 index 0000000..8e0490f --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/image/infra/tika/error/TikaErrorCode.kt @@ -0,0 +1,19 @@ +package com.ject.studytrip.image.infra.tika.error + +import com.ject.studytrip.global.exception.error.ErrorCode +import org.springframework.http.HttpStatus + +enum class TikaErrorCode( + private val status: HttpStatus, + private val message: String, +) : ErrorCode { + // 500 + TIKA_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Tika 서버 에러가 발생했습니다."), + ; + + override fun getName(): String = name + + override fun getStatus(): HttpStatus = status + + override fun getMessage(): String = message +} diff --git a/src/main/kotlin/com/ject/studytrip/image/infra/tika/provider/TikaImageProbeProvider.kt b/src/main/kotlin/com/ject/studytrip/image/infra/tika/provider/TikaImageProbeProvider.kt new file mode 100644 index 0000000..581b927 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/image/infra/tika/provider/TikaImageProbeProvider.kt @@ -0,0 +1,24 @@ +package com.ject.studytrip.image.infra.tika.provider + +import com.ject.studytrip.global.exception.CustomException +import com.ject.studytrip.image.infra.tika.error.TikaErrorCode +import org.apache.tika.Tika +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component + +@Component +class TikaImageProbeProvider( + private val tika: Tika, +) { + companion object { + private val log = LoggerFactory.getLogger(TikaImageProbeProvider::class.java) + } + + fun detectMime(headBytes: ByteArray): String = + try { + tika.detect(headBytes) + } catch (e: Exception) { + log.error("Tika Exception: {}", e.message, e) + throw CustomException(TikaErrorCode.TIKA_SERVER_ERROR) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/member/application/dto/CreateMemberCommand.kt b/src/main/kotlin/com/ject/studytrip/member/application/dto/CreateMemberCommand.kt new file mode 100644 index 0000000..c3de148 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/member/application/dto/CreateMemberCommand.kt @@ -0,0 +1,20 @@ +package com.ject.studytrip.member.application.dto + +data class CreateMemberCommand( + val socialId: String, + val email: String, + val profileImage: String?, + val nickname: String, + val category: String, +) { + companion object { + @JvmStatic + fun of( + socialId: String, + email: String, + profileImage: String?, + nickname: String, + category: String, + ): CreateMemberCommand = CreateMemberCommand(socialId, email, profileImage, nickname, category) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/member/application/dto/MemberDetail.kt b/src/main/kotlin/com/ject/studytrip/member/application/dto/MemberDetail.kt new file mode 100644 index 0000000..3fbe471 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/member/application/dto/MemberDetail.kt @@ -0,0 +1,18 @@ +package com.ject.studytrip.member.application.dto + +import com.ject.studytrip.trip.application.dto.TripCount + +data class MemberDetail( + val memberInfo: MemberInfo, + val tripCount: TripCount, + val studyLogCount: Long, +) { + companion object { + @JvmStatic + fun from( + memberInfo: MemberInfo, + tripCount: TripCount, + studyLogCount: Long, + ): MemberDetail = MemberDetail(memberInfo, tripCount, studyLogCount) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/member/application/dto/MemberInfo.kt b/src/main/kotlin/com/ject/studytrip/member/application/dto/MemberInfo.kt new file mode 100644 index 0000000..5a130b4 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/member/application/dto/MemberInfo.kt @@ -0,0 +1,39 @@ +package com.ject.studytrip.member.application.dto + +import com.ject.studytrip.global.util.DateUtil +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 + +data class MemberInfo( + val memberId: Long, + val socialProvider: SocialProvider, + val socialId: String, + val email: String, + val nickname: String, + val profileImage: String?, + val category: MemberCategory, + val role: MemberRole, + val createdAt: String, + val updatedAt: String, + val deletedAt: String?, +) { + companion object { + @JvmStatic + fun from(member: Member): MemberInfo = + MemberInfo( + member.id, + member.socialProvider, + member.socialId, + member.email, + member.nickname, + member.profileImage, + member.category, + member.role, + DateUtil.formatDateTime(member.createdAt), + DateUtil.formatDateTime(member.updatedAt), + member.deletedAt?.let { DateUtil.formatDateTime(it) }, + ) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/member/application/dto/PresignedProfileImageInfo.kt b/src/main/kotlin/com/ject/studytrip/member/application/dto/PresignedProfileImageInfo.kt new file mode 100644 index 0000000..272adf6 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/member/application/dto/PresignedProfileImageInfo.kt @@ -0,0 +1,16 @@ +package com.ject.studytrip.member.application.dto + +data class PresignedProfileImageInfo( + val memberId: Long, + val tmpKey: String, + val presignedUrl: String, +) { + companion object { + @JvmStatic + fun of( + memberId: Long, + tmpKey: String, + presignedUrl: String, + ): PresignedProfileImageInfo = PresignedProfileImageInfo(memberId, tmpKey, presignedUrl) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/member/application/facade/MemberFacade.kt b/src/main/kotlin/com/ject/studytrip/member/application/facade/MemberFacade.kt new file mode 100644 index 0000000..48264aa --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/member/application/facade/MemberFacade.kt @@ -0,0 +1,179 @@ +package com.ject.studytrip.member.application.facade + +import com.ject.studytrip.global.common.constants.CacheNameConstants.MEMBER +import com.ject.studytrip.image.application.service.ImageService +import com.ject.studytrip.member.application.dto.MemberDetail +import com.ject.studytrip.member.application.dto.MemberInfo +import com.ject.studytrip.member.application.dto.PresignedProfileImageInfo +import com.ject.studytrip.member.application.service.MemberCommandService +import com.ject.studytrip.member.application.service.MemberQueryService +import com.ject.studytrip.member.domain.model.Member +import com.ject.studytrip.member.presentation.dto.request.ConfirmProfileImageRequest +import com.ject.studytrip.member.presentation.dto.request.PresignProfileImageRequest +import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest +import com.ject.studytrip.mission.application.service.DailyMissionCommandService +import com.ject.studytrip.mission.application.service.MissionCommandService +import com.ject.studytrip.pomodoro.application.service.PomodoroCommandService +import com.ject.studytrip.stamp.application.service.StampCommandService +import com.ject.studytrip.studylog.application.service.StudyLogCommandService +import com.ject.studytrip.studylog.application.service.StudyLogDailyMissionCommandService +import com.ject.studytrip.studylog.application.service.StudyLogQueryService +import com.ject.studytrip.trip.application.service.DailyGoalCommandService +import com.ject.studytrip.trip.application.service.TripCommandService +import com.ject.studytrip.trip.application.service.TripQueryService +import com.ject.studytrip.trip.application.service.TripReportCommandService +import com.ject.studytrip.trip.application.service.TripReportQueryService +import com.ject.studytrip.trip.application.service.TripReportStudyLogCommandService +import org.springframework.cache.annotation.CacheEvict +import org.springframework.cache.annotation.Cacheable +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class MemberFacade( + // Query Service + private val memberQueryService: MemberQueryService, + private val tripQueryService: TripQueryService, + private val studyLogQueryService: StudyLogQueryService, + private val tripReportQueryService: TripReportQueryService, + // Command Service + private val memberCommandService: MemberCommandService, + private val tripCommandService: TripCommandService, + private val stampCommandService: StampCommandService, + private val missionCommandService: MissionCommandService, + private val dailyGoalCommandService: DailyGoalCommandService, + private val pomodoroCommandService: PomodoroCommandService, + private val dailyMissionCommandService: DailyMissionCommandService, + private val studyLogCommandService: StudyLogCommandService, + private val studyLogDailyMissionCommandService: StudyLogDailyMissionCommandService, + private val tripReportCommandService: TripReportCommandService, + private val tripReportStudyLogCommandService: TripReportStudyLogCommandService, + // Image Service + private val imageService: ImageService, +) { + companion object { + private const val MEMBER_PROFILE_IMAGE_KEY_PREFIX = "members" + } + + @CacheEvict( + cacheNames = [MEMBER], + key = "T(com.ject.studytrip.global.common.factory.CacheKeyFactory).member(#memberId)", + ) + @Transactional + fun updateMember( + memberId: Long, + request: UpdateMemberRequest, + ) { + val member = memberQueryService.getValidMember(memberId) + + memberCommandService.updateMember(member, request) + } + + @CacheEvict( + cacheNames = [MEMBER], + key = "T(com.ject.studytrip.global.common.factory.CacheKeyFactory).member(#memberId)", + ) + @Transactional + fun deleteMember(memberId: Long) { + val member = memberQueryService.getValidMember(memberId) + + memberCommandService.deleteMember(member) + imageService.cleanup(member.profileImage) + } + + @Transactional + fun restoreMember(memberId: Long) { + val member = memberQueryService.getDeletedMember(memberId) + + memberCommandService.restoreMember(member) + } + + @Transactional + fun hardDeleteMemberCascade(memberId: Long) { + val member = memberQueryService.getValidMember(memberId) + + // 삭제할 이미지 목록 + val imageUrls = collectImageUrlsForMember(member) + + // 멤버와 멤버와 관련된 모든 데이터 즉시 삭제 + cascadeHardDeleteByMemberId(memberId) + + // 이미지 삭제 이벤트 발행, 트랜잭션 커밋 이후 이미지 삭제 처리 + imageService.publishCleanupBatchEvent(imageUrls) + } + + @Cacheable( + cacheNames = [MEMBER], + key = "T(com.ject.studytrip.global.common.factory.CacheKeyFactory).member(#memberId)", + ) + @Transactional(readOnly = true) + fun getMemberDetail(memberId: Long): MemberDetail { + val member = memberQueryService.getValidMember(memberId) + val tripCount = tripQueryService.getActiveTripCountByMemberId(memberId) + val studyLogCount = studyLogQueryService.getActiveStudyLogCountByMemberId(memberId) + + val memberInfo = MemberInfo.from(member) + + return MemberDetail.from(memberInfo, tripCount, studyLogCount) + } + + @Transactional(readOnly = true) + fun issuePresignedUrl( + memberId: Long, + request: PresignProfileImageRequest, + ): PresignedProfileImageInfo { + val member = memberQueryService.getValidMember(memberId) + + val info = imageService.presign(MEMBER_PROFILE_IMAGE_KEY_PREFIX, member.id.toString(), request.originFilename) + + return PresignedProfileImageInfo.of(member.id, info.tmpKey, info.presignedUrl) + } + + @CacheEvict( + cacheNames = [MEMBER], + key = "T(com.ject.studytrip.global.common.factory.CacheKeyFactory).member(#memberId)", + ) + @Transactional + fun confirmImage( + memberId: Long, + request: ConfirmProfileImageRequest, + ) { + val member = memberQueryService.getValidMember(memberId) + val finalKey = imageService.confirm(request.tmpKey) + + // 기존 이미지 삭제 (이미지가 존재하지 않아도 예외 발생 X) + imageService.cleanup(member.profileImage) + + // 새로운 이미지 업데이트 + memberCommandService.updateProfileImage(member, finalKey) + } + + private fun collectImageUrlsForMember(member: Member): List = + buildList { + // TripReport 이미지 목록 조회 + addAll(tripReportQueryService.getTripReportImageUrlsByMemberId(member.id)) + + // StudyLog 이미지 목록 조회 + addAll(studyLogQueryService.getStudyLogImageUrlsByMemberId(member.id)) + + member.profileImage + ?.takeIf { it.isNotBlank() } + ?.let { add(it) } + } + + private fun cascadeHardDeleteByMemberId(memberId: Long) { + // 자식 -> 부모 순으로 삭제 진행 + tripReportStudyLogCommandService.hardDeleteTripReportStudyLogsOwnedByMember(memberId) + tripReportCommandService.hardDeleteTripReportsOwnedByMember(memberId) + studyLogDailyMissionCommandService.hardDeleteStudyLogDailyMissionsOwnedByMember(memberId) + pomodoroCommandService.hardDeletePomodorosOwnedByMember(memberId) + studyLogCommandService.hardDeleteStudyLogsOwnedByMember(memberId) + dailyMissionCommandService.hardDeleteDailyMissionsOwnedByMember(memberId) + dailyGoalCommandService.hardDeleteDailyGoalsOwnedByMember(memberId) + missionCommandService.hardDeleteMissionsOwnedByMember(memberId) + stampCommandService.hardDeleteStampsOwnedByMember(memberId) + tripCommandService.hardDeleteTripsOwnedByMember(memberId) + + memberCommandService.hardDeleteMember(memberId) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/member/application/service/MemberCommandService.kt b/src/main/kotlin/com/ject/studytrip/member/application/service/MemberCommandService.kt new file mode 100644 index 0000000..c91114a --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/member/application/service/MemberCommandService.kt @@ -0,0 +1,68 @@ +package com.ject.studytrip.member.application.service + +import com.ject.studytrip.member.application.dto.CreateMemberCommand +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.SocialProvider +import com.ject.studytrip.member.domain.policy.MemberPolicy +import com.ject.studytrip.member.domain.repository.MemberCommandRepository +import com.ject.studytrip.member.domain.repository.MemberRepository +import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest +import org.springframework.stereotype.Service + +@Service +class MemberCommandService( + private val memberRepository: MemberRepository, + private val memberCommandRepository: MemberCommandRepository, +) { + fun createMemberFromKakao(command: CreateMemberCommand): Member { + validateMemberIsUnique(SocialProvider.KAKAO, command.socialId) + + val member = + MemberFactory.createFromKakao( + command.socialId, + command.email, + command.profileImage, + command.nickname, + MemberCategory.from(command.category), + ) + + return memberRepository.save(member) + } + + fun updateMember( + member: Member, + request: UpdateMemberRequest, + ) { + val category = request.category?.let { MemberCategory.from(it) } + + member.update(request.nickname, category) + } + + fun updateProfileImage( + member: Member, + profileImage: String, + ) { + MemberPolicy.validateNotDeleted(member) + + member.updateProfileImage(profileImage) + } + + fun deleteMember(member: Member) = member.updateDeletedAt() + + fun restoreMember(member: Member) = member.restoreDeletedAt() + + fun hardDeleteMember(memberId: Long) = memberRepository.deleteById(memberId) + + fun hardDeleteMembers() = memberCommandRepository.deleteAllByDeletedAtIsNotNull() + + private fun validateMemberIsUnique( + socialProvider: SocialProvider, + socialId: String, + ) { + val exists = memberRepository.existsBySocialProviderAndSocialId(socialProvider, socialId) + + MemberPolicy.validateNotDuplicated(exists) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/member/application/service/MemberQueryService.kt b/src/main/kotlin/com/ject/studytrip/member/application/service/MemberQueryService.kt new file mode 100644 index 0000000..d45046e --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/member/application/service/MemberQueryService.kt @@ -0,0 +1,56 @@ +package com.ject.studytrip.member.application.service + +import com.ject.studytrip.global.exception.CustomException +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.policy.MemberPolicy +import com.ject.studytrip.member.domain.repository.MemberQueryRepository +import com.ject.studytrip.member.domain.repository.MemberRepository +import org.springframework.stereotype.Service +import java.util.Optional + +@Service +class MemberQueryService( + private val memberRepository: MemberRepository, + private val memberQueryRepository: MemberQueryRepository, +) { + fun getMemberBySocialProviderAndSocialId( + socialProvider: SocialProvider, + socialId: String, + ): Optional = + memberRepository + .findBySocialProviderAndSocialId(socialProvider, socialId) + .map { + MemberPolicy.validateNotDeleted(it) + it + } + + fun getValidMember(memberId: Long): Member { + val member = + memberRepository + .findById(memberId) + .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } + + MemberPolicy.validateNotDeleted(member) + + return member + } + + fun getDeletedMember(memberId: Long): Member { + val member = + memberRepository + .findById(memberId) + .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } + + MemberPolicy.validateDeleted(member) + + return member + } + + fun getMemberRoleByMemberId(memberId: Long): MemberRole = + memberQueryRepository + .findMemberRoleById(memberId) + .orElseThrow { CustomException(MemberErrorCode.MEMBER_NOT_FOUND) } +} diff --git a/src/main/kotlin/com/ject/studytrip/member/domain/error/MemberErrorCode.kt b/src/main/kotlin/com/ject/studytrip/member/domain/error/MemberErrorCode.kt new file mode 100644 index 0000000..228e955 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/member/domain/error/MemberErrorCode.kt @@ -0,0 +1,27 @@ +package com.ject.studytrip.member.domain.error + +import com.ject.studytrip.global.exception.error.ErrorCode +import org.springframework.http.HttpStatus + +enum class MemberErrorCode( + private val status: HttpStatus, + private val message: String, +) : ErrorCode { + // 400 + MEMBER_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "이미 삭제된 멤버입니다."), + MEMBER_NOT_DELETED(HttpStatus.BAD_REQUEST, "멤버가 아직 삭제되지 않았습니다."), + INVALID_MEMBER_CATEGORY(HttpStatus.BAD_REQUEST, "멤버 카테고리가 누락되거나 올바르지 않습니다."), + + // 404 + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "요창한 멤버를 찾을 수 없습니다."), + + // 409 + MEMBER_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 회원가입된 멤버입니다."), + ; + + override fun getName(): String = name + + override fun getStatus(): HttpStatus = status + + override fun getMessage(): String = message +} diff --git a/src/main/kotlin/com/ject/studytrip/member/domain/factory/MemberFactory.kt b/src/main/kotlin/com/ject/studytrip/member/domain/factory/MemberFactory.kt new file mode 100644 index 0000000..89f0480 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/member/domain/factory/MemberFactory.kt @@ -0,0 +1,17 @@ +package com.ject.studytrip.member.domain.factory + +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 + +object MemberFactory { + @JvmStatic + fun createFromKakao( + kakaoId: String, + email: String, + profileImage: String?, + nickname: String, + category: MemberCategory, + ): Member = Member.of(SocialProvider.KAKAO, kakaoId, email, nickname, profileImage, category, MemberRole.ROLE_USER) +} diff --git a/src/main/kotlin/com/ject/studytrip/member/domain/policy/MemberPolicy.kt b/src/main/kotlin/com/ject/studytrip/member/domain/policy/MemberPolicy.kt new file mode 100644 index 0000000..cdd82d8 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/member/domain/policy/MemberPolicy.kt @@ -0,0 +1,25 @@ +package com.ject.studytrip.member.domain.policy + +import com.ject.studytrip.global.exception.CustomException +import com.ject.studytrip.member.domain.error.MemberErrorCode +import com.ject.studytrip.member.domain.model.Member + +object MemberPolicy { + fun validateNotDeleted(member: Member) { + if (member.isDeleted) { + throw CustomException(MemberErrorCode.MEMBER_ALREADY_DELETED) + } + } + + fun validateDeleted(member: Member) { + if (!member.isDeleted) { + throw CustomException(MemberErrorCode.MEMBER_NOT_DELETED) + } + } + + fun validateNotDuplicated(exists: Boolean) { + if (exists) { + throw CustomException(MemberErrorCode.MEMBER_ALREADY_EXISTS) + } + } +} diff --git a/src/main/kotlin/com/ject/studytrip/member/domain/repository/MemberRepository.kt b/src/main/kotlin/com/ject/studytrip/member/domain/repository/MemberRepository.kt new file mode 100644 index 0000000..760fc5d --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/member/domain/repository/MemberRepository.kt @@ -0,0 +1,23 @@ +package com.ject.studytrip.member.domain.repository + +import com.ject.studytrip.member.domain.model.Member +import com.ject.studytrip.member.domain.model.SocialProvider +import java.util.Optional + +interface MemberRepository { + fun findBySocialProviderAndSocialId( + socialProvider: SocialProvider, + socialId: String, + ): Optional + + fun findById(memberId: Long): Optional + + fun existsBySocialProviderAndSocialId( + socialProvider: SocialProvider, + socialId: String, + ): Boolean + + fun save(member: Member): Member + + fun deleteById(memberId: Long) +} diff --git a/src/main/kotlin/com/ject/studytrip/member/infra/jpa/MemberJpaRepository.kt b/src/main/kotlin/com/ject/studytrip/member/infra/jpa/MemberJpaRepository.kt new file mode 100644 index 0000000..0b2df80 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/member/infra/jpa/MemberJpaRepository.kt @@ -0,0 +1,18 @@ +package com.ject.studytrip.member.infra.jpa + +import com.ject.studytrip.member.domain.model.Member +import com.ject.studytrip.member.domain.model.SocialProvider +import org.springframework.data.jpa.repository.JpaRepository +import java.util.Optional + +interface MemberJpaRepository : JpaRepository { + fun findBySocialProviderAndSocialId( + socialProvider: SocialProvider, + socialId: String, + ): Optional + + fun existsBySocialProviderAndSocialId( + socialProvider: SocialProvider, + socialId: String, + ): Boolean +} diff --git a/src/main/kotlin/com/ject/studytrip/member/infra/jpa/MemberRepositoryAdapter.kt b/src/main/kotlin/com/ject/studytrip/member/infra/jpa/MemberRepositoryAdapter.kt new file mode 100644 index 0000000..f585512 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/member/infra/jpa/MemberRepositoryAdapter.kt @@ -0,0 +1,28 @@ +package com.ject.studytrip.member.infra.jpa + +import com.ject.studytrip.member.domain.model.Member +import com.ject.studytrip.member.domain.model.SocialProvider +import com.ject.studytrip.member.domain.repository.MemberRepository +import org.springframework.stereotype.Repository +import java.util.Optional + +@Repository +class MemberRepositoryAdapter( + private val memberJpaRepository: MemberJpaRepository, +) : MemberRepository { + override fun findBySocialProviderAndSocialId( + socialProvider: SocialProvider, + socialId: String, + ): Optional = memberJpaRepository.findBySocialProviderAndSocialId(socialProvider, socialId) + + override fun findById(memberId: Long): Optional = memberJpaRepository.findById(memberId) + + override fun existsBySocialProviderAndSocialId( + socialProvider: SocialProvider, + socialId: String, + ): Boolean = memberJpaRepository.existsBySocialProviderAndSocialId(socialProvider, socialId) + + override fun save(member: Member): Member = memberJpaRepository.save(member) + + override fun deleteById(memberId: Long) = memberJpaRepository.deleteById(memberId) +} diff --git a/src/main/kotlin/com/ject/studytrip/member/presentation/controller/MemberController.kt b/src/main/kotlin/com/ject/studytrip/member/presentation/controller/MemberController.kt new file mode 100644 index 0000000..1f70e8c --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/member/presentation/controller/MemberController.kt @@ -0,0 +1,180 @@ +package com.ject.studytrip.member.presentation.controller + +import com.ject.studytrip.global.common.response.StandardResponse +import com.ject.studytrip.member.application.facade.MemberFacade +import com.ject.studytrip.member.presentation.dto.request.ConfirmProfileImageRequest +import com.ject.studytrip.member.presentation.dto.request.PresignProfileImageRequest +import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest +import com.ject.studytrip.member.presentation.dto.response.LoadMemberDetailResponse +import com.ject.studytrip.member.presentation.dto.response.PresignProfileImageResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import jakarta.validation.constraints.NotNull +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.validation.annotation.Validated +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +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 = "Member", description = "멤버 API") +@RestController +@RequestMapping("/api/members") +@Validated +class MemberController( + private val memberFacade: MemberFacade, +) { + @Operation(summary = "멤버 수정", description = "특정 멤버의 이름 또는 카테고리를 수정합니다.") + @PatchMapping("/me") + fun updateMember( + @AuthenticationPrincipal memberId: String, + @RequestBody @Valid request: UpdateMemberRequest, + ): ResponseEntity { + memberFacade.updateMember(memberId.toLong(), request) + + return ResponseEntity + .status(HttpStatus.OK) + .body(StandardResponse.success(HttpStatus.OK.value(), null)) + } + + @Operation(summary = "멤버 삭제", description = "특정 멤버를 삭제합니다. (회원탈퇴)") + @DeleteMapping("/me") + fun deleteMember( + @AuthenticationPrincipal memberId: String, + ): ResponseEntity { + memberFacade.deleteMember(memberId.toLong()) + + return ResponseEntity + .status(HttpStatus.OK) + .body(StandardResponse.success(HttpStatus.OK.value(), null)) + } + + @Operation(summary = "멤버 복구", description = "삭제된 멤버를 복구합니다.") + @PatchMapping("/me/restore/{memberId}") + fun restoreMember( + @PathVariable @NotNull(message = "멤버 ID는 필수 요청 파라미터입니다.") memberId: Long, + ): ResponseEntity { + memberFacade.restoreMember(memberId) + + return ResponseEntity + .status(HttpStatus.OK) + .body(StandardResponse.success(HttpStatus.OK.value(), null)) + } + + @Operation(summary = "멤버 즉시 삭제", description = "특정 멤버를 즉시 삭제하고, 관련된 모든 데이터를 즉시 삭제한다. (CASCADE)") + @DeleteMapping("/me/hard-delete") + fun hardDeleteMember( + @AuthenticationPrincipal memberId: String, + ): ResponseEntity { + memberFacade.hardDeleteMemberCascade(memberId.toLong()) + + return ResponseEntity + .status(HttpStatus.OK) + .body(StandardResponse.success(HttpStatus.OK.value(), null)) + } + + @Operation(summary = "멤버 상세 조회", description = "특정 멤버를 상세 조회합니다.") + @GetMapping("/me") + fun loadMember( + @AuthenticationPrincipal memberId: String, + ): ResponseEntity { + val result = memberFacade.getMemberDetail(memberId.toLong()) + + return ResponseEntity + .status(HttpStatus.OK) + .body( + StandardResponse.success( + HttpStatus.OK.value(), + LoadMemberDetailResponse.of(result.memberInfo, result.tripCount, result.studyLogCount), + ), + ) + } + + @Operation( + summary = "멤버 프로필 이미지 업로드용 Presigned URL 발급", + description = """ + 멤버 프로필 이미지를 S3 Storage에 업로드하기 위한 Presigned URL을 발급합니다. + + [흐름] + 1) 멤버 수정 화면에서 수정하기 버튼을 클릭합니다. + 2) 이때 만약 프로필 이미지를 변경했다면 해당 파일이름과 함께 본 API를 호출합니다.(예: abc.jpeg 형태) + 2-1) 프로필 이미지를 변경하지 않았으면 멤버 수정 API를 호출합니다. (닉네임 또는 카테고리를 수정했을 경우) + 3) 서버는 업로드에 사용할 Presigned PUT URL과 임시 키(tmpKey)를 반환합니다. + 4) 클라이언트는 반환된 Presigned URL로 이미지를 S3에 업로드합니다. + 5) 업로드가 정상 완료되면 즉시 프로필 이미지 Confirm API를 호출하여 이미지를 검증 및 확정하고 멤버 프로필에 적용합니다. + 6) 이후 다른 수정 사항이 있다면 멤버 수정 API를 호출합니다. (닉네임 또는 카테고리도 수정했을 경우) + + [주의] + - Presigned URL 유효시간은 짧습니다(예: 10분). 만료되면 재발급해야 합니다. + - 요청 값의 originFilename은 꼭 파일 확장자를 포함한 파일명으로 요청해야 합니다. + """, + ) + @PostMapping("/profile-images/presigned") + fun presigned( + @AuthenticationPrincipal memberId: String, + @RequestBody @Valid request: PresignProfileImageRequest, + ): ResponseEntity { + val result = memberFacade.issuePresignedUrl(memberId.toLong(), request) + + return ResponseEntity + .status(HttpStatus.OK) + .body( + StandardResponse.success( + HttpStatus.OK.value(), + PresignProfileImageResponse.of(result.memberId, result.tmpKey, result.presignedUrl), + ), + ) + } + + @Operation( + summary = "업로드된 멤버 프로필 이미지 검증/확정", + description = """ + Presigned URL을 통해 S3에 업로드된 프로필 이미지를 서버에서 검증하고 확정(Confirm)합니다. + + [흐름] + 1) 클라이언트는 발급받은 Presigned PUT URL로 이미지를 업로드합니다. + 2) 업로드 완료 후, Presigned URL 발급 API에서 응답받은 임시키(tmpKey)를 포함해 해당 API를 호출합니다. + 임시키(tmpKey)는 Presigned URL의 전체 경로 중, 버킷 호스트명을 제외한 S3 객체 경로(ObjectKey) 입니다. + (예: https://bucket.s3.ap-northeast-2.amazonaws.com/tmp/members/1/abc.jpg -> tmp/members/1/abc.jpg) + + 3) 서버는 이미지 존재 여부, 크기, MIME 타입 등을 검증한 뒤 최종 경로로 이동시키고 회원 프로필 이미지 정보를 갱신합니다. + 만약 S3 Storage 기술 자체 에러가 발생하면 임시 경로에 저장된 이미지를 즉시 삭제하지 않아 컨펌 재시도가 가능하지만, + 유효하지 않은 이미지 크기/확장자 등 도메인 정책을 위반해 실패할 경우 임시 경로에 저장된 이미지가 즉시 삭제되며 다시 업로드부터 수행해야 합니다. + + S3 Storage 기술 자체 예외 예시 + { + "status": 502 (BAD_GATEWAY), + "message": "Storage 서버 에러가 발생했습니다." + } + + 이미지 도메인 정책 위반 예외 예시 + { + "status": 400 (BAD_REQUEST), + "message": "유효하지 않은 이미지 확장자 입니다." , "유효하지 않은 이미지 MIME 입니다." 등 + } + + [주의] + - 이미지 타입(MIME/Content-Type)은 JPG, JPEG, PNG, WEBP만 허용합니다. 그 외 타입은 도메인 정책 위반으로 예외가 발생합니다. + - 이미지 최대 크기는 5MB로 설정되어있으며, 크기가 0 이하이거나 최대 크기를 벗어날 경우 도메인 정책 위반으로 예외가 발생합니다. + - 업로드는 되었지만 그 이후 문제가 발생하더라고 tmp/ 경로의 객체는 라이프사이클 정책에 따라 자동 정리되기 때문에 따로 삭제 요청 API는 호출하지 않아도 됩니다. + """, + ) + @PostMapping("/profile-images/confirm") + fun confirm( + @AuthenticationPrincipal memberId: String, + @RequestBody @Valid request: ConfirmProfileImageRequest, + ): ResponseEntity { + memberFacade.confirmImage(memberId.toLong(), request) + + return ResponseEntity + .status(HttpStatus.OK) + .body(StandardResponse.success(HttpStatus.OK.value(), null)) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/member/presentation/dto/request/ConfirmProfileImageRequest.kt b/src/main/kotlin/com/ject/studytrip/member/presentation/dto/request/ConfirmProfileImageRequest.kt new file mode 100644 index 0000000..069d0a0 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/member/presentation/dto/request/ConfirmProfileImageRequest.kt @@ -0,0 +1,10 @@ +package com.ject.studytrip.member.presentation.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotEmpty + +data class ConfirmProfileImageRequest( + @field:Schema(description = "업로드된 이미지 임시키") + @field:NotEmpty(message = "업로드된 이미지 임시키는 필수 요청 값입니다.") + val tmpKey: String, +) diff --git a/src/main/kotlin/com/ject/studytrip/member/presentation/dto/request/PresignProfileImageRequest.kt b/src/main/kotlin/com/ject/studytrip/member/presentation/dto/request/PresignProfileImageRequest.kt new file mode 100644 index 0000000..de81fd4 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/member/presentation/dto/request/PresignProfileImageRequest.kt @@ -0,0 +1,10 @@ +package com.ject.studytrip.member.presentation.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotEmpty + +data class PresignProfileImageRequest( + @field:Schema(description = "원본 이미지 파일명") + @field:NotEmpty(message = "원본 이미지 파일명은 필수 요청 값입니다.") + val originFilename: String, +) diff --git a/src/main/kotlin/com/ject/studytrip/member/presentation/dto/request/UpdateMemberRequest.kt b/src/main/kotlin/com/ject/studytrip/member/presentation/dto/request/UpdateMemberRequest.kt new file mode 100644 index 0000000..64dfd51 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/member/presentation/dto/request/UpdateMemberRequest.kt @@ -0,0 +1,19 @@ +package com.ject.studytrip.member.presentation.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Pattern + +data class UpdateMemberRequest( + @field:Schema(description = "수정할 멤버 닉네임") + @field:Pattern( + regexp = "^[a-zA-Z0-9가-힣]{2,10}$", + message = "닉네임은 특수문자를 제외하고 2~10자 이내로 입력해주세요.", + ) + val nickname: String?, + @field:Schema(description = "수정할 멤버 카테고리") + @field:Pattern( + regexp = "^(STUDENT|WORKER|FREELANCER|JOBSEEKER)$", + message = "멤버 카테고리는 STUDENT, WORKER, FREELANCER, JOBSEEKER 중 하나여야 합니다.", + ) + val category: String?, +) diff --git a/src/main/kotlin/com/ject/studytrip/member/presentation/dto/response/LoadMemberDetailResponse.kt b/src/main/kotlin/com/ject/studytrip/member/presentation/dto/response/LoadMemberDetailResponse.kt new file mode 100644 index 0000000..c24a9b5 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/member/presentation/dto/response/LoadMemberDetailResponse.kt @@ -0,0 +1,44 @@ +package com.ject.studytrip.member.presentation.dto.response + +import com.ject.studytrip.member.application.dto.MemberInfo +import com.ject.studytrip.member.domain.model.MemberCategory +import com.ject.studytrip.trip.application.dto.TripCount +import io.swagger.v3.oas.annotations.media.Schema + +data class LoadMemberDetailResponse( + @field:Schema(description = "멤버 ID") + val memberId: Long, + @field:Schema(description = "이메일") + val email: String, + @field:Schema(description = "닉네임") + val nickname: String, + @field:Schema(description = "프로필 이미지") + val profileImage: String?, + @field:Schema(description = "멤버 카테고리") + val category: MemberCategory, + @field:Schema(description = "코스형 여행 개수") + val courseTripCount: Long, + @field:Schema(description = "탐험형 여행 개수") + val exploreTripCount: Long, + @field:Schema(description = "학습 기록 개수") + val studyLogCount: Long, +) { + companion object { + @JvmStatic + fun of( + memberInfo: MemberInfo, + tripCount: TripCount, + studyLogCount: Long, + ): LoadMemberDetailResponse = + LoadMemberDetailResponse( + memberInfo.memberId, + memberInfo.email, + memberInfo.nickname, + memberInfo.profileImage, + memberInfo.category, + tripCount.course, + tripCount.explore, + studyLogCount, + ) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/member/presentation/dto/response/PresignProfileImageResponse.kt b/src/main/kotlin/com/ject/studytrip/member/presentation/dto/response/PresignProfileImageResponse.kt new file mode 100644 index 0000000..69787d4 --- /dev/null +++ b/src/main/kotlin/com/ject/studytrip/member/presentation/dto/response/PresignProfileImageResponse.kt @@ -0,0 +1,21 @@ +package com.ject.studytrip.member.presentation.dto.response + +import io.swagger.v3.oas.annotations.media.Schema + +data class PresignProfileImageResponse( + @field:Schema(description = "멤버 ID") + val memberId: Long, + @field:Schema(description = "멤버 프로필 이미지 임시키") + val tmpKey: String, + @field:Schema(description = "멤버 프로필 이미지 업로드용 Presigned URL") + val presignedUrl: String, +) { + companion object { + @JvmStatic + fun of( + memberId: Long, + tmpKey: String, + presignedUrl: String, + ): PresignProfileImageResponse = PresignProfileImageResponse(memberId, tmpKey, presignedUrl) + } +} diff --git a/src/main/kotlin/com/ject/studytrip/trip/application/facade/TripReportFacade.kt b/src/main/kotlin/com/ject/studytrip/trip/application/facade/TripReportFacade.kt index 4dba90c..a23a5c7 100644 --- a/src/main/kotlin/com/ject/studytrip/trip/application/facade/TripReportFacade.kt +++ b/src/main/kotlin/com/ject/studytrip/trip/application/facade/TripReportFacade.kt @@ -148,6 +148,7 @@ class TripReportFacade( return PresignedTripReportImageResponse.of(tripReport.id, info.tmpKey, info.presignedUrl) } + @Transactional fun confirmImage( tripReportId: Long, request: ConfirmTripReportImageRequest, diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 835de0e..ff3eaa9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,6 +12,13 @@ spring: init: mode: never + jackson: + serialization: + write-dates-as-timestamps: false + deserialization: + fail-on-unknown-properties: false + time-zone: Asia/Seoul + server: forward-headers-strategy: framework diff --git a/src/test/java/com/ject/studytrip/BaseIntegrationTest.java b/src/test/java/com/ject/studytrip/BaseIntegrationTest.java deleted file mode 100644 index e7f2e29..0000000 --- a/src/test/java/com/ject/studytrip/BaseIntegrationTest.java +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 09c83e6..0000000 --- a/src/test/java/com/ject/studytrip/BaseUnitTest.java +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 0f38c07..0000000 --- a/src/test/java/com/ject/studytrip/auth/application/service/KakaoLoginServiceTest.java +++ /dev/null @@ -1,88 +0,0 @@ -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.KakaoTokenResponseFixture; -import com.ject.studytrip.auth.fixture.KakaoUserInfoResponseFixture; -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.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 VALID_ORIGIN = "https://test.com"; - - @InjectMocks private KakaoLoginService kakaoLoginService; - - @Mock private KakaoOauthProvider kakaoOauthProvider; - - @Nested - @DisplayName("getKakaoUserInfo 메서드는") - class GetKakaoUserInfo { - - @Test - @DisplayName("유효하지 않은 인가 코드를 전달하면 예외가 발생한다.") - void shouldThrowExceptionWhenAuthorizationCodeIsInvalid() { - // given - when(kakaoOauthProvider.getKakaoTokens(" ", VALID_ORIGIN)) - .thenThrow(new CustomException(AuthErrorCode.INVALID_KAKAO_AUTHORIZATION_CODE)); - - // when & then - assertThatThrownBy(() -> kakaoLoginService.getKakaoUserInfo(" ", VALID_ORIGIN)) - .isInstanceOf(CustomException.class) - .hasMessage(AuthErrorCode.INVALID_KAKAO_AUTHORIZATION_CODE.getMessage()); - } - - @Test - @DisplayName("카카오 토큰 응답은 왔지만 사용자 정보 조회에 실패하면 예외가 발생한다.") - void shouldThrowExceptionWhenFetchingKakaoUserInfoFails() { - // given - KakaoTokenResponse tokenResponse = new KakaoTokenResponseFixture().build(); - when(kakaoOauthProvider.getKakaoTokens(VALID_CODE, VALID_ORIGIN)) - .thenReturn(tokenResponse); - when(kakaoOauthProvider.getKakaoUserInfo(tokenResponse.accessToken())) - .thenThrow(new CustomException(AuthErrorCode.KAKAO_USER_INFO_FETCH_FAILED)); - - // when & then - assertThatThrownBy(() -> kakaoLoginService.getKakaoUserInfo(VALID_CODE, VALID_ORIGIN)) - .isInstanceOf(CustomException.class) - .hasMessage(AuthErrorCode.KAKAO_USER_INFO_FETCH_FAILED.getMessage()); - } - - @Test - @DisplayName("유효한 인가 코드와 origin을 전달하면 사용자 정보를 반환한다.") - void shouldReturnKakaoUserInfoResponseWhenCodeAndOriginAreValid() { - // given - KakaoTokenResponse kakaoTokenResponse = new KakaoTokenResponseFixture().build(); - KakaoUserInfoResponse kakaoUserInfoResponse = - new KakaoUserInfoResponseFixture().build(); - when(kakaoOauthProvider.getKakaoTokens(VALID_CODE, VALID_ORIGIN)) - .thenReturn(kakaoTokenResponse); - when(kakaoOauthProvider.getKakaoUserInfo("access-token")) - .thenReturn(kakaoUserInfoResponse); - - // when - KakaoUserInfoResponse result = - kakaoLoginService.getKakaoUserInfo(VALID_CODE, VALID_ORIGIN); - - // then - assertThat(result.kakaoId()).isEqualTo(KAKAO_ID); - assertThat(result.getEmail()).isEqualTo(EMAIL); - assertThat(result.getProfileImage()).isEqualTo(PROFILE_IMAGE); - } - } -} diff --git a/src/test/java/com/ject/studytrip/auth/application/service/KakaoSignupProfileServiceTest.java b/src/test/java/com/ject/studytrip/auth/application/service/KakaoSignupProfileServiceTest.java deleted file mode 100644 index 156b580..0000000 --- a/src/test/java/com/ject/studytrip/auth/application/service/KakaoSignupProfileServiceTest.java +++ /dev/null @@ -1,122 +0,0 @@ -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.BDDMockito.given; -import static org.mockito.Mockito.verify; - -import com.ject.studytrip.BaseUnitTest; -import com.ject.studytrip.auth.domain.error.AuthErrorCode; -import com.ject.studytrip.auth.domain.model.KakaoSignupProfile; -import com.ject.studytrip.auth.domain.repository.KakaoSignupProfileRedisRepository; -import com.ject.studytrip.global.exception.CustomException; -import java.util.Optional; -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("KakaoSignupProfileService 단위 테스트") -class KakaoSignupProfileServiceTest extends BaseUnitTest { - private static final String VALID_SIGNUP_KEY = "kakao::signup::profile:valid-key"; - private static final String INVALID_SIGNUP_KEY = "kakao::signup::profile:invalid-key"; - private static final String KAKAO_ID = "12345"; - private static final String KAKAO_PROVIDER = "kakao"; - private static final String EMAIL = "test@kakao.com"; - private static final String PROFILE_IMAGE = "https://kakao.com/profile.jpg"; - - @InjectMocks private KakaoSignupProfileService kakaoSignupProfileService; - - @Mock private KakaoSignupProfileRedisRepository kakaoSignupProfileRedisRepository; - - @Nested - @DisplayName("saveAndIssueSignupKey 메서드는") - class SaveAndIssueSignupKey { - - @Test - @DisplayName("프로필 저장 후 발급된 키를 반환한다.") - void shouldReturnIssuedKey() { - // given - String issuedKey = "issued-signup-key"; - given( - kakaoSignupProfileRedisRepository.saveAndIssueSignupKey( - KAKAO_ID, EMAIL, PROFILE_IMAGE)) - .willReturn(issuedKey); - - // when - String result = - kakaoSignupProfileService.saveAndIssueSignupKey(KAKAO_ID, EMAIL, PROFILE_IMAGE); - - // then - assertThat(result).isEqualTo(issuedKey); - verify(kakaoSignupProfileRedisRepository) - .saveAndIssueSignupKey(KAKAO_ID, EMAIL, PROFILE_IMAGE); - } - } - - @Nested - @DisplayName("getSignupProfileByKey 메서드는") - class GetSignupProfileByKey { - - @Test - @DisplayName("유효한 signupKey로 조회하면 KakaoSignupProfile을 반환한다.") - void shouldReturnKakaoSignupProfileWhenKeyIsValid() { - // given - KakaoSignupProfile expectedProfile = - KakaoSignupProfile.of(KAKAO_ID, KAKAO_PROVIDER, EMAIL, PROFILE_IMAGE); - given(kakaoSignupProfileRedisRepository.findBySignupKey(VALID_SIGNUP_KEY)) - .willReturn(Optional.of(expectedProfile)); - - // when - KakaoSignupProfile result = - kakaoSignupProfileService.getSignupProfileByKey(VALID_SIGNUP_KEY); - - // then - assertThat(result).isEqualTo(expectedProfile); - } - - @Test - @DisplayName("signupKey가 null이면 MISSING_KAKAO_SIGNUP_KEY 예외가 발생한다.") - void shouldThrowExceptionWhenSignupKeyIsNull() { - // when & then - assertThatThrownBy(() -> kakaoSignupProfileService.getSignupProfileByKey(null)) - .isInstanceOf(CustomException.class) - .hasMessage(AuthErrorCode.MISSING_KAKAO_SIGNUP_KEY.getMessage()); - } - - @Test - @DisplayName("signupKey가 빈 문자열이면 MISSING_KAKAO_SIGNUP_KEY 예외가 발생한다.") - void shouldThrowExceptionWhenSignupKeyIsBlank() { - // when & then - assertThatThrownBy(() -> kakaoSignupProfileService.getSignupProfileByKey("")) - .isInstanceOf(CustomException.class) - .hasMessage(AuthErrorCode.MISSING_KAKAO_SIGNUP_KEY.getMessage()); - } - - @Test - @DisplayName("signupKey가 공백만 있으면 MISSING_KAKAO_SIGNUP_KEY 예외가 발생한다.") - void shouldThrowExceptionWhenSignupKeyIsWhitespace() { - // when & then - assertThatThrownBy(() -> kakaoSignupProfileService.getSignupProfileByKey(" ")) - .isInstanceOf(CustomException.class) - .hasMessage(AuthErrorCode.MISSING_KAKAO_SIGNUP_KEY.getMessage()); - } - - @Test - @DisplayName("존재하지 않는 signupKey로 조회하면 INVALID_KAKAO_SIGNUP_KEY 예외가 발생한다.") - void shouldThrowExceptionWhenSignupKeyDoesNotExist() { - // given - given(kakaoSignupProfileRedisRepository.findBySignupKey(INVALID_SIGNUP_KEY)) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy( - () -> - kakaoSignupProfileService.getSignupProfileByKey( - INVALID_SIGNUP_KEY)) - .isInstanceOf(CustomException.class) - .hasMessage(AuthErrorCode.INVALID_KAKAO_SIGNUP_KEY.getMessage()); - } - } -} 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 deleted file mode 100644 index 3997d2b..0000000 --- a/src/test/java/com/ject/studytrip/auth/application/service/TokenServiceTest.java +++ /dev/null @@ -1,247 +0,0 @@ -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.application.dto.TokenInfo; -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.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 - TokenInfo 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 - TokenInfo 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/KakaoLoginRequestFixture.java b/src/test/java/com/ject/studytrip/auth/fixture/KakaoLoginRequestFixture.java deleted file mode 100644 index 820e65b..0000000 --- a/src/test/java/com/ject/studytrip/auth/fixture/KakaoLoginRequestFixture.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.ject.studytrip.auth.fixture; - -import com.ject.studytrip.auth.presentation.dto.request.KakaoLoginRequest; - -public class KakaoLoginRequestFixture { - private String code = "valid-code"; - - public KakaoLoginRequestFixture withCode(String code) { - this.code = code; - return this; - } - - public KakaoLoginRequest build() { - return new KakaoLoginRequest(code); - } -} diff --git a/src/test/java/com/ject/studytrip/auth/fixture/KakaoSignupRequestFixture.java b/src/test/java/com/ject/studytrip/auth/fixture/KakaoSignupRequestFixture.java deleted file mode 100644 index 4d4e77b..0000000 --- a/src/test/java/com/ject/studytrip/auth/fixture/KakaoSignupRequestFixture.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.ject.studytrip.auth.fixture; - -import com.ject.studytrip.auth.presentation.dto.request.KakaoSignupRequest; - -public class KakaoSignupRequestFixture { - private String category = "STUDENT"; - private String nickname = "민우"; - - public KakaoSignupRequestFixture withCategory(String category) { - this.category = category; - return this; - } - - public KakaoSignupRequestFixture withNickname(String nickname) { - this.nickname = nickname; - return this; - } - - public KakaoSignupRequest build() { - return new KakaoSignupRequest(category, nickname); - } -} diff --git a/src/test/java/com/ject/studytrip/auth/fixture/KakaoTokenResponseFixture.java b/src/test/java/com/ject/studytrip/auth/fixture/KakaoTokenResponseFixture.java deleted file mode 100644 index e945609..0000000 --- a/src/test/java/com/ject/studytrip/auth/fixture/KakaoTokenResponseFixture.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.ject.studytrip.auth.fixture; - -import com.ject.studytrip.auth.infra.dto.KakaoTokenResponse; - -public class KakaoTokenResponseFixture { - - public KakaoTokenResponse build() { - return new KakaoTokenResponse( - "bearer", "access-token", 3600, "refresh-token", 7200, "scope"); - } -} diff --git a/src/test/java/com/ject/studytrip/auth/fixture/KakaoUserInfoResponseFixture.java b/src/test/java/com/ject/studytrip/auth/fixture/KakaoUserInfoResponseFixture.java deleted file mode 100644 index a0df5be..0000000 --- a/src/test/java/com/ject/studytrip/auth/fixture/KakaoUserInfoResponseFixture.java +++ /dev/null @@ -1,37 +0,0 @@ -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.KakaoUserInfoResponse; - -public class KakaoUserInfoResponseFixture { - private String kakaoId = "12345"; - private String email = "choi@kakao.com"; - private String profileImage = "https://kakao.com/profile.jpg"; - - public KakaoUserInfoResponseFixture withKakaoId(String kakaoId) { - this.kakaoId = kakaoId; - return this; - } - - public KakaoUserInfoResponseFixture withEmail(String email) { - this.email = email; - return this; - } - - public KakaoUserInfoResponseFixture withProfileImage(String profileImage) { - this.profileImage = profileImage; - return this; - } - - public KakaoUserInfoResponseFixture withKakaoIdAndEmail(String kakaoId, String email) { - this.kakaoId = kakaoId; - this.email = email; - return this; - } - - public KakaoUserInfoResponse build() { - return new KakaoUserInfoResponse( - kakaoId, new KakaoAccount(new KakaoProfile(profileImage), email)); - } -} diff --git a/src/test/java/com/ject/studytrip/auth/fixture/LogoutRequestFixture.java b/src/test/java/com/ject/studytrip/auth/fixture/LogoutRequestFixture.java deleted file mode 100644 index 8d81402..0000000 --- a/src/test/java/com/ject/studytrip/auth/fixture/LogoutRequestFixture.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.ject.studytrip.auth.fixture; - -import com.ject.studytrip.auth.presentation.dto.request.LogoutRequest; - -public class LogoutRequestFixture { - private String accessToken = null; - - public LogoutRequestFixture withAccessToken(String accessToken) { - this.accessToken = accessToken; - return this; - } - - public LogoutRequest build() { - return new LogoutRequest(accessToken); - } -} diff --git a/src/test/java/com/ject/studytrip/auth/fixture/TokenFixture.java b/src/test/java/com/ject/studytrip/auth/fixture/TokenFixture.java deleted file mode 100644 index 8cb3172..0000000 --- a/src/test/java/com/ject/studytrip/auth/fixture/TokenFixture.java +++ /dev/null @@ -1,19 +0,0 @@ -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 final String TOKEN_PREFIX = "Bearer "; - - public static TokenProperties createTokenProperties() { - return new TokenProperties(TEST_SECRET, ACCESS_EXPIRATION_TIME, REFRESH_EXPIRATION_TIME); - } - - public static String authorization(String token) { - return TOKEN_PREFIX + token; - } -} diff --git a/src/test/java/com/ject/studytrip/auth/helper/AuthCookieTestHelper.java b/src/test/java/com/ject/studytrip/auth/helper/AuthCookieTestHelper.java deleted file mode 100644 index 9d369ad..0000000 --- a/src/test/java/com/ject/studytrip/auth/helper/AuthCookieTestHelper.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.ject.studytrip.auth.helper; - -import jakarta.servlet.http.Cookie; -import org.springframework.stereotype.Component; - -@Component -public class AuthCookieTestHelper { - - public Cookie createKakaoSignupProfileCookie(String name, String signupKey) { - Cookie cookie = new Cookie(name, signupKey); - cookie.setHttpOnly(true); - cookie.setSecure(true); - cookie.setPath("/"); - return cookie; - } - - public Cookie createRefreshTokenCookie(String name, String refreshToken) { - Cookie cookie = new Cookie(name, refreshToken); - cookie.setHttpOnly(true); - cookie.setSecure(true); - cookie.setPath("/"); - return cookie; - } -} diff --git a/src/test/java/com/ject/studytrip/auth/helper/KakaoOauthTestHelper.java b/src/test/java/com/ject/studytrip/auth/helper/KakaoOauthTestHelper.java deleted file mode 100644 index a7a7fd7..0000000 --- a/src/test/java/com/ject/studytrip/auth/helper/KakaoOauthTestHelper.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.ject.studytrip.auth.helper; - -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; - -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.global.exception.CustomException; -import com.ject.studytrip.member.domain.error.MemberErrorCode; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; - -@Component -public class KakaoOauthTestHelper { - private final KakaoOauthProvider kakaoOauthProvider; - - @Autowired - public KakaoOauthTestHelper(KakaoOauthProvider kakaoOauthProvider) { - this.kakaoOauthProvider = kakaoOauthProvider; - } - - public void mockSuccess( - KakaoTokenResponse kakaoTokenResponse, KakaoUserInfoResponse kakaoUserInfoResponse) { - given(kakaoOauthProvider.getKakaoTokens(anyString(), anyString())) - .willReturn(kakaoTokenResponse); - given(kakaoOauthProvider.getKakaoUserInfo(anyString())).willReturn(kakaoUserInfoResponse); - } - - public void mockThrowException( - KakaoTokenResponse kakaoTokenResponse, MemberErrorCode memberErrorCode) { - given(kakaoOauthProvider.getKakaoTokens(anyString(), anyString())) - .willReturn(kakaoTokenResponse); - given(kakaoOauthProvider.getKakaoUserInfo(anyString())) - .willThrow(new CustomException(memberErrorCode)); - } -} diff --git a/src/test/java/com/ject/studytrip/auth/helper/TokenTestHelper.java b/src/test/java/com/ject/studytrip/auth/helper/TokenTestHelper.java deleted file mode 100644 index cdba0de..0000000 --- a/src/test/java/com/ject/studytrip/auth/helper/TokenTestHelper.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.ject.studytrip.auth.helper; - -import com.ject.studytrip.auth.infra.provider.TokenProvider; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; - -@Component -public class TokenTestHelper { - private final TokenProvider tokenProvider; - - @Autowired - public TokenTestHelper(TokenProvider tokenProvider) { - this.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 deleted file mode 100644 index a4a8ac1..0000000 --- a/src/test/java/com/ject/studytrip/auth/presentation/controller/AuthControllerIntegrationTest.java +++ /dev/null @@ -1,534 +0,0 @@ -package com.ject.studytrip.auth.presentation.controller; - -import static com.ject.studytrip.global.common.constants.CookieConstants.*; -import static org.hamcrest.Matchers.containsString; -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.domain.error.AuthErrorCode; -import com.ject.studytrip.auth.domain.repository.KakaoSignupProfileRedisRepository; -import com.ject.studytrip.auth.domain.repository.RefreshTokenRedisRepository; -import com.ject.studytrip.auth.fixture.*; -import com.ject.studytrip.auth.helper.AuthCookieTestHelper; -import com.ject.studytrip.auth.helper.KakaoOauthTestHelper; -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.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 jakarta.servlet.http.Cookie; -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; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -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 { - private static final String BASE_AUTH_URL = "/api/auth"; - private static final String TEST_ORIGIN = "http://localhost:8080"; - private static final String RESPONSE_COOKIE_NAME = "Set-Cookie"; - - @Autowired private MemberTestHelper memberTestHelper; - @Autowired private TokenTestHelper tokenTestHelper; - @Autowired private KakaoOauthTestHelper kakaoOauthTestHelper; - @Autowired private AuthCookieTestHelper authCookieTestHelper; - - @Autowired private RefreshTokenRedisRepository refreshTokenRedisRepository; - @Autowired private KakaoSignupProfileRedisRepository kakaoSignupProfileRedisRepository; - - @MockitoBean KakaoOauthProvider kakaoOauthProvider; - - private Member member; - private String accessToken; - private String refreshToken; - private String signupKey; - - @BeforeEach - void setUp() { - member = memberTestHelper.saveMember(); - accessToken = - tokenTestHelper.createAccessToken( - member.getId().toString(), member.getRole().name()); - refreshToken = tokenTestHelper.createRefreshToken(); - - signupKey = - kakaoSignupProfileRedisRepository.saveAndIssueSignupKey( - member.getSocialId(), member.getEmail(), member.getProfileImage()); - - long refreshTokenExpirationTime = Duration.ofSeconds(30).toMillis(); - refreshTokenRedisRepository.saveRefreshToken( - member.getId().toString(), refreshToken, refreshTokenExpirationTime); - } - - @Nested - @DisplayName("카카오 로그인 API") - class KakaoLogin { - private final KakaoTokenResponseFixture kakaoTokenResponseFixture = - new KakaoTokenResponseFixture(); - private final KakaoLoginRequestFixture kakaoLoginRequestFixture = - new KakaoLoginRequestFixture(); - private final KakaoUserInfoResponseFixture kakaoUserInfoResponseFixture = - new KakaoUserInfoResponseFixture(); - - private ResultActions getResultActions(KakaoLoginRequest request) throws Exception { - return mockMvc.perform( - post(BASE_AUTH_URL + "/login/kakao") - .header("Origin", TEST_ORIGIN) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); - } - - @Test - @DisplayName("탈퇴한 사용자가 인가 코드로 로그인 시 400 Bad Request를 반환한다.") - void shouldReturnBadRequestWhenMemberAlreadyDeleted() throws Exception { - // given - member.updateDeletedAt(); - KakaoLoginRequest request = kakaoLoginRequestFixture.build(); - KakaoTokenResponse kakaoTokenResponse = kakaoTokenResponseFixture.build(); - kakaoOauthTestHelper.mockThrowException( - kakaoTokenResponse, MemberErrorCode.MEMBER_ALREADY_DELETED); - - // when - ResultActions resultActions = getResultActions(request); - - // then - resultActions - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - MemberErrorCode.MEMBER_ALREADY_DELETED - .getStatus() - .value())) - .andExpect( - jsonPath("$.data.message") - .value(MemberErrorCode.MEMBER_ALREADY_DELETED.getMessage())); - } - - @Test - @DisplayName("가입되지 않은 사용자 인가 코드로 로그인 시 회원가입 필요 응답을 반환한다.") - void shouldReturnSignupRequiredWhenMemberNotSignUp() throws Exception { - // given - memberTestHelper.deleteMemberById(member.getId()); - KakaoLoginRequest request = kakaoLoginRequestFixture.build(); - KakaoTokenResponse kakaoTokenResponse = kakaoTokenResponseFixture.build(); - KakaoUserInfoResponse kakaoUserInfoResponse = kakaoUserInfoResponseFixture.build(); - kakaoOauthTestHelper.mockSuccess(kakaoTokenResponse, kakaoUserInfoResponse); - - // 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.signupRequired").value(true)) - .andExpect(jsonPath("$.data.accessToken").doesNotExist()) - .andExpect(header().exists(RESPONSE_COOKIE_NAME)) - .andExpect( - header().string( - HttpHeaders.SET_COOKIE, - containsString(OAUTH_SIGNUP_KEY + "="))) - .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("HttpOnly"))) - .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("Secure"))) - .andExpect( - header().string( - HttpHeaders.SET_COOKIE, - containsString("SameSite=None"))); - } - - @Test - @DisplayName("가입된 사용자의 인가 코드로 로그인하면 토큰이 발급된다.") - void shouldReturnTokenResponseWhenLoginIsSuccessful() throws Exception { - // given - KakaoLoginRequest request = kakaoLoginRequestFixture.build(); - KakaoTokenResponse kakaoTokenResponse = kakaoTokenResponseFixture.build(); - KakaoUserInfoResponse kakaoUserInfoResponse = kakaoUserInfoResponseFixture.build(); - kakaoOauthTestHelper.mockSuccess(kakaoTokenResponse, kakaoUserInfoResponse); - - // 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.signupRequired").value(false)) - .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) - .andExpect(header().exists(RESPONSE_COOKIE_NAME)) - .andExpect( - header().string( - HttpHeaders.SET_COOKIE, - containsString(AUTH_REFRESH_TOKEN + "="))) - .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("HttpOnly"))) - .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("Secure"))) - .andExpect( - header().string( - HttpHeaders.SET_COOKIE, - containsString("SameSite=None"))); - } - } - - @Nested - @DisplayName("카카오 회원가입 API") - class KakaoSignup { - private final KakaoSignupRequestFixture kakaoSignupRequestFixture = - new KakaoSignupRequestFixture(); - - private ResultActions getResultActions(KakaoSignupRequest request, Cookie cookie) - throws Exception { - return mockMvc.perform( - post(BASE_AUTH_URL + "/signup/kakao") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - .cookie(cookie)); - } - - @Test - @DisplayName("회원가입 요청 시 signupKey 쿠키가 없으면 400 Bad Request를 반환한다.") - void shouldReturnBadRequestWhenSignupKeyCookieMissing() throws Exception { - // given - memberTestHelper.deleteMemberById(member.getId()); - KakaoSignupRequest request = kakaoSignupRequestFixture.build(); - Cookie nullCookie = - authCookieTestHelper.createKakaoSignupProfileCookie("NULL_COOKIE", signupKey); - - // when - ResultActions resultActions = getResultActions(request, nullCookie); - - // then - resultActions - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - AuthErrorCode.MISSING_KAKAO_SIGNUP_KEY - .getStatus() - .value())) - .andExpect( - jsonPath("$.data.message") - .value(AuthErrorCode.MISSING_KAKAO_SIGNUP_KEY.getMessage())); - } - - @Test - @DisplayName("회원가입 요청 시 signupKey가 유효하지 않으면 400 Bad Request를 반환한다.") - void shouldReturnBadRequestWhenSignupKeyInvalid() throws Exception { - // given - memberTestHelper.deleteMemberById(member.getId()); - KakaoSignupRequest request = kakaoSignupRequestFixture.build(); - Cookie invalidCookie = - authCookieTestHelper.createKakaoSignupProfileCookie( - OAUTH_SIGNUP_KEY, "invalid.pending.key"); - - // when - 유효하지 않은 쿠키로 요청 - ResultActions resultActions = getResultActions(request, invalidCookie); - - // then - resultActions - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - AuthErrorCode.INVALID_KAKAO_SIGNUP_KEY - .getStatus() - .value())) - .andExpect( - jsonPath("$.data.message") - .value(AuthErrorCode.INVALID_KAKAO_SIGNUP_KEY.getMessage())); - } - - @Test - @DisplayName("이미 가입된 사용자가 회원가입 요청 시 409 Conflict를 반환한다.") - void shouldThrowExceptionWhenSignupForExistingMember() throws Exception { - // given - KakaoSignupRequest request = kakaoSignupRequestFixture.build(); - Cookie cookie = - authCookieTestHelper.createKakaoSignupProfileCookie( - OAUTH_SIGNUP_KEY, signupKey); - - // when - ResultActions resultActions = getResultActions(request, cookie); - - // then - resultActions - .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("회원가입 요청 시 유효한 정보라면 토큰이 발급된다.") - void shouldReturnTokenResponseWhenSignupIsSuccessful() throws Exception { - // given - memberTestHelper.deleteMemberById(member.getId()); - KakaoSignupRequest request = kakaoSignupRequestFixture.build(); - Cookie cookie = - authCookieTestHelper.createKakaoSignupProfileCookie( - OAUTH_SIGNUP_KEY, signupKey); - - // when - ResultActions resultActions = getResultActions(request, cookie); - - // then - resultActions - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) - .andExpect(jsonPath("$.data.signupRequired").value(false)) - .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) - .andExpect(header().exists(RESPONSE_COOKIE_NAME)) - .andExpect( - header().string( - HttpHeaders.SET_COOKIE, - containsString(AUTH_REFRESH_TOKEN + "="))) - .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("HttpOnly"))) - .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("Secure"))) - .andExpect( - header().string( - HttpHeaders.SET_COOKIE, - containsString("SameSite=None"))); - } - } - - @Nested - @DisplayName("토큰 재발급 API") - class ReissueToken { - - private ResultActions getResultActions(Cookie cookie) throws Exception { - return mockMvc.perform(post(BASE_AUTH_URL + "/token/reissue").cookie(cookie)); - } - - @Test - @DisplayName("리프레시 토큰이 null 혹은 비어있을 경우 Bad Request를 반환한다.") - void shouldReturnBadRequestWhenRefreshTokenIsNullOrBlank() throws Exception { - // given - Cookie nullCookie = - authCookieTestHelper.createRefreshTokenCookie( - "null.refresh.cookie", refreshToken); - - // when - ResultActions resultActions = getResultActions(nullCookie); - - // then - resultActions - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value(AuthErrorCode.MISSING_REFRESH_TOKEN.getStatus().value())) - .andExpect( - jsonPath("$.data.message") - .value(AuthErrorCode.MISSING_REFRESH_TOKEN.getMessage())); - } - - @Test - @DisplayName("리프레시 토큰이 존재하지 않거나 위조된 경우 401 UNAUTHORIZED를 반환한다.") - void shouldReturnUnauthorizedWhenRefreshTokenIsInvalid() throws Exception { - // given - Cookie invalidRefreshCookie = - authCookieTestHelper.createRefreshTokenCookie( - AUTH_REFRESH_TOKEN, "invalid:refresh:cookie"); - - // when - ResultActions resultActions = getResultActions(invalidRefreshCookie); - - // 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 - Cookie refreshCookie = - authCookieTestHelper.createRefreshTokenCookie(AUTH_REFRESH_TOKEN, refreshToken); - - // when - ResultActions resultActions = getResultActions(refreshCookie); - - // then - resultActions - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) - .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) - .andExpect(header().exists(RESPONSE_COOKIE_NAME)) - .andExpect( - header().string( - HttpHeaders.SET_COOKIE, - containsString(AUTH_REFRESH_TOKEN + "="))) - .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("HttpOnly"))) - .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("Secure"))) - .andExpect( - header().string( - HttpHeaders.SET_COOKIE, - containsString("SameSite=None"))); - } - } - - @Nested - @DisplayName("로그아웃 API") - class Logout { - private final LogoutRequestFixture logoutRequestFixture = new LogoutRequestFixture(); - - private ResultActions getResultActions(LogoutRequest request, Cookie cookie) - throws Exception { - return mockMvc.perform( - post(BASE_AUTH_URL + "/logout") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - .cookie(cookie)); - } - - @Test - @DisplayName("엑세스 토큰이 null 이면 400 Bad Request를 반환한다.") - void shouldReturnBadRequestWhenAccessTokenIsNull() throws Exception { - // given - LogoutRequest request = logoutRequestFixture.build(); - Cookie refreshCookie = - authCookieTestHelper.createRefreshTokenCookie(AUTH_REFRESH_TOKEN, refreshToken); - - // when - ResultActions resultActions = getResultActions(request, refreshCookie); - - // 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").build(); - Cookie refreshCookie = - authCookieTestHelper.createRefreshTokenCookie(AUTH_REFRESH_TOKEN, refreshToken); - - // when - ResultActions resultActions = getResultActions(request, refreshCookie); - - // 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("리프레시 토큰이 null 혹은 비어있을 경우 BAD REQUEST를 반환한다.") - void shouldReturnBadRequestWhenRefreshTokenIsNullOrBlank() throws Exception { - // given - LogoutRequest request = logoutRequestFixture.withAccessToken(accessToken).build(); - Cookie nullCookie = - authCookieTestHelper.createRefreshTokenCookie( - "null.refresh.cookie", refreshToken); - - // when - ResultActions resultActions = getResultActions(request, nullCookie); - - // then - resultActions - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value(AuthErrorCode.MISSING_REFRESH_TOKEN.getStatus().value())) - .andExpect( - jsonPath("$.data.message") - .value(AuthErrorCode.MISSING_REFRESH_TOKEN.getMessage())); - } - - @Test - @DisplayName("리프레시 토큰이 존재하지 않거나 위조된 경우 401 UNAUTHORIZED를 반환한다.") - void shouldReturnUnauthorizedWhenRefreshTokenIsInvalid() throws Exception { - // given - LogoutRequest request = logoutRequestFixture.withAccessToken(accessToken).build(); - Cookie invalidRefreshTokenCookie = - authCookieTestHelper.createRefreshTokenCookie( - AUTH_REFRESH_TOKEN, "invalid.refresh.token"); - - // when - ResultActions resultActions = getResultActions(request, invalidRefreshTokenCookie); - - // 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).build(); - Cookie refreshCookie = - authCookieTestHelper.createRefreshTokenCookie(AUTH_REFRESH_TOKEN, refreshToken); - - // when - ResultActions resultActions = getResultActions(request, refreshCookie); - - // 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/image/application/service/ImageServiceTest.java b/src/test/java/com/ject/studytrip/image/application/service/ImageServiceTest.java deleted file mode 100644 index 7755dea..0000000 --- a/src/test/java/com/ject/studytrip/image/application/service/ImageServiceTest.java +++ /dev/null @@ -1,532 +0,0 @@ -package com.ject.studytrip.image.application.service; - -import static com.ject.studytrip.image.fixture.ImageTestConstants.*; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.atLeast; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; - -import com.ject.studytrip.BaseUnitTest; -import com.ject.studytrip.global.config.properties.CdnProperties; -import com.ject.studytrip.global.exception.CustomException; -import com.ject.studytrip.image.application.dto.CleanupImagesResult; -import com.ject.studytrip.image.application.dto.PresignedImageInfo; -import com.ject.studytrip.image.application.event.ImageEventPublisher; -import com.ject.studytrip.image.domain.error.ImageErrorCode; -import com.ject.studytrip.image.fixture.ImageHeadInfoFixture; -import com.ject.studytrip.image.infra.s3.dto.ImageHeadInfo; -import com.ject.studytrip.image.infra.s3.error.S3ErrorCode; -import com.ject.studytrip.image.infra.s3.provider.S3ImageStorageProvider; -import com.ject.studytrip.image.infra.tika.provider.TikaImageProbeProvider; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -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("ImageService 단위 테스트") -class ImageServiceTest extends BaseUnitTest { - - @InjectMocks private ImageService imageService; - @Mock private S3ImageStorageProvider s3Provider; - @Mock private TikaImageProbeProvider tikaProvider; - @Mock private CdnProperties cdnProperties; - @Mock private ImageEventPublisher imageEventPublisher; - - @Nested - @DisplayName("presign 메서드는") - class Presign { - - @Test - @DisplayName("유효한 파라미터로 presigned URL을 발급한다") - void shouldIssuePresignedUrlWithValidParameters() { - // given - given(s3Provider.issuePresignedUrl(anyString())).willReturn(PRESIGNED_URL); - - // when - PresignedImageInfo info = - imageService.presign(VALID_KEY_PREFIX, VALID_ID, ORIGINAL_FILENAME); - - // then - assertThat(info.presignedUrl()).isEqualTo(PRESIGNED_URL); - verify(s3Provider).issuePresignedUrl(anyString()); - } - - @Test - @DisplayName("유효하지 않은 키 Prefix로 호출하면 예외가 발생한다") - void shouldThrowExceptionWhenKeyPrefixIsInvalid() { - // when & then - assertThatThrownBy(() -> imageService.presign(null, VALID_ID, ORIGINAL_FILENAME)) - .isInstanceOf(CustomException.class) - .hasMessage(ImageErrorCode.INVALID_IMAGE_KEY_PREFIX.getMessage()); - } - - @Test - @DisplayName("존재하지 않는 키 Prefix로 호출하면 예외가 발생한다") - void shouldThrowExceptionWhenKeyPrefixDoesNotExist() { - // when & then - assertThatThrownBy( - () -> - imageService.presign( - "invalid-key-prefix/", VALID_ID, ORIGINAL_FILENAME)) - .isInstanceOf(CustomException.class) - .hasMessage(ImageErrorCode.INVALID_IMAGE_KEY_PREFIX.getMessage()); - } - - @Test - @DisplayName("유효하지 않은 이미지 파일 확장자라면 예외가 발생한다") - void shouldThrowExceptionWhenExtensionIsInvalid() { - // when & then - assertThatThrownBy( - () -> - imageService.presign( - VALID_KEY_PREFIX, VALID_ID, INVALID_ORIGINAL_FILENAME)) - .isInstanceOf(CustomException.class) - .hasMessage(ImageErrorCode.INVALID_IMAGE_EXTENSION.getMessage()); - } - } - - @Nested - @DisplayName("confirm 메서드는") - class Confirm { - - @Test - @DisplayName("유효한 이미지를 검증하고 CDN URL을 반환한다") - void shouldConfirmValidImageAndReturnCdnUrl() { - // given - ImageHeadInfo headInfo = ImageHeadInfoFixture.createImageHeadInfo(); - given(s3Provider.getHeadByKey(TMP_KEY)).willReturn(headInfo); - given(s3Provider.readPrefix(TMP_KEY, (int) VALID_CONTENT_LENGTH)) - .willReturn(JPEG_HEADER_BYTES); - given(tikaProvider.detectMime(JPEG_HEADER_BYTES)).willReturn(VALID_MIME); - given(cdnProperties.domain()).willReturn("test-cdn.cloudfront.net"); - - // when - String result = imageService.confirm(TMP_KEY); - - // then - assertThat(result).isNotNull(); - assertThat(result).startsWith("test-cdn.cloudfront.net/"); - assertThat(result).contains(FINAL_KEY); - verify(s3Provider).getHeadByKey(TMP_KEY); - verify(s3Provider).readPrefix(TMP_KEY, (int) VALID_CONTENT_LENGTH); - verify(tikaProvider).detectMime(JPEG_HEADER_BYTES); - verify(s3Provider).copyByKey(TMP_KEY, FINAL_KEY); - } - - @Test - @DisplayName("tmpKey가 null이면 예외가 발생한다") - void shouldThrowExceptionWhenTmpKeyIsNull() { - // when & then - assertThatThrownBy(() -> imageService.confirm(null)) - .isInstanceOf(CustomException.class) - .hasMessage(ImageErrorCode.INVALID_IMAGE_KEY.getMessage()); - - // cleanup 비호출 여부 - verify(s3Provider, never()).deleteByKey(anyString()); - } - - @Test - @DisplayName("tmpKey가 빈 문자열이면 예외가 발생한다") - void shouldThrowExceptionWhenTmpKeyIsBlank() { - // when & then - assertThatThrownBy(() -> imageService.confirm("")) - .isInstanceOf(CustomException.class) - .hasMessage(ImageErrorCode.INVALID_IMAGE_KEY.getMessage()); - - // cleanup 비호출 여부 - verify(s3Provider, never()).deleteByKey(anyString()); - } - - @Test - @DisplayName("이미지 크기가 유효하지 않으면 cleanup 후 예외가 발생한다") - void shouldCleanupAndThrowExceptionWhenImageSizeIsInvalid() { - // given - ImageHeadInfo headInfo = ImageHeadInfoFixture.createLargeImageHeadInfo(); - given(s3Provider.getHeadByKey(TMP_KEY)).willReturn(headInfo); - - // when & then - assertThatThrownBy(() -> imageService.confirm(TMP_KEY)) - .isInstanceOf(CustomException.class) - .hasMessage(ImageErrorCode.IMAGE_SIZE_EXCEEDED.getMessage()); - - // cleanup 호출 여부 - verify(s3Provider).deleteByKey(TMP_KEY); - } - - @Test - @DisplayName("이미지 크기가 0이면 cleanup 후 예외가 발생한다") - void shouldCleanupAndThrowExceptionWhenImageSizeIsZero() { - // given - ImageHeadInfo headInfo = ImageHeadInfoFixture.createEmptyImageHeadInfo(); - given(s3Provider.getHeadByKey(TMP_KEY)).willReturn(headInfo); - - // when & then - assertThatThrownBy(() -> imageService.confirm(TMP_KEY)) - .isInstanceOf(CustomException.class) - .hasMessage(ImageErrorCode.EMPTY_IMAGE.getMessage()); - - // cleanup 호출 여부 - verify(s3Provider).deleteByKey(TMP_KEY); - } - - @Test - @DisplayName("MIME 타입이 유효하지 않으면 cleanup 후 예외가 발생한다") - void shouldCleanupAndThrowExceptionWhenMimeIsInvalid() { - // given - ImageHeadInfo headInfo = ImageHeadInfoFixture.createImageHeadInfo(); - given(s3Provider.getHeadByKey(TMP_KEY)).willReturn(headInfo); - given(s3Provider.readPrefix(TMP_KEY, (int) VALID_CONTENT_LENGTH)) - .willReturn(JPEG_HEADER_BYTES); - given(tikaProvider.detectMime(JPEG_HEADER_BYTES)).willReturn(INVALID_MIME); - - // when & then - assertThatThrownBy(() -> imageService.confirm(TMP_KEY)) - .isInstanceOf(CustomException.class) - .hasMessage(ImageErrorCode.INVALID_IMAGE_MIME.getMessage()); - - // cleanup 호출 여부 - verify(s3Provider).deleteByKey(TMP_KEY); - } - - @Test - @DisplayName("빈 문자열 tmpKey로 호출하면 예외가 발생한다") - void shouldThrowExceptionWhenTmpKeyIsEmptyString() { - // when & then - assertThatThrownBy(() -> imageService.confirm("")) - .isInstanceOf(CustomException.class) - .hasMessage(ImageErrorCode.INVALID_IMAGE_KEY.getMessage()); - - // cleanup 비호출 여부 - verify(s3Provider, never()).deleteByKey(anyString()); - } - - @Test - @DisplayName("S3 작업 중 에러가 발생하면 예외가 발생한다") - void shouldThrowExceptionWhenS3OperationFails() { - // given - given(s3Provider.getHeadByKey(TMP_KEY)) - .willThrow(new CustomException(S3ErrorCode.S3_STORAGE_SERVER_ERROR)); - - // when & then - assertThatThrownBy(() -> imageService.confirm(TMP_KEY)) - .isInstanceOf(CustomException.class) - .hasMessage(S3ErrorCode.S3_STORAGE_SERVER_ERROR.getMessage()); - - // cleanup 비호출 여부 - verify(s3Provider, never()).deleteByKey(anyString()); - } - - @Test - @DisplayName("이미지 크기가 PROBE_BYTES보다 작으면 전체 크기만큼만 읽는다") - void shouldReadOnlyActualSizeWhenSmallerThanProbeBytes() { - // given - long smallSize = 1024L; - ImageHeadInfo headInfo = ImageHeadInfoFixture.createImageHeadInfo(smallSize); - byte[] smallImageBytes = new byte[(int) smallSize]; - - given(s3Provider.getHeadByKey(TMP_KEY)).willReturn(headInfo); - given(s3Provider.readPrefix(TMP_KEY, (int) smallSize)).willReturn(smallImageBytes); - given(tikaProvider.detectMime(smallImageBytes)).willReturn(VALID_MIME); - given(cdnProperties.domain()).willReturn("test-cdn.cloudfront.net"); - - // when - String result = imageService.confirm(TMP_KEY); - - // then - assertThat(result).isNotNull(); - assertThat(result).startsWith("test-cdn.cloudfront.net/"); - assertThat(result).contains(FINAL_KEY); - verify(s3Provider).readPrefix(TMP_KEY, (int) smallSize); - } - } - - @Nested - @DisplayName("cancel 메서드는") - class Cancel { - - @Test - @DisplayName("업로드된 키들을 삭제한다") - void shouldDeleteUploadedKeys() { - // given - List uploadedKeys = Arrays.asList(TMP_KEY, "tmp/profile/12345/test2.jpg"); - - // when - imageService.cancel(uploadedKeys); - - // then - verify(s3Provider).deleteByKeys(uploadedKeys); - } - - @Test - @DisplayName("빈 리스트로 호출해도 정상 동작한다") - void shouldHandleEmptyList() { - // given - List emptyKeys = Arrays.asList(); - - // when & then - imageService.cancel(emptyKeys); - - // then - verify(s3Provider).deleteByKeys(emptyKeys); - } - } - - @Nested - @DisplayName("cleanup 메서드는") - class Cleanup { - private static final String IMAGE_BASE_URL = "https://test-cdn.cloudfront.net"; - private static final String EXTRACTED_KEY = "members/1/test.jpg"; - - @Test - @DisplayName("유효한 CDN URL에서 키를 추출하고 이미지를 삭제한다") - void shouldExtractKeyAndDeleteImage() { - // given - given(cdnProperties.domain()).willReturn(IMAGE_BASE_URL); - - // when - imageService.cleanup(IMAGE_BASE_URL + "/" + EXTRACTED_KEY); - - // then - verify(s3Provider).deleteByKey(EXTRACTED_KEY); - } - - @Test - @DisplayName("null URL이면 삭제하지 않는다") - void shouldNotDeleteWhenUrlIsNull() { - // given - given(cdnProperties.domain()).willReturn(IMAGE_BASE_URL); - - // when - imageService.cleanup(null); - - // then - verify(s3Provider, never()).deleteByKey(anyString()); - } - - @Test - @DisplayName("빈 URL이면 삭제하지 않는다") - void shouldNotDeleteWhenUrlIsEmpty() { - // given - given(cdnProperties.domain()).willReturn(IMAGE_BASE_URL); - - // when - imageService.cleanup(""); - - // then - verify(s3Provider, never()).deleteByKey(anyString()); - } - - @Test - @DisplayName("잘못된 CDN 도메인이면 삭제하지 않는다") - void shouldNotDeleteWhenCdnDomainMismatch() { - // given - given(cdnProperties.domain()).willReturn(IMAGE_BASE_URL); - - // when - imageService.cleanup("https://wrong-cdn.com/members/1/test.jpg"); - - // then - verify(s3Provider, never()).deleteByKey(anyString()); - } - - @Test - @DisplayName("유효하지 않은 URL 형식이면 삭제하지 않는다") - void shouldNotDeleteWhenUrlFormatIsInvalid() { - // given - given(cdnProperties.domain()).willReturn(IMAGE_BASE_URL); - - // when - imageService.cleanup("invalid-url"); - - // then - verify(s3Provider, never()).deleteByKey(anyString()); - } - } - - @Nested - @DisplayName("cleanupBatch 메서드는") - class CleanupBatch { - private static final String IMAGE_BASE_URL = "https://test-cdn.cloudfront.net"; - private static final String VALID_IMAGE_URL1 = IMAGE_BASE_URL + "/members/1/image1.jpg"; - private static final String VALID_IMAGE_URL2 = IMAGE_BASE_URL + "/members/1/image2.jpg"; - private static final String VALID_KEY1 = "members/1/image1.jpg"; - private static final String VALID_KEY2 = "members/1/image2.jpg"; - - @Test - @DisplayName("빈 리스트로 호출하면 아무 작업도 수행하지 않는다") - void shouldDoNothingWhenListIsEmpty() { - // when - imageService.cleanupBatch(List.of()); - - // then - verify(s3Provider, never()).deleteByKeys(any()); - } - - @Test - @DisplayName("null 리스트로 호출하면 아무 작업도 수행하지 않는다") - void shouldDoNothingWhenListIsNull() { - // when - imageService.cleanupBatch(null); - - // then - verify(s3Provider, never()).deleteByKeys(any()); - } - - @Test - @DisplayName("유효한 이미지 URL 리스트로 배치 삭제를 수행한다") - void shouldDeleteImagesBatchWhenValidUrls() { - // given - List imageUrls = Arrays.asList(VALID_IMAGE_URL1, VALID_IMAGE_URL2); - List keys = Arrays.asList(VALID_KEY1, VALID_KEY2); - given(cdnProperties.domain()).willReturn(IMAGE_BASE_URL); - given(s3Provider.deleteByKeys(keys)).willReturn(CleanupImagesResult.of(2, List.of())); - - // when - imageService.cleanupBatch(imageUrls); - - // then - verify(s3Provider).deleteByKeys(keys); - } - - @Test - @DisplayName("일부 이미지 삭제 실패 시 실패한 키를 수집한다") - void shouldCollectFailedKeysWhenSomeDeletionsFail() { - // given - List imageUrls = Arrays.asList(VALID_IMAGE_URL1, VALID_IMAGE_URL2); - List keys = Arrays.asList(VALID_KEY1, VALID_KEY2); - given(cdnProperties.domain()).willReturn(IMAGE_BASE_URL); - given(s3Provider.deleteByKeys(keys)) - .willReturn(CleanupImagesResult.of(1, Arrays.asList(VALID_KEY2))); - - // when - imageService.cleanupBatch(imageUrls); - - // then - verify(s3Provider).deleteByKeys(keys); - } - - @Test - @DisplayName("1000개 이상의 이미지 URL이 들어오면 배치로 나누어 처리한다") - void shouldSplitBatchWhenUrlsExceedMaxBatch() { - // given - List imageUrls = new ArrayList<>(); - List keys = new ArrayList<>(); - for (int i = 0; i < 1500; i++) { - String url = IMAGE_BASE_URL + "/members/1/image" + i + ".jpg"; - String key = "members/1/image" + i + ".jpg"; - imageUrls.add(url); - keys.add(key); - } - - given(cdnProperties.domain()).willReturn(IMAGE_BASE_URL); - given(s3Provider.deleteByKeys(any())) - .willReturn(CleanupImagesResult.of(1000, List.of())); - - // when - imageService.cleanupBatch(imageUrls); - - // then - verify(s3Provider, atLeast(2)).deleteByKeys(any()); - } - - @Test - @DisplayName("null URL이 포함된 리스트는 필터링하여 처리한다") - void shouldFilterNullUrls() { - // given - List imageUrls = - Arrays.asList(VALID_IMAGE_URL1, null, VALID_IMAGE_URL2, "", "invalid-url"); - List keys = Arrays.asList(VALID_KEY1, VALID_KEY2); - given(cdnProperties.domain()).willReturn(IMAGE_BASE_URL); - given(s3Provider.deleteByKeys(keys)).willReturn(CleanupImagesResult.of(2, List.of())); - - // when - imageService.cleanupBatch(imageUrls); - - // then - verify(s3Provider).deleteByKeys(keys); - } - - @Test - @DisplayName("중복된 이미지 URL은 한 번만 처리한다") - void shouldRemoveDuplicateUrls() { - // given - List imageUrls = - Arrays.asList(VALID_IMAGE_URL1, VALID_IMAGE_URL1, VALID_IMAGE_URL2); - List keys = Arrays.asList(VALID_KEY1, VALID_KEY2); - given(cdnProperties.domain()).willReturn(IMAGE_BASE_URL); - given(s3Provider.deleteByKeys(keys)).willReturn(CleanupImagesResult.of(2, List.of())); - - // when - imageService.cleanupBatch(imageUrls); - - // then - verify(s3Provider).deleteByKeys(keys); - } - - @Test - @DisplayName("잘못된 CDN 도메인을 가진 URL은 필터링하여 처리한다") - void shouldFilterInvalidCdnDomainUrls() { - // given - String wrongDomainUrl = "https://wrong-cdn.com/members/1/image1.jpg"; - List imageUrls = Arrays.asList(VALID_IMAGE_URL1, wrongDomainUrl); - List keys = Arrays.asList(VALID_KEY1); - given(cdnProperties.domain()).willReturn(IMAGE_BASE_URL); - given(s3Provider.deleteByKeys(keys)).willReturn(CleanupImagesResult.of(1, List.of())); - - // when - imageService.cleanupBatch(imageUrls); - - // then - verify(s3Provider).deleteByKeys(keys); - } - } - - @Nested - @DisplayName("publishCleanupBatchEvent 메서드는") - class PublishCleanupBatchEvent { - - @Test - @DisplayName("유효한 이미지 URL 리스트로 이벤트를 발행한다") - void shouldPublishEventWhenImageUrlsAreValid() { - // given - List imageUrls = - Arrays.asList( - "https://cdn.example.com/members/1/image1.jpg", - "https://cdn.example.com/members/1/image2.jpg"); - - // when - imageService.publishCleanupBatchEvent(imageUrls); - - // then - verify(imageEventPublisher).publishCleanupBatch(imageUrls); - } - - @Test - @DisplayName("빈 리스트로 호출하면 이벤트는 발행되지만 내부에서 처리되지 않는다.") - void shouldNotPublishEventWhenListIsEmpty() { - // when - imageService.publishCleanupBatchEvent(List.of()); - - // then - verify(imageEventPublisher).publishCleanupBatch(List.of()); - } - - @Test - @DisplayName("null 리스트로 호출하면 이벤트는 발행되지만 내부에서 처리되지 않는다.") - void shouldNotPublishEventWhenListIsNull() { - // when - imageService.publishCleanupBatchEvent(null); - - // then - verify(imageEventPublisher).publishCleanupBatch(null); - } - } -} diff --git a/src/test/java/com/ject/studytrip/image/fixture/ImageHeadInfoFixture.java b/src/test/java/com/ject/studytrip/image/fixture/ImageHeadInfoFixture.java deleted file mode 100644 index e867117..0000000 --- a/src/test/java/com/ject/studytrip/image/fixture/ImageHeadInfoFixture.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.ject.studytrip.image.fixture; - -import static com.ject.studytrip.image.domain.constants.ImageConstants.MAX_IMAGE_SIZE_BYTES; - -import com.ject.studytrip.image.infra.s3.dto.ImageHeadInfo; - -public class ImageHeadInfoFixture { - private static final long DEFAULT_CONTENT_LENGTH = 1024L; - private static final long LARGE_CONTENT_LENGTH = MAX_IMAGE_SIZE_BYTES + 1; - private static final long EMPTY_CONTENT_LENGTH = 0L; - - public static ImageHeadInfo createImageHeadInfo() { - return ImageHeadInfo.of(DEFAULT_CONTENT_LENGTH); - } - - public static ImageHeadInfo createImageHeadInfo(long contentLength) { - return ImageHeadInfo.of(contentLength); - } - - public static ImageHeadInfo createLargeImageHeadInfo() { - return ImageHeadInfo.of(LARGE_CONTENT_LENGTH); - } - - public static ImageHeadInfo createEmptyImageHeadInfo() { - return ImageHeadInfo.of(EMPTY_CONTENT_LENGTH); - } -} diff --git a/src/test/java/com/ject/studytrip/image/fixture/ImageTestConstants.java b/src/test/java/com/ject/studytrip/image/fixture/ImageTestConstants.java deleted file mode 100644 index a7e3c9f..0000000 --- a/src/test/java/com/ject/studytrip/image/fixture/ImageTestConstants.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.ject.studytrip.image.fixture; - -import static com.ject.studytrip.image.domain.constants.ImageConstants.*; - -public class ImageTestConstants { - public static final String VALID_KEY_PREFIX = "members"; - public static final String VALID_ID = "12345"; - public static final String ORIGINAL_FILENAME = "test.jpg"; - public static final String INVALID_ORIGINAL_FILENAME = "test.txt"; - public static final String TMP_KEY = TMP_PREFIX + "members/12345/test.jpg"; - public static final String FINAL_KEY = "members/12345/test.jpg"; - - // 크기 관련 (ImageConstants와 연동) - public static final long VALID_CONTENT_LENGTH = 1024L; - - // MIME 타입 - public static final String VALID_MIME = "image/jpeg"; - public static final String INVALID_MIME = "text/plain"; - - // URL - public static final String PRESIGNED_URL = "https://s3.amazonaws.com/test-presigned-url"; - - // 바이트 데이터 - public static final byte[] JPEG_HEADER_BYTES = - new byte[] {(byte) 0xFF, (byte) 0xD8, (byte) 0xFF}; // JPEG header -} diff --git a/src/test/java/com/ject/studytrip/member/application/service/MemberCommandServiceTest.java b/src/test/java/com/ject/studytrip/member/application/service/MemberCommandServiceTest.java deleted file mode 100644 index 034a7ad..0000000 --- a/src/test/java/com/ject/studytrip/member/application/service/MemberCommandServiceTest.java +++ /dev/null @@ -1,317 +0,0 @@ -package com.ject.studytrip.member.application.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.verify; - -import com.ject.studytrip.BaseUnitTest; -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.model.Member; -import com.ject.studytrip.member.domain.model.SocialProvider; -import com.ject.studytrip.member.domain.repository.MemberCommandRepository; -import com.ject.studytrip.member.domain.repository.MemberRepository; -import com.ject.studytrip.member.fixture.CreateMemberCommandFixture; -import com.ject.studytrip.member.fixture.MemberFixture; -import com.ject.studytrip.member.fixture.UpdateMemberRequestFixture; -import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest; -import java.time.LocalDateTime; -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("MemberCommandService 단위 테스트") -class MemberCommandServiceTest extends BaseUnitTest { - private static final String NEW_MEMBER_NICKNAME = "팬텀"; - private static final String NEW_MEMBER_CATEGORY = "WORKER"; - - @InjectMocks private MemberCommandService memberCommandService; - @Mock private MemberRepository memberRepository; - @Mock private MemberCommandRepository memberCommandRepository; - - private Member member; - private Member memberWithoutProfileImage; - - private String socialId; - private String nickname; - private String category; - - @BeforeEach - void setUp() { - member = MemberFixture.createMemberFromKakaoWithId(1L); - memberWithoutProfileImage = MemberFixture.createMemberWithoutProfileImageFromKakao(); - - socialId = member.getSocialId(); - nickname = member.getNickname(); - category = member.getCategory().name(); - } - - @Nested - @DisplayName("createMemberFromKakao 메서드는") - class CreateMemberFromKakao { - private final CreateMemberCommandFixture fixture = new CreateMemberCommandFixture(); - - @Test - @DisplayName("이미 가입된 멤버가 존재하면 예외가 발생한다.") - void shouldThrowExceptionWhenMemberAlreadyExists() { - // given - CreateMemberCommand command = fixture.withNickname(nickname).build(); - given( - memberRepository.existsBySocialProviderAndSocialId( - SocialProvider.KAKAO, socialId)) - .willReturn(true); - - // when & then - assertThatThrownBy(() -> memberCommandService.createMemberFromKakao(command)) - .isInstanceOf(CustomException.class) - .hasMessage(MemberErrorCode.MEMBER_ALREADY_EXISTS.getMessage()); - } - - @Test - @DisplayName("카테고리가 유효하지 않으면 예외가 발생한다.") - void shouldThrowExceptionWhenCategoryIsInValid() { - // given - CreateMemberCommand command = fixture.withCategory("INVALID").build(); - given( - memberRepository.existsBySocialProviderAndSocialId( - SocialProvider.KAKAO, socialId)) - .willReturn(false); - - // when & then - assertThatThrownBy(() -> memberCommandService.createMemberFromKakao(command)) - .isInstanceOf(CustomException.class) - .hasMessage(MemberErrorCode.INVALID_MEMBER_CATEGORY.getMessage()); - } - - @Test - @DisplayName("CreateMemberCommand가 유효하면 Member를 생성하고 반환한다.") - void shouldReturnMemberWhenCommandIsValid() { - // given - CreateMemberCommand command = fixture.build(); - given( - memberRepository.existsBySocialProviderAndSocialId( - SocialProvider.KAKAO, socialId)) - .willReturn(false); - given(memberRepository.save(any(Member.class))).willReturn(member); - - // when - Member result = memberCommandService.createMemberFromKakao(command); - - // then - assertThat(result).isEqualTo(member); - } - - @Test - @DisplayName("프로필 이미지가 없어도 Member를 생성하고 반환한다.") - void shouldReturnMemberWhenProfileImageIsNull() { - // given - CreateMemberCommand command = fixture.withProfileImage(null).build(); - given( - memberRepository.existsBySocialProviderAndSocialId( - SocialProvider.KAKAO, socialId)) - .willReturn(false); - given(memberRepository.save(any(Member.class))).willReturn(memberWithoutProfileImage); - - // when - Member result = memberCommandService.createMemberFromKakao(command); - - // then - assertThat(result).isEqualTo(memberWithoutProfileImage); - } - } - - @Nested - @DisplayName("updateNicknameAndCategoryIfPresent 메서드는") - class UpdateNicknameAndCategoryIfPresent { - private final UpdateMemberRequestFixture fixture = new UpdateMemberRequestFixture(); - - @Test - @DisplayName("카테고리가 유효하지 않으면 예외가 발생한다.") - void shouldThrowExceptionWhenCategoryIsInValid() { - // given - UpdateMemberRequest request = fixture.withCategory("INVALID").build(); - - // when & then - assertThatThrownBy( - () -> - memberCommandService.updateNicknameAndCategoryIfPresent( - member, request)) - .isInstanceOf(CustomException.class) - .hasMessage(MemberErrorCode.INVALID_MEMBER_CATEGORY.getMessage()); - } - - @Test - @DisplayName("특정 멤버의 닉네임만 수정하고 DB에 반영한다.") - void shouldUpdateMemberNickname() { - // given - UpdateMemberRequest request = fixture.withNickname(NEW_MEMBER_NICKNAME).build(); - - // when - memberCommandService.updateNicknameAndCategoryIfPresent(member, request); - - // then - assertThat(member.getNickname()).isEqualTo(NEW_MEMBER_NICKNAME); - assertThat(member.getCategory().name()).isEqualTo(category); - } - - @Test - @DisplayName("특정 멤버의 카테고리만 수정하고 DB에 반영한다.") - void shouldUpdateMemberCategory() { - // given - UpdateMemberRequest request = fixture.withCategory(NEW_MEMBER_CATEGORY).build(); - - // when - memberCommandService.updateNicknameAndCategoryIfPresent(member, request); - - // then - assertThat(member.getNickname()).isEqualTo(nickname); - assertThat(member.getCategory().name()).isEqualTo(NEW_MEMBER_CATEGORY); - } - - @Test - @DisplayName("특정 멤버의 닉네임과 카테고리를 수정하고 DB에 반영한다.") - void shouldUpdateMemberNicknameAndCategory() { - // given - UpdateMemberRequest request = - fixture.withNickname(NEW_MEMBER_NICKNAME) - .withCategory(NEW_MEMBER_CATEGORY) - .build(); - - // when - memberCommandService.updateNicknameAndCategoryIfPresent(member, request); - - // then - assertThat(member.getNickname()).isEqualTo(NEW_MEMBER_NICKNAME); - assertThat(member.getCategory().name()).isEqualTo(NEW_MEMBER_CATEGORY); - } - } - - @Nested - @DisplayName("updateProfileImage 메서드는") - class UpdateProfileImage { - private static final String NEW_PROFILE_IMAGE = - "https://cdn.example.com/members/1/profile.jpg"; - - @Test - @DisplayName("삭제된 멤버의 프로필 이미지를 수정하면 예외가 발생한다") - void shouldThrowExceptionWhenMemberIsDeleted() { - // given - member.updateDeletedAt(); - - // when & then - assertThatThrownBy( - () -> - memberCommandService.updateProfileImage( - member, NEW_PROFILE_IMAGE)) - .isInstanceOf(CustomException.class) - .hasMessage(MemberErrorCode.MEMBER_ALREADY_DELETED.getMessage()); - } - - @Test - @DisplayName("유효한 멤버의 프로필 이미지를 수정한다") - void shouldUpdateProfileImageWhenMemberIsValid() { - // given - String oldProfileImage = member.getProfileImage(); - - // when - memberCommandService.updateProfileImage(member, NEW_PROFILE_IMAGE); - - // then - assertThat(member.getProfileImage()).isEqualTo(NEW_PROFILE_IMAGE); - assertThat(member.getProfileImage()).isNotEqualTo(oldProfileImage); - } - } - - @Nested - @DisplayName("deleteMember 메서드는") - class DeleteMember { - - @Test - @DisplayName("멤버를 삭제하면 deletedAt 필드에 현재 시각이 설정된다.") - void shouldDeleteMember() { - // given - assertThat(member.getDeletedAt()).isNull(); - LocalDateTime beforeDeletionTime = LocalDateTime.now(); - - // when - memberCommandService.deleteMember(member); - - // then - assertThat(member.getDeletedAt()).isNotNull(); - assertThat(member.getDeletedAt()).isAfterOrEqualTo(beforeDeletionTime); - } - } - - @Nested - @DisplayName("restoreMember 메서드는") - class RestoreMember { - - @Test - @DisplayName("삭제된 멤버가 복구될 때 deletedAt 필드를 null로 업데이트한다.") - void shouldRestoreDeletedAtWhenDeletedMemberIsRestored() { - // given - member.updateDeletedAt(); - - // when - memberCommandService.restoreMember(member); - - // then - assertThat(member.getDeletedAt()).isNull(); - } - } - - @Nested - @DisplayName("hardDeleteMembers 메서드는") - class HardDeleteMembers { - - @Test - @DisplayName("삭제된 멤버가 없으면 0을 반환한다.") - void shouldReturnZeroWhenDeletedMembersDoNotExist() { - // given - given(memberCommandRepository.deleteAllByDeletedAtIsNotNull()).willReturn(0L); - - // when - long result = memberCommandService.hardDeleteMembers(); - - // then - assertThat(result).isEqualTo(0L); - } - - @Test - @DisplayName("삭제된 멤버가 있으면 해당 개수를 반환한다.") - void shouldReturnCountWhenDeletedMembersExist() { - // given - given(memberCommandRepository.deleteAllByDeletedAtIsNotNull()).willReturn(5L); - - // when - long result = memberCommandService.hardDeleteMembers(); - - // then - assertThat(result).isEqualTo(5L); - } - } - - @Nested - @DisplayName("hardDeleteMemberById 메서드는") - class HardDeleteMemberById { - - @Test - @DisplayName("전달된 멤버 ID로 삭제를 수행한다") - void shouldDeleteById() { - // given - Long memberId = 123L; - - // when - memberCommandService.hardDeleteMemberById(memberId); - - // then - verify(memberRepository).deleteById(memberId); - } - } -} diff --git a/src/test/java/com/ject/studytrip/member/application/service/MemberQueryServiceTest.java b/src/test/java/com/ject/studytrip/member/application/service/MemberQueryServiceTest.java deleted file mode 100644 index d93a69b..0000000 --- a/src/test/java/com/ject/studytrip/member/application/service/MemberQueryServiceTest.java +++ /dev/null @@ -1,239 +0,0 @@ -package com.ject.studytrip.member.application.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.BDDMockito.given; - -import com.ject.studytrip.BaseUnitTest; -import com.ject.studytrip.global.exception.CustomException; -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.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("MemberQueryService 단위 테스트") -class MemberQueryServiceTest extends BaseUnitTest { - @InjectMocks private MemberQueryService memberQueryService; - @Mock private MemberRepository memberRepository; - @Mock private MemberQueryRepository memberQueryRepository; - - private Member member; - - private String socialId; - - @BeforeEach - void setUp() { - member = MemberFixture.createMemberFromKakaoWithId(1L); - - socialId = member.getSocialId(); - } - - @Nested - @DisplayName("getMemberBySocialProviderAndSocialId 메서드는") - class GetMemberBySocialProviderAndSocialId { - - @Test - @DisplayName("탈퇴한 Member라면 예외가 발생한다.") - void shouldThrowExceptionWhenMemberAlreadyDeleted() { - // given - member.updateDeletedAt(); - given(memberRepository.findBySocialProviderAndSocialId(SocialProvider.KAKAO, socialId)) - .willReturn(Optional.of(member)); - - // when & then - assertThatThrownBy( - () -> - memberQueryService.getMemberBySocialProviderAndSocialId( - SocialProvider.KAKAO, socialId)) - .isInstanceOf(CustomException.class) - .hasMessage(MemberErrorCode.MEMBER_ALREADY_DELETED.getMessage()); - } - - @Test - @DisplayName("소셜 ID로 조회 시 존재하면 Member를 반환한다.") - void shouldReturnMemberWhenSocialIdExists() { - // given - given(memberRepository.findBySocialProviderAndSocialId(SocialProvider.KAKAO, socialId)) - .willReturn(Optional.of(member)); - - // when - Optional result = - memberQueryService.getMemberBySocialProviderAndSocialId( - SocialProvider.KAKAO, socialId); - - // then - assertThat(result).isEqualTo(Optional.of(member)); - } - } - - @Nested - @DisplayName("getMember 메서드는") - class GetMember { - - @Test - @DisplayName("존재하지 않는 멤버 ID로 조회하면 예외가 발생한다.") - void shouldThrowExceptionWhenMemberIdNotFound() { - // given - Long invalidId = -1L; - given(memberRepository.findById(invalidId)).willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> memberQueryService.getMember(invalidId)) - .isInstanceOf(CustomException.class) - .hasMessage(MemberErrorCode.MEMBER_NOT_FOUND.getMessage()); - } - - @Test - @DisplayName("멤버 ID가 존재하면 Member를 반환한다.") - void shouldReturnMemberWhenMemberIdExists() { - // given - Long memberId = member.getId(); - given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); - - // when - Member result = memberQueryService.getMember(memberId); - - // then - assertThat(result).isEqualTo(member); - } - } - - @Nested - @DisplayName("getValidMember 메서드는") - class GetValidMember { - - @Test - @DisplayName("존재하지 않는 멤버 ID로 조회하면 예외가 발생한다.") - void shouldThrowExceptionWhenMemberIdNotFound() { - // given - Long invalidId = -1L; - given(memberRepository.findByIdAndDeletedAtIsNull(invalidId)) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> memberQueryService.getValidMember(invalidId)) - .isInstanceOf(CustomException.class) - .hasMessage(MemberErrorCode.MEMBER_NOT_FOUND.getMessage()); - } - - @Test - @DisplayName("멤버가 이미 삭제된 경우 예외가 발생한다.") - void shouldThrowExceptionWhenMemberAlreadyDeleted() { - // given - Long memberId = member.getId(); - member.updateDeletedAt(); - - // when & then - assertThatThrownBy(() -> memberQueryService.getValidMember(memberId)) - .isInstanceOf(CustomException.class) - .hasMessage(MemberErrorCode.MEMBER_NOT_FOUND.getMessage()); - } - - @Test - @DisplayName("유효한 멤버 ID가 주어지면 Member를 반환한다.") - void shouldReturnMemberWhenMemberIdIsValid() { - // given - Long memberId = member.getId(); - given(memberRepository.findByIdAndDeletedAtIsNull(memberId)) - .willReturn(Optional.of(member)); - - // when - Member result = memberQueryService.getValidMember(memberId); - - // then - assertThat(result).isEqualTo(member); - assertThat(result.getDeletedAt()).isNull(); - } - } - - @Nested - @DisplayName("getDeletedMember 메서드는") - class GetDeletedMember { - - @Test - @DisplayName("멤버가 존재하지 않으면 예외가 발생한다.") - void shouldThrowExceptionWhenMemberDoesNotExist() { - // given - Long memberId = -1L; - given(memberRepository.findById(memberId)).willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> memberQueryService.getDeletedMember(memberId)) - .isInstanceOf(CustomException.class) - .hasMessage(MemberErrorCode.MEMBER_NOT_FOUND.getMessage()); - } - - @Test - @DisplayName("멤버가 삭제되지 않았다면 예외가 발생한다.") - void shouldThrowExceptionWhenMemberIsNotDeleted() { - // given - Long memberId = member.getId(); - given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); - - // when & then - assertThatThrownBy(() -> memberQueryService.getDeletedMember(memberId)) - .isInstanceOf(CustomException.class) - .hasMessage(MemberErrorCode.MEMBER_NOT_DELETED.getMessage()); - } - - @Test - @DisplayName("멤버가 이미 삭제되었다면 삭제된 멤버를 반환한다.") - void shouldReturnMemberWhenMemberAlreadyDeleted() { - // given - Long memberId = member.getId(); - member.updateDeletedAt(); - given(memberRepository.findById(memberId)).willReturn(Optional.of(member)); - - // when - Member result = memberQueryService.getDeletedMember(memberId); - - // then - assertThat(result).isEqualTo(member); - } - } - - @Nested - @DisplayName("getRoleByMemberId 메서드는") - class GetRoleByMemberId { - - @Test - @DisplayName("존재하지 않는 멤버 ID로 조회하면 예외가 발생한다.") - void shouldThrowExceptionWhenMemberIdNotFound() { - // given - String invalidId = "-1"; - given(memberQueryRepository.findMemberRoleById(Long.valueOf(invalidId))) - .willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> memberQueryService.getRoleByMemberId(invalidId)) - .isInstanceOf(CustomException.class) - .hasMessage(MemberErrorCode.MEMBER_NOT_FOUND.getMessage()); - } - - @Test - @DisplayName("유효한 멤버 ID가 주어지면 Role을 반환한다.") - void shouldReturnRoleNameWhenMemberIdIsValid() { - // given - String memberId = member.getId().toString(); - MemberRole memberRole = member.getRole(); - given(memberQueryRepository.findMemberRoleById(Long.valueOf(memberId))) - .willReturn(Optional.of(memberRole)); - - // when - String result = memberQueryService.getRoleByMemberId(memberId); - - // then - assertThat(result).isEqualTo(memberRole.name()); - } - } -} diff --git a/src/test/java/com/ject/studytrip/member/fixture/CreateMemberCommandFixture.java b/src/test/java/com/ject/studytrip/member/fixture/CreateMemberCommandFixture.java deleted file mode 100644 index 210b3dc..0000000 --- a/src/test/java/com/ject/studytrip/member/fixture/CreateMemberCommandFixture.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.ject.studytrip.member.fixture; - -import com.ject.studytrip.member.application.dto.CreateMemberCommand; - -public class CreateMemberCommandFixture { - private String socialId = "12345"; - private String email = "choi@kakao.com"; - private String profileImage = "https://kakao.com/profile.jpg"; - private String nickname = "민우"; - private String category = "STUDENT"; - - public CreateMemberCommandFixture withSocialId(String socialId) { - this.socialId = socialId; - return this; - } - - public CreateMemberCommandFixture withEmail(String email) { - this.email = email; - return this; - } - - public CreateMemberCommandFixture withProfileImage(String profileImage) { - this.profileImage = profileImage; - return this; - } - - public CreateMemberCommandFixture withNickname(String nickname) { - this.nickname = nickname; - return this; - } - - public CreateMemberCommandFixture withCategory(String category) { - this.category = category; - return this; - } - - public CreateMemberCommand build() { - return new CreateMemberCommand(socialId, email, profileImage, nickname, category); - } -} diff --git a/src/test/java/com/ject/studytrip/member/fixture/MemberFixture.java b/src/test/java/com/ject/studytrip/member/fixture/MemberFixture.java deleted file mode 100644 index 9f8801b..0000000 --- a/src/test/java/com/ject/studytrip/member/fixture/MemberFixture.java +++ /dev/null @@ -1,38 +0,0 @@ -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 org.springframework.test.util.ReflectionTestUtils; - -public class MemberFixture { - 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 MEMBER_NICKNAME = "민우"; - private static final MemberCategory MEMBER_CATEGORY = MemberCategory.STUDENT; - - public static Member createMemberFromKakao() { - return MemberFactory.createFromKakao( - KAKAO_ID, EMAIL, PROFILE_IMAGE, MEMBER_NICKNAME, MEMBER_CATEGORY); - } - - public static Member createMemberFromKakao(String email, String nickname) { - return MemberFactory.createFromKakao( - KAKAO_ID, email, PROFILE_IMAGE, nickname, MEMBER_CATEGORY); - } - - public static Member createMemberFromKakaoWithId(Long id) { - Member member = - MemberFactory.createFromKakao( - KAKAO_ID, EMAIL, PROFILE_IMAGE, MEMBER_NICKNAME, MEMBER_CATEGORY); - ReflectionTestUtils.setField(member, "id", id); - - return member; - } - - public static Member createMemberWithoutProfileImageFromKakao() { - return MemberFactory.createFromKakao( - KAKAO_ID, EMAIL, null, MEMBER_NICKNAME, MEMBER_CATEGORY); - } -} diff --git a/src/test/java/com/ject/studytrip/member/fixture/UpdateMemberRequestFixture.java b/src/test/java/com/ject/studytrip/member/fixture/UpdateMemberRequestFixture.java deleted file mode 100644 index b26f100..0000000 --- a/src/test/java/com/ject/studytrip/member/fixture/UpdateMemberRequestFixture.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.ject.studytrip.member.fixture; - -import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest; - -public class UpdateMemberRequestFixture { - private String nickname = null; - private String category = null; - - public UpdateMemberRequestFixture withNickname(String nickname) { - this.nickname = nickname; - return this; - } - - public UpdateMemberRequestFixture withCategory(String category) { - this.category = category; - return this; - } - - public UpdateMemberRequest build() { - return new UpdateMemberRequest(nickname, category); - } -} diff --git a/src/test/java/com/ject/studytrip/member/helper/MemberTestHelper.java b/src/test/java/com/ject/studytrip/member/helper/MemberTestHelper.java deleted file mode 100644 index 0187543..0000000 --- a/src/test/java/com/ject/studytrip/member/helper/MemberTestHelper.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.ject.studytrip.member.helper; - -import com.ject.studytrip.member.domain.model.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 Member saveMember() { - Member member = MemberFixture.createMemberFromKakao(); - return memberRepository.save(member); - } - - 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/member/presentation/controller/MemberControllerIntegrationTest.java b/src/test/java/com/ject/studytrip/member/presentation/controller/MemberControllerIntegrationTest.java deleted file mode 100644 index e0e3683..0000000 --- a/src/test/java/com/ject/studytrip/member/presentation/controller/MemberControllerIntegrationTest.java +++ /dev/null @@ -1,623 +0,0 @@ -package com.ject.studytrip.member.presentation.controller; - -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.verify; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import com.ject.studytrip.BaseIntegrationTest; -import com.ject.studytrip.auth.domain.error.AuthErrorCode; -import com.ject.studytrip.auth.fixture.TokenFixture; -import com.ject.studytrip.auth.helper.TokenTestHelper; -import com.ject.studytrip.global.exception.error.CommonErrorCode; -import com.ject.studytrip.image.domain.error.ImageErrorCode; -import com.ject.studytrip.image.infra.s3.provider.S3ImageStorageProvider; -import com.ject.studytrip.member.domain.error.MemberErrorCode; -import com.ject.studytrip.member.domain.model.Member; -import com.ject.studytrip.member.fixture.UpdateMemberRequestFixture; -import com.ject.studytrip.member.helper.MemberTestHelper; -import com.ject.studytrip.member.presentation.dto.request.ConfirmProfileImageRequest; -import com.ject.studytrip.member.presentation.dto.request.PresignProfileImageRequest; -import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest; -import com.ject.studytrip.trip.domain.model.TripCategory; -import com.ject.studytrip.trip.helper.TripTestHelper; -import org.hamcrest.Matchers; -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.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.test.web.servlet.ResultActions; - -@DisplayName("MemberController 통합 테스트") -class MemberControllerIntegrationTest extends BaseIntegrationTest { - private static final String BASE_MEMBER_URL = "/api/members"; - - @Autowired private MemberTestHelper memberTestHelper; - @Autowired private TokenTestHelper tokenTestHelper; - @Autowired private TripTestHelper tripTestHelper; - - @MockitoBean S3ImageStorageProvider s3ImageStorageProvider; - - private Member member; - private String accessToken; - - @BeforeEach - void setUp() { - member = memberTestHelper.saveMember(); - accessToken = - tokenTestHelper.createAccessToken( - member.getId().toString(), member.getRole().name()); - tripTestHelper.saveTrip(member, TripCategory.COURSE); - tripTestHelper.saveTrip(member, TripCategory.COURSE); - tripTestHelper.saveTrip(member, TripCategory.COURSE); - tripTestHelper.saveTrip(member, TripCategory.EXPLORE); - tripTestHelper.saveTrip(member, TripCategory.EXPLORE); - } - - @Nested - @DisplayName("멤버 수정 API") - class UpdateMember { - private final UpdateMemberRequestFixture fixture = new UpdateMemberRequestFixture(); - - private ResultActions getResultActions(String accessToken, UpdateMemberRequest request) - throws Exception { - return mockMvc.perform( - patch(BASE_MEMBER_URL + "/me") - .header( - HttpHeaders.AUTHORIZATION, - TokenFixture.TOKEN_PREFIX + accessToken) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); - } - - @Test - @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다.") - void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { - // given - UpdateMemberRequest request = fixture.withNickname("새로운 닉네임").build(); - - // when - ResultActions resultActions = getResultActions("", request); - - // then - resultActions - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())) - .andExpect( - jsonPath("$.data.message") - .value(AuthErrorCode.UNAUTHENTICATED.getMessage())); - } - - @Test - @DisplayName("삭제된 여행일 경우 404 Not Found를 반환한다.") - void shouldReturnNotFoundWhenMemberAlreadyDeleted() throws Exception { - // given - UpdateMemberRequest request = fixture.withNickname("새로운 닉네임").build(); - member.updateDeletedAt(); - - // when - ResultActions resultActions = getResultActions(accessToken, request); - - // then - resultActions - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value(MemberErrorCode.MEMBER_NOT_FOUND.getStatus().value())) - .andExpect( - jsonPath("$.data.message") - .value(MemberErrorCode.MEMBER_NOT_FOUND.getMessage())); - } - - @Test - @DisplayName("유효한 요청이 들어오면 멤버 닉네임을 수정한다.") - void shouldUpdateMemberNicknameWhenRequestIsValid() throws Exception { - // given - UpdateMemberRequest request = fixture.withNickname("새로운 닉네임").build(); - - // when - ResultActions resultActions = getResultActions(accessToken, request); - - // then - resultActions - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())); - } - - @Test - @DisplayName("유효한 요청이 들어오면 멤버 카테고리를 수정한다.") - void shouldUpdateMemberCategoryWhenRequestIsValid() throws Exception { - // given - UpdateMemberRequest request = fixture.withCategory("WORKER").build(); - - // when - ResultActions resultActions = getResultActions(accessToken, request); - - // then - resultActions - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())); - } - - @Test - @DisplayName("유효한 요청이 들어오면 멤버 닉네임과 카테고리를 수정한다.") - void shouldUpdateMemberNicknameAndCategoryWhenRequestIsValid() throws Exception { - // given - UpdateMemberRequest request = - fixture.withNickname("새로운 닉네임").withCategory("WORKER").build(); - - // when - ResultActions resultActions = getResultActions(accessToken, request); - - // then - resultActions - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())); - } - } - - @Nested - @DisplayName("멤버 삭제 API") - class DeleteMember { - private ResultActions getResultActions(String accessToken) throws Exception { - return mockMvc.perform( - delete(BASE_MEMBER_URL + "/me") - .header( - HttpHeaders.AUTHORIZATION, - TokenFixture.TOKEN_PREFIX + accessToken) - .contentType(MediaType.APPLICATION_JSON)); - } - - @Test - @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다.") - void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { - // when - ResultActions resultActions = getResultActions(""); - - // then - resultActions - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())) - .andExpect( - jsonPath("$.data.message") - .value(AuthErrorCode.UNAUTHENTICATED.getMessage())); - } - - @Test - @DisplayName("삭제된 여행일 경우 404 Not Found를 반환한다.") - void shouldReturnBadRequestWhenMemberAlreadyDeleted() throws Exception { - // given - member.updateDeletedAt(); - - // when - ResultActions resultActions = getResultActions(accessToken); - - // then - resultActions - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value(MemberErrorCode.MEMBER_NOT_FOUND.getStatus().value())) - .andExpect( - jsonPath("$.data.message") - .value(MemberErrorCode.MEMBER_NOT_FOUND.getMessage())); - } - - @Test - @DisplayName("유효한 멤버 ID가 들어오면 멤버를 삭제한다.") - void shouldDeleteMemberWhenMemberIdIsValid() throws Exception { - // when - ResultActions resultActions = getResultActions(accessToken); - - // then - resultActions - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())); - } - } - - @Nested - @DisplayName("멤버 상세 조회 API") - class LoadMemberDetail { - private ResultActions getResultActions(String accessToken) throws Exception { - return mockMvc.perform( - get(BASE_MEMBER_URL + "/me") - .header( - HttpHeaders.AUTHORIZATION, - TokenFixture.TOKEN_PREFIX + accessToken) - .contentType(MediaType.APPLICATION_JSON)); - } - - @Test - @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다.") - void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { - // when - ResultActions resultActions = getResultActions(""); - - // then - resultActions - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())) - .andExpect( - jsonPath("$.data.message") - .value(AuthErrorCode.UNAUTHENTICATED.getMessage())); - } - - @Test - @DisplayName("삭제된 여행일 경우 404 Not Found를 반환한다.") - void shouldReturnBadRequestWhenMemberAlreadyDeleted() throws Exception { - // given - member.updateDeletedAt(); - - // when - ResultActions resultActions = getResultActions(accessToken); - - // then - resultActions - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value(MemberErrorCode.MEMBER_NOT_FOUND.getStatus().value())) - .andExpect( - jsonPath("$.data.message") - .value(MemberErrorCode.MEMBER_NOT_FOUND.getMessage())); - } - - @Test - @DisplayName("유효한 멤버 ID가 들어오면 멤버 상세 정보를 반환한다.") - void shouldReturnMemberDetailWhenMemberIdIsValid() throws Exception { - // when - ResultActions resultActions = getResultActions(accessToken); - - // then - resultActions - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) - .andExpect(jsonPath("$.data.memberId").value(member.getId())) - .andExpect(jsonPath("$.data.email").value(member.getEmail())) - .andExpect(jsonPath("$.data.nickname").value(member.getNickname())) - .andExpect(jsonPath("$.data.profileImage").value(member.getProfileImage())) - .andExpect(jsonPath("$.data.category").value(member.getCategory().name())) - .andExpect(jsonPath("$.data.courseTripCount").value(3)) - .andExpect(jsonPath("$.data.exploreTripCount").value(2)) - .andExpect(jsonPath("$.data.studyLogCount").value(0)); - } - } - - @Nested - @DisplayName("프로필 이미지 Presigned URL 발급 API") - class IssuePresignedUrl { - private ResultActions getResultActions( - String accessToken, PresignProfileImageRequest request) throws Exception { - return mockMvc.perform( - post(BASE_MEMBER_URL + "/profile-images/presigned") - .header( - HttpHeaders.AUTHORIZATION, - TokenFixture.TOKEN_PREFIX + accessToken) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); - } - - @Test - @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다") - void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { - // given - PresignProfileImageRequest request = new PresignProfileImageRequest("test.jpg"); - - // when - ResultActions resultActions = getResultActions("", request); - - // then - resultActions - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())) - .andExpect( - jsonPath("$.data.message") - .value(AuthErrorCode.UNAUTHENTICATED.getMessage())); - } - - @Test - @DisplayName("유효한 파일명으로 Presigned URL을 발급한다") - void shouldIssuePresignedUrlWhenFilenameIsValid() throws Exception { - // given - PresignProfileImageRequest request = new PresignProfileImageRequest("profile.jpg"); - given(s3ImageStorageProvider.issuePresignedUrl(anyString())) - .willReturn("https://mocked-presigned-url.com"); - - // when - ResultActions resultActions = getResultActions(accessToken, request); - - // then - resultActions - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) - .andExpect(jsonPath("$.data.presignedUrl").isNotEmpty()) - .andExpect(jsonPath("$.data.tmpKey").isNotEmpty()) - .andExpect(jsonPath("$.data.tmpKey").value(Matchers.startsWith("tmp/members/"))) - .andExpect( - jsonPath("$.data.tmpKey") - .value(Matchers.containsString(member.getId().toString()))); - - // S3Provider 호출 검증 - verify(s3ImageStorageProvider).issuePresignedUrl(anyString()); - } - - @Test - @DisplayName("파일명이 비어있으면 400 Bad Request를 반환한다") - void shouldReturnBadRequestWhenFilenameIsEmpty() throws Exception { - // given - PresignProfileImageRequest request = new PresignProfileImageRequest(""); - - // when - ResultActions resultActions = getResultActions(accessToken, request); - - // then - resultActions.andExpect(status().isBadRequest()); - } - - @Test - @DisplayName("유효하지 않은 확장자는 400 Bad Request를 반환한다") - void shouldReturnBadRequestWhenExtensionIsInvalid() throws Exception { - // given - PresignProfileImageRequest request = new PresignProfileImageRequest("profile.txt"); - - // when - ResultActions resultActions = getResultActions(accessToken, request); - - // then - resultActions - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - ImageErrorCode.INVALID_IMAGE_EXTENSION - .getStatus() - .value())) - .andExpect( - jsonPath("$.data.message") - .value(ImageErrorCode.INVALID_IMAGE_EXTENSION.getMessage())); - } - } - - @Nested - @DisplayName("프로필 이미지 확정 API") - class ConfirmProfileImage { - private ResultActions getResultActions( - String accessToken, ConfirmProfileImageRequest request) throws Exception { - return mockMvc.perform( - post(BASE_MEMBER_URL + "/profile-images/confirm") - .header( - HttpHeaders.AUTHORIZATION, - TokenFixture.TOKEN_PREFIX + accessToken) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); - } - - @Test - @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다") - void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { - // given - ConfirmProfileImageRequest request = - new ConfirmProfileImageRequest("tmp/members/1/test.jpg"); - - // when - ResultActions resultActions = getResultActions("", request); - - // then - resultActions - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())) - .andExpect( - jsonPath("$.data.message") - .value(AuthErrorCode.UNAUTHENTICATED.getMessage())); - } - - @Test - @DisplayName("tmpKey가 비어있으면 400 Bad Request를 반환한다") - void shouldReturnBadRequestWhenTmpKeyIsEmpty() throws Exception { - // given - ConfirmProfileImageRequest request = new ConfirmProfileImageRequest(""); - - // when - ResultActions resultActions = getResultActions(accessToken, request); - - // then - resultActions.andExpect(status().isBadRequest()); - } - } - - @Nested - @DisplayName("멤버 즉시 삭제 API") - class DeleteMemberHardDelete { - private ResultActions getResultActions(String accessToken) throws Exception { - return mockMvc.perform( - delete(BASE_MEMBER_URL + "/me/hard-delete") - .header( - HttpHeaders.AUTHORIZATION, - TokenFixture.TOKEN_PREFIX + accessToken) - .contentType(MediaType.APPLICATION_JSON)); - } - - @Test - @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다.") - void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { - // when - ResultActions resultActions = getResultActions(""); - - // then - resultActions - .andExpect(status().isUnauthorized()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())) - .andExpect( - jsonPath("$.data.message") - .value(AuthErrorCode.UNAUTHENTICATED.getMessage())); - } - - @Test - @DisplayName("삭제된 멤버일 경우 404 Not Found를 반환한다.") - void shouldReturnNotFoundWhenMemberAlreadyDeleted() throws Exception { - // given - member.updateDeletedAt(); - - // when - ResultActions resultActions = getResultActions(accessToken); - - // then - resultActions - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value(MemberErrorCode.MEMBER_NOT_FOUND.getStatus().value())) - .andExpect( - jsonPath("$.data.message") - .value(MemberErrorCode.MEMBER_NOT_FOUND.getMessage())); - } - - @Test - @DisplayName("유효한 멤버 ID가 들어오면 멤버와 관련된 모든 데이터를 즉시 삭제한다.") - void shouldHardDeleteMemberAndAllRelatedDataWhenMemberIdIsValid() throws Exception { - // when - ResultActions resultActions = getResultActions(accessToken); - - // then - resultActions - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())); - } - } - - @Nested - @DisplayName("멤버 복구 API") - class RestoreMember { - private ResultActions getResultActions(Object memberId) throws Exception { - return mockMvc.perform( - patch(BASE_MEMBER_URL + "/me/restore/{memberId}", memberId) - .contentType(MediaType.APPLICATION_JSON)); - } - - @Test - @DisplayName("PathVariable 멤버 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다.") - void shouldReturnBadRequestWhenMemberIdTypeMismatch() throws Exception { - // given - String memberId = "abc"; - - // when - ResultActions resultActions = getResultActions(memberId); - - // then - resultActions - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH - .getStatus() - .value())) - .andExpect( - jsonPath("$.data.message") - .value( - CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH - .getMessage())); - } - - @Test - @DisplayName("멤버가 존재하지 않으면 404 Not Found를 반환한다.") - void shouldReturnNotFoundWhenMemberDoesNotExist() throws Exception { - // given - Long memberId = -1L; - - // when - ResultActions resultActions = getResultActions(memberId); - - // then - resultActions - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value(MemberErrorCode.MEMBER_NOT_FOUND.getStatus().value())) - .andExpect( - jsonPath("$.data.message") - .value(MemberErrorCode.MEMBER_NOT_FOUND.getMessage())); - } - - @Test - @DisplayName("멤버가 삭제되지 않았다면 400 Bad Request를 반환한다.") - void shouldReturnBadRequestWhenMemberIsNotDeleted() throws Exception { - // given - Long memberId = member.getId(); - - // when - ResultActions resultActions = getResultActions(memberId); - - // then - resultActions - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value(MemberErrorCode.MEMBER_NOT_DELETED.getStatus().value())) - .andExpect( - jsonPath("$.data.message") - .value(MemberErrorCode.MEMBER_NOT_DELETED.getMessage())); - } - - @Test - @DisplayName("유효한 멤버 ID가 들어오면 삭제된 멤버를 복구한다.") - void shouldRestoreMemberWhenMemberIdIsValid() throws Exception { - // given - Long memberId = member.getId(); - member.updateDeletedAt(); - - // when - ResultActions resultActions = getResultActions(memberId); - - // then - resultActions - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())); - } - } -} diff --git a/src/test/kotlin/com/ject/studytrip/BaseIntegrationTest.kt b/src/test/kotlin/com/ject/studytrip/BaseIntegrationTest.kt new file mode 100644 index 0000000..f512d06 --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/BaseIntegrationTest.kt @@ -0,0 +1,41 @@ +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 +abstract class BaseIntegrationTest { + @Autowired + protected lateinit var mockMvc: MockMvc + + @Autowired + protected lateinit var objectMapper: ObjectMapper + + /** + * 응답 본문(JSON)을 지정한 클래스 타입으로 변환하는 메서드, 테스트 응답 결과를 객체로 파싱해 내용 검증에 활용할 수 있음 + * + * @param result MockMvc 응답 결과 + * @param clazz 변환할 클래스 타입 + * @return 파싱된 응답 객체 + */ + protected fun parseResponse( + result: ResultActions, + clazz: Class, + ): T { + val content = result.andReturn().response.contentAsString + return objectMapper.readValue(content, clazz) + } +} diff --git a/src/test/kotlin/com/ject/studytrip/BaseUnitTest.kt b/src/test/kotlin/com/ject/studytrip/BaseUnitTest.kt new file mode 100644 index 0000000..9c3c362 --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/BaseUnitTest.kt @@ -0,0 +1,10 @@ +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) +abstract class BaseUnitTest { + protected val objectMapper: ObjectMapper = ObjectMapper() +} diff --git a/src/test/kotlin/com/ject/studytrip/auth/application/service/KakaoLoginServiceTest.kt b/src/test/kotlin/com/ject/studytrip/auth/application/service/KakaoLoginServiceTest.kt new file mode 100644 index 0000000..3c241d2 --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/auth/application/service/KakaoLoginServiceTest.kt @@ -0,0 +1,89 @@ +package com.ject.studytrip.auth.application.service + +import com.ject.studytrip.BaseUnitTest +import com.ject.studytrip.auth.domain.error.AuthErrorCode +import com.ject.studytrip.auth.fixture.KakaoTokenResponseFixture +import com.ject.studytrip.auth.fixture.KakaoUserInfoResponseFixture +import com.ject.studytrip.auth.infra.dto.KakaoTokenResponse +import com.ject.studytrip.auth.infra.provider.KakaoOauthProvider +import com.ject.studytrip.global.exception.CustomException +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.Mockito.`when` + +@DisplayName("KakaoLoginService 단위 테스트") +class KakaoLoginServiceTest : BaseUnitTest() { + @InjectMocks + lateinit var kakaoLoginService: KakaoLoginService + + @Mock + lateinit var kakaoOauthProvider: KakaoOauthProvider + + companion object { + private const val KAKAO_ID = "12345" + private const val EMAIL = "studytrip@kakao.com" + private const val PROFILE_IMAGE = "https://kakao.com/profile.jpg" + private const val VALID_CODE = "kakao-auth-code-1234567890" + private const val VALID_ORIGIN = "https://test.com" + } + + @Nested + @DisplayName("getKakaoUserInfo 메서드는") + inner class GetKakaoUserInfo { + @Test + @DisplayName("유효하지 않은 인가 코드를 전달하면 예외가 발생한다.") + fun shouldThrowExceptionWhenAuthorizationCodeIsInvalid() { + // given + `when`( + kakaoOauthProvider.getKakaoTokens(" ", VALID_ORIGIN), + ).thenThrow(CustomException(AuthErrorCode.INVALID_KAKAO_AUTHORIZATION_CODE)) + + // when + val exception = assertThrows { kakaoLoginService.getKakaoUserInfo(" ", VALID_ORIGIN) } + + // then + assertThat(exception.message).isEqualTo(AuthErrorCode.INVALID_KAKAO_AUTHORIZATION_CODE.message) + } + + @Test + @DisplayName("카카오 토큰 응답은 왔지만 사용자 정보 조회에 실패하면 예외가 발생한다.") + fun shouldThrowExceptionWhenFetchingKakaoUserInfoFails() { + // given + val tokenResponse: KakaoTokenResponse = KakaoTokenResponseFixture().build() + `when`(kakaoOauthProvider.getKakaoTokens(VALID_CODE, VALID_ORIGIN)).thenReturn(tokenResponse) + `when`( + kakaoOauthProvider.getKakaoUserInfo(tokenResponse.accessToken), + ).thenThrow(CustomException(AuthErrorCode.KAKAO_USER_INFO_FETCH_FAILED)) + + // when + val exception = assertThrows { kakaoLoginService.getKakaoUserInfo(VALID_CODE, VALID_ORIGIN) } + + // then + assertThat(exception.message).isEqualTo(AuthErrorCode.KAKAO_USER_INFO_FETCH_FAILED.message) + } + + @Test + @DisplayName("유효한 인가 코드와 origin을 전달하면 사용자 정보를 반환한다.") + fun shouldReturnKakaoUserInfoResponseWhenCodeAndOriginAreValid() { + // given + val kakaoTokenResponse = KakaoTokenResponseFixture().build() + `when`(kakaoOauthProvider.getKakaoTokens(VALID_CODE, VALID_ORIGIN)).thenReturn(kakaoTokenResponse) + val kakaoUserInfoResponse = KakaoUserInfoResponseFixture().build() + `when`(kakaoOauthProvider.getKakaoUserInfo(kakaoTokenResponse.accessToken)).thenReturn(kakaoUserInfoResponse) + + // when + val result = kakaoLoginService.getKakaoUserInfo(VALID_CODE, VALID_ORIGIN) + + // then + assertThat(result).isEqualTo(kakaoUserInfoResponse) + assertThat(result.kakaoId).isEqualTo(KAKAO_ID) + assertThat(result.email).isEqualTo(EMAIL) + assertThat(result.profileImage).isEqualTo(PROFILE_IMAGE) + } + } +} diff --git a/src/test/kotlin/com/ject/studytrip/auth/application/service/KakaoSignupProfileServiceTest.kt b/src/test/kotlin/com/ject/studytrip/auth/application/service/KakaoSignupProfileServiceTest.kt new file mode 100644 index 0000000..b583a4e --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/auth/application/service/KakaoSignupProfileServiceTest.kt @@ -0,0 +1,128 @@ +package com.ject.studytrip.auth.application.service + +import com.ject.studytrip.BaseUnitTest +import com.ject.studytrip.auth.application.dto.KakaoSignupProfile +import com.ject.studytrip.auth.domain.error.AuthErrorCode +import com.ject.studytrip.auth.domain.repository.KakaoSignupProfileRedisRepository +import com.ject.studytrip.global.exception.CustomException +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.kotlin.given +import org.mockito.kotlin.verify +import java.util.Optional + +@DisplayName("KakaoSignupProfileService 단위 테스트") +class KakaoSignupProfileServiceTest : BaseUnitTest() { + @InjectMocks + lateinit var kakaoSignupProfileService: KakaoSignupProfileService + + @Mock + lateinit var kakaoSignupProfileRedisRepository: KakaoSignupProfileRedisRepository + + companion object { + private const val VALID_SIGNUP_KEY = "kakao::signup::profile:valid-key" + private const val INVALID_SIGNUP_KEY = "kakao::signup::profile:invalid-key" + private const val KAKAO_ID = "12345" + private const val KAKAO_PROVIDER = "kakao" + private const val EMAIL = "studytrip@kakao.com" + private const val PROFILE_IMAGE = "https://kakao.com/profile.jpg" + } + + @Nested + @DisplayName("saveAndIssueSignupKey 메서드는") + inner class SaveAndIssueSignupKey { + @Test + @DisplayName("프로필 저장 후 발급된 키를 반환한다.") + fun shouldReturnIssuedKey() { + // given + val issuedKey = "issued-signup-key" + given(kakaoSignupProfileRedisRepository.saveAndIssueSignupKey(KAKAO_ID, EMAIL, PROFILE_IMAGE)).willReturn(issuedKey) + + // when + val result = kakaoSignupProfileService.saveAndIssueSignupKey(KAKAO_ID, EMAIL, PROFILE_IMAGE) + + // then + assertThat(result).isEqualTo(issuedKey) + } + } + + @Nested + @DisplayName("getSignupProfileByKey 메서드는") + inner class GetSignupProfileByKey { + @Test + @DisplayName("signupKey가 null이면 예외가 발생한다.") + fun shouldThrowExceptionWhenSignupKeyIsNull() { + // when + val exception = assertThrows { kakaoSignupProfileService.getSignupProfileByKey(null) } + + // then + assertThat(exception.message).isEqualTo(AuthErrorCode.MISSING_KAKAO_SIGNUP_KEY.message) + } + + @Test + @DisplayName("signupKey가 빈 문자열이면 예외가 발생한다.") + fun shouldThrowExceptionWhenSignupKeyIsBlank() { + // when + val exception = assertThrows { kakaoSignupProfileService.getSignupProfileByKey("") } + + // then + assertThat(exception.message).isEqualTo(AuthErrorCode.MISSING_KAKAO_SIGNUP_KEY.message) + } + + @Test + @DisplayName("signupKey가 공백이면 예외가 발생한다.") + fun shouldThrowExceptionWhenSignupKeyIsWhitespace() { + // when + val exception = assertThrows { kakaoSignupProfileService.getSignupProfileByKey(" ") } + + // then + assertThat(exception.message).isEqualTo(AuthErrorCode.MISSING_KAKAO_SIGNUP_KEY.message) + } + + @Test + @DisplayName("signupKey가 존재하지 않으면 예외가 발생한다.") + fun shouldThrowExceptionWhenSignupKeyDoesNotExist() { + // given + given(kakaoSignupProfileRedisRepository.findBySignupKey(INVALID_SIGNUP_KEY)).willReturn(Optional.empty()) + + // when + val exception = assertThrows { kakaoSignupProfileService.getSignupProfileByKey(INVALID_SIGNUP_KEY) } + + // then + assertThat(exception.message).isEqualTo(AuthErrorCode.INVALID_KAKAO_SIGNUP_KEY.message) + } + + @Test + @DisplayName("유효한 signupKey가 들어오면 KakaoSignupProfile을 반환한다.") + fun shouldReturnKakaoSignupProfileWhenKeyIsValid() { + // given + val kakaoProfile = KakaoSignupProfile(KAKAO_ID, KAKAO_PROVIDER, EMAIL, PROFILE_IMAGE) + given(kakaoSignupProfileRedisRepository.findBySignupKey(VALID_SIGNUP_KEY)).willReturn(Optional.of(kakaoProfile)) + + // when + val result = kakaoSignupProfileService.getSignupProfileByKey(VALID_SIGNUP_KEY) + + // then + assertThat(result).isEqualTo(kakaoProfile) + } + } + + @Nested + @DisplayName("deleteBySignupKey 메서드는") + inner class DeleteBySignupKey { + @Test + @DisplayName("특정 signupKey를 삭제한다") + fun shouldDeleteSignupKey() { + // when + kakaoSignupProfileService.deleteBySignupKey(VALID_SIGNUP_KEY) + + // then + verify(kakaoSignupProfileRedisRepository).deleteBySignupKey(VALID_SIGNUP_KEY) + } + } +} diff --git a/src/test/kotlin/com/ject/studytrip/auth/application/service/TokenServiceTest.kt b/src/test/kotlin/com/ject/studytrip/auth/application/service/TokenServiceTest.kt new file mode 100644 index 0000000..d9bb02e --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/auth/application/service/TokenServiceTest.kt @@ -0,0 +1,244 @@ +package com.ject.studytrip.auth.application.service + +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.global.exception.CustomException +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows +import org.mockito.BDDMockito.given +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.kotlin.verify +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.context.SecurityContextHolder + +@DisplayName("TokenService 단위 테스트") +class TokenServiceTest : BaseUnitTest() { + @InjectMocks + lateinit var tokenService: TokenService + + @Mock + lateinit var tokenProvider: TokenProvider + + @Mock + lateinit var refreshTokenRedisRepository: RefreshTokenRedisRepository + + @Mock + lateinit var logoutTokenRedisRepository: LogoutTokenRedisRepository + + companion object { + private const val MEMBER_ID = "123" + private const val ROLE = "ROLE_USER" + private const val ACCESS_TOKEN = "access.jwt.token" + private const val REFRESH_TOKEN = "refresh-token" + private const val NEW_ACCESS_TOKEN = "newAccess.jwt.token" + private const val NEW_REFRESH_TOKEN = "newRefresh-token" + private const val REFRESH_TOKEN_EXPIRATION_TIME = 7200L + private const val ACCESS_TOKEN_REMAINING_TIME = 300L + } + + @Nested + @DisplayName("getTokens 메서드는") + inner class GetTokens { + @Test + @DisplayName("멤버 ID와 Role이 주어지면 엑세스 토큰과 리프레시 토큰을 반환한다.") + fun 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 + val result = tokenService.getTokens(MEMBER_ID, ROLE) + + // then + assertThat(result.accessToken).isEqualTo(ACCESS_TOKEN) + assertThat(result.refreshToken).isEqualTo(REFRESH_TOKEN) + verify(refreshTokenRedisRepository).saveRefreshToken(MEMBER_ID, REFRESH_TOKEN, REFRESH_TOKEN_EXPIRATION_TIME) + } + } + + @Nested + @DisplayName("reissueToken 메서드는") + inner class ReissueToken { + @Test + @DisplayName("유효한 리프레시 토큰이 들어오면, 새로운 엑세스 토큰과 리프레시 토큰을 반환한다.") + fun 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 + val result = tokenService.reissueToken(REFRESH_TOKEN, MEMBER_ID, ROLE) + + // then + assertThat(result.accessToken).isEqualTo(NEW_ACCESS_TOKEN) + assertThat(result.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 메서드는") + inner class Logout { + @Test + @DisplayName("리프레시 토큰이 Redis에 존재하지 않으면 예외가 발생한다.") + fun shouldThrowExceptionWhenRefreshTokenDoesNotExistInRedis() { + // given + given(refreshTokenRedisRepository.existsRefreshToken(REFRESH_TOKEN)).willReturn(false) + + // when + val exception = assertThrows { tokenService.logout(ACCESS_TOKEN, REFRESH_TOKEN) } + + // then + assertThat(exception.message).isEqualTo(AuthErrorCode.INVALID_REFRESH_TOKEN.message) + } + + @Test + @DisplayName("유효한 엑세스 토큰과 리프레시 토큰이 들어오면, 엑세스 토큰을 블랙리스트에 저장하고 저장된 리프레시 토큰을 삭제한다.") + fun 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 메서드는") + inner class GetMemberIdByRefreshToken { + @Test + @DisplayName("리프레시 토큰이 Redis에 존재하지 않으면 예외가 발생한다.") + fun shouldThrowExceptionWhenRefreshTokenDoesNotExistInRedis() { + // given + given(refreshTokenRedisRepository.existsRefreshToken(REFRESH_TOKEN)).willReturn(false) + + // when + val exception = assertThrows { tokenService.getMemberIdByRefreshToken(REFRESH_TOKEN) } + + // then + assertThat(exception.message).isEqualTo(AuthErrorCode.INVALID_REFRESH_TOKEN.message) + } + + @Test + @DisplayName("리프레시 토큰이 Redis에 존재하면 멤버 ID를 반환한다.") + fun shouldReturnMemberIdWhenRefreshTokenExistsInRedis() { + // given + given(refreshTokenRedisRepository.existsRefreshToken(REFRESH_TOKEN)).willReturn(true) + given(refreshTokenRedisRepository.findMemberIdByRefreshToken(REFRESH_TOKEN)).willReturn(MEMBER_ID) + + // when + val result = tokenService.getMemberIdByRefreshToken(REFRESH_TOKEN) + + // then + assertThat(result).isEqualTo(MEMBER_ID) + } + } + + @Nested + @DisplayName("setAuthenticationByAccessToken 메서드는") + inner class SetAuthenticationByAccessToken { + @Test + @DisplayName("멤버 ID 추출에 실패하면 예외가 발생한다.") + fun shouldThrowExceptionWhenMemberIdExtractionFails() { + // given + `when`(tokenProvider.extractMemberIdFromToken(ACCESS_TOKEN)).thenThrow(CustomException(AuthErrorCode.INVALID_JWT_TOKEN)) + + // when + val exception = assertThrows { tokenService.setAuthenticationByAccessToken(ACCESS_TOKEN) } + + // then + assertThat(exception.message).isEqualTo(AuthErrorCode.INVALID_JWT_TOKEN.message) + } + + @Test + @DisplayName("Role 추출에 실패하면 예외가 발생한다.") + fun shouldThrowExceptionWhenRoleExtractionFails() { + // given + `when`(tokenProvider.extractMemberIdFromToken(ACCESS_TOKEN)).thenReturn(MEMBER_ID) + `when`(tokenProvider.extractRoleFromToken(ACCESS_TOKEN)).thenThrow(CustomException(AuthErrorCode.INVALID_JWT_TOKEN)) + + // when + val exception = assertThrows { tokenService.setAuthenticationByAccessToken(ACCESS_TOKEN) } + + // then + assertThat(exception.message).isEqualTo(AuthErrorCode.INVALID_JWT_TOKEN.message) + } + + @Test + @DisplayName("토큰에서 멤버 ID와 Role을 추출하여 SecurityContext에 저장한다.") + fun shouldSetAuthenticationInSecurityContext() { + // given + `when`(tokenProvider.extractMemberIdFromToken(ACCESS_TOKEN)).thenReturn(MEMBER_ID) + `when`(tokenProvider.extractRoleFromToken(ACCESS_TOKEN)).thenReturn(ROLE) + + // when + tokenService.setAuthenticationByAccessToken(ACCESS_TOKEN) + + // then + val authentication = SecurityContextHolder.getContext().authentication + assertThat(authentication).isInstanceOf(UsernamePasswordAuthenticationToken::class.java) + assertThat(authentication.name).isEqualTo(MEMBER_ID) + assertThat(authentication.authorities.map(GrantedAuthority::getAuthority)).containsExactly(ROLE) + } + } + + @Nested + @DisplayName("validateActiveAccessToken 메서드는") + inner class ValidateActiveAccessToken { + @Test + @DisplayName("유효하지 않은 엑세스 토큰이면 예외가 발생한다.") + fun shouldThrowExceptionWhenAccessTokenIsInvalid() { + // given + given(tokenProvider.validateAccessToken(ACCESS_TOKEN)).willReturn(false) + + // when + val exception = assertThrows { tokenService.validateActiveAccessToken(ACCESS_TOKEN) } + + // then + assertThat(exception.message).isEqualTo(AuthErrorCode.INVALID_JWT_TOKEN.message) + } + + @Test + @DisplayName("블랙리스트에 포함된 엑세스 토큰이면 예외가 발생한다.") + fun shouldThrowExceptionWhenAccessTokenIsBlacklisted() { + // given + given(tokenProvider.validateAccessToken(ACCESS_TOKEN)).willReturn(true) + given(logoutTokenRedisRepository.existsAccessToken(ACCESS_TOKEN)).willReturn(true) + + // when + val exception = assertThrows { tokenService.validateActiveAccessToken(ACCESS_TOKEN) } + + // then + assertThat(exception.message).isEqualTo(AuthErrorCode.TOKEN_IS_BLACKLISTED.message) + } + + @Test + @DisplayName("유효하고 블랙리스트에 포함되지 않은 토큰이면 예외가 발생하지 않는다") + fun 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/kotlin/com/ject/studytrip/auth/fixture/KakaoLoginRequestFixture.kt b/src/test/kotlin/com/ject/studytrip/auth/fixture/KakaoLoginRequestFixture.kt new file mode 100644 index 0000000..9be7a72 --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/auth/fixture/KakaoLoginRequestFixture.kt @@ -0,0 +1,9 @@ +package com.ject.studytrip.auth.fixture + +import com.ject.studytrip.auth.presentation.dto.request.KakaoLoginRequest + +class KakaoLoginRequestFixture( + private val code: String = "kakao-auth-code-1234567890", +) { + fun build(): KakaoLoginRequest = KakaoLoginRequest(code) +} diff --git a/src/test/kotlin/com/ject/studytrip/auth/fixture/KakaoSignupRequestFixture.kt b/src/test/kotlin/com/ject/studytrip/auth/fixture/KakaoSignupRequestFixture.kt new file mode 100644 index 0000000..4dff5f8 --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/auth/fixture/KakaoSignupRequestFixture.kt @@ -0,0 +1,10 @@ +package com.ject.studytrip.auth.fixture + +import com.ject.studytrip.auth.presentation.dto.request.KakaoSignupRequest + +class KakaoSignupRequestFixture( + private val category: String = "STUDENT", + private val nickname: String = "민우", +) { + fun build(): KakaoSignupRequest = KakaoSignupRequest(category, nickname) +} diff --git a/src/test/kotlin/com/ject/studytrip/auth/fixture/KakaoTokenResponseFixture.kt b/src/test/kotlin/com/ject/studytrip/auth/fixture/KakaoTokenResponseFixture.kt new file mode 100644 index 0000000..8872f4e --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/auth/fixture/KakaoTokenResponseFixture.kt @@ -0,0 +1,15 @@ +package com.ject.studytrip.auth.fixture + +import com.ject.studytrip.auth.infra.dto.KakaoTokenResponse + +class KakaoTokenResponseFixture( + private val tokenType: String = "bearer", + private val accessToken: String = "access-token", + private val accessTokenExpiresIn: Int = 3600, + private val refreshToken: String = "refresh-token", + private val refreshTokenExpiresIn: Int = 7200, + private val scope: String = "scope", +) { + fun build(): KakaoTokenResponse = + KakaoTokenResponse(tokenType, accessToken, accessTokenExpiresIn, refreshToken, refreshTokenExpiresIn, scope) +} diff --git a/src/test/kotlin/com/ject/studytrip/auth/fixture/KakaoUserInfoResponseFixture.kt b/src/test/kotlin/com/ject/studytrip/auth/fixture/KakaoUserInfoResponseFixture.kt new file mode 100644 index 0000000..e3766bb --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/auth/fixture/KakaoUserInfoResponseFixture.kt @@ -0,0 +1,13 @@ +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.KakaoUserInfoResponse + +class KakaoUserInfoResponseFixture( + private val kakaoId: String = "12345", + private val email: String = "studytrip@kakao.com", + private val profileImage: String = "https://kakao.com/profile.jpg", +) { + fun build(): KakaoUserInfoResponse = KakaoUserInfoResponse(kakaoId, KakaoAccount(KakaoProfile(profileImage), email)) +} diff --git a/src/test/kotlin/com/ject/studytrip/auth/fixture/LogoutRequestFixture.kt b/src/test/kotlin/com/ject/studytrip/auth/fixture/LogoutRequestFixture.kt new file mode 100644 index 0000000..a247cb6 --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/auth/fixture/LogoutRequestFixture.kt @@ -0,0 +1,11 @@ +package com.ject.studytrip.auth.fixture + +import com.ject.studytrip.auth.presentation.dto.request.LogoutRequest + +class LogoutRequestFixture( + private val accessToken: String = "logout-access-token", +) { + fun withAccessToken(accessToken: String): LogoutRequestFixture = LogoutRequestFixture(accessToken) + + fun build(): LogoutRequest = LogoutRequest(accessToken) +} diff --git a/src/test/kotlin/com/ject/studytrip/auth/fixture/TokenFixture.kt b/src/test/kotlin/com/ject/studytrip/auth/fixture/TokenFixture.kt new file mode 100644 index 0000000..fbfadda --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/auth/fixture/TokenFixture.kt @@ -0,0 +1,5 @@ +package com.ject.studytrip.auth.fixture + +class TokenFixture { + fun authorization(token: String): String = "Bearer $token" +} diff --git a/src/test/kotlin/com/ject/studytrip/auth/helper/AuthCookieTestHelper.kt b/src/test/kotlin/com/ject/studytrip/auth/helper/AuthCookieTestHelper.kt new file mode 100644 index 0000000..d2ae93d --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/auth/helper/AuthCookieTestHelper.kt @@ -0,0 +1,27 @@ +package com.ject.studytrip.auth.helper + +import jakarta.servlet.http.Cookie +import org.springframework.stereotype.Component + +@Component +class AuthCookieTestHelper { + fun createKakaoSignupProfileCookie( + name: String, + signupKey: String, + ): Cookie = + Cookie(name, signupKey).apply { + isHttpOnly = true + secure = true + path = "/" + } + + fun createRefreshTokenCookie( + name: String, + refreshToken: String, + ): Cookie = + Cookie(name, refreshToken).apply { + isHttpOnly = true + secure = true + path = "/" + } +} diff --git a/src/test/kotlin/com/ject/studytrip/auth/helper/KakaoOauthTestHelper.kt b/src/test/kotlin/com/ject/studytrip/auth/helper/KakaoOauthTestHelper.kt new file mode 100644 index 0000000..21b6961 --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/auth/helper/KakaoOauthTestHelper.kt @@ -0,0 +1,31 @@ +package com.ject.studytrip.auth.helper + +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.global.exception.CustomException +import com.ject.studytrip.member.domain.error.MemberErrorCode +import org.mockito.ArgumentMatchers.anyString +import org.mockito.BDDMockito.given +import org.springframework.stereotype.Component + +@Component +class KakaoOauthTestHelper( + private val kakaoOauthProvider: KakaoOauthProvider, +) { + fun mockSuccess( + kakaoTokenResponse: KakaoTokenResponse, + kakaoUserInfoResponse: KakaoUserInfoResponse, + ) { + given(kakaoOauthProvider.getKakaoTokens(anyString(), anyString())).willReturn(kakaoTokenResponse) + given(kakaoOauthProvider.getKakaoUserInfo(anyString())).willReturn(kakaoUserInfoResponse) + } + + fun mockThrowException( + kakaoTokenResponse: KakaoTokenResponse, + memberErrorCode: MemberErrorCode, + ) { + given(kakaoOauthProvider.getKakaoTokens(anyString(), anyString())).willReturn(kakaoTokenResponse) + given(kakaoOauthProvider.getKakaoUserInfo(anyString())).willThrow(CustomException(memberErrorCode)) + } +} diff --git a/src/test/kotlin/com/ject/studytrip/auth/helper/TokenTestHelper.kt b/src/test/kotlin/com/ject/studytrip/auth/helper/TokenTestHelper.kt new file mode 100644 index 0000000..68d4c43 --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/auth/helper/TokenTestHelper.kt @@ -0,0 +1,16 @@ +package com.ject.studytrip.auth.helper + +import com.ject.studytrip.auth.infra.provider.TokenProvider +import org.springframework.stereotype.Component + +@Component +class TokenTestHelper( + private val tokenProvider: TokenProvider, +) { + fun createAccessToken( + memberId: String, + role: String, + ): String = tokenProvider.createAccessToken(memberId, role) + + fun createRefreshToken(): String = tokenProvider.createRefreshToken() +} diff --git a/src/test/kotlin/com/ject/studytrip/auth/presentation/controller/AuthControllerIntegrationTest.kt b/src/test/kotlin/com/ject/studytrip/auth/presentation/controller/AuthControllerIntegrationTest.kt new file mode 100644 index 0000000..4ea11fe --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/auth/presentation/controller/AuthControllerIntegrationTest.kt @@ -0,0 +1,415 @@ +package com.ject.studytrip.auth.presentation.controller + +import com.ject.studytrip.BaseIntegrationTest +import com.ject.studytrip.auth.domain.error.AuthErrorCode +import com.ject.studytrip.auth.domain.repository.KakaoSignupProfileRedisRepository +import com.ject.studytrip.auth.domain.repository.RefreshTokenRedisRepository +import com.ject.studytrip.auth.fixture.KakaoLoginRequestFixture +import com.ject.studytrip.auth.fixture.KakaoSignupRequestFixture +import com.ject.studytrip.auth.fixture.KakaoTokenResponseFixture +import com.ject.studytrip.auth.fixture.KakaoUserInfoResponseFixture +import com.ject.studytrip.auth.fixture.LogoutRequestFixture +import com.ject.studytrip.auth.helper.AuthCookieTestHelper +import com.ject.studytrip.auth.helper.KakaoOauthTestHelper +import com.ject.studytrip.auth.helper.TokenTestHelper +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.global.common.constants.CookieConstants.AUTH_REFRESH_TOKEN +import com.ject.studytrip.global.common.constants.CookieConstants.OAUTH_SIGNUP_KEY +import com.ject.studytrip.member.domain.error.MemberErrorCode +import com.ject.studytrip.member.domain.model.Member +import com.ject.studytrip.member.helper.MemberTestHelper +import jakarta.servlet.http.Cookie +import org.hamcrest.CoreMatchers.containsString +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.springframework.beans.factory.annotation.Autowired +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.header +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.time.Duration + +@DisplayName("AuthController 통합 테스트") +class AuthControllerIntegrationTest : BaseIntegrationTest() { + @Autowired + private lateinit var memberTestHelper: MemberTestHelper + + @Autowired private lateinit var tokenTestHelper: TokenTestHelper + + @Autowired private lateinit var kakaoOauthTestHelper: KakaoOauthTestHelper + + @Autowired private lateinit var authCookieTestHelper: AuthCookieTestHelper + + @Autowired private lateinit var refreshTokenRedisRepository: RefreshTokenRedisRepository + + @Autowired private lateinit var kakaoSignupProfileRedisRepository: KakaoSignupProfileRedisRepository + + @MockitoBean private lateinit var kakaoOauthProvider: KakaoOauthProvider + + private lateinit var member: Member + private lateinit var accessToken: String + private lateinit var refreshToken: String + private lateinit var signupKey: String + + @BeforeEach + fun setUp() { + member = memberTestHelper.saveMember() + accessToken = tokenTestHelper.createAccessToken(member.id.toString(), member.role.name) + refreshToken = tokenTestHelper.createRefreshToken() + signupKey = kakaoSignupProfileRedisRepository.saveAndIssueSignupKey(member.socialId, member.email, member.profileImage) + + refreshTokenRedisRepository.saveRefreshToken(member.id.toString(), refreshToken, Duration.ofSeconds(30).toMillis()) + } + + companion object { + private const val BASE_AUTH_URL = "/api/auth" + private const val TEST_ORIGIN = "http://localhost:8080" + private const val RESPONSE_COOKIE_NAME = "Set-Cookie" + } + + @Nested + @DisplayName("카카오 회원가입 API") + inner class KakaoSignup { + private val fixture = KakaoSignupRequestFixture() + + private fun getResultActions( + request: KakaoSignupRequest, + cookie: Cookie, + ): ResultActions = + mockMvc.perform( + post("$BASE_AUTH_URL/signup/kakao") + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(objectMapper.writeValueAsString(request)) + .cookie(cookie), + ) + + @Test + @DisplayName("회원가입 요청 시 signupKey 쿠키가 존재하지 않으면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenSignupKeyCookieDoesNotExist() { + // given + memberTestHelper.deleteMemberById(member.id) + val request = fixture.build() + val cookie = authCookieTestHelper.createKakaoSignupProfileCookie("NULL_COOKIE", signupKey) + + // when + val resultActions = getResultActions(request, cookie) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(AuthErrorCode.MISSING_KAKAO_SIGNUP_KEY.status.value())) + .andExpect(jsonPath("$.data.message").value(AuthErrorCode.MISSING_KAKAO_SIGNUP_KEY.message)) + } + + @Test + @DisplayName("회원가입 요청 시 signupKey가 유효하지 않으면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenSignupKeyIsInvalid() { + // given + memberTestHelper.deleteMemberById(member.id) + val request = fixture.build() + val cookie = authCookieTestHelper.createKakaoSignupProfileCookie(OAUTH_SIGNUP_KEY, "invalid.pending.key") + + // when + val resultActions = getResultActions(request, cookie) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(AuthErrorCode.INVALID_KAKAO_SIGNUP_KEY.status.value())) + .andExpect(jsonPath("$.data.message").value(AuthErrorCode.INVALID_KAKAO_SIGNUP_KEY.message)) + } + + @Test + @DisplayName("회원가입 요청 시 이미 가입된 사용자라면 409 Conflict를 반환한다.") + fun shouldReturnConflictWhenMemberAlreadyExists() { + // given + val request = fixture.build() + val cookie = authCookieTestHelper.createKakaoSignupProfileCookie(OAUTH_SIGNUP_KEY, signupKey) + + // when + val resultActions = getResultActions(request, cookie) + + resultActions + .andExpect(status().isConflict) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(MemberErrorCode.MEMBER_ALREADY_EXISTS.status.value())) + .andExpect(jsonPath("$.data.message").value(MemberErrorCode.MEMBER_ALREADY_EXISTS.message)) + } + + @Test + @DisplayName("회원가입 성공 시 토큰이 발급된다.") + fun shouldReturnTokenResponseWhenSignupIsSuccessful() { + // given + memberTestHelper.deleteMemberById(member.id) + val request = fixture.build() + val cookie = authCookieTestHelper.createKakaoSignupProfileCookie(OAUTH_SIGNUP_KEY, signupKey) + + // when + val resultActions = getResultActions(request, cookie) + + // then + resultActions + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + .andExpect(jsonPath("$.data.signupRequired").value(false)) + .andExpect(jsonPath("$.data.accessToken").isNotEmpty) + .andExpect(header().exists(RESPONSE_COOKIE_NAME)) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("$AUTH_REFRESH_TOKEN="))) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("HttpOnly"))) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("Secure"))) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("SameSite=None"))) + } + } + + @Nested + @DisplayName("카카오 로그인 API") + inner class KakaoLogin { + private val kakaoLoginRequestFixture = KakaoLoginRequestFixture() + private val kakaoTokenResponseFixture = KakaoTokenResponseFixture() + private val kakaoUserInfoResponseFixture = KakaoUserInfoResponseFixture() + + private fun getResultActions(request: KakaoLoginRequest): ResultActions = + mockMvc.perform( + post("$BASE_AUTH_URL/login/kakao") + .header("Origin", TEST_ORIGIN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ) + + @Test + @DisplayName("탈퇴한 사용자가 인가 코드로 로그인 시 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenMemberAlreadyDeleted() { + // given + member.updateDeletedAt() + val request = kakaoLoginRequestFixture.build() + val kakaoTokenResponse = kakaoTokenResponseFixture.build() + kakaoOauthTestHelper.mockThrowException(kakaoTokenResponse, MemberErrorCode.MEMBER_ALREADY_DELETED) + + // when + val resultActions = getResultActions(request) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(MemberErrorCode.MEMBER_ALREADY_DELETED.status.value())) + .andExpect(jsonPath("$.data.message").value(MemberErrorCode.MEMBER_ALREADY_DELETED.message)) + } + + @Test + @DisplayName("가입되지 않은 사용자 인가 코드로 로그인 시 회원가입 필요 응답을 반환한다.") + fun shouldReturnSignupRequiredWhenMemberDoesNotExist() { + // given + memberTestHelper.deleteMemberById(member.id) + val request = kakaoLoginRequestFixture.build() + val kakaoTokenResponse = kakaoTokenResponseFixture.build() + val kakaoUserInfoResponse = kakaoUserInfoResponseFixture.build() + kakaoOauthTestHelper.mockSuccess(kakaoTokenResponse, kakaoUserInfoResponse) + + // when + val resultActions = getResultActions(request) + + // then + resultActions + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + .andExpect(jsonPath("$.data.signupRequired").value(true)) + .andExpect(jsonPath("$.data.accessToken").doesNotExist()) + .andExpect(header().exists(RESPONSE_COOKIE_NAME)) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("$OAUTH_SIGNUP_KEY="))) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("HttpOnly"))) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("Secure"))) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("SameSite=None"))) + } + + @Test + @DisplayName("가입된 사용자의 인가 코드로 로그인하면 토큰이 발급된다.") + fun shouldReturnTokenResponseWhenLoginIsSuccessful() { + // given + val request = kakaoLoginRequestFixture.build() + val kakaoTokenResponse = kakaoTokenResponseFixture.build() + val kakaoUserInfoResponse = kakaoUserInfoResponseFixture.build() + kakaoOauthTestHelper.mockSuccess(kakaoTokenResponse, kakaoUserInfoResponse) + + // when + val resultActions = getResultActions(request) + + // then + resultActions + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + .andExpect(jsonPath("$.data.signupRequired").value(false)) + .andExpect(jsonPath("$.data.accessToken").isNotEmpty) + .andExpect(header().exists(RESPONSE_COOKIE_NAME)) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("$AUTH_REFRESH_TOKEN="))) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("HttpOnly"))) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("Secure"))) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("SameSite=None"))) + } + } + + @Nested + @DisplayName("토큰 재발급 API") + inner class ReissueToken { + private fun getResultActions(cookie: Cookie): ResultActions = mockMvc.perform(post("$BASE_AUTH_URL/token/reissue").cookie(cookie)) + + @Test + @DisplayName("리프레시 토큰이 null이거나 비어있다면 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenRefreshTokenIsNullOrBlank() { + // given + val cookie = authCookieTestHelper.createRefreshTokenCookie("null.refresh.cookie", refreshToken) + + // when + val resultActions = getResultActions(cookie) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(AuthErrorCode.MISSING_REFRESH_TOKEN.status.value())) + .andExpect(jsonPath("$.data.message").value(AuthErrorCode.MISSING_REFRESH_TOKEN.message)) + } + + @Test + @DisplayName("리프레시 토큰이 존재하지 않거나 위조되었다면 401 UNAUTHORIZED를 반환한다.") + fun shouldReturnUnauthorizedWhenRefreshTokenIsInvalid() { + // given + val cookie = authCookieTestHelper.createRefreshTokenCookie(AUTH_REFRESH_TOKEN, "invalid:refresh:cookie") + + // when + val resultActions = getResultActions(cookie) + + // then + resultActions + .andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(AuthErrorCode.INVALID_REFRESH_TOKEN.status.value())) + .andExpect(jsonPath("$.data.message").value(AuthErrorCode.INVALID_REFRESH_TOKEN.message)) + } + + @Test + @DisplayName("유효한 요청이 들어오면 새로운 엑세스 토큰과 리프레시 토큰을 재발급한다.") + fun shouldReissueTokenWhenRequestIsValid() { + // given + val cookie = authCookieTestHelper.createRefreshTokenCookie(AUTH_REFRESH_TOKEN, refreshToken) + + // when + val resultActions = getResultActions(cookie) + + // then + resultActions + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + .andExpect(jsonPath("$.data.accessToken").isNotEmpty) + .andExpect(header().exists(RESPONSE_COOKIE_NAME)) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("$AUTH_REFRESH_TOKEN="))) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("HttpOnly"))) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("Secure"))) + .andExpect(header().string(HttpHeaders.SET_COOKIE, containsString("SameSite=None"))) + } + } + + @Nested + @DisplayName("로그아웃 API") + inner class Logout { + private val fixture = LogoutRequestFixture() + + private fun getResultActions( + request: LogoutRequest, + cookie: Cookie, + ): ResultActions = + mockMvc.perform( + post("$BASE_AUTH_URL/logout") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .cookie(cookie), + ) + + @Test + @DisplayName("엑세스 토큰이 존재하지 않거나 위조된 경우 401 UNAUTHORIZED를 반환한다.") + fun shouldReturnUnauthorizedWhenAccessTokenIsInvalid() { + // given + val request = fixture.withAccessToken("invalid.access.token").build() + val cookie = authCookieTestHelper.createRefreshTokenCookie(AUTH_REFRESH_TOKEN, refreshToken) + + // when + val resultActions = getResultActions(request, cookie) + + // then + resultActions + .andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(AuthErrorCode.INVALID_JWT_TOKEN.status.value())) + .andExpect(jsonPath("$.data.message").value(AuthErrorCode.INVALID_JWT_TOKEN.message)) + } + + @Test + @DisplayName("리프레시 토큰이 null 혹은 비어있을 경우 BAD REQUEST를 반환한다.") + fun shouldReturnBadRequestWhenRefreshTokenIsNullOrBlank() { + // given + val request = fixture.withAccessToken(accessToken).build() + val cookie = authCookieTestHelper.createRefreshTokenCookie("null.refresh.cookie", refreshToken) + + // when + val resultActions = getResultActions(request, cookie) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(AuthErrorCode.MISSING_REFRESH_TOKEN.status.value())) + .andExpect(jsonPath("$.data.message").value(AuthErrorCode.MISSING_REFRESH_TOKEN.message)) + } + + @Test + @DisplayName("리프레시 토큰이 존재하지 않거나 위조된 경우 401 UNAUTHORIZED를 반환한다.") + fun shouldReturnUnauthorizedWhenRefreshTokenIsInvalid() { + // given + val request = fixture.withAccessToken(accessToken).build() + val cookie = authCookieTestHelper.createRefreshTokenCookie(AUTH_REFRESH_TOKEN, "invalid.refresh.token") + + // when + val resultActions = getResultActions(request, cookie) + + // then + resultActions + .andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(AuthErrorCode.INVALID_REFRESH_TOKEN.status.value())) + .andExpect(jsonPath("$.data.message").value(AuthErrorCode.INVALID_REFRESH_TOKEN.message)) + } + + @Test + @DisplayName("유효한 요청이 들어오면, 엑세스 토큰을 블랙리스트에 추가하고, 저장된 리프레시 토큰을 제거합니다.") + fun shouldLogoutWhenRequestIsValid() { + // given + val request = fixture.withAccessToken(accessToken).build() + val cookie = authCookieTestHelper.createRefreshTokenCookie(AUTH_REFRESH_TOKEN, refreshToken) + + // when + val resultActions = getResultActions(request, cookie) + + // then + resultActions + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + } + } +} diff --git a/src/test/kotlin/com/ject/studytrip/dummy/application/service/DummyMissionCommandServiceTest.kt b/src/test/kotlin/com/ject/studytrip/dummy/application/service/DummyMissionCommandServiceTest.kt index cf360c6..b8199f6 100644 --- a/src/test/kotlin/com/ject/studytrip/dummy/application/service/DummyMissionCommandServiceTest.kt +++ b/src/test/kotlin/com/ject/studytrip/dummy/application/service/DummyMissionCommandServiceTest.kt @@ -23,7 +23,7 @@ class DummyMissionCommandServiceTest : BaseUnitTest() { @BeforeEach fun setUp() { - val member = MemberFixture.createMemberFromKakao() + val member = MemberFixture().createFromKakao() val courseTrip = TripFixture(member, TripCategory.COURSE).create() val exploreTrip = TripFixture(member, TripCategory.EXPLORE).create() courseStamp = StampFixture(courseTrip, DUMMY_STAMP_COUNT).create() diff --git a/src/test/kotlin/com/ject/studytrip/dummy/application/service/DummyStampCommandServiceTest.kt b/src/test/kotlin/com/ject/studytrip/dummy/application/service/DummyStampCommandServiceTest.kt index d45a03c..1ab3cb2 100644 --- a/src/test/kotlin/com/ject/studytrip/dummy/application/service/DummyStampCommandServiceTest.kt +++ b/src/test/kotlin/com/ject/studytrip/dummy/application/service/DummyStampCommandServiceTest.kt @@ -21,7 +21,7 @@ class DummyStampCommandServiceTest : BaseUnitTest() { @BeforeEach fun setUp() { - val member = MemberFixture.createMemberFromKakao() + val member = MemberFixture().createFromKakao() courseTrip = TripFixture(member, TripCategory.COURSE).create() exploreTrip = TripFixture(member, TripCategory.EXPLORE).create() } diff --git a/src/test/kotlin/com/ject/studytrip/dummy/application/service/DummyTripCommandServiceTest.kt b/src/test/kotlin/com/ject/studytrip/dummy/application/service/DummyTripCommandServiceTest.kt index 20e497a..be7fd80 100644 --- a/src/test/kotlin/com/ject/studytrip/dummy/application/service/DummyTripCommandServiceTest.kt +++ b/src/test/kotlin/com/ject/studytrip/dummy/application/service/DummyTripCommandServiceTest.kt @@ -19,7 +19,7 @@ class DummyTripCommandServiceTest : BaseUnitTest() { @BeforeEach fun setUp() { - member = MemberFixture.createMemberFromKakao() + member = MemberFixture().createFromKakao() } companion object { diff --git a/src/test/kotlin/com/ject/studytrip/dummy/presentation/controller/DummyMissionControllerIntegrationTest.kt b/src/test/kotlin/com/ject/studytrip/dummy/presentation/controller/DummyMissionControllerIntegrationTest.kt index b19bef4..95c7d23 100644 --- a/src/test/kotlin/com/ject/studytrip/dummy/presentation/controller/DummyMissionControllerIntegrationTest.kt +++ b/src/test/kotlin/com/ject/studytrip/dummy/presentation/controller/DummyMissionControllerIntegrationTest.kt @@ -52,7 +52,7 @@ class DummyMissionControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( get(BASE_DUMMY_MISSION_URL) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)) + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)) .param("category", category) .param("count", count.toString()) .contentType(MediaType.APPLICATION_JSON), diff --git a/src/test/kotlin/com/ject/studytrip/dummy/presentation/controller/DummyStampControllerIntegrationTest.kt b/src/test/kotlin/com/ject/studytrip/dummy/presentation/controller/DummyStampControllerIntegrationTest.kt index ef882c7..129fd54 100644 --- a/src/test/kotlin/com/ject/studytrip/dummy/presentation/controller/DummyStampControllerIntegrationTest.kt +++ b/src/test/kotlin/com/ject/studytrip/dummy/presentation/controller/DummyStampControllerIntegrationTest.kt @@ -52,7 +52,7 @@ class DummyStampControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( get(BASE_DUMMY_STAMP_URL) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)) + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)) .param("category", category) .param("count", count.toString()) .contentType(MediaType.APPLICATION_JSON), diff --git a/src/test/kotlin/com/ject/studytrip/image/application/service/ImageServiceTest.kt b/src/test/kotlin/com/ject/studytrip/image/application/service/ImageServiceTest.kt new file mode 100644 index 0000000..2cf104d --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/image/application/service/ImageServiceTest.kt @@ -0,0 +1,463 @@ +package com.ject.studytrip.image.application.service + +import com.ject.studytrip.BaseUnitTest +import com.ject.studytrip.global.config.properties.CdnProperties +import com.ject.studytrip.global.exception.CustomException +import com.ject.studytrip.image.application.dto.CleanupImagesResult +import com.ject.studytrip.image.application.event.ImageEventPublisher +import com.ject.studytrip.image.domain.error.ImageErrorCode +import com.ject.studytrip.image.fixture.ImageHeadInfoFixture +import com.ject.studytrip.image.fixture.ImageTestConstants.FINAL_KEY +import com.ject.studytrip.image.fixture.ImageTestConstants.INVALID_MIME +import com.ject.studytrip.image.fixture.ImageTestConstants.INVALID_ORIGINAL_FILENAME +import com.ject.studytrip.image.fixture.ImageTestConstants.JPEG_HEADER_BYTES +import com.ject.studytrip.image.fixture.ImageTestConstants.ORIGINAL_FILENAME +import com.ject.studytrip.image.fixture.ImageTestConstants.PRESIGNED_URL +import com.ject.studytrip.image.fixture.ImageTestConstants.TMP_KEY +import com.ject.studytrip.image.fixture.ImageTestConstants.VALID_CONTENT_LENGTH +import com.ject.studytrip.image.fixture.ImageTestConstants.VALID_ID +import com.ject.studytrip.image.fixture.ImageTestConstants.VALID_KEY_PREFIX +import com.ject.studytrip.image.fixture.ImageTestConstants.VALID_MIME +import com.ject.studytrip.image.infra.s3.error.S3ErrorCode +import com.ject.studytrip.image.infra.s3.provider.S3ImageStorageProvider +import com.ject.studytrip.image.infra.tika.provider.TikaImageProbeProvider +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.ArgumentMatchers.anyString +import org.mockito.BDDMockito.given +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.atLeast +import org.mockito.kotlin.never +import org.mockito.kotlin.verify + +@DisplayName("ImageService 단위 테스트") +class ImageServiceTest : BaseUnitTest() { + @InjectMocks + private lateinit var imageService: ImageService + + @Mock + private lateinit var s3Provider: S3ImageStorageProvider + + @Mock + private lateinit var tikaProvider: TikaImageProbeProvider + + @Mock + private lateinit var cdnProperties: CdnProperties + + @Mock + private lateinit var imageEventPublisher: ImageEventPublisher + + companion object { + private const val IMAGE_BASE_URL = "https://test-cdn.cloudfront.net" + private const val EXTRACTED_KEY = "members/1/test.jpg" + private const val VALID_IMAGE_URL1 = "$IMAGE_BASE_URL/members/1/image1.jpg" + private const val VALID_IMAGE_URL2 = "$IMAGE_BASE_URL/members/1/image2.jpg" + private const val VALID_KEY1 = "members/1/image1.jpg" + private const val VALID_KEY2 = "members/1/image2.jpg" + } + + @Nested + @DisplayName("presign 메서드는") + inner class Presign { + @Test + @DisplayName("유효하지 않은 키 Prefix로 호출하면 예외가 발생한다.") + fun shouldThrowExceptionWhenKeyPrefixIsInvalid() { + // when + val exception = assertThrows { imageService.presign(null, VALID_ID, ORIGINAL_FILENAME) } + + // then + assertThat(exception.message).isEqualTo(ImageErrorCode.INVALID_IMAGE_KEY_PREFIX.message) + } + + @Test + @DisplayName("존재하지 않는 키 Prefix로 호출하면 예외가 발생한다.") + fun shouldThrowExceptionWhenKeyPrefixDoesNotExist() { + // when + val exception = assertThrows { imageService.presign("invalid-key-prefix/", VALID_ID, ORIGINAL_FILENAME) } + + // then + assertThat(exception.message).isEqualTo(ImageErrorCode.INVALID_IMAGE_KEY_PREFIX.message) + } + + @Test + @DisplayName("유효하지 않은 이미지 파일 확장자라면 예외가 발생한다.") + fun shouldThrowExceptionWhenExtensionIsInvalid() { + // when + val exception = assertThrows { imageService.presign(VALID_KEY_PREFIX, VALID_ID, INVALID_ORIGINAL_FILENAME) } + + // then + assertThat(exception.message).isEqualTo(ImageErrorCode.INVALID_IMAGE_EXTENSION.message) + } + + @Test + @DisplayName("유효한 파라미터로 presigned URL을 발급한다.") + fun shouldIssuePresignedUrlWithValidParameters() { + // given + given(s3Provider.issuePresignedUrl(anyString())).willReturn(PRESIGNED_URL) + + // when + val result = imageService.presign(VALID_KEY_PREFIX, VALID_ID, ORIGINAL_FILENAME) + + // then + assertThat(result.presignedUrl).isEqualTo(PRESIGNED_URL) + verify(s3Provider).issuePresignedUrl(anyString()) + } + } + + @Nested + @DisplayName("confirm 메서드는") + inner class Confirm { + val fixture = ImageHeadInfoFixture() + + @Test + @DisplayName("tmpKey가 빈 문자열이면 예외가 발생한다.") + fun shouldThrowExceptionWhenTmpKeyIsBlank() { + // when + val exception = assertThrows { imageService.confirm(" ") } + + // then + assertThat(exception.message).isEqualTo(ImageErrorCode.INVALID_IMAGE_KEY.message) + verify(s3Provider, never()).deleteByKey(anyString()) // cleanup 비호출 여부 + } + + @Test + @DisplayName("S3 작업 중 에러가 발생하면 예외가 발생한다.") + fun shouldThrowExceptionWhenS3OperationFails() { + // given + given(s3Provider.getHeadByKey(TMP_KEY)).willThrow(CustomException(S3ErrorCode.S3_STORAGE_SERVER_ERROR)) + + // when + val exception = assertThrows { imageService.confirm(TMP_KEY) } + + // then + assertThat(exception.message).isEqualTo(S3ErrorCode.S3_STORAGE_SERVER_ERROR.message) + verify(s3Provider, never()).deleteByKey(anyString()) // cleanup 비호출 여부 + } + + @Test + @DisplayName("이미지 크기가 유효하지 않으면 cleanup 이후 예외가 발생한다.") + fun shouldCleanupAndThrowExceptionWhenImageSizeIsInvalid() { + // given + val headInfo = fixture.createLarge() + given(s3Provider.getHeadByKey(TMP_KEY)).willReturn(headInfo) + + // when + val exception = assertThrows { imageService.confirm(TMP_KEY) } + + // then + assertThat(exception.message).isEqualTo(ImageErrorCode.IMAGE_SIZE_EXCEEDED.message) + verify(s3Provider).deleteByKey(TMP_KEY) // cleanup 호출 여부 + } + + @Test + @DisplayName("이미지 크기가 0이면 cleanup 이후 예외가 발생한다.") + fun shouldCleanupAndThrowExceptionWhenImageSizeIsZero() { + // given + val headInfo = fixture.createEmpty() + given(s3Provider.getHeadByKey(TMP_KEY)).willReturn(headInfo) + + // when + val exception = assertThrows { imageService.confirm(TMP_KEY) } + + // then + assertThat(exception.message).isEqualTo(ImageErrorCode.EMPTY_IMAGE.message) + verify(s3Provider).deleteByKey(TMP_KEY) // cleanup 호출 여부 + } + + @Test + @DisplayName("MIME 타입이 유효하지 않으면 cleanup 이후 예외가 발생한다.") + fun shouldCleanupAndThrowExceptionWhenMimeIsInvalid() { + // given + val headInfo = fixture.create() + given(s3Provider.getHeadByKey(TMP_KEY)).willReturn(headInfo) + given(s3Provider.readPrefix(TMP_KEY, VALID_CONTENT_LENGTH.toInt())).willReturn(JPEG_HEADER_BYTES) + given(tikaProvider.detectMime(JPEG_HEADER_BYTES)).willReturn(INVALID_MIME) + + // when + val exception = assertThrows { imageService.confirm(TMP_KEY) } + + // then + assertThat(exception.message).isEqualTo(ImageErrorCode.INVALID_IMAGE_MIME.message) + verify(s3Provider).deleteByKey(TMP_KEY) // cleanup 호출 여부 + } + + @Test + @DisplayName("이미지 크기가 PROBE_BYTES보다 작으면 전체 크기만큼만 읽는다.") + fun shouldReadOnlyActualSizeWhenSmallerThanProbeBytes() { + // given + val smallSize = 1024L + val headInfo = ImageHeadInfoFixture().create(smallSize) + val smallImageBytes = ByteArray(smallSize.toInt()) + given(s3Provider.getHeadByKey(TMP_KEY)).willReturn(headInfo) + given(s3Provider.readPrefix(TMP_KEY, smallSize.toInt())).willReturn(smallImageBytes) + given(tikaProvider.detectMime(smallImageBytes)).willReturn(VALID_MIME) + given(cdnProperties.domain).willReturn("test-cdn.cloudfront.net") + + // when + val result = imageService.confirm(TMP_KEY) + + // then + assertThat(result).isNotNull + assertThat(result).startsWith("test-cdn.cloudfront.net/") + assertThat(result).contains(FINAL_KEY) + verify(s3Provider).readPrefix(TMP_KEY, smallSize.toInt()) + } + + @Test + @DisplayName("유효한 이미지를 검증하고 CDN URL을 반환한다.") + fun shouldConfirmValidImageAndReturnCdnUrl() { + // given + val headInfo = fixture.create() + given(s3Provider.getHeadByKey(TMP_KEY)).willReturn(headInfo) + given(s3Provider.readPrefix(TMP_KEY, VALID_CONTENT_LENGTH.toInt())).willReturn(JPEG_HEADER_BYTES) + given(tikaProvider.detectMime(JPEG_HEADER_BYTES)).willReturn(VALID_MIME) + given(cdnProperties.domain).willReturn("test-cdn.cloudfront.net") + + // when + val result = imageService.confirm(TMP_KEY) + + // then + assertThat(result).isNotNull + assertThat(result).startsWith("test-cdn.cloudfront.net/") + assertThat(result).contains(FINAL_KEY) + verify(s3Provider).getHeadByKey(TMP_KEY) + verify(s3Provider).readPrefix(TMP_KEY, VALID_CONTENT_LENGTH.toInt()) + verify(tikaProvider).detectMime(JPEG_HEADER_BYTES) + verify(s3Provider).copyByKey(TMP_KEY, FINAL_KEY) + } + } + + @Nested + @DisplayName("cancel 메서드는") + inner class Cancel { + @Test + @DisplayName("업로드된 키들을 삭제한다.") + fun shouldDeleteUploadedKeys() { + // given + val uploadedKeys = listOf(TMP_KEY, "tmp/profile/12345/test2.jpg") + + // when + imageService.cancel(uploadedKeys) + + // then + verify(s3Provider).deleteByKeys(uploadedKeys) + } + + @Test + @DisplayName("빈 리스트로 호출해도 정상 동작한다.") + fun shouldHandleEmptyList() { + // given + val emptyKeys = emptyList() + + // when + imageService.cancel(emptyKeys) + + // then + verify(s3Provider).deleteByKeys(emptyKeys) + } + } + + @Nested + @DisplayName("cleanup 메서드는") + inner class Cleanup { + @Test + @DisplayName("URL이 null이면 삭제하지 않는다.") + fun shouldNotDeleteWhenUrlIsNull() { + // given + given(cdnProperties.domain).willReturn(IMAGE_BASE_URL) + + // when + imageService.cleanup(null) + + // then + verify(s3Provider, never()).deleteByKey(anyString()) + } + + @Test + @DisplayName("URL이 비어있다면 삭제하지 않는다.") + fun shouldNotDeleteWhenUrlIsEmpty() { + // given + given(cdnProperties.domain).willReturn(IMAGE_BASE_URL) + + // when + imageService.cleanup("") + + // then + verify(s3Provider, never()).deleteByKey(anyString()) + } + + @Test + @DisplayName("잘못된 CDN 도메인이면 삭제하지 않는다.") + fun shouldNotDeleteWhenCdnDomainIsMismatched() { + // given + given(cdnProperties.domain).willReturn(IMAGE_BASE_URL) + + // when + imageService.cleanup("https://wrong-cdn.com/members/1/test.jpg") + + // then + verify(s3Provider, never()).deleteByKey(anyString()) + } + + @Test + @DisplayName("유효하지 않은 URL 형식이면 삭제하지 않는다.") + fun shouldNotDeleteWhenUrlFormatIsInvalid() { + // given + given(cdnProperties.domain).willReturn(IMAGE_BASE_URL) + + // when + imageService.cleanup("invalid-url") + + // then + verify(s3Provider, never()).deleteByKey(anyString()) + } + + @Test + @DisplayName("유효한 CDN URL에서 키를 추출하고 이미지를 삭제한다.") + fun shouldExtractKeyAndDeleteImage() { + // given + given(cdnProperties.domain).willReturn(IMAGE_BASE_URL) + + // when + imageService.cleanup("$IMAGE_BASE_URL/$EXTRACTED_KEY") + + // then + verify(s3Provider).deleteByKey(EXTRACTED_KEY) + } + } + + @Nested + @DisplayName("cleanupBatch 메서드는") + inner class CleanupBatch { + @Test + @DisplayName("빈 리스트로 호출하면 아무 작업도 수행하지 않는다.") + fun shouldDoNothingWhenListIsEmpty() { + // when + imageService.cleanupBatch(emptyList()) + + // then + verify(s3Provider, never()).deleteByKeys(any()) + } + + @Test + @DisplayName("일부 이미지 삭제 실패 시 실패한 키를 수집한다.") + fun shouldCollectFailedKeysWhenSomeDeletionsFail() { + // given + val imageUrls = listOf(VALID_IMAGE_URL1, VALID_IMAGE_URL2) + val keys = listOf(VALID_KEY1, VALID_KEY2) + given(cdnProperties.domain).willReturn(IMAGE_BASE_URL) + given(s3Provider.deleteByKeys(keys)).willReturn(CleanupImagesResult(1, listOf(VALID_KEY2))) + + // when + imageService.cleanupBatch(imageUrls) + + // then + verify(s3Provider).deleteByKeys(keys) + } + + @Test + @DisplayName("1000개 이상의 이미지 URL이 들어오면 배치로 나누어 처리한다.") + fun shouldSplitBatchWhenUrlsExceedMaxBatch() { + // given + val imageUrls = List(1500) { "$IMAGE_BASE_URL/members/1/image$it.jpg" } + given(cdnProperties.domain).willReturn(IMAGE_BASE_URL) + given(s3Provider.deleteByKeys(any())).willReturn(CleanupImagesResult(1000, emptyList())) + + // when + imageService.cleanupBatch(imageUrls) + + // then + verify(s3Provider, atLeast(2)).deleteByKeys(any()) + } + + @Test + @DisplayName("중복된 이미지 URL은 한 번만 처리한다.") + fun shouldDeleteDuplicateUrlsOnlyOnceWhenCleanupBatchIsCalled() { + // given + val imageUrls = listOf(VALID_IMAGE_URL1, VALID_IMAGE_URL1, VALID_IMAGE_URL2) + val keys = listOf(VALID_KEY1, VALID_KEY2) + given(cdnProperties.domain).willReturn(IMAGE_BASE_URL) + given(s3Provider.deleteByKeys(keys)).willReturn(CleanupImagesResult(2, emptyList())) + + // when + imageService.cleanupBatch(imageUrls) + + // then + verify(s3Provider).deleteByKeys(keys) + } + + @Test + @DisplayName("잘못된 CDN 도메인을 가진 URL은 필터링하여 처리한다") + fun shouldFilterUrlsWhenCdnDomainIsInvalid() { + // given + val imageUrls = listOf(VALID_IMAGE_URL1, "https://wrong-cdn.com/members/1/image1.jpg") + val keys = listOf(VALID_KEY1) + given(cdnProperties.domain()).willReturn(IMAGE_BASE_URL) + given(s3Provider.deleteByKeys(keys)).willReturn(CleanupImagesResult(1, emptyList())) + + // when + imageService.cleanupBatch(imageUrls) + + // then + verify(s3Provider).deleteByKeys(keys) + } + + @Test + @DisplayName("유효한 이미지 URL 리스트로 배치 삭제를 수행한다.") + fun shouldDeleteImagesInBatchWhenUrlsAreValid() { + // given + val imageUrls = listOf(VALID_IMAGE_URL1, VALID_IMAGE_URL2) + val keys = listOf(VALID_KEY1, VALID_KEY2) + given(cdnProperties.domain).willReturn(IMAGE_BASE_URL) + given(s3Provider.deleteByKeys(keys)).willReturn(CleanupImagesResult(2, emptyList())) + + // when + imageService.cleanupBatch(imageUrls) + + // then + verify(s3Provider).deleteByKeys(keys) + } + } + + @Nested + @DisplayName("publishCleanupBatchEvent 메서드는") + inner class PublishCleanupBatchEvent { + @Test + @DisplayName("빈 리스트로 호출하면 이벤트는 발행되지만 내부에서 처리되지 않는다.") + fun shouldNotPublishEventWhenListIsEmpty() { + // when + imageService.publishCleanupBatchEvent(emptyList()) + + // then + verify(imageEventPublisher).publishCleanupBatch(emptyList()) + } + + @Test + @DisplayName("null 리스트로 호출하면 이벤트는 발행되지만 내부에서 처리되지 않는다.") + fun shouldNotPublishEventWhenListIsNull() { + // when + imageService.publishCleanupBatchEvent(null) + + // then + verify(imageEventPublisher).publishCleanupBatch(null) + } + + @Test + @DisplayName("유효한 이미지 URL 리스트로 이벤트를 발행한다.") + fun shouldPublishEventWhenImageUrlsAreValid() { + // given + val imageUrls = listOf("https://cdn.example.com/members/1/image1.jpg", "https://cdn.example.com/members/1/image2.jpg") + + // when + imageService.publishCleanupBatchEvent(imageUrls) + + // then + verify(imageEventPublisher).publishCleanupBatch(imageUrls) + } + } +} diff --git a/src/test/kotlin/com/ject/studytrip/image/fixture/ImageHeadInfoFixture.kt b/src/test/kotlin/com/ject/studytrip/image/fixture/ImageHeadInfoFixture.kt new file mode 100644 index 0000000..bbb7f0f --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/image/fixture/ImageHeadInfoFixture.kt @@ -0,0 +1,18 @@ +package com.ject.studytrip.image.fixture + +import com.ject.studytrip.image.domain.constants.ImageConstants.MAX_IMAGE_SIZE_BYTES +import com.ject.studytrip.image.infra.s3.dto.ImageHeadInfo + +class ImageHeadInfoFixture( + private val defaultContentLength: Long = 1024L, + private val largeContentLength: Long = MAX_IMAGE_SIZE_BYTES + 1, + private val emptyContentLength: Long = 0L, +) { + fun create(): ImageHeadInfo = ImageHeadInfo(defaultContentLength) + + fun create(contentLength: Long): ImageHeadInfo = ImageHeadInfo(contentLength) + + fun createLarge(): ImageHeadInfo = ImageHeadInfo(largeContentLength) + + fun createEmpty(): ImageHeadInfo = ImageHeadInfo(emptyContentLength) +} diff --git a/src/test/kotlin/com/ject/studytrip/image/fixture/ImageTestConstants.kt b/src/test/kotlin/com/ject/studytrip/image/fixture/ImageTestConstants.kt new file mode 100644 index 0000000..1035f0e --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/image/fixture/ImageTestConstants.kt @@ -0,0 +1,25 @@ +package com.ject.studytrip.image.fixture + +import com.ject.studytrip.image.domain.constants.ImageConstants.TMP_PREFIX + +object ImageTestConstants { + const val VALID_KEY_PREFIX = "members" + const val VALID_ID = "12345" + const val ORIGINAL_FILENAME = "test.jpg" + const val INVALID_ORIGINAL_FILENAME = "test.txt" + const val TMP_KEY = TMP_PREFIX + "members/12345/test.jpg" + const val FINAL_KEY = "members/12345/test.jpg" + + // 크기 관련 + const val VALID_CONTENT_LENGTH = 1024L + + // MIME 타입 + const val VALID_MIME = "image/jpeg" + const val INVALID_MIME = "text/plain" + + // URL + const val PRESIGNED_URL = "https://s3.amazonaws.com/test-presigned-url" + + // 바이트 데이터 + val JPEG_HEADER_BYTES = byteArrayOf(0xFF.toByte(), 0xD8.toByte(), 0xFF.toByte()) +} diff --git a/src/test/kotlin/com/ject/studytrip/member/application/service/MemberCommandServiceTest.kt b/src/test/kotlin/com/ject/studytrip/member/application/service/MemberCommandServiceTest.kt new file mode 100644 index 0000000..7f2ec09 --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/member/application/service/MemberCommandServiceTest.kt @@ -0,0 +1,285 @@ +package com.ject.studytrip.member.application.service + +import com.ject.studytrip.BaseUnitTest +import com.ject.studytrip.global.exception.CustomException +import com.ject.studytrip.member.domain.error.MemberErrorCode +import com.ject.studytrip.member.domain.model.Member +import com.ject.studytrip.member.domain.model.SocialProvider +import com.ject.studytrip.member.domain.repository.MemberCommandRepository +import com.ject.studytrip.member.domain.repository.MemberRepository +import com.ject.studytrip.member.fixture.CreateMemberCommandFixture +import com.ject.studytrip.member.fixture.MemberFixture +import com.ject.studytrip.member.fixture.UpdateMemberRequestFixture +import org.assertj.core.api.Assertions.assertThat +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.junit.jupiter.api.assertThrows +import org.mockito.BDDMockito.given +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.kotlin.any +import org.mockito.kotlin.verify + +@DisplayName("MemberCommandService 단위 테스트") +class MemberCommandServiceTest : BaseUnitTest() { + @InjectMocks + private lateinit var memberCommandService: MemberCommandService + + @Mock + private lateinit var memberRepository: MemberRepository + + @Mock + private lateinit var memberCommandRepository: MemberCommandRepository + + private lateinit var member: Member + private lateinit var memberWithoutProfileImage: Member + + @BeforeEach + fun setUp() { + member = MemberFixture().createFromKakaoWithId(1L) + memberWithoutProfileImage = MemberFixture().createFromKakaoWithoutProfileImage() + } + + companion object { + private const val NEW_MEMBER_NICKNAME = "새로운 멤버 닉네임" + private const val NEW_MEMBER_CATEGORY = "WORKER" + } + + @Nested + @DisplayName("CreateMemberFromKakao 메서드는") + inner class CreateMemberFromKakao { + private val fixture = CreateMemberCommandFixture() + + @Test + @DisplayName("회원가입된 멤버가 이미 존재하면 예외가 발생한다.") + fun shouldThrowExceptionWhenMemberAlreadyExists() { + // given + val command = fixture.withNickname(member.nickname).build() + given(memberRepository.existsBySocialProviderAndSocialId(SocialProvider.KAKAO, member.socialId)).willReturn(true) + + // when + val exception = assertThrows { memberCommandService.createMemberFromKakao(command) } + + // then + assertThat(exception.message).isEqualTo(MemberErrorCode.MEMBER_ALREADY_EXISTS.message) + } + + @Test + @DisplayName("카테고리가 유효하지 않으면 예외가 발생한다.") + fun shouldThrowExceptionWhenCategoryIsInvalid() { + // given + val command = fixture.withCategory("INVALID").build() + given(memberRepository.existsBySocialProviderAndSocialId(SocialProvider.KAKAO, member.socialId)).willReturn(false) + + // when + val exception = assertThrows { memberCommandService.createMemberFromKakao(command) } + + // then + assertThat(exception.message).isEqualTo(MemberErrorCode.INVALID_MEMBER_CATEGORY.message) + } + + @Test + @DisplayName("CreateMemberCommand가 유효하면 멤버를 생성하고 반환한다.") + fun shouldCreateAndReturnMemberWhenCommandIsValid() { + // given + val command = fixture.build() + given(memberRepository.existsBySocialProviderAndSocialId(SocialProvider.KAKAO, member.socialId)).willReturn(false) + given(memberRepository.save(any())).willReturn(member) + + // when + val result = memberCommandService.createMemberFromKakao(command) + + // then + assertThat(result).isEqualTo(member) + } + + @Test + @DisplayName("프로필 이미지가 존재하지 않으면 멤버를 생성하고 반환한다.") + fun shouldCreateAndReturnMemberWhenProfileImageDoesNotExist() { + // given + val command = fixture.withProfileImage(null).build() + given( + memberRepository.existsBySocialProviderAndSocialId(SocialProvider.KAKAO, memberWithoutProfileImage.socialId), + ).willReturn(false) + given(memberRepository.save(any())).willReturn(memberWithoutProfileImage) + + // when + val result = memberCommandService.createMemberFromKakao(command) + + // then + assertThat(result).isEqualTo(memberWithoutProfileImage) + } + } + + @Nested + @DisplayName("updateMember 메서드는") + inner class UpdateMember { + private val fixture = UpdateMemberRequestFixture() + + @Test + @DisplayName("카테고리가 유효하지 않으면 예외가 발생한다.") + fun shouldThrowExceptionWhenCategoryIsInvalid() { + // given + val request = fixture.withCategory("INVALID").build() + + // when + val exception = assertThrows { memberCommandService.updateMember(member, request) } + + // then + assertThat(exception.message).isEqualTo(MemberErrorCode.INVALID_MEMBER_CATEGORY.message) + } + + @Test + @DisplayName("특정 멤버의 닉네임을 수정한다.") + fun shouldUpdateMemberWhenNicknameIsPresent() { + // given + val request = fixture.withNickname(NEW_MEMBER_NICKNAME).build() + + // when + memberCommandService.updateMember(member, request) + + // then + assertThat(member.nickname).isEqualTo(NEW_MEMBER_NICKNAME) + } + + @Test + @DisplayName("특정 멤버의 카테고리를 수정한다.") + fun shouldUpdateMemberWhenCategoryIsPresent() { + // given + val request = fixture.withCategory(NEW_MEMBER_CATEGORY).build() + + // when + memberCommandService.updateMember(member, request) + + // then + assertThat(member.category.name).isEqualTo(NEW_MEMBER_CATEGORY) + } + + @Test + @DisplayName("특정 멤버의 닉네임과 카테고리를 수정한다.") + fun shouldUpdateMemberWhenNicknameAndCategoryArePresent() { + // given + val request = fixture.withNickname(NEW_MEMBER_NICKNAME).withCategory(NEW_MEMBER_CATEGORY).build() + + // when + memberCommandService.updateMember(member, request) + + // then + assertThat(member.nickname).isEqualTo(NEW_MEMBER_NICKNAME) + assertThat(member.category.name).isEqualTo(NEW_MEMBER_CATEGORY) + } + } + + @Nested + @DisplayName("updateProfileImage 메서드는") + inner class UpdateProfileImage { + private val newProfileImage = "https://cdn.example.com/members/1/image.jpg" + + @Test + @DisplayName("삭제된 멤버의 프로필 이미지를 수정하면 예외가 발생한다.") + fun shouldThrowExceptionWhenMemberIsDeleted() { + // given + member.updateDeletedAt() + + // when + val exception = assertThrows { memberCommandService.updateProfileImage(member, newProfileImage) } + + // then + assertThat(exception.message).isEqualTo(MemberErrorCode.MEMBER_ALREADY_DELETED.message) + } + + @Test + @DisplayName("유효한 멤버의 프로필 이미지를 수정한다.") + fun shouldUpdateProfileImageWhenMemberIsValid() { + // given + val oldProfileImage = member.profileImage + + // when + memberCommandService.updateProfileImage(member, newProfileImage) + + // then + assertThat(member.profileImage).isEqualTo(newProfileImage) + assertThat(member.profileImage).isNotEqualTo(oldProfileImage) + } + } + + @Nested + @DisplayName("deleteMember 메서드는") + inner class DeleteMember { + @Test + @DisplayName("멤버가 삭제될 때 deletedAt 필드를 현재 시간으로 업데이트한다. (소프트 삭제)") + fun shouldUpdateDeletedAtWhenMemberIsDeleted() { + // when + memberCommandService.deleteMember(member) + + // then + assertThat(member.deletedAt).isNotNull + } + } + + @Nested + @DisplayName("restoreMember 메서드는") + inner class RestoreMember { + @Test + @DisplayName("멤버가 복구될 때 deletedAt 필드를 null로 업데이트한다.") + fun shouldRestoreDeletedAtWhenDeletedMemberIsRestored() { + // given + member.updateDeletedAt() + + // when + memberCommandService.restoreMember(member) + + // then + assertThat(member.deletedAt).isNull() + } + } + + @Nested + @DisplayName("hardDeleteMembers 메서드는") + inner class HardDeleteMembers { + @Test + @DisplayName("삭제된 멤버가 하나라도 존재하지 않으면 0을 반환한다.") + fun shouldReturnZeroWhenDeletedMembersDoNotExist() { + // given + given(memberCommandRepository.deleteAllByDeletedAtIsNotNull()).willReturn(0L) + + // when + val result = memberCommandService.hardDeleteMembers() + + // then + assertThat(result).isEqualTo(0L) + } + + @Test + @DisplayName("삭제된 멤버가 하나라도 존재하면 해당 개수를 반환한다.") + fun shouldReturnCountWhenDeletedMembersExist() { + // given + given(memberCommandRepository.deleteAllByDeletedAtIsNotNull()).willReturn(5L) + + // when + val result = memberCommandService.hardDeleteMembers() + + // then + assertThat(result).isEqualTo(5L) + } + } + + @Nested + @DisplayName("hardDeleteMember 메서드는") + inner class HardDeleteMember { + @Test + @DisplayName("특정 멤버를 완전 삭제합니다.") + fun shouldHardDeleteMember() { + // given + val memberId = member.id + + // when + memberCommandService.hardDeleteMember(memberId) + + // then + verify(memberRepository).deleteById(memberId) + } + } +} diff --git a/src/test/kotlin/com/ject/studytrip/member/application/service/MemberQueryServiceTest.kt b/src/test/kotlin/com/ject/studytrip/member/application/service/MemberQueryServiceTest.kt new file mode 100644 index 0000000..6d80eed --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/member/application/service/MemberQueryServiceTest.kt @@ -0,0 +1,200 @@ +package com.ject.studytrip.member.application.service + +import com.ject.studytrip.BaseUnitTest +import com.ject.studytrip.global.exception.CustomException +import com.ject.studytrip.member.domain.error.MemberErrorCode +import com.ject.studytrip.member.domain.model.Member +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.MemberFixture +import org.assertj.core.api.Assertions.assertThat +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.junit.jupiter.api.assertThrows +import org.mockito.BDDMockito.given +import org.mockito.InjectMocks +import org.mockito.Mock +import java.util.Optional + +@DisplayName("MemberQueryService 단위 테스트") +class MemberQueryServiceTest : BaseUnitTest() { + @InjectMocks + private lateinit var memberQueryService: MemberQueryService + + @Mock + private lateinit var memberRepository: MemberRepository + + @Mock + private lateinit var memberQueryRepository: MemberQueryRepository + + private lateinit var member: Member + private lateinit var socialId: String + + @BeforeEach + fun setUp() { + member = MemberFixture().createFromKakaoWithId(1L) + socialId = member.socialId + } + + @Nested + @DisplayName("getMemberBySocialProviderAndSocialId 메서드는") + inner class GetMemberBySocialProviderAndSocialId { + @Test + @DisplayName("멤버가 이미 삭제되었다면 예외가 발생한다.") + fun shouldThrowExceptionWhenMemberAlreadyDeleted() { + // given + member.updateDeletedAt() + given(memberRepository.findBySocialProviderAndSocialId(SocialProvider.KAKAO, socialId)).willReturn(Optional.of(member)) + + // when + val exception = + assertThrows { memberQueryService.getMemberBySocialProviderAndSocialId(SocialProvider.KAKAO, socialId) } + + // then + assertThat(exception.message).isEqualTo(MemberErrorCode.MEMBER_ALREADY_DELETED.message) + } + + @Test + @DisplayName("소셜 ID에 대한 멤버가 존재하면 멤버를 반환한다.") + fun shouldReturnMemberWhenSocialIdExists() { + // given + given(memberRepository.findBySocialProviderAndSocialId(SocialProvider.KAKAO, socialId)).willReturn(Optional.of(member)) + + // when + val result = memberQueryService.getMemberBySocialProviderAndSocialId(SocialProvider.KAKAO, socialId) + + // then + assertThat(result).isEqualTo(Optional.of(member)) + } + } + + @Nested + @DisplayName("getValidMember 메서드는") + inner class GetValidMember { + @Test + @DisplayName("멤버가 존재하지 않으면 예외가 발생한다.") + fun shouldThrowExceptionWhenMemberDoesNotExist() { + // given + val memberId = -1L + given(memberRepository.findById(memberId)).willReturn(Optional.empty()) + + // when + val exception = assertThrows { memberQueryService.getValidMember(memberId) } + + // then + assertThat(exception.message).isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND.message) + } + + @Test + @DisplayName("멤버가 이미 삭제되었다면 예외가 발생한다.") + fun shouldThrowExceptionWhenMemberAlreadyDeleted() { + // given + val memberId = member.id + member.updateDeletedAt() + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)) + + // when + val exception = assertThrows { memberQueryService.getValidMember(memberId) } + + // then + assertThat(exception.message).isEqualTo(MemberErrorCode.MEMBER_ALREADY_DELETED.message) + } + + @Test + @DisplayName("멤버가 존재하면 멤버를 반환한다.") + fun shouldReturnTripWhenMemberExists() { + // given + val memberId = member.id + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)) + + // when + val result = memberQueryService.getValidMember(memberId) + + // then + assertThat(result).isEqualTo(member) + } + } + + @Nested + @DisplayName("getDeletedMember 메서드는") + inner class GetDeletedMember { + @Test + @DisplayName("멤버가 존재하지 않으면 예외가 발생한다.") + fun shouldThrowExceptionWhenMemberDoesNotExist() { + // given + val memberId = -1L + given(memberRepository.findById(memberId)).willReturn(Optional.empty()) + + // when + val exception = assertThrows { memberQueryService.getDeletedMember(memberId) } + + // then + assertThat(exception.message).isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND.message) + } + + @Test + @DisplayName("멤버가 삭제되지 않았다면 예외가 발생한다.") + fun shouldThrowExceptionWhenMemberIsNotDeleted() { + // given + val memberId = member.id + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)) + + // when + val exception = assertThrows { memberQueryService.getDeletedMember(memberId) } + + // then + assertThat(exception.message).isEqualTo(MemberErrorCode.MEMBER_NOT_DELETED.message) + } + + @Test + @DisplayName("멤버가 이미 삭제되었다면 멤버를 반환한다.") + fun shouldReturnMemberWhenMemberAlreadyDeleted() { + // given + val memberId = member.id + member.updateDeletedAt() + given(memberRepository.findById(memberId)).willReturn(Optional.of(member)) + + // when + val result = memberQueryService.getDeletedMember(memberId) + + // then + assertThat(result).isEqualTo(member) + } + } + + @Nested + @DisplayName("getMemberRoleByMemberId 메서드는") + inner class GetMemberRoleByMemberId { + @Test + @DisplayName("멤버가 존재하지 않으면 예외가 발생한다.") + fun shouldThrowExceptionWhenMemberDoesNotExist() { + // given + val memberId = -1L + given(memberQueryRepository.findMemberRoleById(memberId)).willReturn(Optional.empty()) + + // when + val exception = assertThrows { memberQueryService.getMemberRoleByMemberId(memberId) } + + // then + assertThat(exception.message).isEqualTo(MemberErrorCode.MEMBER_NOT_FOUND.message) + } + + @Test + @DisplayName("멤버 ID에 대한 멤버가 존재하면 MemberRole을 반환한다.") + fun shouldReturnMemberRoleWhenMemberIdExists() { + // given + val memberId = member.id + val memberRole = member.role + given(memberQueryRepository.findMemberRoleById(memberId.toLong())).willReturn(Optional.of(memberRole)) + + // when + val result = memberQueryService.getMemberRoleByMemberId(memberId) + + // then + assertThat(result).isEqualTo(memberRole) + } + } +} diff --git a/src/test/kotlin/com/ject/studytrip/member/fixture/ConfirmProfileImageRequestFixture.kt b/src/test/kotlin/com/ject/studytrip/member/fixture/ConfirmProfileImageRequestFixture.kt new file mode 100644 index 0000000..3e22378 --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/member/fixture/ConfirmProfileImageRequestFixture.kt @@ -0,0 +1,11 @@ +package com.ject.studytrip.member.fixture + +import com.ject.studytrip.member.presentation.dto.request.ConfirmProfileImageRequest + +class ConfirmProfileImageRequestFixture( + private val tmpKey: String = "tmp/members/1/test.jpg", +) { + fun withTmpKey(tmpKey: String): ConfirmProfileImageRequestFixture = ConfirmProfileImageRequestFixture(tmpKey) + + fun build(): ConfirmProfileImageRequest = ConfirmProfileImageRequest(tmpKey) +} diff --git a/src/test/kotlin/com/ject/studytrip/member/fixture/CreateMemberCommandFixture.kt b/src/test/kotlin/com/ject/studytrip/member/fixture/CreateMemberCommandFixture.kt new file mode 100644 index 0000000..f18760d --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/member/fixture/CreateMemberCommandFixture.kt @@ -0,0 +1,22 @@ +package com.ject.studytrip.member.fixture + +import com.ject.studytrip.member.application.dto.CreateMemberCommand + +class CreateMemberCommandFixture( + private val socialId: String = "12345", + private val email: String = "studytrip@gmail.com", + private val profileImage: String? = "https://kakao.com/profile.jpg", + private val nickname: String = "TEST 멤버 닉네임", + private val category: String = "STUDENT", +) { + fun withProfileImage(profileImage: String?): CreateMemberCommandFixture = + CreateMemberCommandFixture(socialId, email, profileImage, nickname, category) + + fun withNickname(nickname: String): CreateMemberCommandFixture = + CreateMemberCommandFixture(socialId, email, profileImage, nickname, category) + + fun withCategory(category: String): CreateMemberCommandFixture = + CreateMemberCommandFixture(socialId, email, profileImage, nickname, category) + + fun build(): CreateMemberCommand = CreateMemberCommand(socialId, email, profileImage, nickname, category) +} diff --git a/src/test/kotlin/com/ject/studytrip/member/fixture/MemberFixture.kt b/src/test/kotlin/com/ject/studytrip/member/fixture/MemberFixture.kt new file mode 100644 index 0000000..663a591 --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/member/fixture/MemberFixture.kt @@ -0,0 +1,25 @@ +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 org.springframework.test.util.ReflectionTestUtils + +class MemberFixture( + private val kakaoId: String = "12345", + private val email: String = "studytrip@kakao.com", + private val profileImage: String = "https://kakao.com/profile.jpg", + private val nickname: String = "민우", + private val category: MemberCategory = MemberCategory.STUDENT, +) { + fun createFromKakao(): Member = MemberFactory.createFromKakao(kakaoId, email, profileImage, nickname, category) + + fun createFromKakao( + email: String, + nickname: String, + ): Member = MemberFactory.createFromKakao(kakaoId, email, profileImage, nickname, category) + + fun createFromKakaoWithId(id: Long): Member = createFromKakao().also { ReflectionTestUtils.setField(it, "id", id) } + + fun createFromKakaoWithoutProfileImage(): Member = MemberFactory.createFromKakao(kakaoId, email, null, nickname, category) +} diff --git a/src/test/kotlin/com/ject/studytrip/member/fixture/PresignProfileImageRequestFixture.kt b/src/test/kotlin/com/ject/studytrip/member/fixture/PresignProfileImageRequestFixture.kt new file mode 100644 index 0000000..b34e6f3 --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/member/fixture/PresignProfileImageRequestFixture.kt @@ -0,0 +1,11 @@ +package com.ject.studytrip.member.fixture + +import com.ject.studytrip.member.presentation.dto.request.PresignProfileImageRequest + +class PresignProfileImageRequestFixture( + private val originFilename: String = "test.jpg", +) { + fun withOriginFilename(originFilename: String): PresignProfileImageRequestFixture = PresignProfileImageRequestFixture(originFilename) + + fun build(): PresignProfileImageRequest = PresignProfileImageRequest(originFilename) +} diff --git a/src/test/kotlin/com/ject/studytrip/member/fixture/UpdateMemberRequestFixture.kt b/src/test/kotlin/com/ject/studytrip/member/fixture/UpdateMemberRequestFixture.kt new file mode 100644 index 0000000..ab18b48 --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/member/fixture/UpdateMemberRequestFixture.kt @@ -0,0 +1,14 @@ +package com.ject.studytrip.member.fixture + +import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest + +class UpdateMemberRequestFixture( + private val nickname: String? = null, + private val category: String? = null, +) { + fun withNickname(nickname: String?): UpdateMemberRequestFixture = UpdateMemberRequestFixture(nickname, category) + + fun withCategory(category: String?): UpdateMemberRequestFixture = UpdateMemberRequestFixture(nickname, category) + + fun build(): UpdateMemberRequest = UpdateMemberRequest(nickname, category) +} diff --git a/src/test/kotlin/com/ject/studytrip/member/helper/MemberTestHelper.kt b/src/test/kotlin/com/ject/studytrip/member/helper/MemberTestHelper.kt new file mode 100644 index 0000000..a5e9631 --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/member/helper/MemberTestHelper.kt @@ -0,0 +1,25 @@ +package com.ject.studytrip.member.helper + +import com.ject.studytrip.member.domain.model.Member +import com.ject.studytrip.member.domain.repository.MemberRepository +import com.ject.studytrip.member.fixture.MemberFixture +import org.springframework.stereotype.Component + +@Component +class MemberTestHelper( + private val memberRepository: MemberRepository, +) { + fun saveMember(): Member = memberRepository.save(MemberFixture().createFromKakao()) + + fun saveNewMember( + email: String, + nickname: String, + ): Member = memberRepository.save(MemberFixture().createFromKakao(email, nickname)) + + fun saveDeletedMember( + email: String, + nickname: String, + ): Member = memberRepository.save(MemberFixture().createFromKakao(email, nickname).also { it.updateDeletedAt() }) + + fun deleteMemberById(id: Long) = memberRepository.deleteById(id) +} diff --git a/src/test/kotlin/com/ject/studytrip/member/presentation/controller/MemberControllerIntegrationTest.kt b/src/test/kotlin/com/ject/studytrip/member/presentation/controller/MemberControllerIntegrationTest.kt new file mode 100644 index 0000000..8642d54 --- /dev/null +++ b/src/test/kotlin/com/ject/studytrip/member/presentation/controller/MemberControllerIntegrationTest.kt @@ -0,0 +1,547 @@ +package com.ject.studytrip.member.presentation.controller + +import com.ject.studytrip.BaseIntegrationTest +import com.ject.studytrip.auth.domain.error.AuthErrorCode +import com.ject.studytrip.auth.fixture.TokenFixture +import com.ject.studytrip.auth.helper.TokenTestHelper +import com.ject.studytrip.global.exception.error.CommonErrorCode +import com.ject.studytrip.image.domain.error.ImageErrorCode +import com.ject.studytrip.image.infra.s3.provider.S3ImageStorageProvider +import com.ject.studytrip.member.domain.error.MemberErrorCode +import com.ject.studytrip.member.domain.model.Member +import com.ject.studytrip.member.domain.model.MemberRole +import com.ject.studytrip.member.fixture.ConfirmProfileImageRequestFixture +import com.ject.studytrip.member.fixture.PresignProfileImageRequestFixture +import com.ject.studytrip.member.fixture.UpdateMemberRequestFixture +import com.ject.studytrip.member.helper.MemberTestHelper +import com.ject.studytrip.member.presentation.dto.request.ConfirmProfileImageRequest +import com.ject.studytrip.member.presentation.dto.request.PresignProfileImageRequest +import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest +import com.ject.studytrip.trip.helper.TripTestHelper +import org.hamcrest.Matchers +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.ArgumentMatchers.anyString +import org.mockito.BDDMockito.given +import org.mockito.kotlin.verify +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.ResultActions +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@DisplayName("MemberController 통합 테스트") +class MemberControllerIntegrationTest : BaseIntegrationTest() { + @Autowired + private lateinit var memberTestHelper: MemberTestHelper + + @Autowired + private lateinit var tokenTestHelper: TokenTestHelper + + @Autowired + private lateinit var tripTestHelper: TripTestHelper + + @MockitoBean lateinit var s3ImageStorageProvider: S3ImageStorageProvider + + private lateinit var member: Member + private lateinit var token: String + private lateinit var deletedMember: Member + private lateinit var deletedToken: String + + @BeforeEach + fun setUp() { + member = memberTestHelper.saveMember() + token = tokenTestHelper.createAccessToken(member.id.toString(), MemberRole.ROLE_USER.name) + + // 삭제된 멤버 + deletedMember = memberTestHelper.saveDeletedMember("test@gmail.com", "WORKER") + deletedToken = tokenTestHelper.createAccessToken(deletedMember.id.toString(), MemberRole.ROLE_USER.name) + } + + companion object { + private const val BASE_MEMBER_URL = "/api/members" + } + + @Nested + @DisplayName("멤버 수정 API") + inner class UpdateMember { + private val fixture = UpdateMemberRequestFixture() + + private fun getResultActions( + token: String, + request: UpdateMemberRequest, + ): ResultActions = + mockMvc.perform( + patch("$BASE_MEMBER_URL/me") + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ) + + @Test + @DisplayName("인증되지 않은 사용자라면 401 Unauthorized를 반환한다.") + fun shouldReturnUnauthorizedWhenUnauthenticated() { + // given + val request = fixture.build() + + // when + val resultActions = getResultActions("", request) + + // then + resultActions + .andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(AuthErrorCode.UNAUTHENTICATED.status.value())) + .andExpect(jsonPath("$.data.message").value(AuthErrorCode.UNAUTHENTICATED.message)) + } + + @Test + @DisplayName("UpdateMemberRequest 닉네임이 유효하지 않으면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenRequestNicknameIsInvalid() { + // given + val request = fixture.withNickname("!").build() + + // when + val resultActions = getResultActions(token, request) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.status.value())) + .andExpect(jsonPath("$.data.message").value(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.message)) + } + + @Test + @DisplayName("UpdateMemberRequest 카테고리가 유효하지 않으면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenRequestCategoryIsInvalid() { + // given + val request = fixture.withCategory("INVALID").build() + + // when + val resultActions = getResultActions(token, request) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.status.value())) + .andExpect(jsonPath("$.data.message").value(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.message)) + } + + @Test + @DisplayName("멤버가 이미 삭제되었다면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenMemberAlreadyDeleted() { + // given + val request = fixture.build() + + // when + val resultActions = getResultActions(deletedToken, request) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(MemberErrorCode.MEMBER_ALREADY_DELETED.status.value())) + .andExpect(jsonPath("$.data.message").value(MemberErrorCode.MEMBER_ALREADY_DELETED.message)) + } + + @Test + @DisplayName("유효한 요청이 들어오면 멤버를 수정한다.") + fun shouldUpdateMemberNicknameWhenRequestIsValid() { + // given + val request = fixture.withNickname("팬텀").withCategory("JOBSEEKER").build() + + // when + val resultActions = getResultActions(token, request) + + // then + resultActions + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + } + } + + @Nested + @DisplayName("멤버 삭제 API") + inner class DeleteMember { + private fun getResultActions(token: String): ResultActions = + mockMvc.perform( + delete("$BASE_MEMBER_URL/me") + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)), + ) + + @Test + @DisplayName("인증되지 않은 사용자라면 401 Unauthorized를 반환한다.") + fun shouldReturnUnauthorizedWhenUnauthenticated() { + // when + val resultActions = getResultActions("") + + // then + resultActions + .andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(AuthErrorCode.UNAUTHENTICATED.status.value())) + .andExpect(jsonPath("$.data.message").value(AuthErrorCode.UNAUTHENTICATED.message)) + } + + @Test + @DisplayName("멤버가 이미 삭제되었다면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenMemberAlreadyDeleted() { + // when + val resultActions = getResultActions(deletedToken) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(MemberErrorCode.MEMBER_ALREADY_DELETED.status.value())) + .andExpect(jsonPath("$.data.message").value(MemberErrorCode.MEMBER_ALREADY_DELETED.message)) + } + + @Test + @DisplayName("특정 멤버를 삭제한다.") + fun shouldDeleteMember() { + // when + val resultActions = getResultActions(token) + + // then + resultActions + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + } + } + + @Nested + @DisplayName("멤버 복구 API") + inner class RestoreMember { + private fun getResultActions(memberId: Any): ResultActions = + mockMvc.perform( + patch("$BASE_MEMBER_URL/me/restore/{memberId}", memberId) + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)) + .contentType(MediaType.APPLICATION_JSON), + ) + + @Test + @DisplayName("PathVariable 멤버 ID 타입이 올바르지 않으면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenMemberIdTypeMismatch() { + // given + val memberId = "abc" + + // when + val resultActions = getResultActions(memberId) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH.status.value())) + .andExpect(jsonPath("$.data.message").value(CommonErrorCode.METHOD_ARGUMENT_TYPE_MISMATCH.message)) + } + + @Test + @DisplayName("멤버가 존재하지 않으면 404 NotFound를 반환한다.") + fun shouldReturnNotFoundWhenMemberDoesNotExist() { + // given + val memberId = -1L + + // when + val resultActions = getResultActions(memberId) + + // then + resultActions + .andExpect(status().isNotFound) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(MemberErrorCode.MEMBER_NOT_FOUND.status.value())) + .andExpect(jsonPath("$.data.message").value(MemberErrorCode.MEMBER_NOT_FOUND.message)) + } + + @Test + @DisplayName("멤버가 삭제되지 않았다면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenMemberIsNotDeleted() { + // given + val memberId = member.id + + // when + val resultActions = getResultActions(memberId) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(MemberErrorCode.MEMBER_NOT_DELETED.status.value())) + .andExpect(jsonPath("$.data.message").value(MemberErrorCode.MEMBER_NOT_DELETED.message)) + } + + @Test + @DisplayName("삭제된 멤버를 복구한다.") + fun shouldRestoreMember() { + // given + val memberId = deletedMember.id + + // when + val resultActions = getResultActions(memberId) + + // then + resultActions + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + } + } + + @Nested + @DisplayName("멤버 즉시 삭제 API") + inner class HardDeleteMember { + private fun getResultActions(token: String): ResultActions = + mockMvc.perform( + delete("$BASE_MEMBER_URL/me/hard-delete") + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)), + ) + + @Test + @DisplayName("인증되지 않은 사용자라면 401 Unauthorized를 반환한다.") + fun shouldReturnUnauthorizedWhenUnauthenticated() { + // when + val resultActions = getResultActions("") + + // then + resultActions + .andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(AuthErrorCode.UNAUTHENTICATED.status.value())) + .andExpect(jsonPath("$.data.message").value(AuthErrorCode.UNAUTHENTICATED.message)) + } + + @Test + @DisplayName("멤버가 이미 삭제되었다면 400 Bad Request를 반환한다. (배치 삭제 대상)") + fun shouldReturnBadRequestWhenMemberAlreadyDeleted() { + // when + val resultActions = getResultActions(deletedToken) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(MemberErrorCode.MEMBER_ALREADY_DELETED.status.value())) + .andExpect(jsonPath("$.data.message").value(MemberErrorCode.MEMBER_ALREADY_DELETED.message)) + } + + @Test + @DisplayName("특정 멤버를 즉시 삭제하고, 관련된 모든 데이터를 즉시 삭제한다. (CASCADE)") + fun shouldHardDeleteMember() { + // when + val resultActions = getResultActions(token) + + // then + resultActions + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + } + } + + @Nested + @DisplayName("멤버 프로필 이미지 Presigned URL 발급 API") + inner class IssuePresignedUrl { + private val fixture = PresignProfileImageRequestFixture() + + private fun getResultActions( + token: String, + request: PresignProfileImageRequest, + ): ResultActions = + mockMvc.perform( + post("$BASE_MEMBER_URL/profile-images/presigned") + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ) + + @Test + @DisplayName("인증되지 않은 사용자라면 401 Unauthorized를 반환한다.") + fun shouldReturnUnauthorizedWhenUnauthenticated() { + // given + val request = fixture.build() + + // when + val resultActions = getResultActions("", request) + + // then + resultActions + .andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(AuthErrorCode.UNAUTHENTICATED.status.value())) + .andExpect(jsonPath("$.data.message").value(AuthErrorCode.UNAUTHENTICATED.message)) + } + + @Test + @DisplayName("파일명이 비어있으면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenFilenameIsEmpty() { + // given + val request = fixture.withOriginFilename("").build() + + // when + val resultActions = getResultActions(token, request) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.status.value())) + .andExpect(jsonPath("$.data.message").value(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.message)) + } + + @Test + @DisplayName("유효하지 않은 확장자는 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenExtensionIsInvalid() { + // given + val request = fixture.withOriginFilename("test.pdf").build() + + // when + val resultActions = getResultActions(token, request) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(ImageErrorCode.INVALID_IMAGE_EXTENSION.status.value())) + .andExpect(jsonPath("$.data.message").value(ImageErrorCode.INVALID_IMAGE_EXTENSION.message)) + } + + @Test + @DisplayName("파일명이 유효하면 Presigned URL을 발급한다.") + fun shouldIssuePresignedUrlWhenFilenameIsValid() { + // given + val request = fixture.build() + given(s3ImageStorageProvider.issuePresignedUrl(anyString())).willReturn("https://mocked-presigned-url.com") + + // when + val resultActions = getResultActions(token, request) + + // then + resultActions + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.presignedUrl").isNotEmpty) + .andExpect(jsonPath("$.data.tmpKey").isNotEmpty) + .andExpect(jsonPath("$.data.tmpKey").value(Matchers.startsWith("tmp/members/"))) + + // S3Provider 호출 검증 + verify(s3ImageStorageProvider).issuePresignedUrl(anyString()) + } + } + + @Nested + @DisplayName("멤버 프로필 이미지 확정 API") + inner class ConfirmProfileImage { + private val fixture = ConfirmProfileImageRequestFixture() + + private fun getResultActions( + token: String, + request: ConfirmProfileImageRequest, + ): ResultActions = + mockMvc.perform( + post("$BASE_MEMBER_URL/profile-images/confirm") + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ) + + @Test + @DisplayName("인증되지 않은 사용자라면 401 Unauthorized를 반환한다.") + fun shouldReturnUnauthorizedWhenUnauthenticated() { + // given + val request = fixture.build() + + // when + val resultActions = getResultActions("", request) + + // then + resultActions + .andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(AuthErrorCode.UNAUTHENTICATED.status.value())) + .andExpect(jsonPath("$.data.message").value(AuthErrorCode.UNAUTHENTICATED.message)) + } + + @Test + @DisplayName("tmpKey가 비어있으면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenTmpKeyIsEmpty() { + // given + val request = fixture.withTmpKey("").build() + + // when + val resultActions = getResultActions(token, request) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.status.value())) + .andExpect(jsonPath("$.data.message").value(CommonErrorCode.METHOD_ARGUMENT_NOT_VALID.message)) + } + } + + @Nested + @DisplayName("멤버 상세 조회 API") + inner class LoadMember { + private fun getResultActions(token: String): ResultActions = + mockMvc.perform( + get("$BASE_MEMBER_URL/me") + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)), + ) + + @Test + @DisplayName("인증되지 않은 사용자라면 401 Unauthorized를 반환한다.") + fun shouldReturnUnauthorizedWhenUnauthenticated() { + // when + val resultActions = getResultActions("") + + // then + resultActions + .andExpect(status().isUnauthorized) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(AuthErrorCode.UNAUTHENTICATED.status.value())) + .andExpect(jsonPath("$.data.message").value(AuthErrorCode.UNAUTHENTICATED.message)) + } + + @Test + @DisplayName("멤버가 이미 삭제되었다면 400 Bad Request를 반환한다.") + fun shouldReturnBadRequestWhenMemberAlreadyDeleted() { + // when + val resultActions = getResultActions(deletedToken) + + // then + resultActions + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.status").value(MemberErrorCode.MEMBER_ALREADY_DELETED.status.value())) + .andExpect(jsonPath("$.data.message").value(MemberErrorCode.MEMBER_ALREADY_DELETED.message)) + } + + @Test + @DisplayName("특정 멤버를 상세 조회하고 반환한다.") + fun shouldReturnMember() { + // when + val resultActions = getResultActions(token) + + // then + resultActions + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + .andExpect(jsonPath("$.data").isNotEmpty) + } + } +} diff --git a/src/test/kotlin/com/ject/studytrip/mission/application/service/DailyMissionCommandServiceTest.kt b/src/test/kotlin/com/ject/studytrip/mission/application/service/DailyMissionCommandServiceTest.kt index df8dc70..d35f6f6 100644 --- a/src/test/kotlin/com/ject/studytrip/mission/application/service/DailyMissionCommandServiceTest.kt +++ b/src/test/kotlin/com/ject/studytrip/mission/application/service/DailyMissionCommandServiceTest.kt @@ -42,7 +42,7 @@ class DailyMissionCommandServiceTest : BaseUnitTest() { @BeforeEach fun setUp() { - member = MemberFixture.createMemberFromKakaoWithId(1L) + member = MemberFixture().createFromKakaoWithId(1L) val trip = TripFixture(member, TripCategory.COURSE).createWithId(1L) val stamp = StampFixture(trip, 1).createWithId(1L) mission = MissionFixture(stamp).createWithId(1L) diff --git a/src/test/kotlin/com/ject/studytrip/mission/application/service/DailyMissionQueryServiceTest.kt b/src/test/kotlin/com/ject/studytrip/mission/application/service/DailyMissionQueryServiceTest.kt index 12d16e9..5f57186 100644 --- a/src/test/kotlin/com/ject/studytrip/mission/application/service/DailyMissionQueryServiceTest.kt +++ b/src/test/kotlin/com/ject/studytrip/mission/application/service/DailyMissionQueryServiceTest.kt @@ -42,7 +42,7 @@ class DailyMissionQueryServiceTest : BaseUnitTest() { @BeforeEach fun setUp() { - val member = MemberFixture.createMemberFromKakaoWithId(1L) + val member = MemberFixture().createFromKakaoWithId(1L) trip = TripFixture(member, TripCategory.COURSE).createWithId(1L) val stamp = StampFixture(trip, 1).createWithId(1L) val mission = MissionFixture(stamp).createWithId(1L) diff --git a/src/test/kotlin/com/ject/studytrip/mission/application/service/MissionCommandServiceTest.kt b/src/test/kotlin/com/ject/studytrip/mission/application/service/MissionCommandServiceTest.kt index e231089..ff46510 100644 --- a/src/test/kotlin/com/ject/studytrip/mission/application/service/MissionCommandServiceTest.kt +++ b/src/test/kotlin/com/ject/studytrip/mission/application/service/MissionCommandServiceTest.kt @@ -51,7 +51,7 @@ class MissionCommandServiceTest : BaseUnitTest() { @BeforeEach fun setUp() { - member = MemberFixture.createMemberFromKakaoWithId(1L) + member = MemberFixture().createFromKakaoWithId(1L) val courseTrip = TripFixture(member, TripCategory.COURSE).createWithId(1L) val exploreTrip = TripFixture(member, TripCategory.EXPLORE).createWithId(2L) courseStamp = StampFixture(courseTrip, 1).createWithId(1L) diff --git a/src/test/kotlin/com/ject/studytrip/mission/application/service/MissionQueryServiceTest.kt b/src/test/kotlin/com/ject/studytrip/mission/application/service/MissionQueryServiceTest.kt index 6479337..917e626 100644 --- a/src/test/kotlin/com/ject/studytrip/mission/application/service/MissionQueryServiceTest.kt +++ b/src/test/kotlin/com/ject/studytrip/mission/application/service/MissionQueryServiceTest.kt @@ -42,7 +42,7 @@ class MissionQueryServiceTest : BaseUnitTest() { @BeforeEach fun setUp() { - val member = MemberFixture.createMemberFromKakao() + val member = MemberFixture().createFromKakao() val courseTrip = TripFixture(member, TripCategory.COURSE).createWithId(1L) val exploreTrip = TripFixture(member, TripCategory.EXPLORE).createWithId(2L) courseStamp = StampFixture(courseTrip, 1).createWithId(1L) diff --git a/src/test/kotlin/com/ject/studytrip/mission/presentation/controller/MissionControllerIntegrationTest.kt b/src/test/kotlin/com/ject/studytrip/mission/presentation/controller/MissionControllerIntegrationTest.kt index 4653de5..da1abcd 100644 --- a/src/test/kotlin/com/ject/studytrip/mission/presentation/controller/MissionControllerIntegrationTest.kt +++ b/src/test/kotlin/com/ject/studytrip/mission/presentation/controller/MissionControllerIntegrationTest.kt @@ -79,7 +79,7 @@ class MissionControllerIntegrationTest : BaseIntegrationTest() { exploreStamp = stampTestHelper.saveStamp(exploreTrip, 0) exploreMission = missionTestHelper.saveMission(exploreStamp) - val newMember = memberTestHelper.saveMember("test@gmail.com", "test") + val newMember = memberTestHelper.saveNewMember("test@gmail.com", "test") newTrip = tripTestHelper.saveTrip(newMember, TripCategory.COURSE) } @@ -100,7 +100,7 @@ class MissionControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( post(BASE_MISSION_URL, tripId, stampId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)) + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)), ) @@ -349,7 +349,7 @@ class MissionControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( patch("$BASE_MISSION_URL/{missionId}", tripId, stampId, missionId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)) + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)), ) @@ -683,7 +683,7 @@ class MissionControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( delete("$BASE_MISSION_URL/{missionId}", tripId, stampId, missionId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)), + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)), ) @Test @@ -969,7 +969,7 @@ class MissionControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( get(BASE_MISSION_URL, tripId, stampId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)), + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)), ) @Test diff --git a/src/test/kotlin/com/ject/studytrip/pomodoro/application/service/PomodoroCommandServiceTest.kt b/src/test/kotlin/com/ject/studytrip/pomodoro/application/service/PomodoroCommandServiceTest.kt index 6813e1c..c1be83f 100644 --- a/src/test/kotlin/com/ject/studytrip/pomodoro/application/service/PomodoroCommandServiceTest.kt +++ b/src/test/kotlin/com/ject/studytrip/pomodoro/application/service/PomodoroCommandServiceTest.kt @@ -42,7 +42,7 @@ class PomodoroCommandServiceTest : BaseUnitTest() { @BeforeEach fun setUp() { - member = MemberFixture.createMemberFromKakaoWithId(1L) + member = MemberFixture().createFromKakaoWithId(1L) val trip = TripFixture(member, TripCategory.COURSE).createWithId(1L) dailyGoal = DailyGoalFixture(trip).createWithId(1L) pomodoro = PomodoroFixture(dailyGoal).createWithId(1L) diff --git a/src/test/kotlin/com/ject/studytrip/pomodoro/application/service/PomodoroQueryServiceTest.kt b/src/test/kotlin/com/ject/studytrip/pomodoro/application/service/PomodoroQueryServiceTest.kt index 1f69e7a..ba285cd 100644 --- a/src/test/kotlin/com/ject/studytrip/pomodoro/application/service/PomodoroQueryServiceTest.kt +++ b/src/test/kotlin/com/ject/studytrip/pomodoro/application/service/PomodoroQueryServiceTest.kt @@ -41,7 +41,7 @@ class PomodoroQueryServiceTest : BaseUnitTest() { @BeforeEach fun setUp() { - val member = MemberFixture.createMemberFromKakaoWithId(1L) + val member = MemberFixture().createFromKakaoWithId(1L) trip = TripFixture(member, TripCategory.COURSE).createWithId(1L) dailyGoal = DailyGoalFixture(trip).createWithId(1L) pomodoro = PomodoroFixture(dailyGoal).createWithId(1L) diff --git a/src/test/kotlin/com/ject/studytrip/stamp/application/service/StampCommandServiceTest.kt b/src/test/kotlin/com/ject/studytrip/stamp/application/service/StampCommandServiceTest.kt index 53701c1..8b2c2e5 100644 --- a/src/test/kotlin/com/ject/studytrip/stamp/application/service/StampCommandServiceTest.kt +++ b/src/test/kotlin/com/ject/studytrip/stamp/application/service/StampCommandServiceTest.kt @@ -51,7 +51,7 @@ class StampCommandServiceTest : BaseUnitTest() { @BeforeEach fun setUp() { - member = MemberFixture.createMemberFromKakaoWithId(1L) + member = MemberFixture().createFromKakaoWithId(1L) courseTrip = TripFixture(member, TripCategory.COURSE).createWithId(1L) exploreTrip = TripFixture(member, TripCategory.EXPLORE).createWithId(2L) courseStamp1 = StampFixture(courseTrip, 1).createWithId(1L) diff --git a/src/test/kotlin/com/ject/studytrip/stamp/application/service/StampQueryServiceTest.kt b/src/test/kotlin/com/ject/studytrip/stamp/application/service/StampQueryServiceTest.kt index 9712f5a..1533630 100644 --- a/src/test/kotlin/com/ject/studytrip/stamp/application/service/StampQueryServiceTest.kt +++ b/src/test/kotlin/com/ject/studytrip/stamp/application/service/StampQueryServiceTest.kt @@ -44,7 +44,7 @@ class StampQueryServiceTest : BaseUnitTest() { @BeforeEach fun setUp() { - val member = MemberFixture.createMemberFromKakaoWithId(1L) + val member = MemberFixture().createFromKakaoWithId(1L) courseTrip = TripFixture(member, TripCategory.COURSE).createWithId(1L) exploreTrip = TripFixture(member, TripCategory.EXPLORE).createWithId(2L) courseStamp1 = StampFixture(courseTrip, 1).createWithId(1L) diff --git a/src/test/kotlin/com/ject/studytrip/stamp/presentation/controller/StampControllerIntegrationTest.kt b/src/test/kotlin/com/ject/studytrip/stamp/presentation/controller/StampControllerIntegrationTest.kt index 9dc0cd7..c0a7322 100644 --- a/src/test/kotlin/com/ject/studytrip/stamp/presentation/controller/StampControllerIntegrationTest.kt +++ b/src/test/kotlin/com/ject/studytrip/stamp/presentation/controller/StampControllerIntegrationTest.kt @@ -81,7 +81,7 @@ class StampControllerIntegrationTest : BaseIntegrationTest() { exploreStamp1 = stampTestHelper.saveStamp(exploreTrip, 0) exploreStamp2 = stampTestHelper.saveStamp(exploreTrip, 0) - val newMember = memberTestHelper.saveMember("test@gmail.com", "test") + val newMember = memberTestHelper.saveNewMember("test@gmail.com", "test") newTrip = tripTestHelper.saveTrip(newMember, TripCategory.COURSE) } @@ -101,7 +101,7 @@ class StampControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( post(BASE_STAMP_URL, tripId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)) + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)), ) @@ -259,7 +259,7 @@ class StampControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( patch("$BASE_STAMP_URL/{stampId}", tripId, stampId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)) + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)), ) @@ -488,7 +488,7 @@ class StampControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( put("$BASE_STAMP_URL/orders", tripId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)) + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)), ) @@ -713,7 +713,7 @@ class StampControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( delete("$BASE_STAMP_URL/{stampId}", tripId, stampId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)), + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)), ) @Test @@ -917,7 +917,7 @@ class StampControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( patch("$BASE_STAMP_URL/{stampId}/complete", tripId, stampId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)), + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)), ) @Test @@ -1142,7 +1142,7 @@ class StampControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( get(BASE_STAMP_URL, tripId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)), + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)), ) @Test @@ -1265,7 +1265,7 @@ class StampControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( get("$BASE_STAMP_URL/{stampId}", tripId, stampId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)), + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)), ) @Test diff --git a/src/test/kotlin/com/ject/studytrip/studylog/application/service/StudyLogCommandServiceTest.kt b/src/test/kotlin/com/ject/studytrip/studylog/application/service/StudyLogCommandServiceTest.kt index cd7d45e..2f90793 100644 --- a/src/test/kotlin/com/ject/studytrip/studylog/application/service/StudyLogCommandServiceTest.kt +++ b/src/test/kotlin/com/ject/studytrip/studylog/application/service/StudyLogCommandServiceTest.kt @@ -44,7 +44,7 @@ class StudyLogCommandServiceTest : BaseUnitTest() { @BeforeEach fun setUp() { - member = MemberFixture.createMemberFromKakaoWithId(1L) + member = MemberFixture().createFromKakaoWithId(1L) courseTrip = TripFixture(member, TripCategory.COURSE).createWithId(1L) dailyGoal = DailyGoalFixture(courseTrip).createWithId(1L) studyLog = StudyLogFixture(member, dailyGoal).createWithId(1L) @@ -87,10 +87,7 @@ class StudyLogCommandServiceTest : BaseUnitTest() { studyLog.updateDeletedAt() // when - val exception = - assertThrows { - studyLogCommandService.updateImageUrl(studyLog, newImageUrl) - } + val exception = assertThrows { studyLogCommandService.updateImageUrl(studyLog, newImageUrl) } // then assertThat(exception.message).isEqualTo(StudyLogErrorCode.STUDY_LOG_ALREADY_DELETED.message) diff --git a/src/test/kotlin/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionCommandServiceTest.kt b/src/test/kotlin/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionCommandServiceTest.kt index e2ef99a..26fde1a 100644 --- a/src/test/kotlin/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionCommandServiceTest.kt +++ b/src/test/kotlin/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionCommandServiceTest.kt @@ -47,7 +47,7 @@ class StudyLogDailyMissionCommandServiceTest : BaseUnitTest() { @BeforeEach fun setUp() { - member = MemberFixture.createMemberFromKakaoWithId(1L) + member = MemberFixture().createFromKakaoWithId(1L) val trip = TripFixture(member, TripCategory.COURSE).createWithId(1L) val stamp = StampFixture(trip, 1).createWithId(1L) mission1 = MissionFixture(stamp).createWithId(1L) diff --git a/src/test/kotlin/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionQueryServiceTest.kt b/src/test/kotlin/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionQueryServiceTest.kt index f5ed9af..86a7655 100644 --- a/src/test/kotlin/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionQueryServiceTest.kt +++ b/src/test/kotlin/com/ject/studytrip/studylog/application/service/StudyLogDailyMissionQueryServiceTest.kt @@ -38,7 +38,7 @@ class StudyLogDailyMissionQueryServiceTest : BaseUnitTest() { @BeforeEach fun setUp() { - val member = MemberFixture.createMemberFromKakaoWithId(1L) + val member = MemberFixture().createFromKakaoWithId(1L) val trip = TripFixture(member, TripCategory.COURSE).createWithId(1L) val stamp = StampFixture(trip, 1).createWithId(1L) val mission = MissionFixture(stamp).createWithId(1L) diff --git a/src/test/kotlin/com/ject/studytrip/studylog/application/service/StudyLogQueryServiceTest.kt b/src/test/kotlin/com/ject/studytrip/studylog/application/service/StudyLogQueryServiceTest.kt index 4d85b6e..993d344 100644 --- a/src/test/kotlin/com/ject/studytrip/studylog/application/service/StudyLogQueryServiceTest.kt +++ b/src/test/kotlin/com/ject/studytrip/studylog/application/service/StudyLogQueryServiceTest.kt @@ -57,7 +57,7 @@ class StudyLogQueryServiceTest : BaseUnitTest() { @BeforeEach fun setUp() { - member = MemberFixture.createMemberFromKakaoWithId(1L) + member = MemberFixture().createFromKakaoWithId(1L) courseTrip = TripFixture(member, TripCategory.COURSE).createWithId(1L) dailyGoal = DailyGoalFixture(courseTrip).createWithId(1L) studyLog1 = StudyLogFixture(member, dailyGoal).createWithId(1L) diff --git a/src/test/kotlin/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.kt b/src/test/kotlin/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.kt index 5bf4dc8..2f6ea96 100644 --- a/src/test/kotlin/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.kt +++ b/src/test/kotlin/com/ject/studytrip/studylog/presentation/controller/StudyLogControllerIntegrationTest.kt @@ -121,7 +121,7 @@ class StudyLogControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( post("/api/trips/{tripId}/daily-goals/{dailyGoalId}/study-logs", tripId, dailyGoalId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)) + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)) .contentType(MediaType.APPLICATION_JSON_VALUE) .content(objectMapper.writeValueAsString(request)), ) @@ -222,7 +222,7 @@ class StudyLogControllerIntegrationTest : BaseIntegrationTest() { @DisplayName("여행의 소유자가 아니라면 403 Forbidden을 반환한다.") fun shouldReturnForbiddenWhenMemberIsNotTripOwner() { // given - val newMember = memberTestHelper.saveMember("test@gmail.com", "test") + val newMember = memberTestHelper.saveNewMember("test@gmail.com", "test") val newTrip = tripTestHelper.saveTrip(newMember, TripCategory.COURSE) val request = fixture.withSelectedDailyMissionIds(listOf(dailyMission.id)).build() @@ -521,7 +521,7 @@ class StudyLogControllerIntegrationTest : BaseIntegrationTest() { .param("page", page) .param("size", size) .param("order", order) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)), + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)), ) @Test @@ -612,7 +612,7 @@ class StudyLogControllerIntegrationTest : BaseIntegrationTest() { @DisplayName("여행의 소유자가 아니라면 403 Forbidden을 반환한다.") fun shouldReturnForbiddenWhenMemberIsNotTripOwner() { // given - val newMember = memberTestHelper.saveMember("test@gmail.com", "test") + val newMember = memberTestHelper.saveNewMember("test@gmail.com", "test") val newTrip = tripTestHelper.saveTrip(newMember, TripCategory.COURSE) // when val resultActions = getResultActions(token, newTrip.id, DEFAULT_PAGE, DEFAULT_SIZE, ORDER_LATEST) @@ -727,7 +727,7 @@ class StudyLogControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( post("/api/study-logs/{studyLogId}/images/presigned", studyLogId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)) + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)), ) @@ -837,7 +837,7 @@ class StudyLogControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( post("/api/study-logs/{studyLogId}/images/confirm", studyLogId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)) + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)), ) diff --git a/src/test/kotlin/com/ject/studytrip/trip/application/service/DailyGoalCommandServiceTest.kt b/src/test/kotlin/com/ject/studytrip/trip/application/service/DailyGoalCommandServiceTest.kt index ded166d..3a300bc 100644 --- a/src/test/kotlin/com/ject/studytrip/trip/application/service/DailyGoalCommandServiceTest.kt +++ b/src/test/kotlin/com/ject/studytrip/trip/application/service/DailyGoalCommandServiceTest.kt @@ -37,7 +37,7 @@ class DailyGoalCommandServiceTest : BaseUnitTest() { @BeforeEach fun setUp() { - member = MemberFixture.createMemberFromKakaoWithId(1L) + member = MemberFixture().createFromKakaoWithId(1L) trip = TripFixture(member, TripCategory.COURSE).createWithId(1L) dailyGoal = DailyGoalFixture(trip).createWithId(1L) } diff --git a/src/test/kotlin/com/ject/studytrip/trip/application/service/DailyGoalQueryServiceTest.kt b/src/test/kotlin/com/ject/studytrip/trip/application/service/DailyGoalQueryServiceTest.kt index 118c040..5d40e16 100644 --- a/src/test/kotlin/com/ject/studytrip/trip/application/service/DailyGoalQueryServiceTest.kt +++ b/src/test/kotlin/com/ject/studytrip/trip/application/service/DailyGoalQueryServiceTest.kt @@ -36,7 +36,7 @@ class DailyGoalQueryServiceTest : BaseUnitTest() { @BeforeEach fun setUp() { - member = MemberFixture.createMemberFromKakaoWithId(1L) + member = MemberFixture().createFromKakaoWithId(1L) trip = TripFixture(member, TripCategory.COURSE).createWithId(1L) dailyGoal = DailyGoalFixture(trip).createWithId(1L) } diff --git a/src/test/kotlin/com/ject/studytrip/trip/application/service/TripCommandServiceTest.kt b/src/test/kotlin/com/ject/studytrip/trip/application/service/TripCommandServiceTest.kt index eacf188..db76c33 100644 --- a/src/test/kotlin/com/ject/studytrip/trip/application/service/TripCommandServiceTest.kt +++ b/src/test/kotlin/com/ject/studytrip/trip/application/service/TripCommandServiceTest.kt @@ -48,7 +48,7 @@ class TripCommandServiceTest : BaseUnitTest() { @BeforeEach fun setUp() { - member = MemberFixture.createMemberFromKakaoWithId(1L) + member = MemberFixture().createFromKakaoWithId(1L) courseTrip = TripFixture(member, TripCategory.COURSE).createWithId(1L) exploreTrip = TripFixture(member, TripCategory.EXPLORE).createWithId(2L) } @@ -107,7 +107,7 @@ class TripCommandServiceTest : BaseUnitTest() { @Nested @DisplayName("updateTrip 메서드는") inner class UpdateTrip { - val fixture = UpdateTripRequestFixture() + private val fixture = UpdateTripRequestFixture() @Test @DisplayName("특정 여행의 이름을 수정한다.") diff --git a/src/test/kotlin/com/ject/studytrip/trip/application/service/TripQueryServiceTest.kt b/src/test/kotlin/com/ject/studytrip/trip/application/service/TripQueryServiceTest.kt index cfc57ba..c8ffcb1 100644 --- a/src/test/kotlin/com/ject/studytrip/trip/application/service/TripQueryServiceTest.kt +++ b/src/test/kotlin/com/ject/studytrip/trip/application/service/TripQueryServiceTest.kt @@ -47,7 +47,7 @@ class TripQueryServiceTest : BaseUnitTest() { @BeforeEach fun setUp() { - member = MemberFixture.createMemberFromKakaoWithId(1L) + member = MemberFixture().createFromKakaoWithId(1L) trip = TripFixture(member, TripCategory.COURSE).createWithId(1L) } diff --git a/src/test/kotlin/com/ject/studytrip/trip/application/service/TripReportCommandServiceTest.kt b/src/test/kotlin/com/ject/studytrip/trip/application/service/TripReportCommandServiceTest.kt index 3d6e009..d0808b6 100644 --- a/src/test/kotlin/com/ject/studytrip/trip/application/service/TripReportCommandServiceTest.kt +++ b/src/test/kotlin/com/ject/studytrip/trip/application/service/TripReportCommandServiceTest.kt @@ -39,7 +39,7 @@ class TripReportCommandServiceTest : BaseUnitTest() { @BeforeEach fun setUp() { - member = MemberFixture.createMemberFromKakaoWithId(1L) + member = MemberFixture().createFromKakaoWithId(1L) val trip = TripFixture(member, TripCategory.COURSE).create() val dailyGoal = DailyGoalFixture(trip).create() val studyLog1 = StudyLogFixture(member, dailyGoal).createWithId(1L) diff --git a/src/test/kotlin/com/ject/studytrip/trip/application/service/TripReportQueryServiceTest.kt b/src/test/kotlin/com/ject/studytrip/trip/application/service/TripReportQueryServiceTest.kt index 6cec695..f180b3b 100644 --- a/src/test/kotlin/com/ject/studytrip/trip/application/service/TripReportQueryServiceTest.kt +++ b/src/test/kotlin/com/ject/studytrip/trip/application/service/TripReportQueryServiceTest.kt @@ -38,7 +38,7 @@ class TripReportQueryServiceTest : BaseUnitTest() { @BeforeEach fun setUp() { - member = MemberFixture.createMemberFromKakaoWithId(1L) + member = MemberFixture().createFromKakaoWithId(1L) tripReport1 = TripReportFixture(member).createWithId(1L) tripReport2 = TripReportFixture(member).createWithId(2L) tripReports = listOf(tripReport1, tripReport2) diff --git a/src/test/kotlin/com/ject/studytrip/trip/application/service/TripReportStudyLogCommandServiceTest.kt b/src/test/kotlin/com/ject/studytrip/trip/application/service/TripReportStudyLogCommandServiceTest.kt index 904a763..0b357c9 100644 --- a/src/test/kotlin/com/ject/studytrip/trip/application/service/TripReportStudyLogCommandServiceTest.kt +++ b/src/test/kotlin/com/ject/studytrip/trip/application/service/TripReportStudyLogCommandServiceTest.kt @@ -40,7 +40,7 @@ class TripReportStudyLogCommandServiceTest : BaseUnitTest() { @BeforeEach fun setUp() { - member = MemberFixture.createMemberFromKakaoWithId(1L) + member = MemberFixture().createFromKakaoWithId(1L) val trip = TripFixture(member, TripCategory.COURSE).create() val dailyGoal = DailyGoalFixture(trip).create() val studyLog1 = StudyLogFixture(member, dailyGoal).createWithId(1L) diff --git a/src/test/kotlin/com/ject/studytrip/trip/presentation/controller/DailyGoalControllerIntegrationTest.kt b/src/test/kotlin/com/ject/studytrip/trip/presentation/controller/DailyGoalControllerIntegrationTest.kt index 8011350..219edc6 100644 --- a/src/test/kotlin/com/ject/studytrip/trip/presentation/controller/DailyGoalControllerIntegrationTest.kt +++ b/src/test/kotlin/com/ject/studytrip/trip/presentation/controller/DailyGoalControllerIntegrationTest.kt @@ -88,7 +88,7 @@ class DailyGoalControllerIntegrationTest : BaseIntegrationTest() { dailyGoal = dailyGoalTestHelper.saveDailyGoal(trip) dailyMission = dailyMissionTestHelper.saveDailyMission(mission1, dailyGoal) - val newMember = memberTestHelper.saveMember("test@gmail.com", "test") + val newMember = memberTestHelper.saveNewMember("test@gmail.com", "test") newTrip = tripTestHelper.saveTrip(newMember, TripCategory.COURSE) } @@ -108,7 +108,7 @@ class DailyGoalControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( post(BASE_DAILY_GOAL_URL, tripId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)) + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)), ) @@ -396,7 +396,7 @@ class DailyGoalControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( patch("$BASE_DAILY_GOAL_URL/{dailyGoalId}", tripId, dailyGoalId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)) + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)), ) @@ -773,7 +773,7 @@ class DailyGoalControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( delete("$BASE_DAILY_GOAL_URL/{dailyGoalId}", tripId, dailyGoalId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)), + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)), ) @Test @@ -999,7 +999,7 @@ class DailyGoalControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( get("$BASE_DAILY_GOAL_URL/{dailyGoalId}", tripId, dailyGoalId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)), + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)), ) @Test diff --git a/src/test/kotlin/com/ject/studytrip/trip/presentation/controller/TripControllerIntegrationTest.kt b/src/test/kotlin/com/ject/studytrip/trip/presentation/controller/TripControllerIntegrationTest.kt index 853fa84..5920324 100644 --- a/src/test/kotlin/com/ject/studytrip/trip/presentation/controller/TripControllerIntegrationTest.kt +++ b/src/test/kotlin/com/ject/studytrip/trip/presentation/controller/TripControllerIntegrationTest.kt @@ -58,7 +58,7 @@ class TripControllerIntegrationTest : BaseIntegrationTest() { token = tokenTestHelper.createAccessToken(member.id.toString(), MemberRole.ROLE_USER.name) courseTrip = tripTestHelper.saveTrip(member, TripCategory.COURSE) - val newMember = memberTestHelper.saveMember("test@gmail.com", "test") + val newMember = memberTestHelper.saveNewMember("test@gmail.com", "test") newTrip = tripTestHelper.saveTrip(newMember, TripCategory.COURSE) } @@ -79,7 +79,7 @@ class TripControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( post(BASE_TRIP_URL) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)) + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)), ) @@ -198,7 +198,7 @@ class TripControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( patch("$BASE_TRIP_URL/{tripId}", tripId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)) + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)), ) @@ -369,7 +369,7 @@ class TripControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( delete("$BASE_TRIP_URL/{tripId}", tripId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)), + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)), ) @Test @@ -491,7 +491,7 @@ class TripControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( patch("$BASE_TRIP_URL/{tripId}/complete", tripId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)), + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)), ) @Test @@ -657,7 +657,7 @@ class TripControllerIntegrationTest : BaseIntegrationTest() { get(BASE_TRIP_URL) .param("page", page) .param("size", size) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)), + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)), ) @Test @@ -735,7 +735,7 @@ class TripControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( get("$BASE_TRIP_URL/{tripId}", tripId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)), + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)), ) @Test diff --git a/src/test/kotlin/com/ject/studytrip/trip/presentation/controller/TripReportControllerIntegrationTest.kt b/src/test/kotlin/com/ject/studytrip/trip/presentation/controller/TripReportControllerIntegrationTest.kt index 7a8d3fc..5b4c9ff 100644 --- a/src/test/kotlin/com/ject/studytrip/trip/presentation/controller/TripReportControllerIntegrationTest.kt +++ b/src/test/kotlin/com/ject/studytrip/trip/presentation/controller/TripReportControllerIntegrationTest.kt @@ -100,7 +100,7 @@ class TripReportControllerIntegrationTest : BaseIntegrationTest() { studyLogDailyMissionTestHelper.saveStudyLogDailyMissions(studyLog2, dailyMission) tripReport = tripReportTestHelper.saveTripReport(member) - newMember = memberTestHelper.saveMember("test@gmail.com", "test") + newMember = memberTestHelper.saveNewMember("test@gmail.com", "test") } companion object { @@ -120,7 +120,7 @@ class TripReportControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( post(BASE_TRIP_REPORT_URL) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)) + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)), ) @@ -204,7 +204,7 @@ class TripReportControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( delete("$BASE_TRIP_REPORT_URL/{tripReportId}", tripReportId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)), + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)), ) @Test @@ -315,7 +315,7 @@ class TripReportControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( post("$BASE_TRIP_REPORT_URL/{tripReportId}/images/presigned", tripReportId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)) + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)), ) @@ -407,7 +407,7 @@ class TripReportControllerIntegrationTest : BaseIntegrationTest() { ): ResultActions = mockMvc.perform( post("$BASE_TRIP_REPORT_URL/{tripReportId}/images/confirm", tripReportId) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)) + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request)), ) @@ -478,7 +478,7 @@ class TripReportControllerIntegrationTest : BaseIntegrationTest() { get("/api/trips/{tripId}/retrospect", tripId) .param("page", page) .param("size", size) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)), + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)), ) @Test @@ -637,7 +637,7 @@ class TripReportControllerIntegrationTest : BaseIntegrationTest() { private fun getResultActions(token: String): ResultActions = mockMvc.perform( get(BASE_TRIP_REPORT_URL) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)), + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)), ) @Test @@ -682,7 +682,7 @@ class TripReportControllerIntegrationTest : BaseIntegrationTest() { get("$BASE_TRIP_REPORT_URL/{tripReportId}", tripReportId) .param("page", page) .param("size", size) - .header(HttpHeaders.AUTHORIZATION, TokenFixture.authorization(token)), + .header(HttpHeaders.AUTHORIZATION, TokenFixture().authorization(token)), ) @Test