diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index 741c741..d54734c 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -34,15 +34,6 @@ jobs: - name: ๐Ÿ—๏ธ Build with Gradle run: ./gradlew build - env: - REDIS_HOST: ${{ secrets.REDIS_HOST }} - REDIS_PORT: ${{ secrets.REDIS_PORT }} - REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }} - MYSQL_HOST: ${{ secrets.MYSQL_HOST }} - MYSQL_PORT: ${{ secrets.MYSQL_PORT }} - MYSQL_USER: ${{ secrets.MYSQL_USER }} - MYSQL_PASSWORD: ${{ secrets.MYSQL_PASSWORD }} - MYSQL_DATABASE: ${{ secrets.MYSQL_DATABASE }} - name: Log in to Docker Hub uses: docker/login-action@v2 diff --git a/.github/workflows/pr_test.yml b/.github/workflows/pr_test.yml index d53b3e4..3b85aa9 100644 --- a/.github/workflows/pr_test.yml +++ b/.github/workflows/pr_test.yml @@ -9,22 +9,6 @@ jobs: test: runs-on: ubuntu-latest - services: - mysql: - image: mysql:8.0 - env: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: ddip_test - MYSQL_USER: user - MYSQL_PASSWORD: password - ports: - - 3306:3306 - options: >- - --health-cmd="mysqladmin ping -h localhost" - --health-interval=5s - --health-timeout=5s - --health-retries=10 - steps: - name: Checkout code uses: actions/checkout@v4 @@ -37,15 +21,6 @@ jobs: - name: Run Gradle Tests run: ./gradlew test jacocoTestReport - env: - MYSQL_HOST: localhost - MYSQL_PORT: 3306 - MYSQL_USER: user - MYSQL_PASSWORD: password - MYSQL_DATABASE: ddip_test - REDIS_HOST: ${{ secrets.REDIS_HOST }} - REDIS_PORT: ${{ secrets.REDIS_PORT }} - REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }} - name: Write coverage comments on PR uses: madrapps/jacoco-report@v1.6.1 diff --git a/build.gradle b/build.gradle index 2ca5176..9c9ed0a 100644 --- a/build.gradle +++ b/build.gradle @@ -40,7 +40,6 @@ dependencies { // Database & Redis runtimeOnly 'com.mysql:mysql-connector-j' implementation 'org.springframework.boot:spring-boot-starter-data-redis' - implementation 'org.redisson:redisson-spring-boot-starter:3.23.1' implementation 'org.springframework.session:spring-session-data-redis' // Lombok @@ -51,6 +50,8 @@ dependencies { // Test Dependencies testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.testcontainers:testcontainers:1.20.5' + testImplementation 'org.testcontainers:junit-jupiter:1.20.5' // monitoring implementation 'io.micrometer:micrometer-registry-prometheus' diff --git a/src/main/java/com/knu/ddip/DdipApplication.java b/src/main/java/com/knu/ddip/DdipApplication.java index 9b7335d..f5b7b52 100644 --- a/src/main/java/com/knu/ddip/DdipApplication.java +++ b/src/main/java/com/knu/ddip/DdipApplication.java @@ -6,8 +6,8 @@ @SpringBootApplication public class DdipApplication { - public static void main(String[] args) { - SpringApplication.run(DdipApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(DdipApplication.class, args); + } } diff --git a/src/main/java/com/knu/ddip/auth/business/dto/JwtRefreshRequest.java b/src/main/java/com/knu/ddip/auth/business/dto/JwtRefreshRequest.java new file mode 100644 index 0000000..6c82389 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/business/dto/JwtRefreshRequest.java @@ -0,0 +1,7 @@ +package com.knu.ddip.auth.business.dto; + +public record JwtRefreshRequest( + String refreshToken, + String deviceType +) { +} diff --git a/src/main/java/com/knu/ddip/auth/business/dto/JwtResponse.java b/src/main/java/com/knu/ddip/auth/business/dto/JwtResponse.java new file mode 100644 index 0000000..bac42ce --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/business/dto/JwtResponse.java @@ -0,0 +1,7 @@ +package com.knu.ddip.auth.business.dto; + +public record JwtResponse( + String accessToken, + String refreshToken +) { +} diff --git a/src/main/java/com/knu/ddip/auth/business/dto/OAuthContext.java b/src/main/java/com/knu/ddip/auth/business/dto/OAuthContext.java new file mode 100644 index 0000000..4c90f64 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/business/dto/OAuthContext.java @@ -0,0 +1,16 @@ +package com.knu.ddip.auth.business.dto; + +import com.knu.ddip.auth.domain.DeviceType; +import com.knu.ddip.auth.domain.OAuthProvider; +import lombok.AccessLevel; +import lombok.Builder; + +@Builder(access = AccessLevel.PROTECTED) +public record OAuthContext(OAuthProvider oAuthProvider, DeviceType deviceType) { + public static OAuthContext of(OAuthProvider oAuthProvider, DeviceType deviceType) { + return OAuthContext.builder() + .oAuthProvider(oAuthProvider) + .deviceType(deviceType) + .build(); + } +} diff --git a/src/main/java/com/knu/ddip/auth/business/dto/OAuthLoginResponse.java b/src/main/java/com/knu/ddip/auth/business/dto/OAuthLoginResponse.java new file mode 100644 index 0000000..436e2a9 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/business/dto/OAuthLoginResponse.java @@ -0,0 +1,29 @@ +package com.knu.ddip.auth.business.dto; + +import lombok.AccessLevel; +import lombok.Builder; + +import java.util.UUID; + +@Builder(access = AccessLevel.PROTECTED) +public record OAuthLoginResponse( + String accessToken, + String refreshToken, + UUID OAuthMappingEntityId, + boolean needRegister +) { + public static OAuthLoginResponse toJwt(String accessToken, String refreshToken) { + return OAuthLoginResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .needRegister(false) + .build(); + } + + public static OAuthLoginResponse toSignUp(UUID OAuthMappingEntityId) { + return OAuthLoginResponse.builder() + .OAuthMappingEntityId(OAuthMappingEntityId) + .needRegister(true) + .build(); + } +} diff --git a/src/main/java/com/knu/ddip/auth/business/dto/OAuthMappingEntityDto.java b/src/main/java/com/knu/ddip/auth/business/dto/OAuthMappingEntityDto.java new file mode 100644 index 0000000..5b37b39 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/business/dto/OAuthMappingEntityDto.java @@ -0,0 +1,37 @@ +package com.knu.ddip.auth.business.dto; + +import com.knu.ddip.auth.domain.OAuthProvider; +import com.knu.ddip.auth.domain.OAuthToken; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +import java.util.UUID; + +@Getter +@Builder +public class OAuthMappingEntityDto { + private final UUID id; + private final String socialUserId; + private final String socialUserEmail; + private final String socialUserName; + private final OAuthProvider provider; + private final UUID userId; + private final OAuthToken oauthToken; + private final boolean temporary; + + public static OAuthMappingEntityDto create(UUID id, String providerId, String providerEmail, + String providerName, OAuthProvider provider, + UUID userId, OAuthToken oauthToken, boolean temporary) { + return OAuthMappingEntityDto.builder() + .id(id) + .socialUserId(providerId) + .socialUserEmail(providerEmail) + .socialUserName(providerName) + .provider(provider) + .userId(userId) + .oauthToken(oauthToken) + .temporary(temporary) + .build(); + } +} diff --git a/src/main/java/com/knu/ddip/auth/business/dto/OAuthTokenDto.java b/src/main/java/com/knu/ddip/auth/business/dto/OAuthTokenDto.java new file mode 100644 index 0000000..507e698 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/business/dto/OAuthTokenDto.java @@ -0,0 +1,20 @@ +package com.knu.ddip.auth.business.dto; + +import com.knu.ddip.auth.domain.OAuthToken; +import lombok.AccessLevel; +import lombok.Builder; + +@Builder(access = AccessLevel.PROTECTED) +public record OAuthTokenDto( + String accessToken, + String refreshToken, + long expiresIn) { + + public static OAuthTokenDto from(OAuthToken oAuthToken) { + return OAuthTokenDto.builder() + .accessToken(oAuthToken.getAccessToken()) + .refreshToken(oAuthToken.getRefreshToken()) + .expiresIn(oAuthToken.getExpiresIn()) + .build(); + } +} diff --git a/src/main/java/com/knu/ddip/auth/business/dto/TokenDTO.java b/src/main/java/com/knu/ddip/auth/business/dto/TokenDTO.java new file mode 100644 index 0000000..6906702 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/business/dto/TokenDTO.java @@ -0,0 +1,13 @@ +package com.knu.ddip.auth.business.dto; + +import lombok.AccessLevel; +import lombok.Builder; + +@Builder(access = AccessLevel.PROTECTED) +public record TokenDTO( + String value +) { + public static TokenDTO from(String value) { + return TokenDTO.builder().value(value).build(); + } +} diff --git a/src/main/java/com/knu/ddip/auth/business/service/JwtFactory.java b/src/main/java/com/knu/ddip/auth/business/service/JwtFactory.java new file mode 100644 index 0000000..74a3755 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/business/service/JwtFactory.java @@ -0,0 +1,72 @@ +package com.knu.ddip.auth.business.service; + +import com.knu.ddip.auth.domain.Token; +import com.knu.ddip.auth.domain.TokenType; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.Optional; +import java.util.UUID; + +@Component +public class JwtFactory { + public static final long REFRESH_TOKEN_VALIDITY_MILLISECONDS = 14 * 24 * 60 * 60 * 1000; + private static final long ACCESS_TOKEN_VALIDITY_MILLISECONDS = 30 * 60 * 1000; + private final SecretKey secretKey; + + public JwtFactory(@Value("${SECRET_KEY}") String secret) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes()); + } + + public Token createAccessToken(UUID userId) { + return createToken(userId, TokenType.ACCESS, ACCESS_TOKEN_VALIDITY_MILLISECONDS); + } + + public Token createRefreshToken(UUID userId) { + return createToken(userId, TokenType.REFRESH, REFRESH_TOKEN_VALIDITY_MILLISECONDS); + } + + private Token createToken(UUID userId, TokenType tokenType, long validityInMilliseconds) { + Date now = new Date(); + Date validity = new Date(now.getTime() + validityInMilliseconds); + + String tokenValue = Jwts.builder() + .subject(String.valueOf(userId)) + .issuedAt(now) + .expiration(validity) + .claim("type", tokenType.name()) + .signWith(secretKey) + .compact(); + + return Token.of(tokenType, tokenValue, String.valueOf(userId), now, validity); + } + + public Optional parseToken(String tokenValue) { + if (tokenValue == null || tokenValue.isEmpty()) { + return Optional.empty(); + } + + try { + Claims claims = Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(tokenValue) + .getPayload(); + + String subject = claims.getSubject(); + Date issuedAt = claims.getIssuedAt(); + Date expiration = claims.getExpiration(); + String tokenTypeStr = claims.get("type", String.class); + TokenType tokenType = TokenType.valueOf(tokenTypeStr); + + return Optional.of(Token.of(tokenType, tokenValue, subject, issuedAt, expiration)); + } catch (Exception e) { + return Optional.empty(); + } + } +} diff --git a/src/main/java/com/knu/ddip/auth/business/service/JwtService.java b/src/main/java/com/knu/ddip/auth/business/service/JwtService.java new file mode 100644 index 0000000..e0d0a33 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/business/service/JwtService.java @@ -0,0 +1,46 @@ +package com.knu.ddip.auth.business.service; + +import com.knu.ddip.auth.business.dto.JwtRefreshRequest; +import com.knu.ddip.auth.business.dto.JwtResponse; +import com.knu.ddip.auth.business.dto.TokenDTO; +import com.knu.ddip.auth.business.validator.JwtValidator; +import com.knu.ddip.auth.domain.Token; +import com.knu.ddip.auth.exception.TokenBadRequestException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class JwtService { + private final JwtFactory jwtFactory; + private final TokenRepository tokenRepository; + private final JwtValidator JWTValidator; + + public JwtResponse refreshAccessToken(JwtRefreshRequest request) { + String refreshTokenValue = request.refreshToken(); + String deviceType = request.deviceType(); + + Token refreshToken = jwtFactory.parseToken(refreshTokenValue) + .orElseThrow(() -> new TokenBadRequestException("์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ์ž…๋‹ˆ๋‹ค.")); + + UUID userId = UUID.fromString(refreshToken.getSubject()); + + JWTValidator.validateRefreshToken(refreshToken, userId, deviceType); + + Token newAccessToken = jwtFactory.createAccessToken(userId); + Token newRefreshToken = jwtFactory.createRefreshToken(userId); + + TokenDTO newRefreshTokenDTO = newRefreshToken.toTokenDTO(); + + tokenRepository.saveToken(userId, deviceType, newRefreshTokenDTO); + tokenRepository.updateLastRefreshTime(userId, deviceType); + + return new JwtResponse(newAccessToken.getValue(), newRefreshToken.getValue()); + } + + public void logout(UUID userId, String deviceType) { + tokenRepository.removeToken(userId, deviceType); + } +} diff --git a/src/main/java/com/knu/ddip/auth/business/service/OAuthLoginService.java b/src/main/java/com/knu/ddip/auth/business/service/OAuthLoginService.java new file mode 100644 index 0000000..6599b6a --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/business/service/OAuthLoginService.java @@ -0,0 +1,173 @@ +package com.knu.ddip.auth.business.service; + +import com.knu.ddip.auth.business.dto.*; +import com.knu.ddip.auth.business.service.oauth.OAuthRepository; +import com.knu.ddip.auth.business.service.oauth.OAuthService; +import com.knu.ddip.auth.domain.*; +import com.knu.ddip.auth.exception.OAuthBadRequestException; +import com.knu.ddip.auth.exception.OAuthNotFoundException; +import com.knu.ddip.user.business.dto.UserEntityDto; +import com.knu.ddip.user.business.service.UserFactory; +import com.knu.ddip.user.business.service.UserRepository; +import com.knu.ddip.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.net.URI; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class OAuthLoginService { + + private final Map oAuthServices; + private final OAuthRepository oAuthRepository; + private final UserRepository userRepository; + private final JwtFactory jwtFactory; + private final TokenRepository tokenRepository; + + @Value("${OAUTH_APP_REDIRECT_URI}") + private String appRedirectUri; + + public String getOAuthLoginUrl(String provider, String state) { + OAuthContext context = parseOAuthContext(provider, state); + + OAuthService oAuthService = getOAuthService(context.oAuthProvider()); + + if (!oAuthService.isBackendRedirect()) { + throw new OAuthBadRequestException("์ด ์ œ๊ณต์ž๋Š” ๋ฐฑ์—”๋“œ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + return oAuthService.getRedirectUrl(context.deviceType().name()); + } + + @Transactional + public URI handleOAuthCallback(String provider, String code, String state) { + OAuthContext context = parseOAuthContext(provider, state); + + OAuthLoginResponse loginResponse = processOAuthLogin(context.oAuthProvider(), code, + context.deviceType()); + + StringBuilder redirectBuilder = new StringBuilder(appRedirectUri); + + String urlFragmentDelimiter = "#"; + + redirectBuilder.append(urlFragmentDelimiter) + .append("needRegister=").append(loginResponse.needRegister()); + + if (loginResponse.needRegister()) { + redirectBuilder.append("&provider=") + .append(context.oAuthProvider().name().toLowerCase()) + .append("&deviceType=") + .append(context.deviceType().name().toLowerCase()) + .append("&oAuthMappingEntityId=") + .append(loginResponse.OAuthMappingEntityId()); + } else { + redirectBuilder.append("&accessToken=").append(loginResponse.accessToken()) + .append("&refreshToken=").append(loginResponse.refreshToken()); + } + + return URI.create(redirectBuilder.toString()); + } + + private OAuthContext parseOAuthContext(String provider, String state) { + OAuthProvider oAuthProvider = OAuthProvider.fromString(provider); + DeviceType deviceType = DeviceType.fromString(state); + return OAuthContext.of(oAuthProvider, deviceType); + } + + @Transactional + public OAuthLoginResponse processOAuthLogin(OAuthProvider provider, String code, + DeviceType deviceType) { + OAuthService oAuthService = getOAuthService(provider); + + OAuthUserInfo userInfo = oAuthService.getUserInfo(code); + + OAuthToken oauthToken = userInfo.getOAuthToken(); + + Optional existingMappingDto = + oAuthRepository.findBySocialUserIdAndProvider(userInfo.getSocialUserId(), provider); + + if (existingMappingDto.isPresent()) { + OAuthMappingEntityDto mappingDto = existingMappingDto.get(); + + OAuthTokenDto oauthTokenDto = OAuthTokenDto.from(oauthToken); + oAuthRepository.updateToken(mappingDto.getId(), oauthTokenDto); + + if (mappingDto.getUserId() != null && !mappingDto.isTemporary()) { + JwtResponse jwtResponse = generateTokensForUser(mappingDto.getUserId(), + deviceType); + return OAuthLoginResponse.toJwt(jwtResponse.accessToken(), + jwtResponse.refreshToken()); + } else { + return OAuthLoginResponse.toSignUp(mappingDto.getId()); + } + } + + OAuthMapping tempMapping = OAuthMapping.createTemporary( + userInfo.getSocialUserId(), + userInfo.getEmail(), + userInfo.getName(), + provider, + oauthToken + ); + + OAuthMappingEntityDto newMapping = tempMapping.toDto(); + OAuthMappingEntityDto savedMapping = oAuthRepository.save(newMapping); + + return OAuthLoginResponse.toSignUp(savedMapping.getId()); + } + + @Transactional + public JwtResponse linkOAuthWithUser(User user, String oauthMappingEntityId, + OAuthProvider provider, DeviceType deviceType) { + Optional oAuthMappingEntityDtoOpt = + oAuthRepository.findByOauthMappingEntityIdAndProvider( + UUID.fromString(oauthMappingEntityId), provider); + + if (oAuthMappingEntityDtoOpt.isEmpty()) { + throw new OAuthNotFoundException("OAuth ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + OAuthMappingEntityDto mappingDto = oAuthMappingEntityDtoOpt.get(); + + OAuthMapping oAuthMappingDomain = OAuthMapping.fromDto(mappingDto); + + OAuthMapping updatedDomain = oAuthMappingDomain.linkToUser(user.getId()); + + OAuthMappingEntityDto updatedDto = updatedDomain.toDto(); + oAuthRepository.update(updatedDto); + + return generateTokensForUser(user.getId(), deviceType); + } + + private JwtResponse generateTokensForUser(UUID userId, DeviceType deviceType) { + UserEntityDto userEntityDto = userRepository.getById(userId); + UserFactory.create(userEntityDto.getId(), userEntityDto.getEmail(), + userEntityDto.getNickname(), userEntityDto.getStatus()); + + Token accessToken = jwtFactory.createAccessToken(userId); + Token refreshToken = jwtFactory.createRefreshToken(userId); + + TokenDTO refreshTokenDTO = refreshToken.toTokenDTO(); + + tokenRepository.saveToken(userId, deviceType.name(), refreshTokenDTO); + + return new JwtResponse(accessToken.getValue(), refreshToken.getValue()); + } + + private OAuthService getOAuthService(OAuthProvider provider) { + String serviceBeanName = provider.name().toLowerCase() + "OAuthService"; + OAuthService service = oAuthServices.get(serviceBeanName); + + if (service == null) { + throw new OAuthBadRequestException("์ง€์›ํ•˜์ง€ ์•Š๋Š” OAuth ์ œ๊ณต์ž์ž…๋‹ˆ๋‹ค: " + provider); + } + + return service; + } +} diff --git a/src/main/java/com/knu/ddip/auth/business/service/TokenRepository.java b/src/main/java/com/knu/ddip/auth/business/service/TokenRepository.java new file mode 100644 index 0000000..08f6f12 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/business/service/TokenRepository.java @@ -0,0 +1,18 @@ +package com.knu.ddip.auth.business.service; + +import com.knu.ddip.auth.business.dto.TokenDTO; + +import java.util.Optional; +import java.util.UUID; + +public interface TokenRepository { + void saveToken(UUID userId, String deviceType, TokenDTO tokenDTO); + + TokenDTO findToken(UUID userId, String deviceType); + + void removeToken(UUID userId, String deviceType); + + Optional getLastRefreshTime(UUID userId, String deviceType); + + void updateLastRefreshTime(UUID userId, String deviceType); +} diff --git a/src/main/java/com/knu/ddip/auth/business/service/oauth/OAuthRepository.java b/src/main/java/com/knu/ddip/auth/business/service/oauth/OAuthRepository.java new file mode 100644 index 0000000..4890f0d --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/business/service/oauth/OAuthRepository.java @@ -0,0 +1,23 @@ +package com.knu.ddip.auth.business.service.oauth; + +import com.knu.ddip.auth.business.dto.OAuthMappingEntityDto; +import com.knu.ddip.auth.business.dto.OAuthTokenDto; +import com.knu.ddip.auth.domain.OAuthProvider; + +import java.util.Optional; +import java.util.UUID; + +public interface OAuthRepository { + + OAuthMappingEntityDto save(OAuthMappingEntityDto entityDto); + + Optional findBySocialUserIdAndProvider(String socialUserId, + OAuthProvider provider); + + Optional findByOauthMappingEntityIdAndProvider(UUID OauthMappingEntityId, + OAuthProvider provider); + + void update(OAuthMappingEntityDto entityDto); + + void updateToken(UUID mappingId, OAuthTokenDto oauthTokenDto); +} diff --git a/src/main/java/com/knu/ddip/auth/business/service/oauth/OAuthService.java b/src/main/java/com/knu/ddip/auth/business/service/oauth/OAuthService.java new file mode 100644 index 0000000..1d10956 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/business/service/oauth/OAuthService.java @@ -0,0 +1,11 @@ +package com.knu.ddip.auth.business.service.oauth; + +import com.knu.ddip.auth.domain.OAuthUserInfo; + +public interface OAuthService { + OAuthUserInfo getUserInfo(String code); + + String getRedirectUrl(String state); + + boolean isBackendRedirect(); +} diff --git a/src/main/java/com/knu/ddip/auth/business/service/oauth/kakao/KakaoOAuthService.java b/src/main/java/com/knu/ddip/auth/business/service/oauth/kakao/KakaoOAuthService.java new file mode 100644 index 0000000..da03bbe --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/business/service/oauth/kakao/KakaoOAuthService.java @@ -0,0 +1,105 @@ +package com.knu.ddip.auth.business.service.oauth.kakao; + +import com.knu.ddip.auth.business.service.oauth.OAuthService; +import com.knu.ddip.auth.domain.OAuthProvider; +import com.knu.ddip.auth.domain.OAuthToken; +import com.knu.ddip.auth.domain.OAuthUserInfo; +import com.knu.ddip.auth.exception.OAuthErrorException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +@Service +@RequiredArgsConstructor +public class KakaoOAuthService implements OAuthService { + + private static final String KAKAO_TOKEN_URI = "https://kauth.kakao.com/oauth/token"; + private static final String KAKAO_USER_INFO_URI = "https://kapi.kakao.com/v2/user/me"; + private static final String KAKAO_AUTHORIZE_URI = "https://kauth.kakao.com/oauth/authorize"; + private final RestTemplate restTemplate; + + @Value("${KAKAO_REST_API_KEY}") + private String clientId; + + @Value("${KAKAO_BACKEND_REDIRECT_URI}") + private String redirectUri; + + @Override + public OAuthUserInfo getUserInfo(String code) { + OAuthToken oauthToken = getOAuthToken(code); + + return getUserInfoByToken(oauthToken); + } + + @Override + public String getRedirectUrl(String state) { + + return KAKAO_AUTHORIZE_URI + + "?client_id=" + clientId + + "&redirect_uri=" + redirectUri + + "&response_type=code" + + "&state=" + state; + } + + @Override + public boolean isBackendRedirect() { + return true; + } + + private OAuthToken getOAuthToken(String code) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", clientId); + params.add("redirect_uri", redirectUri); + params.add("code", code); + + HttpEntity> request = new HttpEntity<>(params, headers); + + try { + ResponseEntity response = restTemplate.exchange( + KAKAO_TOKEN_URI, + HttpMethod.POST, + request, + KakaoTokenResponse.class + ); + + KakaoTokenResponse kakaoTokenResponse = response.getBody(); + + return kakaoTokenResponse.toDomain(); + + } catch (Exception e) { + throw new OAuthErrorException("์นด์นด์˜ค ๋กœ๊ทธ์ธ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."); + } + } + + private OAuthUserInfo getUserInfoByToken(OAuthToken oauthToken) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(oauthToken.getAccessToken()); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + HttpEntity> request = new HttpEntity<>(headers); + + try { + ResponseEntity response = restTemplate.exchange( + KAKAO_USER_INFO_URI, + HttpMethod.GET, + request, + KakaoUserInfoResponse.class + ); + + KakaoUserInfoResponse userInfoResponse = response.getBody(); + + return userInfoResponse.toDomain(OAuthProvider.KAKAO, oauthToken); + + } catch (Exception e) { + throw new OAuthErrorException("์นด์นด์˜ค ๋กœ๊ทธ์ธ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."); + } + } + +} diff --git a/src/main/java/com/knu/ddip/auth/business/service/oauth/kakao/KakaoTokenResponse.java b/src/main/java/com/knu/ddip/auth/business/service/oauth/kakao/KakaoTokenResponse.java new file mode 100644 index 0000000..48106a0 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/business/service/oauth/kakao/KakaoTokenResponse.java @@ -0,0 +1,17 @@ +package com.knu.ddip.auth.business.service.oauth.kakao; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.knu.ddip.auth.domain.OAuthToken; + +public record KakaoTokenResponse( + @JsonProperty("access_token") String accessToken, + @JsonProperty("refresh_token") String refreshToken, + @JsonProperty("expires_in") Long expiresIn, + @JsonProperty("refresh_token_expires_in") Long refreshTokenExpiresIn, + @JsonProperty("token_type") String tokenType, + String scope +) { + public OAuthToken toDomain() { + return OAuthToken.ofKakao(this.accessToken, this.refreshToken, this.expiresIn); + } +} diff --git a/src/main/java/com/knu/ddip/auth/business/service/oauth/kakao/KakaoUserInfoResponse.java b/src/main/java/com/knu/ddip/auth/business/service/oauth/kakao/KakaoUserInfoResponse.java new file mode 100644 index 0000000..9591f77 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/business/service/oauth/kakao/KakaoUserInfoResponse.java @@ -0,0 +1,29 @@ +package com.knu.ddip.auth.business.service.oauth.kakao; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.knu.ddip.auth.domain.OAuthProvider; +import com.knu.ddip.auth.domain.OAuthToken; +import com.knu.ddip.auth.domain.OAuthUserInfo; + +public record KakaoUserInfoResponse( + String id, + @JsonProperty("kakao_account") KakaoAccount kakaoAccount +) { + public OAuthUserInfo toDomain(OAuthProvider provider, OAuthToken oAuthToken) { + String email = kakaoAccount != null ? kakaoAccount.email : null; + String nickname = kakaoAccount != null && kakaoAccount.profile != null + ? kakaoAccount.profile.nickname : null; + + return OAuthUserInfo.create(id, email, nickname, provider, oAuthToken); + } + + public record KakaoAccount( + String email, + Profile profile + ) { + public record Profile( + String nickname + ) { + } + } +} diff --git a/src/main/java/com/knu/ddip/auth/business/validator/JwtValidator.java b/src/main/java/com/knu/ddip/auth/business/validator/JwtValidator.java new file mode 100644 index 0000000..72d8303 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/business/validator/JwtValidator.java @@ -0,0 +1,67 @@ +package com.knu.ddip.auth.business.validator; + +import com.knu.ddip.auth.business.dto.TokenDTO; +import com.knu.ddip.auth.business.service.JwtFactory; +import com.knu.ddip.auth.business.service.TokenRepository; +import com.knu.ddip.auth.domain.Token; +import com.knu.ddip.auth.exception.TokenBadRequestException; +import com.knu.ddip.auth.exception.TokenConflictException; +import com.knu.ddip.auth.exception.TokenExpiredException; +import com.knu.ddip.auth.exception.TokenStolenException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class JwtValidator { + private static final long REFRESH_RATE_LIMIT_MILLISECONDS = 60 * 1000; + + private final TokenRepository tokenRepository; + private final JwtFactory jwtFactory; + + public void validateRefreshToken(Token token, UUID userId, String deviceType) { + if (!token.isRefreshToken()) { + throw new TokenBadRequestException("๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ์ด ์•„๋‹™๋‹ˆ๋‹ค."); + } + + if (token.isExpired()) { + throw new TokenExpiredException("๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + } + + TokenDTO storedTokenDTO = tokenRepository.findToken(userId, deviceType); + + if (storedTokenDTO.value() == null) { + throw new TokenBadRequestException("์ €์žฅ๋œ ๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ์ด ์—†์Šต๋‹ˆ๋‹ค. ์žฌ๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."); + } + + Token storedToken = jwtFactory.parseToken(storedTokenDTO.value()) + .orElseThrow(() -> new TokenBadRequestException("์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ์ž…๋‹ˆ๋‹ค.")); + + if (!storedToken.isSameValue(token.getValue())) { + tokenRepository.removeToken(userId, deviceType); + throw new TokenStolenException("๋ฆฌํ”„๋ ˆ์‹œ ํ† ํฐ์ด ์ผ์น˜ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ํ† ํฐ ํƒˆ์ทจ ๊ฐ€๋Šฅ์„ฑ."); + } + + tokenRepository.getLastRefreshTime(userId, deviceType) + .ifPresent(this::checkRefreshInterval); + } + + private void checkRefreshInterval(long lastRefreshTime) { + long currentTime = System.currentTimeMillis(); + if ((currentTime - lastRefreshTime) < REFRESH_RATE_LIMIT_MILLISECONDS) { + throw new TokenConflictException("1๋ถ„ ์ด๋‚ด์— ์ด๋ฏธ ์žฌ๋ฐœ๊ธ‰ ์š”์ฒญ์ด ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค."); + } + } + + public void validateAccessToken(Token token) { + if (!token.isAccessToken()) { + throw new TokenBadRequestException("์•ก์„ธ์Šค ํ† ํฐ์ด ์•„๋‹™๋‹ˆ๋‹ค."); + } + + if (token.isExpired()) { + throw new TokenExpiredException("์•ก์„ธ์Šค ํ† ํฐ์ด ๋งŒ๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."); + } + } +} diff --git a/src/main/java/com/knu/ddip/auth/domain/AuthUser.java b/src/main/java/com/knu/ddip/auth/domain/AuthUser.java new file mode 100644 index 0000000..c279b26 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/domain/AuthUser.java @@ -0,0 +1,17 @@ +package com.knu.ddip.auth.domain; + +import lombok.Builder; +import lombok.Getter; + +import java.util.UUID; + +//TODO: ์ถ”ํ›„ email, role ๋“ฑ ์ถ”๊ฐ€ ๊ณ ๋ ค +@Getter +@Builder +public class AuthUser { + private UUID id; + + public static AuthUser from(Token token) { + return new AuthUser(UUID.fromString(token.getSubject())); + } +} diff --git a/src/main/java/com/knu/ddip/auth/domain/DeviceType.java b/src/main/java/com/knu/ddip/auth/domain/DeviceType.java new file mode 100644 index 0000000..3a661fd --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/domain/DeviceType.java @@ -0,0 +1,19 @@ +package com.knu.ddip.auth.domain; + +import com.knu.ddip.auth.exception.OAuthBadRequestException; + +public enum DeviceType { + TABLET, PHONE; + + public static DeviceType fromString(String value) { + if (value == null || value.isEmpty()) { + throw new OAuthBadRequestException("state๊ฐ’์ด ์—†์Šต๋‹ˆ๋‹ค."); + } + + try { + return DeviceType.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new OAuthBadRequestException("์˜ฌ๋ฐ”๋ฅธ DeviceType์ด ์•„๋‹™๋‹ˆ๋‹ค."); + } + } +} diff --git a/src/main/java/com/knu/ddip/auth/domain/OAuthMapping.java b/src/main/java/com/knu/ddip/auth/domain/OAuthMapping.java new file mode 100644 index 0000000..e1913ed --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/domain/OAuthMapping.java @@ -0,0 +1,76 @@ +package com.knu.ddip.auth.domain; + +import com.knu.ddip.auth.business.dto.OAuthMappingEntityDto; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.util.UUID; + +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class OAuthMapping { + private final UUID id; + private final String socialUserId; + private final String socialUserEmail; + private final String socialUserName; + private final OAuthProvider provider; + private UUID userId; + private OAuthToken oauthToken; + private boolean temporary; + + public static OAuthMapping fromDto(OAuthMappingEntityDto dto) { + return OAuthMapping.builder() + .id(dto.getId()) + .socialUserId(dto.getSocialUserId()) + .socialUserEmail(dto.getSocialUserEmail()) + .socialUserName(dto.getSocialUserName()) + .provider(dto.getProvider()) + .userId(dto.getUserId()) + .oauthToken(dto.getOauthToken()) + .temporary(dto.isTemporary()) + .build(); + } + + public static OAuthMapping createTemporary(String providerId, String providerEmail, + String providerName, OAuthProvider provider, OAuthToken oauthToken) { + return OAuthMapping.builder() + .socialUserId(providerId) + .socialUserEmail(providerEmail) + .socialUserName(providerName) + .provider(provider) + .oauthToken(oauthToken) + .temporary(true) + .build(); + } + + public OAuthMapping linkToUser(UUID userId) { + OAuthMapping linked = OAuthMapping.builder() + .id(id) + .socialUserId(socialUserId) + .socialUserEmail(socialUserEmail) + .socialUserName(socialUserName) + .provider(provider) + .userId(userId) + .oauthToken(oauthToken) + .temporary(false) + .build(); + + return linked; + } + + public OAuthMappingEntityDto toDto() { + return OAuthMappingEntityDto.create( + id, + socialUserId, + socialUserEmail, + socialUserName, + provider, + userId, + oauthToken, + temporary + ); + } +} diff --git a/src/main/java/com/knu/ddip/auth/domain/OAuthProvider.java b/src/main/java/com/knu/ddip/auth/domain/OAuthProvider.java new file mode 100644 index 0000000..b160516 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/domain/OAuthProvider.java @@ -0,0 +1,20 @@ +package com.knu.ddip.auth.domain; + +import com.knu.ddip.auth.exception.OAuthBadRequestException; + +public enum OAuthProvider { + KAKAO, APPLE, + UNSUPPORTED; + + public static OAuthProvider fromString(String value) { + if (value == null || value.isEmpty()) { + throw new OAuthBadRequestException("Provider ๊ฐ’์ด ์—†์Šต๋‹ˆ๋‹ค."); + } + + try { + return OAuthProvider.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new OAuthBadRequestException("์˜ฌ๋ฐ”๋ฅธ Provider๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค."); + } + } +} diff --git a/src/main/java/com/knu/ddip/auth/domain/OAuthToken.java b/src/main/java/com/knu/ddip/auth/domain/OAuthToken.java new file mode 100644 index 0000000..f438d70 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/domain/OAuthToken.java @@ -0,0 +1,50 @@ +package com.knu.ddip.auth.domain; + +import com.knu.ddip.auth.exception.OAuthErrorException; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class OAuthToken { + private final OAuthProvider provider; + private final String accessToken; + private final String refreshToken; + private final Long expiresIn; + private final LocalDateTime issuedAt; + + public static OAuthToken ofKakao(String accessToken, String refreshToken, Long expiresIn) { + if (accessToken == null) { + throw new OAuthErrorException("์‘๋‹ต์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์•„ accessToken์ด ์ „๋‹ฌ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."); + } + + return OAuthToken.builder() + .provider(OAuthProvider.KAKAO) + .accessToken(accessToken) + .refreshToken(refreshToken) + .expiresIn(expiresIn) + .issuedAt(LocalDateTime.now()) + .build(); + } + + public static OAuthToken create(OAuthProvider provider, String accessToken, String refreshToken, + Long expiresIn) { + + if (accessToken == null) { + return null; + } + + return OAuthToken.builder() + .provider(provider) + .accessToken(accessToken) + .refreshToken(refreshToken) + .expiresIn(expiresIn) + .issuedAt(LocalDateTime.now()) + .build(); + } +} diff --git a/src/main/java/com/knu/ddip/auth/domain/OAuthUserInfo.java b/src/main/java/com/knu/ddip/auth/domain/OAuthUserInfo.java new file mode 100644 index 0000000..15c5e00 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/domain/OAuthUserInfo.java @@ -0,0 +1,32 @@ +package com.knu.ddip.auth.domain; + +import com.knu.ddip.auth.exception.OAuthErrorException; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class OAuthUserInfo { + private String socialUserId; + private String email; + private String name; + private OAuthProvider provider; + private OAuthToken oAuthToken; + + public static OAuthUserInfo create(String socialUserId, String email, String name, + OAuthProvider provider, OAuthToken oauthToken) { + + if (socialUserId == null) { + throw new OAuthErrorException("์‘๋‹ต์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์•„ socialUserId๊ฐ€ ์ „๋‹ฌ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."); + } + + return OAuthUserInfo.builder() + .socialUserId(socialUserId) + .email(email) + .name(name == null ? "Unknown" : name) + .provider(provider) + .oAuthToken(oauthToken) + .build(); + } +} diff --git a/src/main/java/com/knu/ddip/auth/domain/Token.java b/src/main/java/com/knu/ddip/auth/domain/Token.java new file mode 100644 index 0000000..fec6899 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/domain/Token.java @@ -0,0 +1,43 @@ +package com.knu.ddip.auth.domain; + +import com.knu.ddip.auth.business.dto.TokenDTO; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Date; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Token { + private final TokenType type; + private final String value; + private final String subject; + private final Date issueAt; + private final Date expiration; + + public static Token of(TokenType type, String value, String subject, Date issueAt, + Date expiration) { + return new Token(type, value, subject, issueAt, expiration); + } + + public boolean isExpired() { + return expiration.before(new Date()); + } + + public boolean isAccessToken() { + return this.type.equals(TokenType.ACCESS); + } + + public boolean isRefreshToken() { + return this.type.equals(TokenType.REFRESH); + } + + public boolean isSameValue(String value) { + return this.value.equals(value); + } + + public TokenDTO toTokenDTO() { + return TokenDTO.from(this.value); + } +} diff --git a/src/main/java/com/knu/ddip/auth/domain/TokenType.java b/src/main/java/com/knu/ddip/auth/domain/TokenType.java new file mode 100644 index 0000000..5a7c734 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/domain/TokenType.java @@ -0,0 +1,6 @@ +package com.knu.ddip.auth.domain; + +//TODO: ์ถ”ํ›„ KAKAO ์ถ”๊ฐ€ ์˜ˆ์ • +public enum TokenType { + ACCESS, REFRESH +} diff --git a/src/main/java/com/knu/ddip/auth/exception/OAuthBadRequestException.java b/src/main/java/com/knu/ddip/auth/exception/OAuthBadRequestException.java new file mode 100644 index 0000000..b70ba2a --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/exception/OAuthBadRequestException.java @@ -0,0 +1,7 @@ +package com.knu.ddip.auth.exception; + +public class OAuthBadRequestException extends RuntimeException { + public OAuthBadRequestException(String message) { + super(message); + } +} diff --git a/src/main/java/com/knu/ddip/auth/exception/OAuthErrorException.java b/src/main/java/com/knu/ddip/auth/exception/OAuthErrorException.java new file mode 100644 index 0000000..6155b9c --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/exception/OAuthErrorException.java @@ -0,0 +1,7 @@ +package com.knu.ddip.auth.exception; + +public class OAuthErrorException extends RuntimeException { + public OAuthErrorException(String message) { + super(message); + } +} diff --git a/src/main/java/com/knu/ddip/auth/exception/OAuthNotFoundException.java b/src/main/java/com/knu/ddip/auth/exception/OAuthNotFoundException.java new file mode 100644 index 0000000..471d76f --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/exception/OAuthNotFoundException.java @@ -0,0 +1,7 @@ +package com.knu.ddip.auth.exception; + +public class OAuthNotFoundException extends RuntimeException { + public OAuthNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/knu/ddip/auth/exception/TokenBadRequestException.java b/src/main/java/com/knu/ddip/auth/exception/TokenBadRequestException.java new file mode 100644 index 0000000..5c44cf8 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/exception/TokenBadRequestException.java @@ -0,0 +1,7 @@ +package com.knu.ddip.auth.exception; + +public class TokenBadRequestException extends RuntimeException { + public TokenBadRequestException(String message) { + super(message); + } +} diff --git a/src/main/java/com/knu/ddip/auth/exception/TokenConflictException.java b/src/main/java/com/knu/ddip/auth/exception/TokenConflictException.java new file mode 100644 index 0000000..cd2945a --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/exception/TokenConflictException.java @@ -0,0 +1,7 @@ +package com.knu.ddip.auth.exception; + +public class TokenConflictException extends RuntimeException { + public TokenConflictException(String message) { + super(message); + } +} diff --git a/src/main/java/com/knu/ddip/auth/exception/TokenExpiredException.java b/src/main/java/com/knu/ddip/auth/exception/TokenExpiredException.java new file mode 100644 index 0000000..fb2784f --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/exception/TokenExpiredException.java @@ -0,0 +1,7 @@ +package com.knu.ddip.auth.exception; + +public class TokenExpiredException extends RuntimeException { + public TokenExpiredException(String message) { + super(message); + } +} diff --git a/src/main/java/com/knu/ddip/auth/exception/TokenStolenException.java b/src/main/java/com/knu/ddip/auth/exception/TokenStolenException.java new file mode 100644 index 0000000..4950917 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/exception/TokenStolenException.java @@ -0,0 +1,7 @@ +package com.knu.ddip.auth.exception; + +public class TokenStolenException extends RuntimeException { + public TokenStolenException(String message) { + super(message); + } +} diff --git a/src/main/java/com/knu/ddip/auth/infrastructure/entity/OAuthMappingEntity.java b/src/main/java/com/knu/ddip/auth/infrastructure/entity/OAuthMappingEntity.java new file mode 100644 index 0000000..5aa0a41 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/infrastructure/entity/OAuthMappingEntity.java @@ -0,0 +1,121 @@ +package com.knu.ddip.auth.infrastructure.entity; + +import com.knu.ddip.auth.business.dto.OAuthMappingEntityDto; +import com.knu.ddip.auth.business.dto.OAuthTokenDto; +import com.knu.ddip.auth.domain.OAuthProvider; +import com.knu.ddip.auth.domain.OAuthToken; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.UuidGenerator; +import org.hibernate.type.SqlTypes; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "OAUTH_MAPPING") +public class OAuthMappingEntity { + @Id + @UuidGenerator + @Column(columnDefinition = "char(36)", updatable = false, nullable = false) + @JdbcTypeCode(SqlTypes.CHAR) + private UUID id; + + @Column(nullable = false) + private String socialUserId; + + private String socialUserEmail; + + private String socialUserName; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private OAuthProvider provider; + + @Column(columnDefinition = "char(36)") + @JdbcTypeCode(SqlTypes.CHAR) + private UUID userId; + + private String accessToken; + + private String refreshToken; + + private Long expiresIn; + + private LocalDateTime tokenIssuedAt; + + @Column(nullable = false) + private boolean temporary; + + public static OAuthMappingEntity fromEntityDto(OAuthMappingEntityDto dto) { + OAuthMappingEntity entity = OAuthMappingEntity.builder() + .id(dto.getId()) + .socialUserId(dto.getSocialUserId()) + .socialUserEmail(dto.getSocialUserEmail()) + .socialUserName(dto.getSocialUserName()) + .provider(dto.getProvider()) + .userId(dto.getUserId()) + .temporary(dto.isTemporary()) + .build(); + + OAuthToken oauthToken = dto.getOauthToken(); + if (oauthToken != null) { + entity.accessToken = oauthToken.getAccessToken(); + entity.refreshToken = oauthToken.getRefreshToken(); + entity.expiresIn = oauthToken.getExpiresIn(); + entity.tokenIssuedAt = oauthToken.getIssuedAt(); + } + + return entity; + } + + public OAuthMappingEntityDto toEntityDto() { + OAuthToken oauthToken = OAuthToken.create( + provider, accessToken, refreshToken, expiresIn + ); + + return OAuthMappingEntityDto.create( + id, + socialUserId, + socialUserEmail, + socialUserName, + provider, + userId, + oauthToken, + temporary + ); + } + + public void updateFromDto(OAuthMappingEntityDto dto) { + if (dto.getUserId() != null) { + this.userId = dto.getUserId(); + } + + if (dto.getOauthToken() != null) { + OAuthToken token = dto.getOauthToken(); + this.accessToken = token.getAccessToken(); + this.refreshToken = token.getRefreshToken(); + this.expiresIn = token.getExpiresIn(); + this.tokenIssuedAt = token.getIssuedAt(); + } + + if (dto.getSocialUserEmail() != null) { + this.socialUserEmail = dto.getSocialUserEmail(); + } + if (dto.getSocialUserName() != null) { + this.socialUserName = dto.getSocialUserName(); + } + this.temporary = dto.isTemporary(); + } + + public void updateFromTokenDto(OAuthTokenDto dto) { + this.accessToken = dto.accessToken(); + this.refreshToken = dto.refreshToken(); + this.expiresIn = dto.expiresIn(); + } +} diff --git a/src/main/java/com/knu/ddip/auth/infrastructure/repository/OAuthMappingJpaRepository.java b/src/main/java/com/knu/ddip/auth/infrastructure/repository/OAuthMappingJpaRepository.java new file mode 100644 index 0000000..7cbb8bf --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/infrastructure/repository/OAuthMappingJpaRepository.java @@ -0,0 +1,18 @@ +package com.knu.ddip.auth.infrastructure.repository; + +import com.knu.ddip.auth.domain.OAuthProvider; +import com.knu.ddip.auth.infrastructure.entity.OAuthMappingEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface OAuthMappingJpaRepository extends JpaRepository { + + Optional findBySocialUserIdAndProvider(String socialUserId, + OAuthProvider provider); + + Optional findByIdAndProvider(UUID id, OAuthProvider provider); +} diff --git a/src/main/java/com/knu/ddip/auth/infrastructure/repository/OAuthRepositoryImpl.java b/src/main/java/com/knu/ddip/auth/infrastructure/repository/OAuthRepositoryImpl.java new file mode 100644 index 0000000..7c4b185 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/infrastructure/repository/OAuthRepositoryImpl.java @@ -0,0 +1,59 @@ +package com.knu.ddip.auth.infrastructure.repository; + +import com.knu.ddip.auth.business.dto.OAuthMappingEntityDto; +import com.knu.ddip.auth.business.dto.OAuthTokenDto; +import com.knu.ddip.auth.business.service.oauth.OAuthRepository; +import com.knu.ddip.auth.domain.OAuthProvider; +import com.knu.ddip.auth.exception.OAuthNotFoundException; +import com.knu.ddip.auth.infrastructure.entity.OAuthMappingEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +@RequiredArgsConstructor +public class OAuthRepositoryImpl implements OAuthRepository { + + private final OAuthMappingJpaRepository oAuthMappingJpaRepository; + + @Override + public OAuthMappingEntityDto save(OAuthMappingEntityDto entityDto) { + OAuthMappingEntity entity = OAuthMappingEntity.fromEntityDto(entityDto); + OAuthMappingEntity oAuthMappingEntity = oAuthMappingJpaRepository.save(entity); + return oAuthMappingEntity.toEntityDto(); + } + + @Override + public Optional findBySocialUserIdAndProvider(String socialUserId, + OAuthProvider provider) { + return oAuthMappingJpaRepository.findBySocialUserIdAndProvider(socialUserId, provider) + .map(OAuthMappingEntity::toEntityDto); + } + + @Override + public Optional findByOauthMappingEntityIdAndProvider( + UUID OauthMappingEntityId, OAuthProvider provider) { + return oAuthMappingJpaRepository.findByIdAndProvider(OauthMappingEntityId, provider) + .map(OAuthMappingEntity::toEntityDto); + } + + @Override + public void update(OAuthMappingEntityDto entityDto) { + OAuthMappingEntity existingEntity = oAuthMappingJpaRepository.findById(entityDto.getId()) + .orElseThrow(() -> new OAuthNotFoundException( + "์—…๋ฐ์ดํŠธํ•  OAuth ๋งคํ•‘์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: " + entityDto.getId())); + + existingEntity.updateFromDto(entityDto); + } + + @Override + public void updateToken(UUID mappingId, OAuthTokenDto oauthTokenDto) { + OAuthMappingEntity existingEntity = oAuthMappingJpaRepository.findById(mappingId) + .orElseThrow(() -> new OAuthNotFoundException( + "์—…๋ฐ์ดํŠธํ•  OAuth ๋งคํ•‘์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: " + mappingId)); + + existingEntity.updateFromTokenDto(oauthTokenDto); + } +} diff --git a/src/main/java/com/knu/ddip/auth/infrastructure/repository/RedisTokenRepositoryImpl.java b/src/main/java/com/knu/ddip/auth/infrastructure/repository/RedisTokenRepositoryImpl.java new file mode 100644 index 0000000..88ed00f --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/infrastructure/repository/RedisTokenRepositoryImpl.java @@ -0,0 +1,67 @@ +package com.knu.ddip.auth.infrastructure.repository; + +import com.knu.ddip.auth.business.dto.TokenDTO; +import com.knu.ddip.auth.business.service.TokenRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static com.knu.ddip.auth.business.service.JwtFactory.REFRESH_TOKEN_VALIDITY_MILLISECONDS; + +@Component +@RequiredArgsConstructor +public class RedisTokenRepositoryImpl implements TokenRepository { + private static final String TOKEN_KEY_PREFIX = "refreshToken:"; + private static final String REFRESH_TIME_KEY_PREFIX = "refresh-time:"; + private static final long REFRESH_REQUEST_INTERVAL_LIMIT = 60 * 1000L; + + private final RedisTemplate redisTemplate; + + @Override + public void saveToken(UUID userId, String deviceType, TokenDTO tokenDTO) { + String key = generateKey(userId, deviceType); + redisTemplate.opsForValue() + .set(key, tokenDTO.value(), REFRESH_TOKEN_VALIDITY_MILLISECONDS, TimeUnit.MILLISECONDS); + updateLastRefreshTime(userId, deviceType); + } + + @Override + public TokenDTO findToken(UUID userId, String deviceType) { + String key = generateKey(userId, deviceType); + String tokenValue = redisTemplate.opsForValue().get(key); + return TokenDTO.from(tokenValue); + } + + @Override + public void removeToken(UUID userId, String deviceType) { + String key = generateKey(userId, deviceType); + redisTemplate.delete(key); + } + + @Override + public Optional getLastRefreshTime(UUID userId, String deviceType) { + String timeKey = generateTimeKey(userId, deviceType); + String value = redisTemplate.opsForValue().get(timeKey); + return Optional.ofNullable(value).map(Long::valueOf); + } + + @Override + public void updateLastRefreshTime(UUID userId, String deviceType) { + String timeKey = generateTimeKey(userId, deviceType); + redisTemplate.opsForValue() + .set(timeKey, String.valueOf(System.currentTimeMillis()), + REFRESH_REQUEST_INTERVAL_LIMIT, TimeUnit.MILLISECONDS); + } + + private String generateKey(UUID userId, String deviceType) { + return TOKEN_KEY_PREFIX + userId + ":" + deviceType; + } + + private String generateTimeKey(UUID userId, String deviceType) { + return REFRESH_TIME_KEY_PREFIX + userId + ":" + deviceType; + } +} diff --git a/src/main/java/com/knu/ddip/auth/presentation/annotation/Login.java b/src/main/java/com/knu/ddip/auth/presentation/annotation/Login.java new file mode 100644 index 0000000..4a75269 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/presentation/annotation/Login.java @@ -0,0 +1,11 @@ +package com.knu.ddip.auth.presentation.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface Login { +} diff --git a/src/main/java/com/knu/ddip/auth/presentation/annotation/RequireAuth.java b/src/main/java/com/knu/ddip/auth/presentation/annotation/RequireAuth.java new file mode 100644 index 0000000..8230afe --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/presentation/annotation/RequireAuth.java @@ -0,0 +1,12 @@ +package com.knu.ddip.auth.presentation.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequireAuth { + boolean optional() default false; +} diff --git a/src/main/java/com/knu/ddip/auth/presentation/api/OAuthApi.java b/src/main/java/com/knu/ddip/auth/presentation/api/OAuthApi.java new file mode 100644 index 0000000..ec49eab --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/presentation/api/OAuthApi.java @@ -0,0 +1,31 @@ +package com.knu.ddip.auth.presentation.api; + + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.view.RedirectView; + +@RequestMapping("/auth/oauth") +@Tag(name = "OAuth", description = "OAuth ๊ด€๋ จ API") +public interface OAuthApi { + + @GetMapping("/{provider}/login") + @Operation(summary = "OAuth ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€ ์ด๋™", + description = "provider๋ณ„ ๋กœ๊ทธ์ธ ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•œ๋‹ค. state๋Š” deviceType(WEB/APP)") + RedirectView redirectToOAuthLoginPage( + @PathVariable("provider") String provider, + @RequestParam(value = "state", required = false) String state); + + @GetMapping("/{provider}/callback") + @Operation(summary = "OAuth์šฉ ์ฝœ๋ฐฑ [์ง์ ‘ ์‚ฌ์šฉ ๊ธˆ์ง€]", + description = "provider ์ธก์—์„œ ์ฝœ๋ฐฑ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ๋กœ ์‚ฌ์šฉํ•  ์—”๋“œํฌ์ธํŠธ. ์ง์ ‘ ์‚ฌ์šฉ ๊ธˆ์ง€.") + ResponseEntity handleOAuthCallback( + @PathVariable("provider") String provider, + @RequestParam("code") String code, + @RequestParam(value = "state") String state); +} diff --git a/src/main/java/com/knu/ddip/auth/presentation/controller/OAuthController.java b/src/main/java/com/knu/ddip/auth/presentation/controller/OAuthController.java new file mode 100644 index 0000000..e4b3513 --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/presentation/controller/OAuthController.java @@ -0,0 +1,44 @@ +package com.knu.ddip.auth.presentation.controller; + +import com.knu.ddip.auth.business.service.OAuthLoginService; +import com.knu.ddip.auth.presentation.api.OAuthApi; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.view.RedirectView; + +import java.net.URI; + +@RestController +@RequiredArgsConstructor +public class OAuthController implements OAuthApi { + + private final OAuthLoginService oAuthLoginService; + + @Override + public RedirectView redirectToOAuthLoginPage( + @PathVariable("provider") String provider, + @RequestParam(value = "state", required = false) String state) { + + String redirectUrl = oAuthLoginService.getOAuthLoginUrl(provider, state); + + return new RedirectView(redirectUrl); + } + + @Override + public ResponseEntity handleOAuthCallback( + @PathVariable("provider") String provider, + @RequestParam("code") String code, + @RequestParam(value = "state") String state) { + + URI redirectInfo = oAuthLoginService.handleOAuthCallback(provider, code, state); + + HttpHeaders headers = new HttpHeaders(); + headers.setLocation(redirectInfo); + return new ResponseEntity<>(headers, HttpStatus.FOUND); + } +} diff --git a/src/main/java/com/knu/ddip/auth/presentation/interceptor/AuthInterceptor.java b/src/main/java/com/knu/ddip/auth/presentation/interceptor/AuthInterceptor.java new file mode 100644 index 0000000..e74358f --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/presentation/interceptor/AuthInterceptor.java @@ -0,0 +1,55 @@ +package com.knu.ddip.auth.presentation.interceptor; + +import com.knu.ddip.auth.business.service.JwtFactory; +import com.knu.ddip.auth.business.validator.JwtValidator; +import com.knu.ddip.auth.domain.Token; +import com.knu.ddip.auth.exception.TokenBadRequestException; +import com.knu.ddip.auth.presentation.annotation.RequireAuth; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +@RequiredArgsConstructor +public class AuthInterceptor implements HandlerInterceptor { + private static final String AUTH_TOKEN_ATTRIBUTE = "AUTH_TOKEN"; + private static final String BEARER_PREFIX = "Bearer "; + private final JwtFactory jwtFactory; + private final JwtValidator JWTValidator; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, + Object handler) { + if (!(handler instanceof HandlerMethod handlerMethod)) { + return true; + } + + boolean requiresAuth = handlerMethod.hasMethodAnnotation(RequireAuth.class) || + handlerMethod.getBeanType().isAnnotationPresent(RequireAuth.class); + + if (!requiresAuth) { + return true; + } + + String tokenValue = extractTokenFromHeader(request); + Token token = jwtFactory.parseToken(tokenValue) + .orElseThrow(() -> new TokenBadRequestException("์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ์ž…๋‹ˆ๋‹ค.")); + + JWTValidator.validateAccessToken(token); + + request.setAttribute(AUTH_TOKEN_ATTRIBUTE, token); + + return true; + } + + private String extractTokenFromHeader(HttpServletRequest request) { + String authHeader = request.getHeader("Authorization"); + if (authHeader == null || !authHeader.startsWith(BEARER_PREFIX)) { + throw new TokenBadRequestException("Authorization ํ—ค๋”๊ฐ€ ์—†๊ฑฐ๋‚˜ Bearer ํ˜•์‹์ด ์•„๋‹™๋‹ˆ๋‹ค."); + } + return authHeader.substring(BEARER_PREFIX.length()); + } +} diff --git a/src/main/java/com/knu/ddip/auth/presentation/resolver/AuthUserArgumentResolver.java b/src/main/java/com/knu/ddip/auth/presentation/resolver/AuthUserArgumentResolver.java new file mode 100644 index 0000000..19152aa --- /dev/null +++ b/src/main/java/com/knu/ddip/auth/presentation/resolver/AuthUserArgumentResolver.java @@ -0,0 +1,46 @@ +package com.knu.ddip.auth.presentation.resolver; + +import com.knu.ddip.auth.domain.AuthUser; +import com.knu.ddip.auth.domain.Token; +import com.knu.ddip.auth.exception.TokenBadRequestException; +import com.knu.ddip.auth.presentation.annotation.Login; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +@Component +@RequiredArgsConstructor +public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver { + private static final String AUTH_TOKEN_ATTRIBUTE = "AUTH_TOKEN"; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + boolean hasAuthenticatedUserAnnotation = parameter.hasMethodAnnotation(Login.class); + boolean hasAuthUserParameterType = parameter.getParameterType().equals(AuthUser.class); + + return hasAuthenticatedUserAnnotation && hasAuthUserParameterType; + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + Token token = (Token) request.getAttribute(AUTH_TOKEN_ATTRIBUTE); + + if (token == null) { + throw new TokenBadRequestException( + "์ธ์ฆ ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ธ์ฆ์ด ํ•„์š”ํ•œ ๋กœ์ง์ธ ๊ฒฝ์šฐ @requiresAuth ๋ฅผ ์ถ”๊ฐ€ํ•˜์„ธ์š”."); + } + + return buildAuthUserFromToken(token); + } + + private AuthUser buildAuthUserFromToken(Token token) { + return AuthUser.from(token); + } +} diff --git a/src/main/java/com/knu/ddip/common/config/RedisConfig.java b/src/main/java/com/knu/ddip/common/config/RedisConfig.java index 8037403..d4c4f51 100644 --- a/src/main/java/com/knu/ddip/common/config/RedisConfig.java +++ b/src/main/java/com/knu/ddip/common/config/RedisConfig.java @@ -1,13 +1,19 @@ package com.knu.ddip.common.config; +import io.lettuce.core.ClientOptions; +import io.lettuce.core.SocketOptions; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisPassword; import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.StringRedisSerializer; +import java.time.Duration; + @Configuration public class RedisConfig { @@ -19,27 +25,30 @@ public class RedisConfig { private String password; @Bean - LettuceConnectionFactory connectionFactory() { - RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(); - redisStandaloneConfiguration.setHostName(host); - redisStandaloneConfiguration.setPort(port); - redisStandaloneConfiguration.setPassword(password); - return new LettuceConnectionFactory( - redisStandaloneConfiguration); + public LettuceConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration serverConfig = new RedisStandaloneConfiguration(host, port); + serverConfig.setPassword(RedisPassword.of(password)); + + LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder() + .commandTimeout(Duration.ofMillis(500)) + .clientOptions(ClientOptions.builder() + .autoReconnect(true) + .socketOptions( + SocketOptions.builder().connectTimeout(Duration.ofMillis(1000)).build()) + .disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS) + .build()) + .build(); + + return new LettuceConnectionFactory(serverConfig, clientConfig); } @Bean - public RedisTemplate redisTemplate() { - RedisTemplate template = new RedisTemplate<>(); - template.setConnectionFactory(connectionFactory()); - - StringRedisSerializer stringSerializer = new StringRedisSerializer(); - template.setKeySerializer(stringSerializer); - template.setValueSerializer(stringSerializer); - template.setHashKeySerializer(stringSerializer); - template.setHashValueSerializer(stringSerializer); - - template.afterPropertiesSet(); - return template; + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + return redisTemplate; } + } diff --git a/src/main/java/com/knu/ddip/common/config/WebConfig.java b/src/main/java/com/knu/ddip/common/config/WebConfig.java index e2315a2..fabc814 100644 --- a/src/main/java/com/knu/ddip/common/config/WebConfig.java +++ b/src/main/java/com/knu/ddip/common/config/WebConfig.java @@ -2,6 +2,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; @@ -9,6 +10,12 @@ @Configuration public class WebConfig implements WebMvcConfigurer { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } + @Bean public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); diff --git a/src/main/java/com/knu/ddip/common/exception/GlobalExceptionHandler.java b/src/main/java/com/knu/ddip/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..982d1e4 --- /dev/null +++ b/src/main/java/com/knu/ddip/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,70 @@ +package com.knu.ddip.common.exception; + +import com.knu.ddip.auth.exception.*; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(OAuthBadRequestException.class) + public ResponseEntity handleOAuthBadRequestExceptionException(OAuthBadRequestException e) { + + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.getMessage()); + problemDetail.setTitle("OAuth Bad Request"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problemDetail); + } + + @ExceptionHandler(OAuthErrorException.class) + public ResponseEntity handleOAuthErrorExceptionException(OAuthErrorException e) { + + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage()); + problemDetail.setTitle("OAuth Error"); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(problemDetail); + } + + @ExceptionHandler(OAuthNotFoundException.class) + public ResponseEntity handleOAuthNotFoundExceptionException(OAuthNotFoundException e) { + + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage()); + problemDetail.setTitle("OAuth Not Found"); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problemDetail); + } + + @ExceptionHandler(TokenBadRequestException.class) + public ResponseEntity handleTokenBadRequestExceptionException(TokenBadRequestException e) { + + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, e.getMessage()); + problemDetail.setTitle("Token Bad Request"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problemDetail); + } + + @ExceptionHandler(TokenConflictException.class) + public ResponseEntity handleTokenConflictExceptionException(TokenConflictException e) { + + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage()); + problemDetail.setTitle("Token Conflict"); + return ResponseEntity.status(HttpStatus.CONFLICT).body(problemDetail); + } + + @ExceptionHandler(TokenExpiredException.class) + public ResponseEntity handleTokenExpiredException(TokenExpiredException e) { + + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatusCode.valueOf(461), e.getMessage()); + problemDetail.setTitle("Token Expired"); + return ResponseEntity.status(HttpStatusCode.valueOf(461)).body(problemDetail); + } + + @ExceptionHandler(TokenStolenException.class) + public ResponseEntity handleTokenStolenException(TokenStolenException e) { + + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatusCode.valueOf(462), e.getMessage()); + problemDetail.setTitle("Token Stolen"); + return ResponseEntity.status(HttpStatusCode.valueOf(462)).body(problemDetail); + } + +} diff --git a/src/main/java/com/knu/ddip/user/business/dto/SignupRequest.java b/src/main/java/com/knu/ddip/user/business/dto/SignupRequest.java new file mode 100644 index 0000000..98693a5 --- /dev/null +++ b/src/main/java/com/knu/ddip/user/business/dto/SignupRequest.java @@ -0,0 +1,27 @@ +package com.knu.ddip.user.business.dto; + +import com.knu.ddip.auth.domain.OAuthProvider; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +public record SignupRequest( + @NotBlank + @Email + String email, + + @NotBlank + String nickname, + + @NotBlank + String oAuthMappingEntityId, + + @NotNull + OAuthProvider provider, + + @NotBlank + String deviceType +) { +} diff --git a/src/main/java/com/knu/ddip/user/business/dto/UniqueMailResponse.java b/src/main/java/com/knu/ddip/user/business/dto/UniqueMailResponse.java new file mode 100644 index 0000000..ba9ad99 --- /dev/null +++ b/src/main/java/com/knu/ddip/user/business/dto/UniqueMailResponse.java @@ -0,0 +1,22 @@ +package com.knu.ddip.user.business.dto; + +public record UniqueMailResponse( + boolean isUnique, + String message +) { + public static UniqueMailResponse ofUnique() { + return new UniqueMailResponse(true, "์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค."); + } + + public static UniqueMailResponse ofDuplicate() { + return new UniqueMailResponse(false, "์‚ฌ์šฉ ์ค‘์ธ ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค."); + } + + public static UniqueMailResponse ofInActive() { + return new UniqueMailResponse(false, "ํœด๋ฉด ์œ ์ €์˜ ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค."); + } + + public static UniqueMailResponse ofWithDrawn() { + return new UniqueMailResponse(false, "ํƒˆํ‡ดํ•œ ์‚ฌ์šฉ์ž์˜ ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค."); + } +} diff --git a/src/main/java/com/knu/ddip/user/business/dto/UserEntityDto.java b/src/main/java/com/knu/ddip/user/business/dto/UserEntityDto.java new file mode 100644 index 0000000..ac33c02 --- /dev/null +++ b/src/main/java/com/knu/ddip/user/business/dto/UserEntityDto.java @@ -0,0 +1,41 @@ +package com.knu.ddip.user.business.dto; + +import com.knu.ddip.user.business.service.UserFactory; +import com.knu.ddip.user.domain.User; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.UUID; + +@Getter +@Builder(access = AccessLevel.PROTECTED) +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class UserEntityDto { + private final UUID id; + private final String email; + private final String nickname; + private final String status; + + public static UserEntityDto create(UUID id, String email, String nickname, String status) { + return UserEntityDto.builder() + .id(id) + .email(email) + .nickname(nickname) + .status(status) + .build(); + } + + public static UserEntityDto create(UUID id, String email, String status) { + return UserEntityDto.builder() + .id(id) + .email(email) + .status(status) + .build(); + } + + public User toDomain() { + return UserFactory.create(id, email, nickname, status); + } +} diff --git a/src/main/java/com/knu/ddip/user/business/service/UserFactory.java b/src/main/java/com/knu/ddip/user/business/service/UserFactory.java new file mode 100644 index 0000000..6f0b1b7 --- /dev/null +++ b/src/main/java/com/knu/ddip/user/business/service/UserFactory.java @@ -0,0 +1,18 @@ +package com.knu.ddip.user.business.service; + +import com.knu.ddip.user.domain.User; +import com.knu.ddip.user.domain.UserDomain; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Service +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class UserFactory { + + public static User create(UUID id, String email, String nickname, String status) { + return UserDomain.create(id, email, nickname, status); + } +} diff --git a/src/main/java/com/knu/ddip/user/business/service/UserRepository.java b/src/main/java/com/knu/ddip/user/business/service/UserRepository.java new file mode 100644 index 0000000..17e80d1 --- /dev/null +++ b/src/main/java/com/knu/ddip/user/business/service/UserRepository.java @@ -0,0 +1,20 @@ +package com.knu.ddip.user.business.service; + +import com.knu.ddip.user.business.dto.UserEntityDto; + +import java.util.Optional; +import java.util.UUID; + +public interface UserRepository { + UserEntityDto save(String email, String nickName, String status); + + void update(UserEntityDto userEntityDto); + + void delete(UUID userId); + + UserEntityDto getByEmail(String email); + + Optional findOptionalByEmail(String email); + + UserEntityDto getById(UUID userId); +} diff --git a/src/main/java/com/knu/ddip/user/business/service/UserService.java b/src/main/java/com/knu/ddip/user/business/service/UserService.java new file mode 100644 index 0000000..b91f5f3 --- /dev/null +++ b/src/main/java/com/knu/ddip/user/business/service/UserService.java @@ -0,0 +1,74 @@ +package com.knu.ddip.user.business.service; + +import com.knu.ddip.auth.business.dto.JwtResponse; +import com.knu.ddip.auth.business.service.OAuthLoginService; +import com.knu.ddip.auth.domain.DeviceType; +import com.knu.ddip.auth.exception.OAuthBadRequestException; +import com.knu.ddip.user.business.dto.SignupRequest; +import com.knu.ddip.user.business.dto.UniqueMailResponse; +import com.knu.ddip.user.business.dto.UserEntityDto; +import com.knu.ddip.user.domain.User; +import com.knu.ddip.user.domain.UserStatus; +import com.knu.ddip.user.exception.UserEmailDuplicateException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@Transactional +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final OAuthLoginService oAuthLoginService; + + public User getUserByEmail(String email) { + UserEntityDto userEntityDto = userRepository.getByEmail(email); + return userEntityDto.toDomain(); + } + + public UniqueMailResponse checkEmailUniqueness(String email) { + Optional optionalUser = userRepository.findOptionalByEmail(email); + + if (optionalUser.isEmpty()) { + return UniqueMailResponse.ofUnique(); + } + + UserEntityDto userEntityDto = optionalUser.get(); + User user = userEntityDto.toDomain(); + + if (user.isWithdrawn()) { + return UniqueMailResponse.ofWithDrawn(); + } else if (user.isInactive()) { + return UniqueMailResponse.ofInActive(); + } else { + return UniqueMailResponse.ofDuplicate(); + } + } + + @Transactional + public JwtResponse signUp(SignupRequest request) { + UniqueMailResponse uniqueCheck = checkEmailUniqueness(request.email()); + if (!uniqueCheck.isUnique()) { + throw new UserEmailDuplicateException(uniqueCheck.message()); + } + + UserEntityDto userEntityDto = userRepository.save(request.email(), request.nickname(), + UserStatus.ACTIVE.name()); + User user = userEntityDto.toDomain(); + + try { + return oAuthLoginService.linkOAuthWithUser( + user, + request.oAuthMappingEntityId(), + request.provider(), + DeviceType.fromString(request.deviceType()) + ); + } catch (OAuthBadRequestException e) { + userRepository.delete(user.getId()); + throw e; + } + } +} diff --git a/src/main/java/com/knu/ddip/user/domain/User.java b/src/main/java/com/knu/ddip/user/domain/User.java new file mode 100644 index 0000000..5a74c13 --- /dev/null +++ b/src/main/java/com/knu/ddip/user/domain/User.java @@ -0,0 +1,19 @@ +package com.knu.ddip.user.domain; + +import com.knu.ddip.user.business.dto.UserEntityDto; + +import java.util.UUID; + +public interface User { + UserEntityDto toEntityDto(); + + UUID getId(); + + String getEmail(); + + String getNickname(); + + boolean isWithdrawn(); + + boolean isInactive(); +} diff --git a/src/main/java/com/knu/ddip/user/domain/UserDomain.java b/src/main/java/com/knu/ddip/user/domain/UserDomain.java new file mode 100644 index 0000000..507cac2 --- /dev/null +++ b/src/main/java/com/knu/ddip/user/domain/UserDomain.java @@ -0,0 +1,40 @@ +package com.knu.ddip.user.domain; + +import com.knu.ddip.user.business.dto.UserEntityDto; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.UUID; + +import static com.knu.ddip.user.domain.UserStatus.INACTIVE; +import static com.knu.ddip.user.domain.UserStatus.WITHDRAWN; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class UserDomain implements User { + + private final UUID id; + private final String email; + private final String nickname; + private final String status; + + public static UserDomain create(UUID id, String email, String nickname, String status) { + return new UserDomain(id, email, nickname, status); + } + + @Override + public UserEntityDto toEntityDto() { + return UserEntityDto.create(id, email, nickname, status); + } + + @Override + public boolean isWithdrawn() { + return this.status.equals(WITHDRAWN.name()); + } + + @Override + public boolean isInactive() { + return this.status.equals(INACTIVE.name()); + } +} diff --git a/src/main/java/com/knu/ddip/user/domain/UserStatus.java b/src/main/java/com/knu/ddip/user/domain/UserStatus.java new file mode 100644 index 0000000..0d3ee1f --- /dev/null +++ b/src/main/java/com/knu/ddip/user/domain/UserStatus.java @@ -0,0 +1,7 @@ +package com.knu.ddip.user.domain; + +public enum UserStatus { + ACTIVE, + INACTIVE, + WITHDRAWN; +} diff --git a/src/main/java/com/knu/ddip/user/exception/UserEmailDuplicateException.java b/src/main/java/com/knu/ddip/user/exception/UserEmailDuplicateException.java new file mode 100644 index 0000000..8c92d0a --- /dev/null +++ b/src/main/java/com/knu/ddip/user/exception/UserEmailDuplicateException.java @@ -0,0 +1,7 @@ +package com.knu.ddip.user.exception; + +public class UserEmailDuplicateException extends RuntimeException { + public UserEmailDuplicateException(String message) { + super(message); + } +} diff --git a/src/main/java/com/knu/ddip/user/exception/UserNotFoundException.java b/src/main/java/com/knu/ddip/user/exception/UserNotFoundException.java new file mode 100644 index 0000000..1f95e7f --- /dev/null +++ b/src/main/java/com/knu/ddip/user/exception/UserNotFoundException.java @@ -0,0 +1,7 @@ +package com.knu.ddip.user.exception; + +public class UserNotFoundException extends RuntimeException { + public UserNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/knu/ddip/user/infrastructure/entity/UserEntity.java b/src/main/java/com/knu/ddip/user/infrastructure/entity/UserEntity.java new file mode 100644 index 0000000..48316e2 --- /dev/null +++ b/src/main/java/com/knu/ddip/user/infrastructure/entity/UserEntity.java @@ -0,0 +1,59 @@ +package com.knu.ddip.user.infrastructure.entity; + +import com.knu.ddip.user.business.dto.UserEntityDto; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.UuidGenerator; +import org.hibernate.type.SqlTypes; + +import java.util.UUID; + +@Entity +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "USERS") +public class UserEntity { + @Id + @UuidGenerator + @Column(columnDefinition = "char(36)", updatable = false, nullable = false) + @JdbcTypeCode(SqlTypes.CHAR) + private UUID id; + + @Column(unique = true) + private String email; + + private String nickName; + + @Enumerated(EnumType.STRING) + private UserEntityStatus status; + + public static UserEntity create(String email, String nickName, String status) { + return UserEntity.builder() + .email(email) + .nickName(nickName) + .status(UserEntityStatus.valueOf(status)) + .build(); + } + + public static UserEntity create(UUID id, String email, String nickName, String status) { + return UserEntity.builder() + .id(id) + .email(email) + .nickName(nickName) + .status(UserEntityStatus.valueOf(status)) + .build(); + } + + public void update(UserEntityDto userEntityDto) { + this.email = userEntityDto.getEmail(); + this.nickName = userEntityDto.getNickname(); + this.status = UserEntityStatus.valueOf(userEntityDto.getStatus()); + } + + public UserEntityDto toEntityDto() { + return UserEntityDto.create(id, email, nickName, status.name()); + } +} diff --git a/src/main/java/com/knu/ddip/user/infrastructure/entity/UserEntityStatus.java b/src/main/java/com/knu/ddip/user/infrastructure/entity/UserEntityStatus.java new file mode 100644 index 0000000..ad24e4d --- /dev/null +++ b/src/main/java/com/knu/ddip/user/infrastructure/entity/UserEntityStatus.java @@ -0,0 +1,7 @@ +package com.knu.ddip.user.infrastructure.entity; + +public enum UserEntityStatus { + ACTIVE, + INACTIVE, + WITHDRAWN; +} diff --git a/src/main/java/com/knu/ddip/user/infrastructure/repository/UserJpaRepository.java b/src/main/java/com/knu/ddip/user/infrastructure/repository/UserJpaRepository.java new file mode 100644 index 0000000..e825369 --- /dev/null +++ b/src/main/java/com/knu/ddip/user/infrastructure/repository/UserJpaRepository.java @@ -0,0 +1,13 @@ +package com.knu.ddip.user.infrastructure.repository; + +import com.knu.ddip.user.infrastructure.entity.UserEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface UserJpaRepository extends JpaRepository { + Optional findByEmail(String email); +} diff --git a/src/main/java/com/knu/ddip/user/infrastructure/repository/UserRepositoryImpl.java b/src/main/java/com/knu/ddip/user/infrastructure/repository/UserRepositoryImpl.java new file mode 100644 index 0000000..c32ba77 --- /dev/null +++ b/src/main/java/com/knu/ddip/user/infrastructure/repository/UserRepositoryImpl.java @@ -0,0 +1,62 @@ +package com.knu.ddip.user.infrastructure.repository; + +import com.knu.ddip.user.business.dto.UserEntityDto; +import com.knu.ddip.user.business.service.UserRepository; +import com.knu.ddip.user.exception.UserNotFoundException; +import com.knu.ddip.user.infrastructure.entity.UserEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +@RequiredArgsConstructor +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository userJpaRepository; + + @Override + public UserEntityDto save(String email, String nickName, String status) { + UserEntity user = UserEntity.create(email, nickName, status); + UserEntity savedUser = userJpaRepository.save(user); + return savedUser.toEntityDto(); + } + + @Override + public void update(UserEntityDto userEntityDto) { + UserEntity user = userJpaRepository.findById(userEntityDto.getId()) + .orElseThrow(() -> new UserNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + user.update(userEntityDto); + } + + @Override + public void delete(UUID userId) { + UserEntity user = userJpaRepository.findById(userId) + .orElseThrow(() -> new UserNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + userJpaRepository.delete(user); + } + + public UserEntityDto getByEmail(String email) { + UserEntity user = userJpaRepository.findByEmail(email) + .orElseThrow(() -> new UserNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + return user.toEntityDto(); + } + + @Override + public Optional findOptionalByEmail(String email) { + return userJpaRepository.findByEmail(email) + .map(UserEntity::toEntityDto); + } + + @Override + public UserEntityDto getById(UUID userId) { + UserEntity user = userJpaRepository.findById(userId) + .orElseThrow(() -> new UserNotFoundException("์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")); + + return user.toEntityDto(); + } +} diff --git a/src/main/java/com/knu/ddip/user/presentation/api/UserApi.java b/src/main/java/com/knu/ddip/user/presentation/api/UserApi.java new file mode 100644 index 0000000..d6a4d83 --- /dev/null +++ b/src/main/java/com/knu/ddip/user/presentation/api/UserApi.java @@ -0,0 +1,27 @@ +package com.knu.ddip.user.presentation.api; + +import com.knu.ddip.auth.business.dto.JwtResponse; +import com.knu.ddip.user.business.dto.SignupRequest; +import com.knu.ddip.user.business.dto.UniqueMailResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "์œ ์ €", description = "์œ ์ € ๊ด€๋ จ API") +@RequestMapping("/api") +public interface UserApi { + + @PostMapping("/signup") + @Operation(summary = "ํšŒ์› ๊ฐ€์ž…", + description = "Oauth ๋กœ์ง ํ†ต๊ณผ ํ›„ ๋งค์นญ๋˜๋Š” ์œ ์ €๊ฐ€ ์—†์„ ์‹œ ํšŒ์›๊ฐ€์ž…์„ ์ง„ํ–‰ํ•œ๋‹ค.") + ResponseEntity signup( + @Valid @RequestBody SignupRequest request); + + @GetMapping("/my/mail") + @Operation(summary = "๋ฉ”์ผ ์ค‘๋ณต์กฐํšŒ", + description = "๋ฉ”์ผ์ด ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ์ง€ ์กฐํšŒํ•œ๋‹ค. ํœด๋ฉด์œ ์ €/ํƒˆํ‡ดํ•œ ์œ ์ €์˜ ๋ฉ”์ผ๋„ ์‚ฌ์šฉ ๋ถˆ๊ฐ€.") + ResponseEntity checkEmailUniqueness( + @RequestParam("v") String email); +} diff --git a/src/main/java/com/knu/ddip/user/presentation/controller/UserController.java b/src/main/java/com/knu/ddip/user/presentation/controller/UserController.java new file mode 100644 index 0000000..10f5da3 --- /dev/null +++ b/src/main/java/com/knu/ddip/user/presentation/controller/UserController.java @@ -0,0 +1,40 @@ +package com.knu.ddip.user.presentation.controller; + +import com.knu.ddip.auth.business.dto.JwtResponse; +import com.knu.ddip.user.business.dto.SignupRequest; +import com.knu.ddip.user.business.dto.UniqueMailResponse; +import com.knu.ddip.user.business.service.UserService; +import com.knu.ddip.user.presentation.api.UserApi; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class UserController implements UserApi { + + private final UserService userService; + + @Override + public ResponseEntity signup( + @Valid @RequestBody SignupRequest request) { + JwtResponse jwtResponse = userService.signUp(request); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(jwtResponse); + } + + @Override + public ResponseEntity checkEmailUniqueness( + @RequestParam("v") String email) { + UniqueMailResponse result = userService.checkEmailUniqueness(email); + return ResponseEntity.status(HttpStatus.ACCEPTED).body(result); + } + + //TODO: ๋กœ๊ทธ์•„์›ƒ + //TODO: ํšŒ์› ํƒˆํ‡ด + //TODO: user ์ •๋ณด ์ˆ˜์ • + //TODO: ์œ ์ € ์ •๋ณด ๋“ฑ๋ก ์—ฌ๋ถ€ ํ™•์ธ +} diff --git a/src/test/java/com/knu/ddip/DBTest/MySQLForTestConnectionTest.java b/src/test/java/com/knu/ddip/DBTest/MySQLForTestConnectionTest.java new file mode 100644 index 0000000..6f9f8b6 --- /dev/null +++ b/src/test/java/com/knu/ddip/DBTest/MySQLForTestConnectionTest.java @@ -0,0 +1,38 @@ +package com.knu.ddip.DBTest; + +import com.knu.ddip.config.MySQLTestConfig; +import com.knu.ddip.config.MySQLTestContainerConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.SQLException; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@ExtendWith(MySQLTestContainerConfig.class) +@Import(MySQLTestConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class MySQLForTestConnectionTest { + + @Autowired + private DataSource dataSource; + + @Test + public void testDatabaseConnection() throws SQLException { + try (Connection connection = dataSource.getConnection()) { + assertThat(connection).isNotNull(); + assertThat(connection.isValid(1)).isTrue(); + + String url = connection.getMetaData().getURL(); + System.out.println("Database URL: " + url); + assertThat(url).contains("jdbc:mysql://"); + } + } +} diff --git a/src/test/java/com/knu/ddip/DBTest/MysqlConnectionTest.java b/src/test/java/com/knu/ddip/DBTest/MysqlConnectionTest.java deleted file mode 100644 index 19d4c36..0000000 --- a/src/test/java/com/knu/ddip/DBTest/MysqlConnectionTest.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.knu.ddip.DBTest; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import javax.sql.DataSource; -import java.sql.Connection; -import java.sql.Statement; - -@SpringBootTest -public class MysqlConnectionTest { - - @Autowired - private DataSource dataSource; - - @Test - void mysqlConnectionTest() throws Exception { - try (Connection connection = dataSource.getConnection(); - Statement stmt = connection.createStatement()) { - - Assertions.assertFalse(connection.isClosed(), "MySQL ์—ฐ๊ฒฐ ์‹คํŒจ"); - System.out.println("โœ… MySQL ์—ฐ๊ฒฐ ์„ฑ๊ณต: " + connection.getMetaData().getURL()); - } - } -} diff --git a/src/test/java/com/knu/ddip/DBTest/RedisConnectionTest.java b/src/test/java/com/knu/ddip/DBTest/RedisConnectionTest.java deleted file mode 100644 index 5bedefd..0000000 --- a/src/test/java/com/knu/ddip/DBTest/RedisConnectionTest.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.knu.ddip.DBTest; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.data.redis.core.StringRedisTemplate; - -@SpringBootTest -public class RedisConnectionTest { - - @Autowired - private StringRedisTemplate redisTemplate; - - @Test - void redisConnectionTest() { - redisTemplate.opsForValue().set("connectionTest", "success"); - String result = redisTemplate.opsForValue().get("connectionTest"); - - Assertions.assertEquals("success", result, "Redis ์—ฐ๊ฒฐ ์‹คํŒจ"); - System.out.println("Redis ์—ฐ๊ฒฐ ์„ฑ๊ณต: " + result); - - redisTemplate.delete("connectionTest"); - } -} diff --git a/src/test/java/com/knu/ddip/DBTest/RedisForTestConnectionTest.java b/src/test/java/com/knu/ddip/DBTest/RedisForTestConnectionTest.java new file mode 100644 index 0000000..6b3bf20 --- /dev/null +++ b/src/test/java/com/knu/ddip/DBTest/RedisForTestConnectionTest.java @@ -0,0 +1,75 @@ +package com.knu.ddip.DBTest; + +import com.knu.ddip.config.RedisTestConfig; +import com.knu.ddip.config.RedisTestContainerConfig; +import com.knu.ddip.config.TestEnvironmentConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.core.RedisTemplate; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataRedisTest +@ExtendWith({RedisTestContainerConfig.class, TestEnvironmentConfig.class}) +@Import(RedisTestConfig.class) +class RedisForTestConnectionTest { + + @Autowired + private RedisTemplate redisTemplate; + + @Test + public void testRedisConnection() { + // Given + String testKey = "test:connection"; + String testValue = "connection-successful"; + + // When + redisTemplate.opsForValue().set(testKey, testValue); + String retrievedValue = (String) redisTemplate.opsForValue().get(testKey); + + // Then + assertThat(retrievedValue).isEqualTo(testValue); + + // Cleanup + redisTemplate.delete(testKey); + } + + @Test + public void testRedisConnectionInfo() { + // When + RedisConnection connection = redisTemplate.getConnectionFactory().getConnection(); + + try { + // Then + assertThat(connection).isNotNull(); + assertThat(connection.ping()).isNotNull(); + + System.out.println("Redis connection successful"); + System.out.println("Redis ping response: " + new String(connection.ping())); + } finally { + connection.close(); + } + } + + @Test + public void testRedisOperations() { + // Given + String hashKey = "test:hash"; + String field = "field1"; + String value = "value1"; + + // When + redisTemplate.opsForHash().put(hashKey, field, value); + String retrievedValue = (String) redisTemplate.opsForHash().get(hashKey, field); + + // Then + assertThat(retrievedValue).isEqualTo(value); + + // Cleanup + redisTemplate.delete(hashKey); + } +} diff --git a/src/test/java/com/knu/ddip/DdipApplicationTests.java b/src/test/java/com/knu/ddip/DdipApplicationTests.java index 8d2aa9e..5485b8d 100644 --- a/src/test/java/com/knu/ddip/DdipApplicationTests.java +++ b/src/test/java/com/knu/ddip/DdipApplicationTests.java @@ -1,9 +1,14 @@ package com.knu.ddip; +import com.knu.ddip.config.MySQLTestContainerConfig; +import com.knu.ddip.config.RedisTestContainerConfig; +import com.knu.ddip.config.TestEnvironmentConfig; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest +@ExtendWith({RedisTestContainerConfig.class, MySQLTestContainerConfig.class, TestEnvironmentConfig.class}) class DdipApplicationTests { @Test diff --git a/src/test/java/com/knu/ddip/auth/business/service/JwtFactoryTest.java b/src/test/java/com/knu/ddip/auth/business/service/JwtFactoryTest.java new file mode 100644 index 0000000..46acaa6 --- /dev/null +++ b/src/test/java/com/knu/ddip/auth/business/service/JwtFactoryTest.java @@ -0,0 +1,250 @@ +package com.knu.ddip.auth.business.service; + +import com.knu.ddip.auth.domain.Token; +import com.knu.ddip.auth.domain.TokenType; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class JwtFactoryTest { + + private JwtFactory jwtFactory; + private String TEST_SECRET; + private SecretKey testSecretKey; + + @BeforeEach + void setUp() { + TEST_SECRET = "testsecretkeymustbelongerthan256bitstomakeitwork00000"; + jwtFactory = new JwtFactory(TEST_SECRET); + testSecretKey = Keys.hmacShaKeyFor(TEST_SECRET.getBytes()); + } + + @Test + public void createAccessToken_returnValidAccessToken() { + //Given + UUID userId = TestFixture.USER_ID; + + //When + Token token = jwtFactory.createAccessToken(userId); + + //Then + assertThat(token).isNotNull(); + assertThat(token.getType()).isEqualTo(TokenType.ACCESS); + assertThat(token.getSubject()).isEqualTo(userId.toString()); + assertThat(token.isExpired()).isFalse(); + assertThat(token.isAccessToken()).isTrue(); + } + + @Test + public void createRefreshToken_returnValidRefreshToken() { + //Given + UUID userId = TestFixture.USER_ID; + + //When + Token token = jwtFactory.createRefreshToken(userId); + + //Then + assertThat(token).isNotNull(); + assertThat(token.getType()).isEqualTo(TokenType.REFRESH); + assertThat(token.getSubject()).isEqualTo(userId.toString()); + assertThat(token.isExpired()).isFalse(); + assertThat(token.isRefreshToken()).isTrue(); + } + + @Test + public void parseToken_whenValidToken_returnToken() { + //Given + UUID userId = TestFixture.USER_ID; + Token originalToken = jwtFactory.createAccessToken(userId); + String tokenValue = originalToken.getValue(); + + //When + Optional parsedToken = jwtFactory.parseToken(tokenValue); + + //Then + assertThat(parsedToken).isPresent(); + assertThat(parsedToken.get().getSubject()).isEqualTo(originalToken.getSubject()); + assertThat(parsedToken.get().getType()).isEqualTo(originalToken.getType()); + } + + @Test + public void parseToken_whenInvalidToken_returnEmpty() { + //Given + String invalidToken = TestFixture.INVALID_TOKEN; + + //When + Optional parsedToken = jwtFactory.parseToken(invalidToken); + + //Then + assertThat(parsedToken).isEmpty(); + } + + @Test + public void parseToken_whenNullToken_returnEmpty() { + //Given + String nullToken = null; + + //When + Optional parsedToken = jwtFactory.parseToken(nullToken); + + //Then + assertThat(parsedToken).isEmpty(); + } + + @Test + public void parseToken_whenTokenWithDifferentSignature_returnEmpty() { + //Given + UUID userId = TestFixture.USER_ID; + Token originalToken = jwtFactory.createAccessToken(userId); + String tokenValue = originalToken.getValue(); + + String tampered = + tokenValue.substring(0, tokenValue.lastIndexOf('.') + 1) + "invalidSignature"; + + //When + Optional parsedToken = jwtFactory.parseToken(tampered); + + //Then + assertThat(parsedToken).isEmpty(); + } + + @Test + public void verifyTokenType_whenAccessTokenIsUsedAsRefresh_returnFalse() { + //Given + UUID userId = TestFixture.USER_ID; + Token accessToken = jwtFactory.createAccessToken(userId); + + //When & Then + assertThat(accessToken.isRefreshToken()).isFalse(); + } + + @Test + public void verifyTokenType_whenRefreshTokenIsUsedAsAccess_returnFalse() { + //Given + UUID userId = TestFixture.USER_ID; + Token refreshToken = jwtFactory.createRefreshToken(userId); + + //When & Then + assertThat(refreshToken.isAccessToken()).isFalse(); + } + + @Test + public void parseToken_whenEmptyToken_returnEmpty() { + //Given + String emptyToken = TestFixture.EMPTY_TOKEN; + + //When + Optional parsedToken = jwtFactory.parseToken(emptyToken); + + //Then + assertThat(parsedToken).isEmpty(); + } + + @Test + public void parseToken_whenMalformedToken_returnEmpty() { + //Given + String malformedToken = TestFixture.MALFORMED_TOKEN; + + //When + Optional parsedToken = jwtFactory.parseToken(malformedToken); + + //Then + assertThat(parsedToken).isEmpty(); + } + + @Test + public void parseToken_whenExpiredToken_returnEmpty() { + //Given + String expiredToken = TestFixture.createExpiredToken(TestFixture.USER_ID, testSecretKey); + + //When + Optional parsedToken = jwtFactory.parseToken(expiredToken); + + //Then + assertThat(parsedToken).isEmpty(); + } + + @Test + public void parseToken_whenMissingTypeField_returnEmpty() { + //Given + String tokenWithoutType = TestFixture.createTokenWithoutType(TestFixture.USER_ID, + testSecretKey); + + //When + Optional parsedToken = jwtFactory.parseToken(tokenWithoutType); + + //Then + assertThat(parsedToken).isEmpty(); + } + + @Test + public void parseToken_whenInvalidTypeValue_returnEmpty() { + //Given + String tokenWithInvalidType = TestFixture.createTokenWithInvalidType(TestFixture.USER_ID, + testSecretKey); + + //When + Optional parsedToken = jwtFactory.parseToken(tokenWithInvalidType); + + //Then + assertThat(parsedToken).isEmpty(); + } + + static class TestFixture { + static final UUID USER_ID = UUID.randomUUID(); + static final String INVALID_TOKEN = "invalid.token.value"; + static final String MALFORMED_TOKEN = "not.a.validJWTtoken"; + static final String EMPTY_TOKEN = ""; + + static Date getPastDate() { + return new Date(System.currentTimeMillis() - 1000 * 60 * 60); // 1 hour ago + } + + static Date getFutureDate(Date now) { + return new Date(now.getTime() + 1000 * 60 * 60); // 1 hour later + } + + static String createExpiredToken(UUID userId, SecretKey secretKey) { + Date past = getPastDate(); + return Jwts.builder() + .subject(userId.toString()) + .issuedAt(past) + .expiration(past) // Already expired + .claim("type", TokenType.ACCESS.name()) + .signWith(secretKey) + .compact(); + } + + static String createTokenWithoutType(UUID userId, SecretKey secretKey) { + Date now = new Date(); + Date future = getFutureDate(now); + return Jwts.builder() + .subject(userId.toString()) + .issuedAt(now) + .expiration(future) + // No type claim + .signWith(secretKey) + .compact(); + } + + static String createTokenWithInvalidType(UUID userId, SecretKey secretKey) { + Date now = new Date(); + Date future = getFutureDate(now); + return Jwts.builder() + .subject(userId.toString()) + .issuedAt(now) + .expiration(future) + .claim("type", "INVALID_TYPE") // Invalid type value + .signWith(secretKey) + .compact(); + } + } +} diff --git a/src/test/java/com/knu/ddip/auth/business/service/JwtServiceTest.java b/src/test/java/com/knu/ddip/auth/business/service/JwtServiceTest.java new file mode 100644 index 0000000..09e2c5f --- /dev/null +++ b/src/test/java/com/knu/ddip/auth/business/service/JwtServiceTest.java @@ -0,0 +1,170 @@ +package com.knu.ddip.auth.business.service; + +import com.knu.ddip.auth.business.dto.JwtRefreshRequest; +import com.knu.ddip.auth.business.dto.JwtResponse; +import com.knu.ddip.auth.business.dto.TokenDTO; +import com.knu.ddip.auth.business.validator.JwtValidator; +import com.knu.ddip.auth.domain.Token; +import com.knu.ddip.auth.domain.TokenType; +import com.knu.ddip.auth.exception.TokenBadRequestException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Date; +import java.util.Optional; +import java.util.UUID; + +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.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class JwtServiceTest { + + @Mock + private JwtFactory jwtFactory; + + @Mock + private TokenRepository tokenRepository; + + @Mock + private JwtValidator JWTValidator; + + private JwtService jwtService; + private UUID userId; + private String deviceType; + + @BeforeEach + void setUp() { + jwtService = new JwtService(jwtFactory, tokenRepository, JWTValidator); + userId = UUID.randomUUID(); + deviceType = "web"; + } + + @Test + public void refreshAccessToken_whenValidRequest_returnNewTokens() { + //Given + String refreshTokenValue = "refresh-token-value"; + JwtRefreshRequest request = new JwtRefreshRequest(refreshTokenValue, deviceType); + + Date future = new Date(System.currentTimeMillis() + 1000 * 60 * 60); + Token refreshToken = Token.of(TokenType.REFRESH, refreshTokenValue, userId.toString(), + new Date(), + future); + Token newAccessToken = Token.of(TokenType.ACCESS, "new-access-token", userId.toString(), + new Date(), + future); + Token newRefreshToken = Token.of(TokenType.REFRESH, "new-refresh-token", userId.toString(), + new Date(), + future); + TokenDTO newRefreshTokenDTO = newRefreshToken.toTokenDTO(); + + when(jwtFactory.parseToken(refreshTokenValue)).thenReturn(Optional.of(refreshToken)); + when(jwtFactory.createAccessToken(userId)).thenReturn(newAccessToken); + when(jwtFactory.createRefreshToken(userId)).thenReturn(newRefreshToken); + doNothing().when(JWTValidator).validateRefreshToken(any(), eq(userId), eq(deviceType)); + + //When + JwtResponse response = jwtService.refreshAccessToken(request); + + //Then + assertThat(response.accessToken()).isEqualTo(newAccessToken.getValue()); + assertThat(response.refreshToken()).isEqualTo(newRefreshToken.getValue()); + verify(tokenRepository).saveToken(userId, deviceType, newRefreshTokenDTO); + verify(tokenRepository).updateLastRefreshTime(userId, deviceType); + } + + @Test + public void refreshAccessToken_whenInvalidToken_throwException() { + //Given + String refreshTokenValue = "invalid-token"; + JwtRefreshRequest request = new JwtRefreshRequest(refreshTokenValue, deviceType); + + when(jwtFactory.parseToken(refreshTokenValue)).thenReturn(Optional.empty()); + + //When, Then + assertThatThrownBy(() -> jwtService.refreshAccessToken(request)) + .isInstanceOf(TokenBadRequestException.class); + } + + @Test + public void logout_callsRemoveToken() { + //When + jwtService.logout(userId, deviceType); + + //Then + verify(tokenRepository).removeToken(userId, deviceType); + } + + @Test + public void refreshAccessToken_whenValidatorThrowsException_propagatesException() { + //Given + String refreshTokenValue = "refresh-token-value"; + JwtRefreshRequest request = new JwtRefreshRequest(refreshTokenValue, deviceType); + + Date future = new Date(System.currentTimeMillis() + 1000 * 60 * 60); + Token refreshToken = Token.of(TokenType.REFRESH, refreshTokenValue, userId.toString(), + new Date(), + future); + + when(jwtFactory.parseToken(refreshTokenValue)).thenReturn(Optional.of(refreshToken)); + doThrow(new TokenBadRequestException("Invalid token")) + .when(JWTValidator).validateRefreshToken(any(), eq(userId), eq(deviceType)); + + //When, Then + assertThatThrownBy(() -> jwtService.refreshAccessToken(request)) + .isInstanceOf(TokenBadRequestException.class); + } + + @Test + public void refreshAccessToken_whenAccessTokenCreationFails_throwsException() { + //Given + String refreshTokenValue = "refresh-token-value"; + JwtRefreshRequest request = new JwtRefreshRequest(refreshTokenValue, deviceType); + + Date future = new Date(System.currentTimeMillis() + 1000 * 60 * 60); + Token refreshToken = Token.of(TokenType.REFRESH, refreshTokenValue, userId.toString(), + new Date(), + future); + + when(jwtFactory.parseToken(refreshTokenValue)).thenReturn(Optional.of(refreshToken)); + doNothing().when(JWTValidator).validateRefreshToken(any(), eq(userId), eq(deviceType)); + when(jwtFactory.createAccessToken(userId)).thenThrow( + new RuntimeException("Token creation failed")); + + //When, Then + assertThatThrownBy(() -> jwtService.refreshAccessToken(request)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Token creation failed"); + } + + @Test + public void refreshAccessToken_whenRefreshTokenCreationFails_throwsException() { + //Given + String refreshTokenValue = "refresh-token-value"; + JwtRefreshRequest request = new JwtRefreshRequest(refreshTokenValue, deviceType); + + Date future = new Date(System.currentTimeMillis() + 1000 * 60 * 60); + Token refreshToken = Token.of(TokenType.REFRESH, refreshTokenValue, userId.toString(), + new Date(), + future); + Token accessToken = Token.of(TokenType.ACCESS, "access-token", userId.toString(), + new Date(), future); + + when(jwtFactory.parseToken(refreshTokenValue)).thenReturn(Optional.of(refreshToken)); + doNothing().when(JWTValidator).validateRefreshToken(any(), eq(userId), eq(deviceType)); + when(jwtFactory.createAccessToken(userId)).thenReturn(accessToken); + when(jwtFactory.createRefreshToken(userId)).thenThrow( + new RuntimeException("Token creation failed")); + + //When, Then + assertThatThrownBy(() -> jwtService.refreshAccessToken(request)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Token creation failed"); + } +} diff --git a/src/test/java/com/knu/ddip/auth/business/service/OAuthLoginServiceTest.java b/src/test/java/com/knu/ddip/auth/business/service/OAuthLoginServiceTest.java new file mode 100644 index 0000000..aa6e9c4 --- /dev/null +++ b/src/test/java/com/knu/ddip/auth/business/service/OAuthLoginServiceTest.java @@ -0,0 +1,473 @@ +package com.knu.ddip.auth.business.service; + +import com.knu.ddip.auth.business.dto.*; +import com.knu.ddip.auth.business.service.oauth.OAuthRepository; +import com.knu.ddip.auth.business.service.oauth.OAuthService; +import com.knu.ddip.auth.domain.*; +import com.knu.ddip.auth.exception.OAuthBadRequestException; +import com.knu.ddip.auth.exception.OAuthNotFoundException; +import com.knu.ddip.user.business.dto.UserEntityDto; +import com.knu.ddip.user.business.service.UserFactory; +import com.knu.ddip.user.business.service.UserRepository; +import com.knu.ddip.user.domain.User; +import com.knu.ddip.user.domain.UserStatus; +import com.knu.ddip.user.exception.UserNotFoundException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.net.URI; +import java.util.*; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class OAuthLoginServiceTest { + + private final UUID userId = UUID.randomUUID(); + private final UUID oAuthMappingEntityId = UUID.randomUUID(); + @Mock + private OAuthRepository oAuthRepository; + @Mock + private UserRepository userRepository; + @Mock + private JwtFactory jwtFactory; + @Mock + private TokenRepository tokenRepository; + @Mock + private OAuthService kakaoOAuthService; + @Mock + private OAuthService googleOAuthService; + @InjectMocks + private OAuthLoginService oAuthLoginService; + + @BeforeEach + void setUp() { + Map oAuthServiceMap = new HashMap<>(); + oAuthServiceMap.put("kakaoOAuthService", kakaoOAuthService); + oAuthServiceMap.put("googleOAuthService", googleOAuthService); + + ReflectionTestUtils.setField(oAuthLoginService, "oAuthServices", oAuthServiceMap); + ReflectionTestUtils.setField(oAuthLoginService, "appRedirectUri", "myapp://oauth/callback"); + } + + @Test + void getOAuthLoginUrl_ShouldReturnCorrectUrl() { + // Given + OAuthProvider provider = OAuthProvider.KAKAO; + DeviceType deviceType = DeviceType.PHONE; + when(kakaoOAuthService.isBackendRedirect()).thenReturn(true); + when(kakaoOAuthService.getRedirectUrl(deviceType.name())).thenReturn( + "https://kauth.kakao.com/oauth/authorize?..."); + + // When + String url = oAuthLoginService.getOAuthLoginUrl(provider.name(), deviceType.name()); + + // Then + assertThat(url).isEqualTo("https://kauth.kakao.com/oauth/authorize?..."); + verify(kakaoOAuthService).isBackendRedirect(); + verify(kakaoOAuthService).getRedirectUrl(deviceType.name()); + } + + @Test + void getOAuthLoginUrl_WhenProviderDoesNotSupportBackendRedirect_ThenThrowsException() { + // Given + OAuthProvider provider = OAuthProvider.KAKAO; + DeviceType deviceType = DeviceType.PHONE; + when(kakaoOAuthService.isBackendRedirect()).thenReturn(false); + + // When, Then + assertThatExceptionOfType(OAuthBadRequestException.class) + .isThrownBy(() -> oAuthLoginService.getOAuthLoginUrl(provider.name(), deviceType.name())) + .withMessage("์ด ์ œ๊ณต์ž๋Š” ๋ฐฑ์—”๋“œ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ๋ฅผ ์ง€์›ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."); + } + + @Test + void handleOAuthCallback_WhenExistingUser_ThenRedirectWithTokens() { + // Given + OAuthProvider provider = OAuthProvider.KAKAO; + String code = "test-code"; + DeviceType deviceType = DeviceType.PHONE; + + OAuthUserInfo userInfo = mockOAuthUserInfo(); + OAuthMappingEntityDto mapping = mockExistingMapping(); + + Date now = new Date(); + Date accessExpiry = new Date(now.getTime() + 3600 * 1000); + Date refreshExpiry = new Date(now.getTime() + 86400 * 1000); + + Token accessToken = Token.of(TokenType.ACCESS, "access-token-value", userId.toString(), now, + accessExpiry); + Token refreshToken = Token.of(TokenType.REFRESH, "refresh-token-value", userId.toString(), + now, refreshExpiry); + + UserEntityDto mockUser = UserEntityDto.create(UUID.randomUUID(), "test", "test", "test"); + + when(kakaoOAuthService.getUserInfo(code)).thenReturn(userInfo); + when(oAuthRepository.findBySocialUserIdAndProvider(anyString(), eq(provider))) + .thenReturn(Optional.of(mapping)); + doNothing().when(oAuthRepository).updateToken(any(), any(OAuthTokenDto.class)); + + when(jwtFactory.createAccessToken(userId)).thenReturn(accessToken); + when(jwtFactory.createRefreshToken(userId)).thenReturn(refreshToken); + when(userRepository.getById(any())).thenReturn(mockUser); + + // When + URI result = oAuthLoginService.handleOAuthCallback(provider.name(), code, deviceType.name()); + + // Then + assertThat(result.toString()).contains("needRegister=false"); + assertThat(result.toString()).contains("accessToken=access-token-value"); + assertThat(result.toString()).contains("refreshToken=refresh-token-value"); + verify(tokenRepository).saveToken(eq(userId), eq(deviceType.name()), any(TokenDTO.class)); + } + + @Test + void handleOAuthCallback_WhenNewUser_ThenRedirectWithSignupInfo() { + // Given + OAuthProvider provider = OAuthProvider.KAKAO; + String code = "test-code"; + DeviceType deviceType = DeviceType.PHONE; + + OAuthUserInfo userInfo = mockOAuthUserInfo(); + OAuthMappingEntityDto temporaryMapping = mockTemporaryMapping(); + + when(kakaoOAuthService.getUserInfo(code)).thenReturn(userInfo); + when(oAuthRepository.findBySocialUserIdAndProvider(anyString(), eq(provider))) + .thenReturn(Optional.empty()); + when(oAuthRepository.save(any(OAuthMappingEntityDto.class))).thenReturn(temporaryMapping); + + // When + URI result = oAuthLoginService.handleOAuthCallback(provider.name(), code, deviceType.name()); + + // Then + assertThat(result.toString()).contains("needRegister=true"); + assertThat(result.toString()).contains("provider=kakao"); + assertThat(result.toString()).contains("deviceType=phone"); + assertThat(result.toString()).startsWith("myapp://oauth/callback"); + } + + @Test + void linkOAuthWithUser_ShouldGenerateTokens() { + // Given + User user = UserFactory.create(userId, "test@example.com", "testuser", + UserStatus.ACTIVE.name()); + DeviceType deviceType = DeviceType.PHONE; + OAuthProvider provider = OAuthProvider.KAKAO; + OAuthMappingEntityDto mapping = mockTemporaryMapping(); + UserEntityDto userEntityDto = UserEntityDto.create(userId, "test@example.com", "testuser", + UserStatus.ACTIVE.name()); + + Date now = new Date(); + Date accessExpiry = new Date(now.getTime() + 3600 * 1000); + Date refreshExpiry = new Date(now.getTime() + 86400 * 1000); + + Token accessToken = Token.of(TokenType.ACCESS, "access-token-value", userId.toString(), now, + accessExpiry); + Token refreshToken = Token.of(TokenType.REFRESH, "refresh-token-value", userId.toString(), + now, refreshExpiry); + + when(oAuthRepository.findByOauthMappingEntityIdAndProvider(oAuthMappingEntityId, provider)) + .thenReturn(Optional.of(mapping)); + doNothing().when(oAuthRepository).update(any(OAuthMappingEntityDto.class)); + when(userRepository.getById(userId)).thenReturn(userEntityDto); + when(jwtFactory.createAccessToken(userId)).thenReturn(accessToken); + when(jwtFactory.createRefreshToken(userId)).thenReturn(refreshToken); + + // When + JwtResponse response = oAuthLoginService.linkOAuthWithUser(user, + oAuthMappingEntityId.toString(), provider, deviceType); + + // Then + assertThat(response.accessToken()).isEqualTo("access-token-value"); + assertThat(response.refreshToken()).isEqualTo("refresh-token-value"); + verify(tokenRepository).saveToken(eq(userId), eq(deviceType.name()), any(TokenDTO.class)); + } + + @Test + void linkOAuthWithUser_WithInvalidMapping_ShouldThrowException() { + // Given + User user = UserFactory.create(userId, "test@example.com", "testuser", + UserStatus.ACTIVE.name()); + DeviceType deviceType = DeviceType.PHONE; + OAuthProvider provider = OAuthProvider.KAKAO; + + when(oAuthRepository.findByOauthMappingEntityIdAndProvider(any(UUID.class), eq(provider))) + .thenReturn(Optional.empty()); + + // When, Then + assertThatExceptionOfType(OAuthNotFoundException.class) + .isThrownBy( + () -> oAuthLoginService.linkOAuthWithUser(user, oAuthMappingEntityId.toString(), + provider, deviceType)) + .withMessage("OAuth ์ •๋ณด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + } + + @Test + void handleOAuthCallback_WithNullState_ShouldDefaultToPhoneDeviceType() { + // Given + OAuthProvider provider = OAuthProvider.KAKAO; + String code = "test-code"; + DeviceType deviceType = DeviceType.PHONE; + + OAuthUserInfo userInfo = mockOAuthUserInfo(); + OAuthMappingEntityDto mapping = mockExistingMapping(); + + Date now = new Date(); + Token accessToken = Token.of(TokenType.ACCESS, "access-token", userId.toString(), now, + new Date(now.getTime() + 3600000)); + Token refreshToken = Token.of(TokenType.REFRESH, "refresh-token", userId.toString(), now, + new Date(now.getTime() + 86400000)); + + UserEntityDto mockUser = UserEntityDto.create(UUID.randomUUID(), "test", "test", "test"); + + when(kakaoOAuthService.getUserInfo(code)).thenReturn(userInfo); + when(oAuthRepository.findBySocialUserIdAndProvider(anyString(), eq(provider))) + .thenReturn(Optional.of(mapping)); + when(jwtFactory.createAccessToken(userId)).thenReturn(accessToken); + when(jwtFactory.createRefreshToken(userId)).thenReturn(refreshToken); + when(userRepository.getById(any())).thenReturn(mockUser); + + // When + URI result = oAuthLoginService.handleOAuthCallback(provider.name(), code, deviceType.name()); + + // Then + assertThat(result.toString()).contains("needRegister=false"); + assertThat(result.toString()).contains("accessToken=access-token"); + verify(tokenRepository).saveToken(eq(userId), eq(deviceType.name()), any(TokenDTO.class)); + } + + @Test + void generateTokensForUser_WhenUserNotFound_ShouldThrowException() { + // Given + DeviceType deviceType = DeviceType.PHONE; + when(userRepository.getById(userId)).thenThrow(new UserNotFoundException("User not found")); + + // When, Then + assertThatExceptionOfType(UserNotFoundException.class) + .isThrownBy(() -> { + ReflectionTestUtils.invokeMethod(oAuthLoginService, "generateTokensForUser", userId, + deviceType); + }) + .withMessage("User not found"); + } + + @Test + void processOAuthLogin_WhenExistingUserWithTemporaryStatus_ShouldReturnSignupResponse() { + // Given + OAuthProvider provider = OAuthProvider.KAKAO; + String code = "test-code"; + DeviceType deviceType = DeviceType.PHONE; + + OAuthUserInfo userInfo = mockOAuthUserInfo(); + + UUID temporaryUserId = UUID.randomUUID(); + OAuthMappingEntityDto mappingWithUserIdButTemporary = OAuthMappingEntityDto.create( + oAuthMappingEntityId, + "social-user-123", + "test@example.com", + "Test User", + OAuthProvider.KAKAO, + temporaryUserId, + userInfo.getOAuthToken(), + true + ); + + when(kakaoOAuthService.getUserInfo(code)).thenReturn(userInfo); + when(oAuthRepository.findBySocialUserIdAndProvider(anyString(), eq(provider))) + .thenReturn(Optional.of(mappingWithUserIdButTemporary)); + doNothing().when(oAuthRepository).updateToken(any(), any(OAuthTokenDto.class)); + + // When + OAuthLoginResponse response = ReflectionTestUtils.invokeMethod( + oAuthLoginService, "processOAuthLogin", provider, code, deviceType); + + // Then + assertThat(response).isNotNull(); + assertThat(response.needRegister()).isTrue(); + assertThat(response.OAuthMappingEntityId()).isEqualTo(oAuthMappingEntityId); + } + + @Test + void processOAuthLogin_WhenUserIdIsNullButNotTemporary_ShouldHandleGracefully() { + // Given + OAuthProvider provider = OAuthProvider.KAKAO; + String code = "test-code"; + DeviceType deviceType = DeviceType.PHONE; + + OAuthUserInfo userInfo = mockOAuthUserInfo(); + + OAuthMappingEntityDto unusualMapping = OAuthMappingEntityDto.create( + oAuthMappingEntityId, + "social-user-123", + "test@example.com", + "Test User", + OAuthProvider.KAKAO, + null, + userInfo.getOAuthToken(), + false + ); + + when(kakaoOAuthService.getUserInfo(code)).thenReturn(userInfo); + when(oAuthRepository.findBySocialUserIdAndProvider(anyString(), eq(provider))) + .thenReturn(Optional.of(unusualMapping)); + doNothing().when(oAuthRepository).updateToken(any(), any(OAuthTokenDto.class)); + + // When + OAuthLoginResponse response = ReflectionTestUtils.invokeMethod( + oAuthLoginService, "processOAuthLogin", provider, code, deviceType); + + // Then + assertThat(response).isNotNull(); + assertThat(response.needRegister()).isTrue(); + assertThat(response.OAuthMappingEntityId()).isEqualTo(oAuthMappingEntityId); + } + + @Test + void linkOAuthWithUser_WithInvalidUUID_ShouldThrowException() { + // Given + User user = UserFactory.create(userId, "test@example.com", "testuser", + UserStatus.ACTIVE.name()); + String invalidUUID = "not-a-uuid"; + + // When, Then + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> { + oAuthLoginService.linkOAuthWithUser(user, invalidUUID, OAuthProvider.KAKAO, + DeviceType.PHONE); + }); + } + + @Test + void getOAuthService_WhenUnsupportedProvider_ShouldThrowException() { + // Given + OAuthProvider provider = OAuthProvider.UNSUPPORTED; + + // When, Then + assertThatExceptionOfType(OAuthBadRequestException.class) + .isThrownBy(() -> oAuthLoginService.getOAuthLoginUrl(provider.name(), DeviceType.PHONE.name())) + .withMessage("์ง€์›ํ•˜์ง€ ์•Š๋Š” OAuth ์ œ๊ณต์ž์ž…๋‹ˆ๋‹ค: " + provider); + } + + @Test + void handleOAuthCallback_WhenNewUserAndErrorSavingMapping_ShouldThrowException() { + // Given + OAuthProvider provider = OAuthProvider.KAKAO; + String code = "test-code"; + DeviceType deviceType = DeviceType.PHONE; + OAuthUserInfo userInfo = mockOAuthUserInfo(); + + when(kakaoOAuthService.getUserInfo(code)).thenReturn(userInfo); + when(oAuthRepository.findBySocialUserIdAndProvider(anyString(), eq(provider))) + .thenReturn(Optional.empty()); + when(oAuthRepository.save(any(OAuthMappingEntityDto.class))) + .thenThrow(new RuntimeException("Database error")); + + // When, Then + assertThatExceptionOfType(RuntimeException.class) + .isThrownBy(() -> oAuthLoginService.handleOAuthCallback(provider.name(), code, deviceType.name())) + .withMessage("Database error"); + } + + @Test + void processOAuthLogin_WhenOAuthServiceThrowsException_ShouldPropagateException() { + // Given + OAuthProvider provider = OAuthProvider.KAKAO; + String code = "test-code"; + DeviceType deviceType = DeviceType.PHONE; + + when(kakaoOAuthService.getUserInfo(code)) + .thenThrow(new OAuthBadRequestException("API error")); + + // When, Then + assertThatExceptionOfType(OAuthBadRequestException.class) + .isThrownBy(() -> oAuthLoginService.processOAuthLogin(provider, code, deviceType)) + .withMessage("API error"); + } + + @Test + void linkOAuthWithUser_WhenUpdateFails_ShouldPropagateException() { + // Given + User user = UserFactory.create(userId, "test@example.com", "testuser", + UserStatus.ACTIVE.name()); + DeviceType deviceType = DeviceType.PHONE; + OAuthProvider provider = OAuthProvider.KAKAO; + OAuthMappingEntityDto mapping = mockTemporaryMapping(); + + when(oAuthRepository.findByOauthMappingEntityIdAndProvider(oAuthMappingEntityId, provider)) + .thenReturn(Optional.of(mapping)); + doThrow(new RuntimeException("Update failed")) + .when(oAuthRepository).update(any(OAuthMappingEntityDto.class)); + + // When, Then + assertThatExceptionOfType(RuntimeException.class) + .isThrownBy(() -> + oAuthLoginService.linkOAuthWithUser(user, oAuthMappingEntityId.toString(), provider, + deviceType)) + .withMessage("Update failed"); + } + + @Test + void generateTokensForUser_WhenTokenCreationFails_ShouldPropagateException() { + // Given + DeviceType deviceType = DeviceType.PHONE; + UserEntityDto userEntityDto = UserEntityDto.create(userId, "test@example.com", "testuser", + UserStatus.ACTIVE.name()); + + when(userRepository.getById(userId)).thenReturn(userEntityDto); + when(jwtFactory.createAccessToken(userId)).thenThrow( + new RuntimeException("Token creation failed")); + + // When, Then + assertThatExceptionOfType(RuntimeException.class) + .isThrownBy(() -> { + ReflectionTestUtils.invokeMethod(oAuthLoginService, "generateTokensForUser", userId, + deviceType); + }) + .withMessage("Token creation failed"); + } + + private OAuthUserInfo mockOAuthUserInfo() { + OAuthToken oauthToken = OAuthToken.ofKakao( + "fake-token", "fake-token", 10000L); + + return OAuthUserInfo.create("social-user-123", "test@example.com", "Test User", + OAuthProvider.KAKAO, oauthToken); + } + + private OAuthMappingEntityDto mockExistingMapping() { + OAuthToken oauthToken = OAuthToken.ofKakao( + "fake-token", "fake-token", 10000L); + + return OAuthMappingEntityDto.create( + oAuthMappingEntityId, + "social-user-123", + "test@example.com", + "Test User", + OAuthProvider.KAKAO, + userId, + oauthToken, + false + ); + } + + private OAuthMappingEntityDto mockTemporaryMapping() { + OAuthToken oauthToken = OAuthToken.ofKakao( + "fake-token", "fake-token", 10000L); + + OAuthMapping oAuthMapping = OAuthMapping.createTemporary("social-user-123", + "test@example.com", + "Test User", + OAuthProvider.KAKAO, + oauthToken); + + return oAuthMapping.toDto(); + } +} diff --git a/src/test/java/com/knu/ddip/auth/business/service/oauth/KakaoOAuthServiceTest.java b/src/test/java/com/knu/ddip/auth/business/service/oauth/KakaoOAuthServiceTest.java new file mode 100644 index 0000000..0f2f37e --- /dev/null +++ b/src/test/java/com/knu/ddip/auth/business/service/oauth/KakaoOAuthServiceTest.java @@ -0,0 +1,426 @@ +package com.knu.ddip.auth.business.service.oauth; + +import com.knu.ddip.auth.business.service.oauth.kakao.KakaoOAuthService; +import com.knu.ddip.auth.business.service.oauth.kakao.KakaoTokenResponse; +import com.knu.ddip.auth.business.service.oauth.kakao.KakaoUserInfoResponse; +import com.knu.ddip.auth.domain.DeviceType; +import com.knu.ddip.auth.domain.OAuthUserInfo; +import com.knu.ddip.auth.exception.OAuthErrorException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class KakaoOAuthServiceTest { + + private final String FAKE_CLIENT_ID = "fake-client-id"; + private final String FAKE_REDIRECT_URI = "http://test/oauth/callback"; + @Mock + private RestTemplate restTemplate; + @InjectMocks + private KakaoOAuthService kakaoOAuthService; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(kakaoOAuthService, "clientId", FAKE_CLIENT_ID); + ReflectionTestUtils.setField(kakaoOAuthService, "redirectUri", FAKE_REDIRECT_URI); + } + + @Test + void isBackendRedirect_ShouldReturnTrue() { + assertThat(kakaoOAuthService.isBackendRedirect()).isTrue(); + } + + @Test + void getRedirectUrl_ShouldReturnCorrectUrl() { + // Given + String state = DeviceType.PHONE.name(); + + // When + String redirectUrl = kakaoOAuthService.getRedirectUrl(state); + + // Then + assertThat(redirectUrl).contains("kauth.kakao.com/oauth/authorize"); + assertThat(redirectUrl).contains("client_id=" + FAKE_CLIENT_ID); + assertThat(redirectUrl).contains("redirect_uri=" + FAKE_REDIRECT_URI); + assertThat(redirectUrl).contains("state=" + state); + } + + @Test + void getUserInfo_ShouldRetrieveTokenAndUserInfo() { + // Given + String code = "test-code"; + + KakaoTokenResponse tokenResponse = new KakaoTokenResponse( + "fake-access-token", + "fake-refresh-token", + 21599L, + 999999L, + "bearer", + "profile" + ); + + ResponseEntity tokenResponseEntity = + new ResponseEntity<>(tokenResponse, HttpStatus.OK); + + when(restTemplate.exchange( + contains("kauth.kakao.com/oauth/token"), + eq(HttpMethod.POST), + any(), + eq(KakaoTokenResponse.class) + )).thenReturn(tokenResponseEntity); + + KakaoUserInfoResponse.KakaoAccount.Profile profile = new KakaoUserInfoResponse.KakaoAccount.Profile( + "TestUser"); + KakaoUserInfoResponse.KakaoAccount account = new KakaoUserInfoResponse.KakaoAccount( + "test@example.com", profile); + KakaoUserInfoResponse userInfoResponse = new KakaoUserInfoResponse("123456789", account); + + ResponseEntity userInfoResponseEntity = + new ResponseEntity<>(userInfoResponse, HttpStatus.OK); + + when(restTemplate.exchange( + contains("kapi.kakao.com/v2/user/me"), + eq(HttpMethod.GET), + any(), + eq(KakaoUserInfoResponse.class) + )).thenReturn(userInfoResponseEntity); + + // When + OAuthUserInfo result = kakaoOAuthService.getUserInfo(code); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getSocialUserId()).isEqualTo("123456789"); + assertThat(result.getEmail()).isEqualTo("test@example.com"); + assertThat(result.getName()).isEqualTo("TestUser"); + assertThat(result.getOAuthToken().getAccessToken()).isEqualTo("fake-access-token"); + assertThat(result.getOAuthToken().getRefreshToken()).isEqualTo("fake-refresh-token"); + } + + @Test + void getUserInfo_WhenTokenRequestFails_ShouldThrowOAuthException() { + // Given + String code = "invalid-code"; + when(restTemplate.exchange( + contains("kauth.kakao.com/oauth/token"), + eq(HttpMethod.POST), + any(), + eq(KakaoTokenResponse.class) + )).thenThrow(new RestClientException("API Error")); + + // When, Then + assertThatExceptionOfType(OAuthErrorException.class) + .isThrownBy(() -> kakaoOAuthService.getUserInfo(code)); + } + + @Test + void getUserInfo_WhenUserInfoRequestFails_ShouldThrowOAuthException() { + // Given + String code = "test-code"; + + KakaoTokenResponse tokenResponse = new KakaoTokenResponse( + "fake-access-token", null, null, null, "bearer", null); + ResponseEntity tokenResponseEntity = + new ResponseEntity<>(tokenResponse, HttpStatus.OK); + + when(restTemplate.exchange( + contains("kauth.kakao.com/oauth/token"), + eq(HttpMethod.POST), + any(), + eq(KakaoTokenResponse.class) + )).thenReturn(tokenResponseEntity); + + when(restTemplate.exchange( + contains("kapi.kakao.com/v2/user/me"), + eq(HttpMethod.GET), + any(), + eq(KakaoUserInfoResponse.class) + )).thenThrow(new RestClientException("API Error")); + + // When, Then + assertThatExceptionOfType(OAuthErrorException.class) + .isThrownBy(() -> kakaoOAuthService.getUserInfo(code)); + } + + @Test + void getUserInfo_WhenUserInfoResponseIsNull_ShouldThrowOAuthException() { + // Given + String code = "test-code"; + + KakaoTokenResponse tokenResponse = new KakaoTokenResponse( + "fake-access-token", null, null, null, null, null); + ResponseEntity tokenResponseEntity = + new ResponseEntity<>(tokenResponse, HttpStatus.OK); + + when(restTemplate.exchange( + contains("kauth.kakao.com/oauth/token"), + eq(HttpMethod.POST), + any(), + eq(KakaoTokenResponse.class) + )).thenReturn(tokenResponseEntity); + + ResponseEntity userInfoResponseEntity = + new ResponseEntity<>(null, HttpStatus.OK); + + when(restTemplate.exchange( + contains("kapi.kakao.com/v2/user/me"), + eq(HttpMethod.GET), + any(), + eq(KakaoUserInfoResponse.class) + )).thenReturn(userInfoResponseEntity); + + // When, Then + assertThatExceptionOfType(OAuthErrorException.class) + .isThrownBy(() -> kakaoOAuthService.getUserInfo(code)); + } + + @Test + void getUserInfo_WithMissingOptionalFields_ShouldHandleGracefully() { + // Given + String code = "test-code"; + + KakaoTokenResponse tokenResponse = new KakaoTokenResponse( + "fake-access-token", null, null, null, null, null); + ResponseEntity tokenResponseEntity = + new ResponseEntity<>(tokenResponse, HttpStatus.OK); + + when(restTemplate.exchange( + contains("kauth.kakao.com/oauth/token"), + eq(HttpMethod.POST), + any(), + eq(KakaoTokenResponse.class) + )).thenReturn(tokenResponseEntity); + + KakaoUserInfoResponse userInfoResponse = new KakaoUserInfoResponse("123456789", null); + ResponseEntity userInfoResponseEntity = + new ResponseEntity<>(userInfoResponse, HttpStatus.OK); + + when(restTemplate.exchange( + contains("kapi.kakao.com/v2/user/me"), + eq(HttpMethod.GET), + any(), + eq(KakaoUserInfoResponse.class) + )).thenReturn(userInfoResponseEntity); + + // When + OAuthUserInfo result = kakaoOAuthService.getUserInfo(code); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getSocialUserId()).isEqualTo("123456789"); + assertThat(result.getEmail()).isNull(); + assertThat(result.getName()).isEqualTo("Unknown"); + assertThat(result.getOAuthToken().getAccessToken()).isEqualTo("fake-access-token"); + } + + @Test + void getUserInfo_WithEmailButNoProfile_ShouldHandleGracefully() { + // Given + String code = "test-code"; + + KakaoTokenResponse tokenResponse = new KakaoTokenResponse( + "fake-access-token", null, null, null, null, null); + ResponseEntity tokenResponseEntity = + new ResponseEntity<>(tokenResponse, HttpStatus.OK); + + when(restTemplate.exchange( + contains("kauth.kakao.com/oauth/token"), + eq(HttpMethod.POST), + any(), + eq(KakaoTokenResponse.class) + )).thenReturn(tokenResponseEntity); + + KakaoUserInfoResponse.KakaoAccount account = new KakaoUserInfoResponse.KakaoAccount( + "test@example.com", null); + KakaoUserInfoResponse userInfoResponse = new KakaoUserInfoResponse("123456789", account); + ResponseEntity userInfoResponseEntity = + new ResponseEntity<>(userInfoResponse, HttpStatus.OK); + + when(restTemplate.exchange( + contains("kapi.kakao.com/v2/user/me"), + eq(HttpMethod.GET), + any(), + eq(KakaoUserInfoResponse.class) + )).thenReturn(userInfoResponseEntity); + + // When + OAuthUserInfo result = kakaoOAuthService.getUserInfo(code); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getSocialUserId()).isEqualTo("123456789"); + assertThat(result.getEmail()).isEqualTo("test@example.com"); + assertThat(result.getName()).isEqualTo("Unknown"); + } + + @Test + void getUserInfo_WhenTokenResponseBodyIsNull_ShouldThrowOAuthException() { + // Given + String code = "test-code"; + + ResponseEntity tokenResponseEntity = new ResponseEntity<>(null, + HttpStatus.OK); + when(restTemplate.exchange( + contains("kauth.kakao.com/oauth/token"), + eq(HttpMethod.POST), + any(), + eq(KakaoTokenResponse.class) + )).thenReturn(tokenResponseEntity); + + // When, Then + assertThatExceptionOfType(OAuthErrorException.class) + .isThrownBy(() -> kakaoOAuthService.getUserInfo(code)); + } + + @Test + void getUserInfo_WhenUserIdIsNull_ShouldThrowOAuthException() { + // Given + String code = "test-code"; + + KakaoTokenResponse tokenResponse = new KakaoTokenResponse( + "fake-access-token", null, null, null, null, null); + ResponseEntity tokenResponseEntity = + new ResponseEntity<>(tokenResponse, HttpStatus.OK); + + when(restTemplate.exchange( + contains("kauth.kakao.com/oauth/token"), + eq(HttpMethod.POST), + any(), + eq(KakaoTokenResponse.class) + )).thenReturn(tokenResponseEntity); + + KakaoUserInfoResponse userInfoResponse = new KakaoUserInfoResponse(null, null); + ResponseEntity userInfoResponseEntity = + new ResponseEntity<>(userInfoResponse, HttpStatus.OK); + + when(restTemplate.exchange( + contains("kapi.kakao.com/v2/user/me"), + eq(HttpMethod.GET), + any(), + eq(KakaoUserInfoResponse.class) + )).thenReturn(userInfoResponseEntity); + + // When, Then + assertThatExceptionOfType(OAuthErrorException.class) + .isThrownBy(() -> kakaoOAuthService.getUserInfo(code)); + } + + @Test + void getUserInfo_WithNullEmail_ShouldHandleGracefully() { + // Given + String code = "test-code"; + + KakaoTokenResponse tokenResponse = new KakaoTokenResponse( + "fake-access-token", null, null, null, null, null); + ResponseEntity tokenResponseEntity = + new ResponseEntity<>(tokenResponse, HttpStatus.OK); + + when(restTemplate.exchange( + contains("kauth.kakao.com/oauth/token"), + eq(HttpMethod.POST), + any(), + eq(KakaoTokenResponse.class) + )).thenReturn(tokenResponseEntity); + + KakaoUserInfoResponse.KakaoAccount account = new KakaoUserInfoResponse.KakaoAccount(null, + null); + KakaoUserInfoResponse userInfoResponse = new KakaoUserInfoResponse("123456789", account); + ResponseEntity userInfoResponseEntity = + new ResponseEntity<>(userInfoResponse, HttpStatus.OK); + + when(restTemplate.exchange( + contains("kapi.kakao.com/v2/user/me"), + eq(HttpMethod.GET), + any(), + eq(KakaoUserInfoResponse.class) + )).thenReturn(userInfoResponseEntity); + + // When + OAuthUserInfo result = kakaoOAuthService.getUserInfo(code); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getSocialUserId()).isEqualTo("123456789"); + assertThat(result.getEmail()).isNull(); + assertThat(result.getName()).isEqualTo("Unknown"); + } + + @Test + void getUserInfo_WithNullAccessToken_ShouldThrowOAuthException() { + // Given + String code = "test-code"; + + KakaoTokenResponse tokenResponse = new KakaoTokenResponse( + null, null, null, null, null, null); + ResponseEntity tokenResponseEntity = + new ResponseEntity<>(tokenResponse, HttpStatus.OK); + + when(restTemplate.exchange( + contains("kauth.kakao.com/oauth/token"), + eq(HttpMethod.POST), + any(), + eq(KakaoTokenResponse.class) + )).thenReturn(tokenResponseEntity); + + // When, Then + assertThatExceptionOfType(OAuthErrorException.class) + .isThrownBy(() -> kakaoOAuthService.getUserInfo(code)); + } + + @Test + void getUserInfo_WithProfileButNullNickname_ShouldHandleGracefully() { + // Given + String code = "test-code"; + + KakaoTokenResponse tokenResponse = new KakaoTokenResponse( + "fake-access-token", null, null, null, null, null); + ResponseEntity tokenResponseEntity = + new ResponseEntity<>(tokenResponse, HttpStatus.OK); + + when(restTemplate.exchange( + contains("kauth.kakao.com/oauth/token"), + eq(HttpMethod.POST), + any(), + eq(KakaoTokenResponse.class) + )).thenReturn(tokenResponseEntity); + + KakaoUserInfoResponse.KakaoAccount.Profile profile = new KakaoUserInfoResponse.KakaoAccount.Profile( + null); + KakaoUserInfoResponse.KakaoAccount account = new KakaoUserInfoResponse.KakaoAccount( + "test@example.com", profile); + KakaoUserInfoResponse userInfoResponse = new KakaoUserInfoResponse("123456789", account); + ResponseEntity userInfoResponseEntity = + new ResponseEntity<>(userInfoResponse, HttpStatus.OK); + + when(restTemplate.exchange( + contains("kapi.kakao.com/v2/user/me"), + eq(HttpMethod.GET), + any(), + eq(KakaoUserInfoResponse.class) + )).thenReturn(userInfoResponseEntity); + + // When + OAuthUserInfo result = kakaoOAuthService.getUserInfo(code); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getSocialUserId()).isEqualTo("123456789"); + assertThat(result.getEmail()).isEqualTo("test@example.com"); + assertThat(result.getName()).isEqualTo("Unknown"); + } +} diff --git a/src/test/java/com/knu/ddip/auth/business/validator/JwtValidatorTest.java b/src/test/java/com/knu/ddip/auth/business/validator/JwtValidatorTest.java new file mode 100644 index 0000000..992b91a --- /dev/null +++ b/src/test/java/com/knu/ddip/auth/business/validator/JwtValidatorTest.java @@ -0,0 +1,178 @@ +package com.knu.ddip.auth.business.validator; + +import com.knu.ddip.auth.business.dto.TokenDTO; +import com.knu.ddip.auth.business.service.JwtFactory; +import com.knu.ddip.auth.business.service.TokenRepository; +import com.knu.ddip.auth.domain.Token; +import com.knu.ddip.auth.domain.TokenType; +import com.knu.ddip.auth.exception.TokenBadRequestException; +import com.knu.ddip.auth.exception.TokenConflictException; +import com.knu.ddip.auth.exception.TokenExpiredException; +import com.knu.ddip.auth.exception.TokenStolenException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Date; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JwtValidatorTest { + + @Mock + private TokenRepository tokenRepository; + + @Mock + private JwtFactory jwtFactory; + + private JwtValidator JWTValidator; + private UUID userId; + private String deviceType; + + @BeforeEach + void setUp() { + JWTValidator = new JwtValidator(tokenRepository, jwtFactory); + userId = UUID.randomUUID(); + deviceType = "web"; + } + + @Test + public void validateAccessToken_whenValidAccessToken_noExceptionThrown() { + //Given + Date future = new Date(System.currentTimeMillis() + 1000 * 60); + Token token = Token.of(TokenType.ACCESS, "value", userId.toString(), new Date(), future); + + //When, Then + assertThatCode(() -> JWTValidator.validateAccessToken(token)) + .doesNotThrowAnyException(); + } + + @Test + public void validateAccessToken_whenNotAccessToken_throwJWTBadRequestException() { + //Given + Date future = new Date(System.currentTimeMillis() + 1000 * 60); + Token token = Token.of(TokenType.REFRESH, "value", userId.toString(), new Date(), future); + + //When, Then + assertThatThrownBy(() -> JWTValidator.validateAccessToken(token)) + .isInstanceOf(TokenBadRequestException.class); + } + + @Test + public void validateAccessToken_whenExpiredToken_throwAccessTokenExpiredException() { + //Given + Date past = new Date(System.currentTimeMillis() - 1000 * 60); + Token token = Token.of(TokenType.ACCESS, "value", userId.toString(), new Date(), past); + + //When, Then + assertThatThrownBy(() -> JWTValidator.validateAccessToken(token)) + .isInstanceOf(TokenExpiredException.class); + } + + @Test + public void validateRefreshToken_whenValidRefreshToken_noExceptionThrown() { + //Given + Date future = new Date(System.currentTimeMillis() + 1000 * 60); + String tokenValue = "refresh-token-value"; + Token token = Token.of(TokenType.REFRESH, tokenValue, userId.toString(), new Date(), + future); + TokenDTO tokenDTO = token.toTokenDTO(); + + when(tokenRepository.findToken(userId, deviceType)).thenReturn(tokenDTO); + when(jwtFactory.parseToken(tokenDTO.value())).thenReturn(Optional.of(token)); + when(tokenRepository.getLastRefreshTime(userId, deviceType)).thenReturn(Optional.of(0L)); + + //When, Then + assertThatCode(() -> JWTValidator.validateRefreshToken(token, userId, deviceType)) + .doesNotThrowAnyException(); + } + + @Test + public void validateRefreshToken_whenNotRefreshToken_throwJWTBadRequestException() { + //Given + Date future = new Date(System.currentTimeMillis() + 1000 * 60); + Token token = Token.of(TokenType.ACCESS, "value", userId.toString(), new Date(), future); + + //When, Then + assertThatThrownBy(() -> JWTValidator.validateRefreshToken(token, userId, deviceType)) + .isInstanceOf(TokenBadRequestException.class); + } + + @Test + public void validateRefreshToken_givenNullStoredTokenValue_throwsTokenBadRequestException() { + //Given + Date future = new Date(System.currentTimeMillis() + 1000 * 60); + Token nullValueToken = Token.of(TokenType.REFRESH, null, userId.toString(), new Date(), + future); + TokenDTO nullValueTokenDTO = nullValueToken.toTokenDTO(); + + //When + when(tokenRepository.findToken(userId, deviceType)).thenReturn(nullValueTokenDTO); + + //Then + assertThatThrownBy( + () -> JWTValidator.validateRefreshToken(nullValueToken, userId, deviceType)) + .isInstanceOf(TokenBadRequestException.class); + } + + @Test + public void validateRefreshToken_whenExpiredToken_throwAccessTokenExpiredException() { + //Given + Date past = new Date(System.currentTimeMillis() - 1000 * 60); + Token token = Token.of(TokenType.REFRESH, "value", userId.toString(), new Date(), past); + + //When, Then + assertThatThrownBy(() -> JWTValidator.validateRefreshToken(token, userId, deviceType)) + .isInstanceOf(TokenExpiredException.class); + } + + @Test + public void validateRefreshToken_whenTokenMismatch_throwRefreshTokenStolenException() { + //Given + Date future = new Date(System.currentTimeMillis() + 1000 * 60); + Token storedToken = Token.of(TokenType.REFRESH, "stored-value", userId.toString(), + new Date(), future); + Token requestToken = Token.of(TokenType.REFRESH, "different-value", userId.toString(), + new Date(), + future); + TokenDTO storedTokenDTO = requestToken.toTokenDTO(); + + when(tokenRepository.findToken(userId, deviceType)).thenReturn(storedTokenDTO); + when(jwtFactory.parseToken(storedTokenDTO.value())).thenReturn(Optional.of(storedToken)); + + //When, Then + assertThatThrownBy( + () -> JWTValidator.validateRefreshToken(requestToken, userId, deviceType)) + .isInstanceOf(TokenStolenException.class); + verify(tokenRepository).removeToken(userId, deviceType); + } + + @Test + public void validateRefreshToken_whenRecentRefresh_throwJWTConflictException() { + //Given + Date future = new Date(System.currentTimeMillis() + 1000 * 60); + String tokenValue = "refresh-token-value"; + Token token = Token.of(TokenType.REFRESH, tokenValue, userId.toString(), new Date(), + future); + TokenDTO tokenDTO = token.toTokenDTO(); + + long recentTime = System.currentTimeMillis() - 30 * 1000; + + when(tokenRepository.findToken(userId, deviceType)).thenReturn(tokenDTO); + when(jwtFactory.parseToken(tokenDTO.value())).thenReturn(Optional.of(token)); + when(tokenRepository.getLastRefreshTime(userId, deviceType)).thenReturn( + Optional.of(recentTime)); + + //When, Then + assertThatThrownBy(() -> JWTValidator.validateRefreshToken(token, userId, deviceType)) + .isInstanceOf(TokenConflictException.class); + } +} diff --git a/src/test/java/com/knu/ddip/auth/domain/AuthUserTest.java b/src/test/java/com/knu/ddip/auth/domain/AuthUserTest.java new file mode 100644 index 0000000..7dceb11 --- /dev/null +++ b/src/test/java/com/knu/ddip/auth/domain/AuthUserTest.java @@ -0,0 +1,39 @@ +package com.knu.ddip.auth.domain; + +import org.junit.jupiter.api.Test; + +import java.util.Date; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class AuthUserTest { + + @Test + public void from_whenValidToken_returnAuthUser() { + //Given + UUID userId = UUID.randomUUID(); + Token token = Token.of(TokenType.ACCESS, "token-value", userId.toString(), new Date(), + new Date()); + + //When + AuthUser authUser = AuthUser.from(token); + + //Then + assertThat(authUser.getId()).isEqualTo(userId); + } + + @Test + public void builder_whenValidInput_returnAuthUser() { + //Given + UUID userId = UUID.randomUUID(); + + //When + AuthUser authUser = AuthUser.builder() + .id(userId) + .build(); + + //Then + assertThat(authUser.getId()).isEqualTo(userId); + } +} diff --git a/src/test/java/com/knu/ddip/auth/domain/DeviceTypeTest.java b/src/test/java/com/knu/ddip/auth/domain/DeviceTypeTest.java new file mode 100644 index 0000000..9336094 --- /dev/null +++ b/src/test/java/com/knu/ddip/auth/domain/DeviceTypeTest.java @@ -0,0 +1,65 @@ +package com.knu.ddip.auth.domain; + +import com.knu.ddip.auth.exception.OAuthBadRequestException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DeviceTypeTest { + + @Test + void fromString_WithWebString_ShouldReturnPhoneType() { + // Given + String value = "phone"; + + // When + DeviceType result = DeviceType.fromString(value); + + // Then + assertThat(result).isEqualTo(DeviceType.PHONE); + } + + @Test + void fromString_WithAppString_ShouldReturnPhoneType() { + // Given + String value = "phone"; + + // When + DeviceType result = DeviceType.fromString(value); + + // Then + assertThat(result).isEqualTo(DeviceType.PHONE); + } + + @Test + void fromString_WithMixedCaseString_ShouldBeCaseInsensitive() { + // Given + String value = "PhoNe"; + + // When + DeviceType result = DeviceType.fromString(value); + + // Then + assertThat(result).isEqualTo(DeviceType.PHONE); + } + + @Test + void fromString_WithNullString_ShouldThrowException() { + // When, Then + assertThatThrownBy(() -> DeviceType.fromString(null)) + .isInstanceOf(OAuthBadRequestException.class) + .hasMessage("state๊ฐ’์ด ์—†์Šต๋‹ˆ๋‹ค."); + } + + @Test + void fromString_WithEmptyString_ShouldThrowException() { + // Given + String value = ""; + + // When, Then + assertThatThrownBy(() -> DeviceType.fromString(value)) + .isInstanceOf(OAuthBadRequestException.class) + .hasMessage("state๊ฐ’์ด ์—†์Šต๋‹ˆ๋‹ค."); + } +} diff --git a/src/test/java/com/knu/ddip/auth/domain/OAuthMappingTest.java b/src/test/java/com/knu/ddip/auth/domain/OAuthMappingTest.java new file mode 100644 index 0000000..9f17073 --- /dev/null +++ b/src/test/java/com/knu/ddip/auth/domain/OAuthMappingTest.java @@ -0,0 +1,165 @@ +package com.knu.ddip.auth.domain; + +import com.knu.ddip.auth.business.dto.OAuthMappingEntityDto; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class OAuthMappingTest { + + @Test + void fromDto_WithToken() { + // Given + UUID id = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + + OAuthToken token = OAuthToken.builder() + .provider(OAuthProvider.KAKAO) + .accessToken("access-token") + .refreshToken("refresh-token") + .expiresIn(3600L) + .issuedAt(LocalDateTime.now()) + .build(); + + OAuthMappingEntityDto dto = OAuthMappingEntityDto.create( + id, + "social-123", + "user@example.com", + "User Name", + OAuthProvider.KAKAO, + userId, + token, + false + ); + + // When + OAuthMapping domain = OAuthMapping.fromDto(dto); + + // Then + assertThat(domain).isNotNull(); + assertThat(domain.getId()).isEqualTo(id); + assertThat(domain.getSocialUserId()).isEqualTo("social-123"); + assertThat(domain.getSocialUserEmail()).isEqualTo("user@example.com"); + assertThat(domain.getSocialUserName()).isEqualTo("User Name"); + assertThat(domain.getProvider()).isEqualTo(OAuthProvider.KAKAO); + assertThat(domain.getUserId()).isEqualTo(userId); + assertThat(domain.getOauthToken()).isNotNull(); + assertThat(domain.getOauthToken().getAccessToken()).isEqualTo("access-token"); + assertThat(domain.isTemporary()).isFalse(); + } + + @Test + void createTemporary() { + // Given + String socialId = "social-123"; + String email = "user@example.com"; + String name = "User Name"; + OAuthProvider provider = OAuthProvider.KAKAO; + + OAuthToken token = OAuthToken.builder() + .provider(provider) + .accessToken("access-token") + .refreshToken("refresh-token") + .expiresIn(3600L) + .issuedAt(LocalDateTime.now()) + .build(); + + // When + OAuthMapping domain = OAuthMapping.createTemporary( + socialId, email, name, provider, token + ); + + // Then + assertThat(domain).isNotNull(); + assertThat(domain.getId()).isNull(); + assertThat(domain.getSocialUserId()).isEqualTo(socialId); + assertThat(domain.getSocialUserEmail()).isEqualTo(email); + assertThat(domain.getSocialUserName()).isEqualTo(name); + assertThat(domain.getProvider()).isEqualTo(provider); + assertThat(domain.getUserId()).isNull(); + assertThat(domain.getOauthToken()).isEqualTo(token); + assertThat(domain.isTemporary()).isTrue(); + } + + @Test + void linkToUser_ShouldCreateNewInstanceWithUpdatedUserIdAndTemporaryFlag() { + // Given + UUID id = UUID.randomUUID(); + UUID newUserId = UUID.randomUUID(); + + OAuthToken token = OAuthToken.builder() + .provider(OAuthProvider.KAKAO) + .accessToken("access-token") + .refreshToken("refresh-token") + .expiresIn(3600L) + .issuedAt(LocalDateTime.now()) + .build(); + + OAuthMapping domain = OAuthMapping.builder() + .id(id) + .socialUserId("social-123") + .socialUserEmail("user@example.com") + .socialUserName("User Name") + .provider(OAuthProvider.KAKAO) + .oauthToken(token) + .temporary(true) + .build(); + + // When + OAuthMapping linkedDomain = domain.linkToUser(newUserId); + + // Then + assertThat(linkedDomain).isNotNull(); + assertThat(linkedDomain).isNotSameAs(domain); + assertThat(linkedDomain.getId()).isEqualTo(id); + assertThat(linkedDomain.getUserId()).isEqualTo(newUserId); + assertThat(linkedDomain.isTemporary()).isFalse(); + + assertThat(domain.getUserId()).isNull(); + assertThat(domain.isTemporary()).isTrue(); + } + + @Test + void toDto_ShouldMapCorrectly() { + // Given + UUID id = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + LocalDateTime issuedAt = LocalDateTime.now(); + + OAuthToken token = OAuthToken.builder() + .provider(OAuthProvider.KAKAO) + .accessToken("access-token") + .refreshToken("refresh-token") + .expiresIn(3600L) + .issuedAt(issuedAt) + .build(); + + OAuthMapping domain = OAuthMapping.builder() + .id(id) + .socialUserId("social-123") + .socialUserEmail("user@example.com") + .socialUserName("User Name") + .provider(OAuthProvider.KAKAO) + .userId(userId) + .oauthToken(token) + .temporary(false) + .build(); + + // When + OAuthMappingEntityDto dto = domain.toDto(); + + // Then + assertThat(dto).isNotNull(); + assertThat(dto.getId()).isEqualTo(id); + assertThat(dto.getSocialUserId()).isEqualTo("social-123"); + assertThat(dto.getSocialUserEmail()).isEqualTo("user@example.com"); + assertThat(dto.getSocialUserName()).isEqualTo("User Name"); + assertThat(dto.getProvider()).isEqualTo(OAuthProvider.KAKAO); + assertThat(dto.getUserId()).isEqualTo(userId); + assertThat(dto.getOauthToken()).isEqualTo(token); + assertThat(dto.isTemporary()).isFalse(); + } +} diff --git a/src/test/java/com/knu/ddip/auth/domain/OAuthProviderTest.java b/src/test/java/com/knu/ddip/auth/domain/OAuthProviderTest.java new file mode 100644 index 0000000..cd2bb8b --- /dev/null +++ b/src/test/java/com/knu/ddip/auth/domain/OAuthProviderTest.java @@ -0,0 +1,73 @@ +package com.knu.ddip.auth.domain; + +import com.knu.ddip.auth.exception.OAuthBadRequestException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OAuthProviderTest { + + @Test + void fromString_WithKakaoString_ShouldReturnKakaoProvider() { + // Given + String value = "kakao"; + + // When + OAuthProvider result = OAuthProvider.fromString(value); + + // Then + assertThat(result).isEqualTo(OAuthProvider.KAKAO); + } + + @Test + void fromString_WithAppleString_ShouldReturnAppleProvider() { + // Given + String value = "apple"; + + // When + OAuthProvider result = OAuthProvider.fromString(value); + + // Then + assertThat(result).isEqualTo(OAuthProvider.APPLE); + } + + @Test + void fromString_WithMixedCase_ShouldBeCaseInsensitive() { + // Given + String value = "KaKaO"; + + // When + OAuthProvider result = OAuthProvider.fromString(value); + + // Then + assertThat(result).isEqualTo(OAuthProvider.KAKAO); + } + + @Test + void fromString_WithNull_ShouldThrowException() { + // When, Then + assertThatThrownBy(() -> OAuthProvider.fromString(null)) + .isInstanceOf(OAuthBadRequestException.class) + .hasMessage("Provider ๊ฐ’์ด ์—†์Šต๋‹ˆ๋‹ค."); + } + + @Test + void fromString_WithEmpty_ShouldThrowException() { + // When, Then + assertThatThrownBy(() -> OAuthProvider.fromString("")) + .isInstanceOf(OAuthBadRequestException.class) + .hasMessage("Provider ๊ฐ’์ด ์—†์Šต๋‹ˆ๋‹ค."); + } + + @ParameterizedTest + @ValueSource(strings = {"google", "facebook", "naver", "linkedin"}) + void fromString_WithUnsupportedValue_ShouldThrowException(String value) { + // When, Then + assertThatThrownBy(() -> OAuthProvider.fromString(value)) + .isInstanceOf(OAuthBadRequestException.class) + .hasMessage("์˜ฌ๋ฐ”๋ฅธ Provider๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค."); + } +} diff --git a/src/test/java/com/knu/ddip/auth/domain/OAuthTokenTest.java b/src/test/java/com/knu/ddip/auth/domain/OAuthTokenTest.java new file mode 100644 index 0000000..8e8ac03 --- /dev/null +++ b/src/test/java/com/knu/ddip/auth/domain/OAuthTokenTest.java @@ -0,0 +1,78 @@ +package com.knu.ddip.auth.domain; + +import com.knu.ddip.auth.exception.OAuthErrorException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OAuthTokenTest { + + @Test + void ofKakao_WhenValidParameters_ThenCreateOAuthToken() { + // Given + String accessToken = "valid-access-token"; + String refreshToken = "valid-refresh-token"; + Long expiresIn = 3600L; + + // When + OAuthToken token = OAuthToken.ofKakao(accessToken, refreshToken, expiresIn); + + // Then + assertThat(token).isNotNull(); + assertThat(token.getProvider()).isEqualTo(OAuthProvider.KAKAO); + assertThat(token.getAccessToken()).isEqualTo(accessToken); + assertThat(token.getRefreshToken()).isEqualTo(refreshToken); + assertThat(token.getExpiresIn()).isEqualTo(expiresIn); + assertThat(token.getIssuedAt()).isNotNull(); + } + + @Test + void ofKakao_WhenAccessTokenIsNull_ThenThrowOAuthErrorException() { + // Given + String accessToken = null; + String refreshToken = "valid-refresh-token"; + Long expiresIn = 3600L; + + // When, Then + assertThatThrownBy( + () -> OAuthToken.ofKakao(accessToken, refreshToken, expiresIn)) + .isInstanceOf(OAuthErrorException.class) + .hasMessage("์‘๋‹ต์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์•„ accessToken์ด ์ „๋‹ฌ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."); + } + + @Test + void create_WhenValidParameters_ThenCreateOAuthToken() { + // Given + OAuthProvider provider = OAuthProvider.KAKAO; + String accessToken = "valid-access-token"; + String refreshToken = "valid-refresh-token"; + Long expiresIn = 3600L; + + // When + OAuthToken token = OAuthToken.create(provider, accessToken, refreshToken, expiresIn); + + // Then + assertThat(token).isNotNull(); + assertThat(token.getProvider()).isEqualTo(provider); + assertThat(token.getAccessToken()).isEqualTo(accessToken); + assertThat(token.getRefreshToken()).isEqualTo(refreshToken); + assertThat(token.getExpiresIn()).isEqualTo(expiresIn); + assertThat(token.getIssuedAt()).isNotNull(); + } + + @Test + void create_WhenAccessTokenIsNull_ThenReturnNull() { + // Given + OAuthProvider provider = OAuthProvider.KAKAO; + String accessToken = null; + String refreshToken = "valid-refresh-token"; + Long expiresIn = 3600L; + + // When + OAuthToken token = OAuthToken.create(provider, accessToken, refreshToken, expiresIn); + + // Then + assertThat(token).isNull(); + } +} diff --git a/src/test/java/com/knu/ddip/auth/domain/OAuthUserInfoTest.java b/src/test/java/com/knu/ddip/auth/domain/OAuthUserInfoTest.java new file mode 100644 index 0000000..fc42c81 --- /dev/null +++ b/src/test/java/com/knu/ddip/auth/domain/OAuthUserInfoTest.java @@ -0,0 +1,118 @@ +package com.knu.ddip.auth.domain; + +import com.knu.ddip.auth.exception.OAuthErrorException; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class OAuthUserInfoTest { + + @Test + void create_WhenValidParameters_ThenCreateOAuthUserInfo() { + // Given + String socialUserId = "oauth-user-id"; + String email = "user@example.com"; + String name = "User Name"; + OAuthProvider provider = OAuthProvider.KAKAO; + OAuthToken oauthToken = OAuthToken.builder() + .provider(provider) + .accessToken("access-token") + .refreshToken("refresh-token") + .expiresIn(3600L) + .issuedAt(LocalDateTime.now()) + .build(); + + // When + OAuthUserInfo userInfo = OAuthUserInfo.create(socialUserId, email, name, provider, + oauthToken); + + // Then + assertThat(userInfo).isNotNull(); + assertThat(userInfo.getSocialUserId()).isEqualTo(socialUserId); + assertThat(userInfo.getEmail()).isEqualTo(email); + assertThat(userInfo.getName()).isEqualTo(name); + assertThat(userInfo.getProvider()).isEqualTo(provider); + assertThat(userInfo.getOAuthToken()).isEqualTo(oauthToken); + } + + @Test + void create_WhenSocialUserIdIsNull_ThenThrowOAuthErrorException() { + // Given + String socialUserId = null; + String email = "user@example.com"; + String name = "User Name"; + OAuthProvider provider = OAuthProvider.KAKAO; + OAuthToken oauthToken = OAuthToken.builder() + .provider(provider) + .accessToken("access-token") + .refreshToken("refresh-token") + .expiresIn(3600L) + .issuedAt(LocalDateTime.now()) + .build(); + + // When, Then + assertThatThrownBy( + () -> OAuthUserInfo.create(socialUserId, email, name, provider, oauthToken)) + .isInstanceOf(OAuthErrorException.class) + .hasMessage("์‘๋‹ต์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์•„ socialUserId๊ฐ€ ์ „๋‹ฌ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."); + } + + @Test + void create_WhenNameIsNull_ThenUseDefaultName() { + // Given + String socialUserId = "oauth-user-id"; + String email = "user@example.com"; + String name = null; + OAuthProvider provider = OAuthProvider.KAKAO; + OAuthToken oauthToken = OAuthToken.builder() + .provider(provider) + .accessToken("access-token") + .refreshToken("refresh-token") + .expiresIn(3600L) + .issuedAt(LocalDateTime.now()) + .build(); + + // When + OAuthUserInfo userInfo = OAuthUserInfo.create(socialUserId, email, name, provider, + oauthToken); + + // Then + assertThat(userInfo).isNotNull(); + assertThat(userInfo.getSocialUserId()).isEqualTo(socialUserId); + assertThat(userInfo.getEmail()).isEqualTo(email); + assertThat(userInfo.getName()).isEqualTo("Unknown"); + assertThat(userInfo.getProvider()).isEqualTo(provider); + assertThat(userInfo.getOAuthToken()).isEqualTo(oauthToken); + } + + @Test + void create_WhenEmailIsNull_ThenAcceptNullEmail() { + // Given + String socialUserId = "oauth-user-id"; + String email = null; + String name = "User Name"; + OAuthProvider provider = OAuthProvider.KAKAO; + OAuthToken oauthToken = OAuthToken.builder() + .provider(provider) + .accessToken("access-token") + .refreshToken("refresh-token") + .expiresIn(3600L) + .issuedAt(LocalDateTime.now()) + .build(); + + // When + OAuthUserInfo userInfo = OAuthUserInfo.create(socialUserId, email, name, provider, + oauthToken); + + // Then + assertThat(userInfo).isNotNull(); + assertThat(userInfo.getSocialUserId()).isEqualTo(socialUserId); + assertThat(userInfo.getEmail()).isNull(); + assertThat(userInfo.getName()).isEqualTo(name); + assertThat(userInfo.getProvider()).isEqualTo(provider); + assertThat(userInfo.getOAuthToken()).isEqualTo(oauthToken); + } +} diff --git a/src/test/java/com/knu/ddip/auth/domain/TokenTest.java b/src/test/java/com/knu/ddip/auth/domain/TokenTest.java new file mode 100644 index 0000000..18f2746 --- /dev/null +++ b/src/test/java/com/knu/ddip/auth/domain/TokenTest.java @@ -0,0 +1,80 @@ +package com.knu.ddip.auth.domain; + +import org.junit.jupiter.api.Test; + +import java.util.Date; + +import static org.assertj.core.api.Assertions.assertThat; + +class TokenTest { + + @Test + public void of_returnToken() { + //Given + TokenType type = TokenType.ACCESS; + String value = "token-value"; + String subject = "user-id"; + Date issueAt = new Date(); + Date expiration = new Date(System.currentTimeMillis() + 1000 * 60); + + //When + Token token = Token.of(type, value, subject, issueAt, expiration); + + //Then + assertThat(token.getType()).isEqualTo(type); + assertThat(token.getValue()).isEqualTo(value); + assertThat(token.getSubject()).isEqualTo(subject); + assertThat(token.getIssueAt()).isEqualTo(issueAt); + assertThat(token.getExpiration()).isEqualTo(expiration); + } + + @Test + public void isExpired_whenExpired_returnTrue() { + //Given + Date past = new Date(System.currentTimeMillis() - 1000 * 60); + Token token = Token.of(TokenType.ACCESS, "value", "subject", new Date(), past); + + //When + boolean isExpired = token.isExpired(); + + //Then + assertThat(isExpired).isTrue(); + } + + @Test + public void isExpired_whenNotExpired_returnFalse() { + //Given + Date future = new Date(System.currentTimeMillis() + 1000 * 60); + Token token = Token.of(TokenType.ACCESS, "value", "subject", new Date(), future); + + //When + boolean isExpired = token.isExpired(); + + //Then + assertThat(isExpired).isFalse(); + } + + @Test + public void isAccessToken_whenAccessToken_returnTrue() { + //Given + Token token = Token.of(TokenType.ACCESS, "value", "subject", new Date(), new Date()); + + //When + boolean isAccessToken = token.isAccessToken(); + + //Then + assertThat(isAccessToken).isTrue(); + } + + @Test + public void isRefreshToken_whenRefreshToken_returnTrue() { + //Given + Token token = Token.of(TokenType.REFRESH, "value", "subject", new Date(), new Date()); + + //When + boolean isRefreshToken = token.isRefreshToken(); + + //Then + assertThat(isRefreshToken).isTrue(); + } +} diff --git a/src/test/java/com/knu/ddip/auth/infrastructure/interceptor/AuthInterceptorTest.java b/src/test/java/com/knu/ddip/auth/infrastructure/interceptor/AuthInterceptorTest.java new file mode 100644 index 0000000..ca16c1e --- /dev/null +++ b/src/test/java/com/knu/ddip/auth/infrastructure/interceptor/AuthInterceptorTest.java @@ -0,0 +1,165 @@ +package com.knu.ddip.auth.infrastructure.interceptor; + +import com.knu.ddip.auth.business.service.JwtFactory; +import com.knu.ddip.auth.business.validator.JwtValidator; +import com.knu.ddip.auth.domain.Token; +import com.knu.ddip.auth.domain.TokenType; +import com.knu.ddip.auth.exception.TokenBadRequestException; +import com.knu.ddip.auth.presentation.annotation.RequireAuth; +import com.knu.ddip.auth.presentation.interceptor.AuthInterceptor; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.method.HandlerMethod; + +import java.util.Date; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class AuthInterceptorTest { + + private static final String AUTH_TOKEN_ATTRIBUTE = "AUTH_TOKEN"; + + @InjectMocks + private AuthInterceptor interceptor; + + @Mock + private JwtFactory jwtFactory; + + @Mock + private JwtValidator JWTValidator; + + @Mock + private HttpServletRequest request; + + @Mock + private HttpServletResponse response; + + @Mock + private HandlerMethod handlerMethod; + + private Token token; + private UUID userId; + + @BeforeEach + void setUp() { + userId = UUID.randomUUID(); + token = Token.of(TokenType.ACCESS, "token-value", userId.toString(), new Date(), + new Date(System.currentTimeMillis() + 3600000)); + } + + @Test + public void preHandle_whenNotHandlerMethod_returnsTrue() throws Exception { + // Given + Object handler = new Object(); + + // When + boolean result = interceptor.preHandle(request, response, handler); + + // Then + assertThat(result).isTrue(); + verifyNoInteractions(jwtFactory, JWTValidator); + } + + @Test + public void preHandle_whenNoRequiredAuth_returnsTrue() { + // Given + when(handlerMethod.hasMethodAnnotation(RequireAuth.class)).thenReturn(false); + when(handlerMethod.getBeanType()).thenReturn((Class) TestController.class); + + // When + boolean result = interceptor.preHandle(request, response, handlerMethod); + + // Then + assertThat(result).isTrue(); + verifyNoInteractions(jwtFactory, JWTValidator); + } + + @Test + public void preHandle_whenMethodRequiresAuth_validatesToken() { + // Given + when(handlerMethod.hasMethodAnnotation(RequireAuth.class)).thenReturn(true); + when(request.getHeader("Authorization")).thenReturn("Bearer token-value"); + when(jwtFactory.parseToken("token-value")).thenReturn(Optional.of(token)); + doNothing().when(JWTValidator).validateAccessToken(token); + + // When + boolean result = interceptor.preHandle(request, response, handlerMethod); + + // Then + assertThat(result).isTrue(); + verify(request).setAttribute(AUTH_TOKEN_ATTRIBUTE, token); + } + + @Test + public void preHandle_whenClassRequiresAuth_validatesToken() { + // Given + when(handlerMethod.hasMethodAnnotation(RequireAuth.class)).thenReturn(false); + when(handlerMethod.getBeanType()).thenReturn((Class) SecuredController.class); + when(request.getHeader("Authorization")).thenReturn("Bearer token-value"); + when(jwtFactory.parseToken("token-value")).thenReturn(Optional.of(token)); + doNothing().when(JWTValidator).validateAccessToken(token); + + // When + boolean result = interceptor.preHandle(request, response, handlerMethod); + + // Then + assertThat(result).isTrue(); + verify(request).setAttribute(AUTH_TOKEN_ATTRIBUTE, token); + } + + @Test + public void preHandle_whenNoAuthHeader_throwsJWTBadRequestException() { + // Given + when(handlerMethod.hasMethodAnnotation(RequireAuth.class)).thenReturn(true); + when(request.getHeader("Authorization")).thenReturn(null); + + // When & Then + assertThatThrownBy(() -> interceptor.preHandle(request, response, handlerMethod)) + .isInstanceOf(TokenBadRequestException.class); + } + + @Test + public void preHandle_whenInvalidBearerFormat_throwsJWTBadRequestException() { + // Given + when(handlerMethod.hasMethodAnnotation(RequireAuth.class)).thenReturn(true); + when(request.getHeader("Authorization")).thenReturn("Invalid token-value"); + + // When & Then + assertThatThrownBy(() -> interceptor.preHandle(request, response, handlerMethod)) + .isInstanceOf(TokenBadRequestException.class); + } + + @Test + public void preHandle_whenInvalidToken_throwsJWTBadRequestException() { + // Given + when(handlerMethod.hasMethodAnnotation(RequireAuth.class)).thenReturn(true); + when(request.getHeader("Authorization")).thenReturn("Bearer invalid-token"); + when(jwtFactory.parseToken("invalid-token")).thenReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> interceptor.preHandle(request, response, handlerMethod)) + .isInstanceOf(TokenBadRequestException.class); + } + + private static class TestController { + public void testMethod() { + } + } + + @RequireAuth + private static class SecuredController { + public void testMethod() { + } + } +} diff --git a/src/test/java/com/knu/ddip/auth/infrastructure/repository/OAuthRepositoryImplTest.java b/src/test/java/com/knu/ddip/auth/infrastructure/repository/OAuthRepositoryImplTest.java new file mode 100644 index 0000000..84ee7ac --- /dev/null +++ b/src/test/java/com/knu/ddip/auth/infrastructure/repository/OAuthRepositoryImplTest.java @@ -0,0 +1,221 @@ +package com.knu.ddip.auth.infrastructure.repository; + +import com.knu.ddip.auth.business.dto.OAuthMappingEntityDto; +import com.knu.ddip.auth.domain.OAuthProvider; +import com.knu.ddip.auth.domain.OAuthToken; +import com.knu.ddip.auth.exception.OAuthErrorException; +import com.knu.ddip.auth.exception.OAuthNotFoundException; +import com.knu.ddip.auth.infrastructure.entity.OAuthMappingEntity; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.UUID; + +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.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class OAuthRepositoryImplTest { + + @Mock + private OAuthMappingJpaRepository oAuthMappingJpaRepository; + + @InjectMocks + private OAuthRepositoryImpl oAuthRepository; + + @Captor + private ArgumentCaptor entityCaptor; + + @Test + void save_WhenEntityDtoProvided_ThenSaveAndReturnDto() { + // Given + UUID id = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + OAuthToken token = OAuthToken.ofKakao("access-token", "refresh-token", 3600L); + + OAuthMappingEntityDto entityDto = OAuthMappingEntityDto.create( + id, + "social-123", + "user@example.com", + "User Name", + OAuthProvider.KAKAO, + userId, + token, + false + ); + + OAuthMappingEntity entity = OAuthMappingEntity.fromEntityDto(entityDto); + + when(oAuthMappingJpaRepository.save(any(OAuthMappingEntity.class))).thenReturn(entity); + + // When + OAuthMappingEntityDto result = oAuthRepository.save(entityDto); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getId()).isEqualTo(id); + assertThat(result.getSocialUserId()).isEqualTo("social-123"); + assertThat(result.getUserId()).isEqualTo(userId); + assertThat(result.getProvider()).isEqualTo(OAuthProvider.KAKAO); + assertThat(result.getOauthToken().getAccessToken()).isEqualTo("access-token"); + verify(oAuthMappingJpaRepository).save(any(OAuthMappingEntity.class)); + } + + @Test + void findBySocialUserIdAndProvider_WhenExists_ThenReturnDto() { + // Given + UUID id = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + String socialUserId = "social-123"; + OAuthProvider provider = OAuthProvider.KAKAO; + + OAuthToken token = OAuthToken.ofKakao("access-token", "refresh-token", 3600L); + OAuthMappingEntityDto entityDto = OAuthMappingEntityDto.create( + id, + socialUserId, + "user@example.com", + "User Name", + provider, + userId, + token, + false + ); + + OAuthMappingEntity entity = OAuthMappingEntity.fromEntityDto(entityDto); + + when(oAuthMappingJpaRepository.findBySocialUserIdAndProvider(socialUserId, provider)) + .thenReturn(Optional.of(entity)); + + // When + Optional result = oAuthRepository.findBySocialUserIdAndProvider( + socialUserId, provider); + + // Then + assertThat(result).isPresent(); + assertThat(result.get().getSocialUserId()).isEqualTo(socialUserId); + assertThat(result.get().getProvider()).isEqualTo(provider); + verify(oAuthMappingJpaRepository).findBySocialUserIdAndProvider(socialUserId, provider); + } + + @Test + void findBySocialUserIdAndProvider_WhenNotExists_ThenReturnEmpty() { + // Given + String socialUserId = "non-existent"; + OAuthProvider provider = OAuthProvider.KAKAO; + + when(oAuthMappingJpaRepository.findBySocialUserIdAndProvider(socialUserId, provider)) + .thenReturn(Optional.empty()); + + // When + Optional result = oAuthRepository.findBySocialUserIdAndProvider( + socialUserId, provider); + + // Then + assertThat(result).isEmpty(); + verify(oAuthMappingJpaRepository).findBySocialUserIdAndProvider(socialUserId, provider); + } + + @Test + void findByOauthMappingEntityIdAndProvider_WhenExists_ThenReturnDto() { + // Given + UUID id = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + OAuthProvider provider = OAuthProvider.KAKAO; + + OAuthToken token = OAuthToken.ofKakao("access-token", "refresh-token", 3600L); + OAuthMappingEntityDto entityDto = OAuthMappingEntityDto.create( + id, + "social-123", + "user@example.com", + "User Name", + provider, + userId, + token, + false + ); + + OAuthMappingEntity entity = OAuthMappingEntity.fromEntityDto(entityDto); + + when(oAuthMappingJpaRepository.findByIdAndProvider(id, provider)) + .thenReturn(Optional.of(entity)); + + // When + Optional result = oAuthRepository.findByOauthMappingEntityIdAndProvider( + id, provider); + + // Then + assertThat(result).isPresent(); + assertThat(result.get().getId()).isEqualTo(id); + assertThat(result.get().getProvider()).isEqualTo(provider); + verify(oAuthMappingJpaRepository).findByIdAndProvider(id, provider); + } + + @Test + public void findByOauthMappingEntityIdAndProvider_WhenNotExists_ThenReturnEmpty() { + // Given + UUID id = UUID.randomUUID(); + OAuthProvider provider = OAuthProvider.KAKAO; + + when(oAuthMappingJpaRepository.findByIdAndProvider(id, provider)) + .thenReturn(Optional.empty()); + + // When + Optional result = oAuthRepository.findByOauthMappingEntityIdAndProvider( + id, provider); + + // Then + assertThat(result).isEmpty(); + verify(oAuthMappingJpaRepository).findByIdAndProvider(id, provider); + } + + @Test + void update_WhenEntityNotExists_ThenThrowException() { + // Given + UUID id = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + OAuthToken token = OAuthToken.ofKakao("access-token", "refresh-token", 3600L); + + OAuthMappingEntityDto entityDto = OAuthMappingEntityDto.create( + id, + "social-123", + "user@example.com", + "User Name", + OAuthProvider.KAKAO, + userId, + token, + false + ); + + when(oAuthMappingJpaRepository.findById(id)).thenReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> oAuthRepository.update(entityDto)) + .isInstanceOf(OAuthNotFoundException.class) + .hasMessageContaining("์—…๋ฐ์ดํŠธํ•  OAuth ๋งคํ•‘์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค"); + } + + @Test + void ofKakao_WhenAccessTokenIsNull_ShouldThrowException() { + // Given + String accessToken = null; + String refreshToken = "refresh-token"; + Long expiresIn = 3600L; + String tokenType = "bearer"; + String scope = "profile"; + + // When & Then + assertThatThrownBy(() -> + OAuthToken.ofKakao(accessToken, refreshToken, expiresIn) + ).isInstanceOf(OAuthErrorException.class) + .hasMessage("์‘๋‹ต์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์•„ accessToken์ด ์ „๋‹ฌ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค."); + } +} diff --git a/src/test/java/com/knu/ddip/auth/infrastructure/repository/RedisTokenRepositoryImplIntegrationTest.java b/src/test/java/com/knu/ddip/auth/infrastructure/repository/RedisTokenRepositoryImplIntegrationTest.java new file mode 100644 index 0000000..6cec3d5 --- /dev/null +++ b/src/test/java/com/knu/ddip/auth/infrastructure/repository/RedisTokenRepositoryImplIntegrationTest.java @@ -0,0 +1,167 @@ +package com.knu.ddip.auth.infrastructure.repository; + +import com.knu.ddip.auth.business.dto.TokenDTO; +import com.knu.ddip.auth.business.service.JwtFactory; +import com.knu.ddip.auth.domain.Token; +import com.knu.ddip.config.IntegrationTestConfig; +import com.knu.ddip.config.MySQLTestContainerConfig; +import com.knu.ddip.config.RedisTestContainerConfig; +import com.knu.ddip.config.TestEnvironmentConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@ExtendWith({RedisTestContainerConfig.class, MySQLTestContainerConfig.class, TestEnvironmentConfig.class}) +@Import(IntegrationTestConfig.class) +class RedisTokenRepositoryImplIntegrationTest { + + @Autowired + private RedisTokenRepositoryImpl tokenRepository; + + @Autowired + private JwtFactory jwtFactory; + + @Autowired + private RedisTemplate redisTemplate; + + private UUID userId; + private String deviceType; + private Token refreshToken; + private TokenDTO refreshTokenDTO; + + @BeforeEach + void setUp() { + redisTemplate.getConnectionFactory().getConnection().flushAll(); + + userId = UUID.randomUUID(); + deviceType = "PHONE"; + + refreshToken = jwtFactory.createRefreshToken(userId); + refreshTokenDTO = refreshToken.toTokenDTO(); + } + + @Test + public void saveToken_whenValidInputs_thenTokenIsStored() { + // When + tokenRepository.saveToken(userId, deviceType, refreshTokenDTO); + + // Then + TokenDTO foundTokenDTO = tokenRepository.findToken(userId, deviceType); + Optional foundToken = jwtFactory.parseToken(foundTokenDTO.value()); + + assertThat(foundToken).isPresent(); + assertThat(foundToken.get().getValue()).isEqualTo(refreshToken.getValue()); + assertThat(foundToken.get().getSubject()).isEqualTo(userId.toString()); + assertThat(foundToken.get().isRefreshToken()).isTrue(); + } + + @Test + public void findToken_whenTokenDoesNotExist_thenReturnsNullTokenValue() { + // Given + UUID nonExistingUserId = UUID.randomUUID(); + + // When + TokenDTO result = tokenRepository.findToken(nonExistingUserId, deviceType); + + // Then + assertThat(result.value()).isNull(); + } + + @Test + public void removeToken_whenTokenExists_thenTokenIsRemoved() { + // Given + tokenRepository.saveToken(userId, deviceType, refreshTokenDTO); + + // When + tokenRepository.removeToken(userId, deviceType); + + // Then + TokenDTO result = tokenRepository.findToken(userId, deviceType); // ๊ฐ™์€ userId๋กœ ์ˆ˜์ • + assertThat(result.value()).isNull(); + } + + @Test + public void getLastRefreshTime_whenTimeIsSet_thenReturnsCorrectTime() { + // Given + tokenRepository.saveToken(userId, deviceType, refreshTokenDTO); + long beforeUpdate = System.currentTimeMillis(); + + // When + tokenRepository.updateLastRefreshTime(userId, deviceType); + long afterUpdate = System.currentTimeMillis(); + Optional lastRefreshTime = tokenRepository.getLastRefreshTime(userId, deviceType); + + // Then + assertThat(lastRefreshTime).isPresent(); + assertThat(lastRefreshTime.get()).isBetween(beforeUpdate, afterUpdate); + } + + @Test + public void getLastRefreshTime_whenTimeNotSet_thenReturnsEmpty() { + // Given + UUID newUserId = UUID.randomUUID(); + + // When + Optional lastRefreshTime = tokenRepository.getLastRefreshTime(newUserId, deviceType); + + // Then + assertThat(lastRefreshTime).isEmpty(); + } + + @Test + public void saveToken_whenCalledMultipleTimesForSameUser_thenOverwritesPreviousToken() throws InterruptedException { + // Given + tokenRepository.saveToken(userId, deviceType, refreshTokenDTO); + + Thread.sleep(1000); + + Token newRefreshToken = jwtFactory.createRefreshToken(userId); + TokenDTO newRefreshTokenDTO = newRefreshToken.toTokenDTO(); + + // When + tokenRepository.saveToken(userId, deviceType, newRefreshTokenDTO); + + // Then + TokenDTO foundTokenDTO = tokenRepository.findToken(userId, deviceType); + + assertThat(foundTokenDTO).isNotNull(); + assertThat(foundTokenDTO.value()).isEqualTo(newRefreshToken.getValue()); + assertThat(foundTokenDTO.value()).isNotEqualTo(refreshToken.getValue()); + } + + @Test + public void saveToken_whenDifferentDeviceTypes_thenStoresSeparateTokens() { + // Given + String otherDeviceType = "tablet"; + Token otherDeviceToken = jwtFactory.createRefreshToken(userId); + TokenDTO otherDeviceTokenDTO = otherDeviceToken.toTokenDTO(); + + // When + tokenRepository.saveToken(userId, deviceType, refreshTokenDTO); + tokenRepository.saveToken(userId, otherDeviceType, otherDeviceTokenDTO); + + // Then + TokenDTO tabletToken = tokenRepository.findToken(userId, deviceType); + TokenDTO phoneToken = tokenRepository.findToken(userId, otherDeviceType); + + assertThat(tabletToken).isNotNull(); + assertThat(phoneToken).isNotNull(); + assertThat(tabletToken.value()).isEqualTo(refreshToken.getValue()); + assertThat(phoneToken.value()).isEqualTo(otherDeviceToken.getValue()); + + tokenRepository.removeToken(userId, deviceType); + + assertThat(tokenRepository.findToken(userId, deviceType)).isNotNull(); + assertThat(tokenRepository.findToken(userId, otherDeviceType)).isNotNull(); + } +} diff --git a/src/test/java/com/knu/ddip/auth/infrastructure/repository/RedisTokenRepositoryImplTest.java b/src/test/java/com/knu/ddip/auth/infrastructure/repository/RedisTokenRepositoryImplTest.java new file mode 100644 index 0000000..b272386 --- /dev/null +++ b/src/test/java/com/knu/ddip/auth/infrastructure/repository/RedisTokenRepositoryImplTest.java @@ -0,0 +1,137 @@ +package com.knu.ddip.auth.infrastructure.repository; + +import com.knu.ddip.auth.business.dto.TokenDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class RedisTokenRepositoryImplTest { + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + private RedisTokenRepositoryImpl tokenRepository; + private UUID userId; + private String deviceType; + + @BeforeEach + void setUp() { + lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations); + tokenRepository = new RedisTokenRepositoryImpl(redisTemplate); + userId = UUID.randomUUID(); + deviceType = "web"; + } + + @Test + public void saveToken_storesTokenInRedis() { + //Given + String tokenValue = "token-value"; + TokenDTO tokenDTO = TokenDTO.from(tokenValue); + String expectedKey = "refreshToken:" + userId + ":" + deviceType; + + //When + tokenRepository.saveToken(userId, deviceType, tokenDTO); + + //Then + verify(valueOperations).set(eq(expectedKey), eq(tokenDTO.value()), anyLong(), + eq(TimeUnit.MILLISECONDS)); + verify(valueOperations).set(startsWith("refresh-time:"), anyString(), anyLong(), + eq(TimeUnit.MILLISECONDS)); + } + + @Test + public void findToken_whenTokenExists_returnToken() { + //Given + String tokenValue = "token-value"; + TokenDTO tokenDTO = TokenDTO.from(tokenValue); + String expectedKey = "refreshToken:" + userId + ":" + deviceType; + + when(valueOperations.get(expectedKey)).thenReturn(tokenValue); + + //When + TokenDTO result = tokenRepository.findToken(userId, deviceType); + + //Then + assertThat(result.value()).isEqualTo(tokenDTO.value()); + } + + @Test + public void findToken_whenTokenDoesNotExist_returnNullToken() { + //Given + String expectedKey = "refreshToken:" + userId + ":" + deviceType; + when(valueOperations.get(expectedKey)).thenReturn(null); + + //When + TokenDTO result = tokenRepository.findToken(userId, deviceType); + + //Then + assertThat(result.value()).isNull(); + } + + @Test + public void removeToken_deletesTokenFromRedis() { + //Given + String expectedKey = "refreshToken:" + userId + ":" + deviceType; + + //When + tokenRepository.removeToken(userId, deviceType); + + //Then + verify(redisTemplate).delete(expectedKey); + } + + @Test + public void getLastRefreshTime_returnsTime() { + // Given + String timeKey = "refresh-time:" + userId + ":" + deviceType; + long timestamp = System.currentTimeMillis(); + when(valueOperations.get(timeKey)).thenReturn(String.valueOf(timestamp)); + + // When + Optional result = tokenRepository.getLastRefreshTime(userId, deviceType); + + // Then + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(timestamp); + } + + @Test + public void updateLastRefreshTime_storesTimeInRedis() { + //Given + String timeKey = "refresh-time:" + userId + ":" + deviceType; + + //When + tokenRepository.updateLastRefreshTime(userId, deviceType); + + //Then + verify(valueOperations).set(eq(timeKey), anyString(), anyLong(), eq(TimeUnit.MILLISECONDS)); + } + + @Test + void getLastRefreshTime_shouldReturnEmptyOptional_whenTimeDoesNotExist() { + // given + String timeKey = "refresh-time:" + userId + ":" + deviceType; + when(redisTemplate.opsForValue().get(timeKey)).thenReturn(null); + + // when + Optional result = tokenRepository.getLastRefreshTime(userId, deviceType); + + // then + assertThat(result).isEmpty(); + } +} diff --git a/src/test/java/com/knu/ddip/auth/infrastructure/repository/entity/OAuthMappingEntityTest.java b/src/test/java/com/knu/ddip/auth/infrastructure/repository/entity/OAuthMappingEntityTest.java new file mode 100644 index 0000000..fab6e02 --- /dev/null +++ b/src/test/java/com/knu/ddip/auth/infrastructure/repository/entity/OAuthMappingEntityTest.java @@ -0,0 +1,378 @@ +package com.knu.ddip.auth.infrastructure.repository.entity; + +import com.knu.ddip.auth.business.dto.OAuthMappingEntityDto; +import com.knu.ddip.auth.domain.OAuthProvider; +import com.knu.ddip.auth.domain.OAuthToken; +import com.knu.ddip.auth.infrastructure.entity.OAuthMappingEntity; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class OAuthMappingEntityTest { + + @Test + void fromEntityDto_WithToken() { + // Given + UUID id = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + + OAuthToken token = OAuthToken.create( + OAuthProvider.KAKAO, "access-token", "refresh-token", 3600L); + + OAuthMappingEntityDto dto = OAuthMappingEntityDto.create( + id, + "social-123", + "user@example.com", + "User Name", + OAuthProvider.KAKAO, + userId, + token, + false + ); + + // When + OAuthMappingEntity entity = OAuthMappingEntity.fromEntityDto(dto); + + // Then + assertThat(entity).isNotNull(); + assertThat(entity.getId()).isEqualTo(id); + assertThat(entity.getSocialUserId()).isEqualTo("social-123"); + assertThat(entity.getSocialUserEmail()).isEqualTo("user@example.com"); + assertThat(entity.getSocialUserName()).isEqualTo("User Name"); + assertThat(entity.getProvider()).isEqualTo(OAuthProvider.KAKAO); + assertThat(entity.getUserId()).isEqualTo(userId); + assertThat(entity.getAccessToken()).isEqualTo("access-token"); + assertThat(entity.getRefreshToken()).isEqualTo("refresh-token"); + assertThat(entity.getExpiresIn()).isEqualTo(3600L); + assertThat(entity.getTokenIssuedAt()).isNotNull(); + assertThat(entity.isTemporary()).isFalse(); + } + + @Test + void fromEntityDto_WithoutToken() { + // Given + UUID id = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + + OAuthMappingEntityDto dto = OAuthMappingEntityDto.create( + id, + "social-123", + "user@example.com", + "User Name", + OAuthProvider.KAKAO, + userId, + null, + true + ); + + // When + OAuthMappingEntity entity = OAuthMappingEntity.fromEntityDto(dto); + + // Then + assertThat(entity).isNotNull(); + assertThat(entity.getId()).isEqualTo(id); + assertThat(entity.getSocialUserId()).isEqualTo("social-123"); + assertThat(entity.getSocialUserEmail()).isEqualTo("user@example.com"); + assertThat(entity.getSocialUserName()).isEqualTo("User Name"); + assertThat(entity.getProvider()).isEqualTo(OAuthProvider.KAKAO); + assertThat(entity.getUserId()).isEqualTo(userId); + assertThat(entity.getAccessToken()).isNull(); + assertThat(entity.getRefreshToken()).isNull(); + assertThat(entity.getExpiresIn()).isNull(); + assertThat(entity.getTokenIssuedAt()).isNull(); + assertThat(entity.isTemporary()).isTrue(); + } + + @Test + void toEntityDto_WithToken() { + // Given + UUID id = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + LocalDateTime issuedAt = LocalDateTime.now(); + + OAuthMappingEntity entity = createEntityWithToken(id, userId, issuedAt, false); + + // When + OAuthMappingEntityDto dto = entity.toEntityDto(); + + // Then + assertThat(dto).isNotNull(); + assertThat(dto.getId()).isEqualTo(id); + assertThat(dto.getSocialUserId()).isEqualTo("social-123"); + assertThat(dto.getSocialUserEmail()).isEqualTo("user@example.com"); + assertThat(dto.getSocialUserName()).isEqualTo("User Name"); + assertThat(dto.getProvider()).isEqualTo(OAuthProvider.KAKAO); + assertThat(dto.getUserId()).isEqualTo(userId); + assertThat(dto.getOauthToken()).isNotNull(); + assertThat(dto.getOauthToken().getAccessToken()).isEqualTo("access-token"); + assertThat(dto.getOauthToken().getRefreshToken()).isEqualTo("refresh-token"); + assertThat(dto.getOauthToken().getExpiresIn()).isEqualTo(3600L); + assertThat(dto.isTemporary()).isFalse(); + } + + @Test + void toEntityDto_WithoutToken() { + // Given + UUID id = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + + OAuthMappingEntity entity = createEntityWithoutToken(id, userId, true); + + // When + OAuthMappingEntityDto dto = entity.toEntityDto(); + + // Then + assertThat(dto).isNotNull(); + assertThat(dto.getId()).isEqualTo(id); + assertThat(dto.getSocialUserId()).isEqualTo("social-123"); + assertThat(dto.getSocialUserEmail()).isEqualTo("user@example.com"); + assertThat(dto.getSocialUserName()).isEqualTo("User Name"); + assertThat(dto.getProvider()).isEqualTo(OAuthProvider.KAKAO); + assertThat(dto.getUserId()).isEqualTo(userId); + assertThat(dto.getOauthToken()).isNull(); + assertThat(dto.isTemporary()).isTrue(); + } + + @Test + void buildOAuthToken_WithNullAccessToken_ShouldReturnNull() { + // Given + UUID id = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + + OAuthMappingEntity entity = createEntityWithoutToken(id, userId, false); + + // When + OAuthMappingEntityDto dto = entity.toEntityDto(); + + // Then + assertThat(dto.getOauthToken()).isNull(); + } + + @Test + void buildOAuthToken_WithNullTokenIssuedAt_ShouldCreateTokenWithCurrentTime() { + // Given + UUID id = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + + OAuthMappingEntity entity = OAuthMappingEntity.builder() + .id(id) + .socialUserId("social-123") + .socialUserEmail("user@example.com") + .socialUserName("User Name") + .provider(OAuthProvider.KAKAO) + .userId(userId) + .accessToken("access-token") + .refreshToken("refresh-token") + .expiresIn(3600L) + .tokenIssuedAt(null) + .temporary(false) + .build(); + + // When + OAuthMappingEntityDto dto = entity.toEntityDto(); + + // Then + assertThat(dto.getOauthToken()).isNotNull(); + assertThat(dto.getOauthToken().getAccessToken()).isEqualTo("access-token"); + assertThat(dto.getOauthToken().getIssuedAt()).isNotNull(); + } + + @Test + void updateFromDto_WhenAllFieldsProvided_ShouldUpdateAllFields() { + // Given + UUID id = UUID.randomUUID(); + UUID oldUserId = UUID.randomUUID(); + UUID newUserId = UUID.randomUUID(); + LocalDateTime oldIssuedAt = LocalDateTime.now().minusDays(1); + LocalDateTime newIssuedAt = LocalDateTime.now(); + + OAuthMappingEntity entity = createEntityWithToken(id, oldUserId, oldIssuedAt, true); + + OAuthToken newToken = OAuthToken.create( + OAuthProvider.KAKAO, "new-access-token", "new-refresh-token", 7200L); + + OAuthMappingEntityDto updateDto = OAuthMappingEntityDto.create( + id, + "social-123", + "updated@example.com", + "Updated Name", + OAuthProvider.KAKAO, + newUserId, + newToken, + false + ); + + // When + entity.updateFromDto(updateDto); + + // Then + assertThat(entity.getUserId()).isEqualTo(newUserId); + assertThat(entity.getSocialUserEmail()).isEqualTo("updated@example.com"); + assertThat(entity.getSocialUserName()).isEqualTo("Updated Name"); + assertThat(entity.getAccessToken()).isEqualTo("new-access-token"); + assertThat(entity.getRefreshToken()).isEqualTo("new-refresh-token"); + assertThat(entity.getExpiresIn()).isEqualTo(7200L); + assertThat(entity.isTemporary()).isFalse(); + } + + @Test + void updateFromDto_WhenUserIdIsNull_ShouldNotUpdateUserId() { + // Given + UUID id = UUID.randomUUID(); + UUID originalUserId = UUID.randomUUID(); + LocalDateTime issuedAt = LocalDateTime.now(); + + OAuthMappingEntity entity = createEntityWithToken(id, originalUserId, issuedAt, true); + + OAuthMappingEntityDto updateDto = OAuthMappingEntityDto.create( + id, + "social-123", + "updated@example.com", + "Updated Name", + OAuthProvider.KAKAO, + null, + null, + false + ); + + // When + entity.updateFromDto(updateDto); + + // Then + assertThat(entity.getUserId()).isEqualTo(originalUserId); + assertThat(entity.getSocialUserEmail()).isEqualTo("updated@example.com"); + assertThat(entity.getSocialUserName()).isEqualTo("Updated Name"); + assertThat(entity.isTemporary()).isFalse(); + } + + @Test + void updateFromDto_WhenOAuthTokenIsNull_ShouldNotUpdateTokenFields() { + // Given + UUID id = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + UUID newUserId = UUID.randomUUID(); + LocalDateTime issuedAt = LocalDateTime.now(); + + OAuthMappingEntity entity = createEntityWithToken(id, userId, issuedAt, true); + String originalAccessToken = entity.getAccessToken(); + String originalRefreshToken = entity.getRefreshToken(); + Long originalExpiresIn = entity.getExpiresIn(); + LocalDateTime originalIssuedAt = entity.getTokenIssuedAt(); + + OAuthMappingEntityDto updateDto = OAuthMappingEntityDto.create( + id, + "social-123", + "updated@example.com", + "Updated Name", + OAuthProvider.KAKAO, + newUserId, + null, + false + ); + + // When + entity.updateFromDto(updateDto); + + // Then + assertThat(entity.getUserId()).isEqualTo(newUserId); + assertThat(entity.getSocialUserEmail()).isEqualTo("updated@example.com"); + assertThat(entity.getSocialUserName()).isEqualTo("Updated Name"); + assertThat(entity.getAccessToken()).isEqualTo(originalAccessToken); + assertThat(entity.getRefreshToken()).isEqualTo(originalRefreshToken); + assertThat(entity.getExpiresIn()).isEqualTo(originalExpiresIn); + assertThat(entity.getTokenIssuedAt()).isEqualTo(originalIssuedAt); + assertThat(entity.isTemporary()).isFalse(); + } + + @Test + void updateFromDto_WhenSocialUserEmailIsNull_ShouldNotUpdateEmail() { + // Given + UUID id = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + LocalDateTime issuedAt = LocalDateTime.now(); + + OAuthMappingEntity entity = createEntityWithToken(id, userId, issuedAt, true); + String originalEmail = entity.getSocialUserEmail(); + + OAuthMappingEntityDto updateDto = OAuthMappingEntityDto.create( + id, + "social-123", + null, + "Updated Name", + OAuthProvider.KAKAO, + userId, + null, + false + ); + + // When + entity.updateFromDto(updateDto); + + // Then + assertThat(entity.getSocialUserEmail()).isEqualTo(originalEmail); + assertThat(entity.getSocialUserName()).isEqualTo("Updated Name"); + assertThat(entity.isTemporary()).isFalse(); + } + + @Test + void updateFromDto_WhenSocialUserNameIsNull_ShouldNotUpdateName() { + // Given + UUID id = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + LocalDateTime issuedAt = LocalDateTime.now(); + + OAuthMappingEntity entity = createEntityWithToken(id, userId, issuedAt, true); + String originalName = entity.getSocialUserName(); + + OAuthMappingEntityDto updateDto = OAuthMappingEntityDto.create( + id, + "social-123", + "updated@example.com", + null, + OAuthProvider.KAKAO, + userId, + null, + false + ); + + // When + entity.updateFromDto(updateDto); + + // Then + assertThat(entity.getSocialUserEmail()).isEqualTo("updated@example.com"); + assertThat(entity.getSocialUserName()).isEqualTo(originalName); + assertThat(entity.isTemporary()).isFalse(); + } + + private OAuthMappingEntity createEntityWithToken(UUID id, UUID userId, LocalDateTime issuedAt, + boolean temporary) { + return OAuthMappingEntity.builder() + .id(id) + .socialUserId("social-123") + .socialUserEmail("user@example.com") + .socialUserName("User Name") + .provider(OAuthProvider.KAKAO) + .userId(userId) + .accessToken("access-token") + .refreshToken("refresh-token") + .expiresIn(3600L) + .tokenIssuedAt(issuedAt) + .temporary(temporary) + .build(); + } + + private OAuthMappingEntity createEntityWithoutToken(UUID id, UUID userId, boolean temporary) { + return OAuthMappingEntity.builder() + .id(id) + .socialUserId("social-123") + .socialUserEmail("user@example.com") + .socialUserName("User Name") + .provider(OAuthProvider.KAKAO) + .userId(userId) + .temporary(temporary) + .build(); + } +} diff --git a/src/test/java/com/knu/ddip/auth/infrastructure/resolver/AuthUserArgumentResolverTest.java b/src/test/java/com/knu/ddip/auth/infrastructure/resolver/AuthUserArgumentResolverTest.java new file mode 100644 index 0000000..eea53db --- /dev/null +++ b/src/test/java/com/knu/ddip/auth/infrastructure/resolver/AuthUserArgumentResolverTest.java @@ -0,0 +1,120 @@ +package com.knu.ddip.auth.infrastructure.resolver; + +import com.knu.ddip.auth.domain.AuthUser; +import com.knu.ddip.auth.domain.Token; +import com.knu.ddip.auth.domain.TokenType; +import com.knu.ddip.auth.exception.TokenBadRequestException; +import com.knu.ddip.auth.presentation.annotation.Login; +import com.knu.ddip.auth.presentation.resolver.AuthUserArgumentResolver; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.Date; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AuthUserArgumentResolverTest { + + private static final String AUTH_TOKEN_ATTRIBUTE = "AUTH_TOKEN"; + + @InjectMocks + private AuthUserArgumentResolver resolver; + + @Mock + private MethodParameter parameter; + + @Mock + private ModelAndViewContainer mavContainer; + + @Mock + private NativeWebRequest webRequest; + + @Mock + private WebDataBinderFactory binderFactory; + + @Mock + private HttpServletRequest httpRequest; + + @Test + public void supportsParameter_whenHasLoginAnnotationAndAuthUserType_returnsTrue() { + // Given + when(parameter.hasMethodAnnotation(Login.class)).thenReturn(true); + when(parameter.getParameterType()).thenReturn((Class) AuthUser.class); + + // When + boolean result = resolver.supportsParameter(parameter); + + // Then + assertThat(result).isTrue(); + } + + @Test + public void supportsParameter_whenNoLoginAnnotation_returnsFalse() { + // Given + when(parameter.hasMethodAnnotation(Login.class)).thenReturn(false); + when(parameter.getParameterType()).thenReturn((Class) AuthUser.class); + + // When + boolean result = resolver.supportsParameter(parameter); + + // Then + assertThat(result).isFalse(); + } + + @Test + public void supportsParameter_whenNotAuthUserType_returnsFalse() { + // Given + when(parameter.hasMethodAnnotation(Login.class)).thenReturn(true); + when(parameter.getParameterType()).thenReturn((Class) String.class); + + // When + boolean result = resolver.supportsParameter(parameter); + + // Then + assertThat(result).isFalse(); + } + + @Test + public void resolveArgument_whenTokenExists_returnsAuthUser() { + // Given + UUID userId = UUID.randomUUID(); + Date expiryDate = new Date(System.currentTimeMillis() + 3600000); + Token token = Token.of(TokenType.ACCESS, "token-value", String.valueOf(userId), new Date(), + expiryDate); + + when(webRequest.getNativeRequest()).thenReturn(httpRequest); + when(httpRequest.getAttribute(AUTH_TOKEN_ATTRIBUTE)).thenReturn(token); + + // When + AuthUser authUser = (AuthUser) resolver.resolveArgument(parameter, mavContainer, webRequest, + binderFactory); + + // Then + assertThat(authUser).isNotNull(); + assertThat(authUser.getId()).isEqualTo(userId); + } + + @Test + public void resolveArgument_whenTokenNotExists_throwsJWTBadRequestException() { + // Given + when(webRequest.getNativeRequest()).thenReturn(httpRequest); + when(httpRequest.getAttribute(AUTH_TOKEN_ATTRIBUTE)).thenReturn(null); + + // When & Then + assertThatThrownBy(() -> + resolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory)) + .isInstanceOf(TokenBadRequestException.class); + } +} diff --git a/src/test/java/com/knu/ddip/config/IntegrationTestConfig.java b/src/test/java/com/knu/ddip/config/IntegrationTestConfig.java new file mode 100644 index 0000000..f9c2fa8 --- /dev/null +++ b/src/test/java/com/knu/ddip/config/IntegrationTestConfig.java @@ -0,0 +1,12 @@ +package com.knu.ddip.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Import; + +@TestConfiguration +@Import({ + RedisTestConfig.class, + MySQLTestConfig.class +}) +public class IntegrationTestConfig { +} diff --git a/src/test/java/com/knu/ddip/config/MySQLTestConfig.java b/src/test/java/com/knu/ddip/config/MySQLTestConfig.java new file mode 100644 index 0000000..ac9caff --- /dev/null +++ b/src/test/java/com/knu/ddip/config/MySQLTestConfig.java @@ -0,0 +1,31 @@ +package com.knu.ddip.config; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +import javax.sql.DataSource; + +@TestConfiguration +public class MySQLTestConfig { + + @Bean + @Primary + public DataSource testDataSource() { + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(System.getProperty("spring.datasource.url")); + config.setUsername(System.getProperty("spring.datasource.username")); + config.setPassword(System.getProperty("spring.datasource.password")); + config.setDriverClassName(System.getProperty("spring.datasource.driver-class-name")); + + config.setMaximumPoolSize(5); + config.setMinimumIdle(1); + config.setConnectionTimeout(30000); + config.setIdleTimeout(600000); + config.setMaxLifetime(1800000); + + return new HikariDataSource(config); + } +} diff --git a/src/test/java/com/knu/ddip/config/MySQLTestContainerConfig.java b/src/test/java/com/knu/ddip/config/MySQLTestContainerConfig.java new file mode 100644 index 0000000..a6a1e2d --- /dev/null +++ b/src/test/java/com/knu/ddip/config/MySQLTestContainerConfig.java @@ -0,0 +1,43 @@ +package com.knu.ddip.config; + +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +public class MySQLTestContainerConfig implements BeforeAllCallback { + private static final String MYSQL_IMAGE = "mysql:8.0.36"; + private static final String MYSQL_DB = "test_db"; + private static final String MYSQL_USER = "test_user"; + private static final String MYSQL_PASSWORD = "test_password"; + + private static final int MYSQL_PORT = 3306; + private static GenericContainer mysqlContainer; + + @Override + public void beforeAll(ExtensionContext context) { + mysqlContainer = new GenericContainer<>(DockerImageName.parse(MYSQL_IMAGE)) + .withExposedPorts(MYSQL_PORT) + .withEnv("MYSQL_DATABASE", MYSQL_DB) + .withEnv("MYSQL_USER", MYSQL_USER) + .withEnv("MYSQL_PASSWORD", MYSQL_PASSWORD) + .withEnv("MYSQL_ROOT_PASSWORD", "root"); + + mysqlContainer.start(); + + String jdbcUrl = String.format("jdbc:mysql://%s:%d/%s?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC", + mysqlContainer.getHost(), + mysqlContainer.getMappedPort(MYSQL_PORT), + MYSQL_DB + ); + + System.setProperty("spring.datasource.url", jdbcUrl); + System.setProperty("spring.datasource.username", MYSQL_USER); + System.setProperty("spring.datasource.password", MYSQL_PASSWORD); + System.setProperty("spring.datasource.driver-class-name", "com.mysql.cj.jdbc.Driver"); + + System.setProperty("spring.jpa.properties.hibernate.dialect", "org.hibernate.dialect.MySQL8Dialect"); + System.setProperty("spring.jpa.hibernate.ddl-auto", "create-drop"); + System.setProperty("SPRING_JPA_SHOW_SQL", "false"); + } +} diff --git a/src/test/java/com/knu/ddip/config/RedisTestConfig.java b/src/test/java/com/knu/ddip/config/RedisTestConfig.java new file mode 100644 index 0000000..5685251 --- /dev/null +++ b/src/test/java/com/knu/ddip/config/RedisTestConfig.java @@ -0,0 +1,42 @@ +package com.knu.ddip.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@TestConfiguration +public class RedisTestConfig { + + @Bean + @Primary + public LettuceConnectionFactory testRedisConnectionFactory() { + String host = System.getProperty("spring.data.redis.host", "localhost"); + int port = Integer.parseInt(System.getProperty("spring.data.redis.port", "6379")); + String password = System.getProperty("spring.data.redis.password", ""); + + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port); + if (!password.isEmpty()) { + config.setPassword(password); + } + + return new LettuceConnectionFactory(config); + } + + @Bean + @Primary + public RedisTemplate testRedisTemplate(LettuceConnectionFactory testRedisConnectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(testRedisConnectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + template.afterPropertiesSet(); + return template; + } +} diff --git a/src/test/java/com/knu/ddip/config/RedisTestContainerConfig.java b/src/test/java/com/knu/ddip/config/RedisTestContainerConfig.java new file mode 100644 index 0000000..867a3b6 --- /dev/null +++ b/src/test/java/com/knu/ddip/config/RedisTestContainerConfig.java @@ -0,0 +1,29 @@ +package com.knu.ddip.config; + +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +public class RedisTestContainerConfig implements BeforeAllCallback { + private static final String REDIS_IMAGE = "redis:7.0.8-alpine"; + private static final int REDIS_PORT = 6379; + private static final String REDIS_PASSWORD = "testpassword"; + private GenericContainer redisGenericContainer; + + @Override + public void beforeAll(ExtensionContext context) { + redisGenericContainer = + new GenericContainer(DockerImageName.parse(REDIS_IMAGE)) + .withExposedPorts(REDIS_PORT) + .withCommand("redis-server --requirepass " + REDIS_PASSWORD) + ; + + redisGenericContainer.start(); + + System.setProperty("spring.data.redis.host", redisGenericContainer.getHost()); + System.setProperty("spring.data.redis.port", + String.valueOf(redisGenericContainer.getMappedPort(REDIS_PORT))); + System.setProperty("spring.data.redis.password", REDIS_PASSWORD); + } +} diff --git a/src/test/java/com/knu/ddip/config/TestEnvironmentConfig.java b/src/test/java/com/knu/ddip/config/TestEnvironmentConfig.java new file mode 100644 index 0000000..be11750 --- /dev/null +++ b/src/test/java/com/knu/ddip/config/TestEnvironmentConfig.java @@ -0,0 +1,54 @@ +package com.knu.ddip.config; + +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; + +public class TestEnvironmentConfig implements BeforeAllCallback { + + // JWT ํ…Œ์ŠคํŠธ์šฉ ์‹œํฌ๋ฆฟ ํ‚ค + private static final String TEST_JWT_SECRET = "testSecretKeyForJwtTestingPurposesOnlyDoNotUseInProduction123456"; + + // Redis ํ…Œ์ŠคํŠธ์šฉ ์„ค์ •๊ฐ’ + private static final String TEST_REDIS_HOST = "localhost"; + private static final String TEST_REDIS_PORT = "6379"; + private static final String TEST_REDIS_PASSWORD = "testpassword"; + + // MySQL ํ…Œ์ŠคํŠธ์šฉ ์„ค์ •๊ฐ’ + private static final String TEST_MYSQL_HOST = "localhost"; + private static final String TEST_MYSQL_PORT = "3306"; + private static final String TEST_MYSQL_USER = "testuser"; + private static final String TEST_MYSQL_PASSWORD = "testpassword"; + private static final String TEST_MYSQL_DATABASE = "testdb"; + + // OAuth ํ…Œ์ŠคํŠธ์šฉ ์„ค์ •๊ฐ’ + private static final String TEST_OAUTH_APP_REDIRECT_URI = "http://localhost:3000/test"; + + // Kakao OAuth ํ…Œ์ŠคํŠธ์šฉ ์„ค์ •๊ฐ’ + private static final String TEST_KAKAO_REST_API_KEY = "test_kakao_api_key_for_testing_only"; + private static final String TEST_KAKAO_BACKEND_REDIRECT_URI = "http://localhost:8080/auth/oauth/kakao/callback/test"; + + @Override + public void beforeAll(ExtensionContext context) { + // JWT ์„ค์ • + System.setProperty("SECRET_KEY", TEST_JWT_SECRET); + + // Redis ์„ค์ • + System.setProperty("REDIS_HOST", TEST_REDIS_HOST); + System.setProperty("REDIS_PORT", TEST_REDIS_PORT); + System.setProperty("REDIS_PASSWORD", TEST_REDIS_PASSWORD); + + // MySQL ์„ค์ • + System.setProperty("MYSQL_HOST", TEST_MYSQL_HOST); + System.setProperty("MYSQL_PORT", TEST_MYSQL_PORT); + System.setProperty("MYSQL_USER", TEST_MYSQL_USER); + System.setProperty("MYSQL_PASSWORD", TEST_MYSQL_PASSWORD); + System.setProperty("MYSQL_DATABASE", TEST_MYSQL_DATABASE); + + // OAuth ์„ค์ • + System.setProperty("OAUTH_APP_REDIRECT_URI", TEST_OAUTH_APP_REDIRECT_URI); + + // Kakao OAuth ์„ค์ • + System.setProperty("KAKAO_REST_API_KEY", TEST_KAKAO_REST_API_KEY); + System.setProperty("KAKAO_BACKEND_REDIRECT_URI", TEST_KAKAO_BACKEND_REDIRECT_URI); + } +} diff --git a/src/test/java/com/knu/ddip/user/business/UserFactoryTest.java b/src/test/java/com/knu/ddip/user/business/UserFactoryTest.java new file mode 100644 index 0000000..a0f7f84 --- /dev/null +++ b/src/test/java/com/knu/ddip/user/business/UserFactoryTest.java @@ -0,0 +1,31 @@ +package com.knu.ddip.user.business; + +import com.knu.ddip.user.business.service.UserFactory; +import com.knu.ddip.user.domain.User; +import com.knu.ddip.user.domain.UserDomain; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +public class UserFactoryTest { + + @Test + public void create_whenStatusIsActive_returnActiveUser() { + //Given + UUID id = UUID.randomUUID(); + String email = "test@example.com"; + String status = "ACTIVE"; + String nickname = "testName"; + + //When + User user = UserFactory.create(id, email, nickname, status); + + //Then + assertInstanceOf(UserDomain.class, user); + assertEquals(id, user.getId()); + assertEquals(email, user.getEmail()); + } +} diff --git a/src/test/java/com/knu/ddip/user/business/service/UserServiceTest.java b/src/test/java/com/knu/ddip/user/business/service/UserServiceTest.java new file mode 100644 index 0000000..dfecd86 --- /dev/null +++ b/src/test/java/com/knu/ddip/user/business/service/UserServiceTest.java @@ -0,0 +1,158 @@ +package com.knu.ddip.user.business.service; + +import com.knu.ddip.auth.business.dto.JwtResponse; +import com.knu.ddip.auth.business.service.OAuthLoginService; +import com.knu.ddip.auth.domain.OAuthProvider; +import com.knu.ddip.user.business.dto.SignupRequest; +import com.knu.ddip.user.business.dto.UniqueMailResponse; +import com.knu.ddip.user.business.dto.UserEntityDto; +import com.knu.ddip.user.domain.User; +import com.knu.ddip.user.exception.UserEmailDuplicateException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private OAuthLoginService oAuthLoginService; + + @InjectMocks + private UserService userService; + + @Test + void getUserByEmail_WhenEmailExists_ThenReturnUser() { + //Given + String email = "test@example.com"; + UUID userId = UUID.randomUUID(); + UserEntityDto userEntityDto = UserEntityDto.create( + userId, email, "testUser", "ACTIVE" + ); + when(userRepository.getByEmail(email)).thenReturn(userEntityDto); + + //When + User result = userService.getUserByEmail(email); + + //Then + assertNotNull(result); + assertEquals(userId, result.getId()); + assertEquals(email, result.getEmail()); + assertEquals("testUser", result.getNickname()); + } + + @Test + void checkEmailUniqueness_WhenEmailNotExists_ThenReturnUnique() { + //Given + String email = "test@example.com"; + when(userRepository.findOptionalByEmail(email)).thenReturn(Optional.empty()); + + //When + UniqueMailResponse response = userService.checkEmailUniqueness(email); + + //Then + assertTrue(response.isUnique()); + assertEquals("์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค.", response.message()); + } + + @Test + void checkEmailUniqueness_WhenEmailWithdrawn_ThenReturnWithdrawn() { + //Given + String email = "test@example.com"; + UserEntityDto withdrawnUser = UserEntityDto.create( + UUID.randomUUID(), email, "testUser", "WITHDRAWN" + ); + when(userRepository.findOptionalByEmail(email)).thenReturn(Optional.of(withdrawnUser)); + + //When + UniqueMailResponse response = userService.checkEmailUniqueness(email); + + //Then + assertFalse(response.isUnique()); + assertEquals("ํƒˆํ‡ดํ•œ ์‚ฌ์šฉ์ž์˜ ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค.", response.message()); + } + + @Test + void checkEmailUniqueness_WhenEmailInactive_ThenReturnInactive() { + //Given + String email = "test@example.com"; + UserEntityDto inactiveUser = UserEntityDto.create( + UUID.randomUUID(), email, "testUser", "INACTIVE" + ); + when(userRepository.findOptionalByEmail(email)).thenReturn(Optional.of(inactiveUser)); + + //When + UniqueMailResponse response = userService.checkEmailUniqueness(email); + + //Then + assertFalse(response.isUnique()); + assertEquals("ํœด๋ฉด ์œ ์ €์˜ ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค.", response.message()); + } + + @Test + void checkEmailUniqueness_WhenEmailActive_ThenReturnDuplicate() { + //Given + String email = "test@example.com"; + UserEntityDto activeUser = UserEntityDto.create( + UUID.randomUUID(), email, "testUser", "ACTIVE" + ); + when(userRepository.findOptionalByEmail(email)).thenReturn(Optional.of(activeUser)); + + //When + UniqueMailResponse response = userService.checkEmailUniqueness(email); + + //Then + assertFalse(response.isUnique()); + assertEquals("์‚ฌ์šฉ ์ค‘์ธ ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค.", response.message()); + } + + @Test + void signUp_WhenEmailUnique_ThenCreateUser() { + //Given + SignupRequest request = new SignupRequest("test@example.com", "testUser", "id", + OAuthProvider.KAKAO, "phone"); + UUID userId = UUID.randomUUID(); + + when(userRepository.findOptionalByEmail(request.email())).thenReturn(Optional.empty()); + when(userRepository.save(request.email(), request.nickname(), "ACTIVE")) + .thenReturn( + UserEntityDto.create(userId, request.email(), request.nickname(), "ACTIVE")); + when(oAuthLoginService.linkOAuthWithUser(any(), any(), any(), any())).thenReturn( + any(JwtResponse.class)); + + //When + userService.signUp(request); + + //Then + verify(userRepository).save(request.email(), request.nickname(), "ACTIVE"); + } + + @Test + void signUp_WhenEmailDuplicate_ThenThrowException() { + //Given + SignupRequest request = new SignupRequest("test@example.com", "testUser", "id", + OAuthProvider.KAKAO, "phone"); + UserEntityDto existingUser = UserEntityDto.create( + UUID.randomUUID(), request.email(), "existingUser", "ACTIVE" + ); + when(userRepository.findOptionalByEmail(request.email())).thenReturn( + Optional.of(existingUser)); + + //When & Then + Exception exception = assertThrows(UserEmailDuplicateException.class, () -> + userService.signUp(request) + ); + assertTrue(exception.getMessage().contains("์‚ฌ์šฉ ์ค‘์ธ ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค.")); + } +} diff --git a/src/test/java/com/knu/ddip/user/domain/UserDomainTest.java b/src/test/java/com/knu/ddip/user/domain/UserDomainTest.java new file mode 100644 index 0000000..12a6a47 --- /dev/null +++ b/src/test/java/com/knu/ddip/user/domain/UserDomainTest.java @@ -0,0 +1,25 @@ +package com.knu.ddip.user.domain; + +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class UserDomainTest { + + @Test + public void create_returnActiveUser() { + //Given + UUID id = UUID.randomUUID(); + String email = "test@example.com"; + String nickName = "testName"; + + //When + UserDomain userDomain = UserDomain.create(id, email, nickName, "ACTIVE"); + + //Then + assertEquals(id, userDomain.getId()); + assertEquals(email, userDomain.getEmail()); + } +} diff --git a/src/test/java/com/knu/ddip/user/repository/UserRepositoryImplTest.java b/src/test/java/com/knu/ddip/user/repository/UserRepositoryImplTest.java new file mode 100644 index 0000000..0cd889d --- /dev/null +++ b/src/test/java/com/knu/ddip/user/repository/UserRepositoryImplTest.java @@ -0,0 +1,222 @@ +package com.knu.ddip.user.repository; + +import com.knu.ddip.config.IntegrationTestConfig; +import com.knu.ddip.config.MySQLTestContainerConfig; +import com.knu.ddip.config.RedisTestContainerConfig; +import com.knu.ddip.config.TestEnvironmentConfig; +import com.knu.ddip.user.business.dto.UserEntityDto; +import com.knu.ddip.user.exception.UserNotFoundException; +import com.knu.ddip.user.infrastructure.repository.UserRepositoryImpl; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@DataJpaTest +@ExtendWith({RedisTestContainerConfig.class, MySQLTestContainerConfig.class, TestEnvironmentConfig.class}) +@Import({IntegrationTestConfig.class, UserRepositoryImpl.class}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class UserRepositoryImplTest { + + @Autowired + private UserRepositoryImpl userRepository; + + @Test + public void save() { + //Given + String email = "exist@example.com"; + String status = "ACTIVE"; + String nickName = "testName"; + + //When + UserEntityDto savedUser = userRepository.save(email, nickName, status); + + //Then + assertEquals(email, savedUser.getEmail()); + assertEquals(status, savedUser.getStatus()); + } + + @Test + public void getByEmail_whenUserExist_returnUserEntityDto() { + //Given + String email = "exist@example.com"; + String status = "ACTIVE"; + String nickName = "testName"; + + UserEntityDto savedUser = userRepository.save(email, nickName, status); + + //When + UserEntityDto searchedUser = userRepository.getByEmail(email); + + //Then + assertEquals(email, searchedUser.getEmail()); + assertEquals(status, searchedUser.getStatus()); + } + + @Test + public void getByEmail_whenUserNoExist_throwNoUserException() { + //Given + String email = "exist@example.com"; + + //When, Then + assertThrows(UserNotFoundException.class, () -> userRepository.getByEmail(email)); + } + + @Test + public void save_inactiveUser() { + //Given + String email = "inactive@example.com"; + String status = "INACTIVE"; + String nickName = "testName"; + + //When + UserEntityDto savedUser = userRepository.save(email, nickName, status); + + //Then + assertEquals(email, savedUser.getEmail()); + assertEquals(status, savedUser.getStatus()); + } + + @Test + public void save_withdrawnUser() { + //Given + String email = "withdrawn@example.com"; + String status = "WITHDRAWN"; + String nickName = "testName"; + + //When + UserEntityDto savedUser = userRepository.save(email, nickName, status); + + //Then + assertEquals(email, savedUser.getEmail()); + assertEquals(status, savedUser.getStatus()); + } + + @Test + public void getById_whenUserExist_returnUserEntityDto() { + //Given + String email = "exist@example.com"; + String status = "ACTIVE"; + String nickName = "testName"; + + UserEntityDto savedUser = userRepository.save(email, nickName, status); + UUID userId = savedUser.getId(); + + //When + UserEntityDto searchedUser = userRepository.getById(userId); + + //Then + assertEquals(email, searchedUser.getEmail()); + assertEquals(status, searchedUser.getStatus()); + } + + @Test + public void getById_whenUserNoExist_throwNoUserException() { + //Given + UUID randomId = UUID.randomUUID(); + + //When, Then + assertThrows(UserNotFoundException.class, () -> userRepository.getById(randomId)); + } + + @Test + public void update_whenUserExist_updateUserSuccessfully() { + //Given + String email = "update@example.com"; + String status = "ACTIVE"; + String nickName = "originalName"; + + UserEntityDto savedUser = userRepository.save(email, nickName, status); + + String updatedNickName = "updatedName"; + UserEntityDto updateUser = UserEntityDto.create( + savedUser.getId(), + email, + updatedNickName, + status + ); + + //When + userRepository.update(updateUser); + UserEntityDto retrievedUser = userRepository.getByEmail(email); + + //Then + assertEquals(updatedNickName, retrievedUser.getNickname()); + } + + @Test + public void update_whenUserNoExist_throwNoUserException() { + //Given + UserEntityDto nonExistingUser = UserEntityDto.create( + UUID.randomUUID(), + "nonexist@example.com", + "nonexistName", + "ACTIVE" + ); + + //When, Then + assertThrows(UserNotFoundException.class, () -> userRepository.update(nonExistingUser)); + } + + @Test + public void delete_whenUserExist_deleteUserSuccessfully() { + //Given + String email = "todelete@example.com"; + String status = "ACTIVE"; + String nickName = "deleteName"; + + UserEntityDto savedUser = userRepository.save(email, nickName, status); + UUID userId = savedUser.getId(); + + //When + userRepository.delete(userId); + + //Then + assertThrows(UserNotFoundException.class, () -> userRepository.getByEmail(email)); + } + + @Test + public void delete_whenUserNoExist_throwNoUserException() { + //Given + UUID randomId = UUID.randomUUID(); + + //When, Then + assertThrows(UserNotFoundException.class, () -> userRepository.delete(randomId)); + } + + @Test + public void findOptionalByEmail_whenUserExist_returnOptionalWithUser() { + //Given + String email = "optional@example.com"; + String status = "ACTIVE"; + String nickName = "optionalName"; + + userRepository.save(email, nickName, status); + + //When + Optional result = userRepository.findOptionalByEmail(email); + + //Then + assertTrue(result.isPresent()); + assertEquals(email, result.get().getEmail()); + } + + @Test + public void findOptionalByEmail_whenUserNoExist_returnEmptyOptional() { + //Given + String email = "nonexistent@example.com"; + + //When + Optional result = userRepository.findOptionalByEmail(email); + + //Then + assertTrue(result.isEmpty()); + } +}