diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index d3d7821..0229a67 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -36,7 +36,7 @@ jobs: echo "${{ secrets.PROD }}" > ./application-prod.yml - name: Test with Gradle - run: ./gradlew test + run: ./gradlew clean test --info --stacktrace - name: Build with Gradle run: ./gradlew build -x test diff --git a/.gradle/8.8/checksums/checksums.lock b/.gradle/8.8/checksums/checksums.lock index dd80ee7..223d366 100644 Binary files a/.gradle/8.8/checksums/checksums.lock and b/.gradle/8.8/checksums/checksums.lock differ diff --git a/.gradle/8.8/checksums/md5-checksums.bin b/.gradle/8.8/checksums/md5-checksums.bin index 91e5085..e24e8f3 100644 Binary files a/.gradle/8.8/checksums/md5-checksums.bin and b/.gradle/8.8/checksums/md5-checksums.bin differ diff --git a/.gradle/8.8/checksums/sha1-checksums.bin b/.gradle/8.8/checksums/sha1-checksums.bin index 5cbcbf2..5dc4b75 100644 Binary files a/.gradle/8.8/checksums/sha1-checksums.bin and b/.gradle/8.8/checksums/sha1-checksums.bin differ diff --git a/build.gradle b/build.gradle index 087489b..d80b64f 100644 --- a/build.gradle +++ b/build.gradle @@ -62,12 +62,17 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect' + // Rabbit MQ implementation 'org.springframework.boot:spring-boot-starter-amqp' // spring-retry implementation 'org.springframework.retry:spring-retry' implementation 'org.springframework:spring-aspects' + + // bucket4j + implementation 'com.bucket4j:bucket4j_jdk17-redis-common:8.13.1' + implementation 'com.bucket4j:bucket4j_jdk17-lettuce:8.13.1' } tasks.named('test') { diff --git a/src/main/java/gdsc/cau/puangbe/auth/external/JwtProvider.java b/src/main/java/gdsc/cau/puangbe/auth/external/JwtProvider.java index a4f4d86..c55810f 100644 --- a/src/main/java/gdsc/cau/puangbe/auth/external/JwtProvider.java +++ b/src/main/java/gdsc/cau/puangbe/auth/external/JwtProvider.java @@ -132,7 +132,7 @@ public String getTokenFromAuthorizationHeader(String authorizationHeader) { if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { return authorizationHeader.substring(7); } - throw new AuthException(ResponseCode.BAD_REQUEST); + throw new AuthException(ResponseCode.USER_NO_AUTH_HEADER); } } \ No newline at end of file diff --git a/src/main/java/gdsc/cau/puangbe/auth/external/KakaoProvider.java b/src/main/java/gdsc/cau/puangbe/auth/external/KakaoProvider.java index f29274d..f3a1229 100644 --- a/src/main/java/gdsc/cau/puangbe/auth/external/KakaoProvider.java +++ b/src/main/java/gdsc/cau/puangbe/auth/external/KakaoProvider.java @@ -3,6 +3,8 @@ import gdsc.cau.puangbe.auth.config.KakaoLoginProperties; import gdsc.cau.puangbe.auth.dto.KakaoIDTokenPublicKeyList; import gdsc.cau.puangbe.auth.dto.KakaoToken; +import gdsc.cau.puangbe.common.exception.AuthException; +import gdsc.cau.puangbe.common.util.ResponseCode; import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.CacheConfig; import org.springframework.cache.annotation.Cacheable; @@ -12,6 +14,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; @Component @RequiredArgsConstructor @@ -21,30 +24,38 @@ public class KakaoProvider { // 카카오 인가 코드로 카카오 토큰 발급 public KakaoToken getTokenByCode(String code) { - return WebClient.create() - .post() - .uri(kakaoLoginProperties.getTokenUri()) - .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) - .body(BodyInserters. - fromFormData("grant_type", "authorization_code") - .with("client_id", kakaoLoginProperties.getClientId()) - .with("redirect_uri", kakaoLoginProperties.getRedirectUri()) - .with("code", code) - .with("client_secret", kakaoLoginProperties.getClientSecret())) - .retrieve() - .bodyToMono(KakaoToken.class) - .block(); + try { + return WebClient.create() + .post() + .uri(kakaoLoginProperties.getTokenUri()) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE) + .body(BodyInserters. + fromFormData("grant_type", "authorization_code") + .with("client_id", kakaoLoginProperties.getClientId()) + .with("redirect_uri", kakaoLoginProperties.getRedirectUri()) + .with("code", code) + .with("client_secret", kakaoLoginProperties.getClientSecret())) + .retrieve() + .bodyToMono(KakaoToken.class) + .block(); + } catch (WebClientResponseException e) { + throw new AuthException(ResponseCode.USER_INVALID_KAKAO_CODE); + } } // 카카오 인증 서버로부터 카카오 ID 토큰 공개키 목록 조회하여 캐싱 @Cacheable(key = "'all'") public KakaoIDTokenPublicKeyList getOIDCPublicKeyList() { - return WebClient.create() - .get() - .uri(kakaoLoginProperties.getPublicKeyUri()) - .retrieve() - .bodyToMono(KakaoIDTokenPublicKeyList.class) - .block(); + try { + return WebClient.create() + .get() + .uri(kakaoLoginProperties.getPublicKeyUri()) + .retrieve() + .bodyToMono(KakaoIDTokenPublicKeyList.class) + .block(); + } catch (WebClientResponseException e) { + throw new AuthException(ResponseCode.INTERNAL_SERVER_ERROR); + } } // 카카오 인증 서버로부터 카카오 ID 토큰 공개키 목록 재조회하여 캐싱 diff --git a/src/main/java/gdsc/cau/puangbe/auth/external/OIDCProvider.java b/src/main/java/gdsc/cau/puangbe/auth/external/OIDCProvider.java index bd8b91c..b682adc 100644 --- a/src/main/java/gdsc/cau/puangbe/auth/external/OIDCProvider.java +++ b/src/main/java/gdsc/cau/puangbe/auth/external/OIDCProvider.java @@ -57,7 +57,7 @@ private KakaoIDTokenPayload extractPayloadFromTokenString(String token) { // 온점(.)을 기준으로 헤더, 페이로드, 서명을 분리 String[] parts = token.split("\\."); if (parts.length != 3) { - throw new AuthException(ResponseCode.BAD_REQUEST); // Invalid JWT token + throw new AuthException(ResponseCode.USER_INVALID_TOKEN); // Invalid JWT token } // 페이로드를 추출하여 Base64 방식으로 디코딩 @@ -100,7 +100,7 @@ private String extractHeaderKidFromTokenString(String token) { // 온점(.)을 기준으로 헤더, 페이로드, 서명을 분리 String[] parts = token.split("\\."); if (parts.length != 3) { - throw new AuthException(ResponseCode.BAD_REQUEST); // Invalid JWT token + throw new AuthException(ResponseCode.INTERNAL_SERVER_ERROR); } // 헤더을 추출하여 Base64 방식으로 디코딩 @@ -119,7 +119,7 @@ private KakaoIDTokenJWK getOIDCPublicKey(String kid, KakaoIDTokenPublicKeyList k return kakaoIdTokenPublicKeyList.getKeys().stream() .filter(kakaoIDTokenJWK -> kakaoIDTokenJWK.getKid().equals(kid)) .findFirst() - .orElseThrow(() -> new AuthException(ResponseCode.BAD_REQUEST)); // 일치하는 PK 없음 + .orElseThrow(() -> new AuthException(ResponseCode.UNAUTHORIZED)); // 일치하는 PK 없음 } // JWK로 RSA Public Key 생성 diff --git a/src/main/java/gdsc/cau/puangbe/common/config/RateLimiterConfig.java b/src/main/java/gdsc/cau/puangbe/common/config/RateLimiterConfig.java new file mode 100644 index 0000000..0188cbd --- /dev/null +++ b/src/main/java/gdsc/cau/puangbe/common/config/RateLimiterConfig.java @@ -0,0 +1,30 @@ +package gdsc.cau.puangbe.common.config; + +import io.github.bucket4j.distributed.ExpirationAfterWriteStrategy; +import io.github.bucket4j.redis.lettuce.Bucket4jLettuce; +import io.github.bucket4j.redis.lettuce.cas.LettuceBasedProxyManager; +import io.lettuce.core.RedisClient; +import io.lettuce.core.api.StatefulRedisConnection; +import io.lettuce.core.codec.ByteArrayCodec; +import io.lettuce.core.codec.RedisCodec; +import io.lettuce.core.codec.StringCodec; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; + +@Configuration +@RequiredArgsConstructor +public class RateLimiterConfig { + + private final RedisClient redisClient; + + @Bean + public LettuceBasedProxyManager proxyManager() { + StatefulRedisConnection connection = redisClient.connect(RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE)); + return Bucket4jLettuce.casBasedBuilder(connection) + .expirationAfterWrite(ExpirationAfterWriteStrategy.basedOnTimeForRefillingBucketUpToMax(Duration.ofHours(1))) // 1시간마다 리필 + .build(); + } +} diff --git a/src/main/java/gdsc/cau/puangbe/common/config/RedisConfig.java b/src/main/java/gdsc/cau/puangbe/common/config/RedisConfig.java index 90797ab..2d696df 100644 --- a/src/main/java/gdsc/cau/puangbe/common/config/RedisConfig.java +++ b/src/main/java/gdsc/cau/puangbe/common/config/RedisConfig.java @@ -1,5 +1,8 @@ package gdsc.cau.puangbe.common.config; +import io.lettuce.core.RedisClient; +import io.lettuce.core.RedisURI; +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.RedisConnectionFactory; @@ -10,6 +13,10 @@ @Configuration public class RedisConfig { + @Value("${spring.data.redis.host}") + String host; + @Value("${spring.data.redis.port}") + Integer port; // String key, Long value(requestId)를 다루기 위한 RedisTemplate입니다. (현재 유저의 요청의 대기열 역할을 수행하게 됩니다) // opsForSet().add()를 통해 requestId를 추가할 수 있으며, 이 때 key는 별도로 만든 키로 설정합니다. (RequestQueue 등) @@ -33,4 +40,9 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connecti public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) { return new StringRedisTemplate(connectionFactory); } + + @Bean + public RedisClient redisClient() { + return RedisClient.create(RedisURI.builder().withHost(host).withPort(port).build()); + } } diff --git a/src/main/java/gdsc/cau/puangbe/common/config/WebConfig.java b/src/main/java/gdsc/cau/puangbe/common/config/WebConfig.java index fb8d884..87295c2 100644 --- a/src/main/java/gdsc/cau/puangbe/common/config/WebConfig.java +++ b/src/main/java/gdsc/cau/puangbe/common/config/WebConfig.java @@ -1,9 +1,11 @@ package gdsc.cau.puangbe.common.config; import gdsc.cau.puangbe.common.config.resolver.PuangUserArgumentResolver; +import gdsc.cau.puangbe.common.interceptor.RateLimiterInterceptor; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.util.List; @@ -11,10 +13,17 @@ @Configuration @RequiredArgsConstructor public class WebConfig implements WebMvcConfigurer { + private final PuangUserArgumentResolver puangUserArgumentResolver; + private final RateLimiterInterceptor rateLimiterInterceptor; @Override public void addArgumentResolvers(List resolvers) { resolvers.add(puangUserArgumentResolver); } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(rateLimiterInterceptor); + } } diff --git a/src/main/java/gdsc/cau/puangbe/common/enums/RateLimitPolicy.java b/src/main/java/gdsc/cau/puangbe/common/enums/RateLimitPolicy.java new file mode 100644 index 0000000..aae1fce --- /dev/null +++ b/src/main/java/gdsc/cau/puangbe/common/enums/RateLimitPolicy.java @@ -0,0 +1,54 @@ +package gdsc.cau.puangbe.common.enums; + +import gdsc.cau.puangbe.common.exception.RateLimiterException; +import gdsc.cau.puangbe.common.util.ResponseCode; +import io.github.bucket4j.Bandwidth; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.time.Duration; +import java.util.Arrays; + +@Getter +@RequiredArgsConstructor +public enum RateLimitPolicy { + GENERAL("general") { + @Override + public Bandwidth getLimit() { + return Bandwidth.builder() + .capacity(50) + .refillIntervally(50, Duration.ofSeconds(1)) + .build(); + } + // 버킷 용량: 토큰 50개 + // 1초에 토큰 50개씩 리필 (버킷 용량 내에서) + // 1시간마다 버킷 전체 리필(RateLimiterConfig에 설정됨) + }, + + HEAVY("heavy") { + @Override + public Bandwidth getLimit() { + return Bandwidth.builder() + .capacity(1) + .refillIntervally(1, Duration.ofSeconds(1)) + .build(); + } + // 버킷 용량: 토큰 1개 + // 1초에 토큰 1개씩 리필 (버킷 용량 내에서) + // 1시간마다 버킷 전체 리필(RateLimiterConfig에 설정됨) + }, + + ; + + public abstract Bandwidth getLimit(); + + private final String planName; + + public static Bandwidth resolvePlan(final String targetPlan) { + return Arrays.stream(RateLimitPolicy.values()) + .filter(policy -> policy.getPlanName().equals(targetPlan)) + .map(RateLimitPolicy::getLimit) + .findFirst() + .orElseThrow(() -> new RateLimiterException(ResponseCode.RATE_LIMITER_POLICY_ERROR)); + } +} \ No newline at end of file diff --git a/src/main/java/gdsc/cau/puangbe/common/exception/GlobalExceptionHandler.java b/src/main/java/gdsc/cau/puangbe/common/exception/GlobalExceptionHandler.java index 828084a..d9aa8fa 100644 --- a/src/main/java/gdsc/cau/puangbe/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/gdsc/cau/puangbe/common/exception/GlobalExceptionHandler.java @@ -26,4 +26,10 @@ public APIResponse handleAuthException(AuthException e) { log.info("AuthException: {}", e.getMessage()); return APIResponse.fail(e.getResponseCode(), e.getMessage()); } + + @ExceptionHandler(RateLimiterException.class) + public APIResponse handleRateLimiterException(RateLimiterException e) { + log.info("RateLimiterException: {}", e.getMessage()); + return APIResponse.fail(e.getResponseCode(), e.getMessage()); + } } diff --git a/src/main/java/gdsc/cau/puangbe/common/exception/RateLimiterException.java b/src/main/java/gdsc/cau/puangbe/common/exception/RateLimiterException.java new file mode 100644 index 0000000..8b1e659 --- /dev/null +++ b/src/main/java/gdsc/cau/puangbe/common/exception/RateLimiterException.java @@ -0,0 +1,9 @@ +package gdsc.cau.puangbe.common.exception; + +import gdsc.cau.puangbe.common.util.ResponseCode; + +public class RateLimiterException extends BaseException { + public RateLimiterException(ResponseCode responseCode) { + super(responseCode); + } +} diff --git a/src/main/java/gdsc/cau/puangbe/common/interceptor/RateLimiterInterceptor.java b/src/main/java/gdsc/cau/puangbe/common/interceptor/RateLimiterInterceptor.java new file mode 100644 index 0000000..6a9c200 --- /dev/null +++ b/src/main/java/gdsc/cau/puangbe/common/interceptor/RateLimiterInterceptor.java @@ -0,0 +1,79 @@ +package gdsc.cau.puangbe.common.interceptor; + +import gdsc.cau.puangbe.auth.external.JwtProvider; +import gdsc.cau.puangbe.common.enums.RateLimitPolicy; +import gdsc.cau.puangbe.common.exception.RateLimiterException; +import gdsc.cau.puangbe.common.util.ResponseCode; +import io.github.bucket4j.Bucket; +import io.github.bucket4j.BucketConfiguration; +import io.github.bucket4j.redis.lettuce.cas.LettuceBasedProxyManager; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.time.Duration; + +@Component +@RequiredArgsConstructor +public class RateLimiterInterceptor implements HandlerInterceptor { + private final LettuceBasedProxyManager proxyManager; + private final RedisTemplate redisTemplate; + private final JwtProvider jwtProvider; + + private final String LIMITER_PREFIX = "rate-limiter-count:"; + private final String BLOCKED_PREFIX = "rate-limiter-blocked:"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + // key 생성 (kakaoId + API 엔트포인트) + String authorizationHeader = request.getHeader("Authorization"); + if (authorizationHeader == null) { + return true; + } + + String accessToken = jwtProvider.getTokenFromAuthorizationHeader(authorizationHeader); + String kakaoId = jwtProvider.getKakaoIdFromExpiredToken(accessToken); + String servletPath = request.getServletPath(); + String key = kakaoId + servletPath; + + if (isBlocked(key)) { + throw new RateLimiterException(ResponseCode.RATE_LIMITER_TOO_MANY_REQUESTS); + } + + // key에 해당하는 bucket 로드. 없으면 생성 + Bucket bucket = proxyManager.getProxy(LIMITER_PREFIX + key, () -> getRateLimitPolicy(servletPath)); + + if (!bucket.tryConsume(1)) { + blockClient(key); + throw new RateLimiterException(ResponseCode.RATE_LIMITER_TOO_MANY_REQUESTS); + } + return true; + } + + private BucketConfiguration bucketConfiguration(String bucketPlan) { + return BucketConfiguration.builder() + .addLimit(RateLimitPolicy.resolvePlan(bucketPlan)) + .build(); + } + + private BucketConfiguration getRateLimitPolicy(String contextPath) { + switch (contextPath) { + case "/api/photo-request": + return bucketConfiguration("heavy"); + + default: + return bucketConfiguration("general"); + } + } + + private void blockClient(String key) { + redisTemplate.opsForValue().set(BLOCKED_PREFIX + key, 0L, Duration.ofMinutes(5)); + } + + private boolean isBlocked(String key) { + return Boolean.TRUE.equals(redisTemplate.hasKey(BLOCKED_PREFIX + key)); + } +} diff --git a/src/main/java/gdsc/cau/puangbe/common/util/ResponseCode.java b/src/main/java/gdsc/cau/puangbe/common/util/ResponseCode.java index b7131fe..1cfe477 100644 --- a/src/main/java/gdsc/cau/puangbe/common/util/ResponseCode.java +++ b/src/main/java/gdsc/cau/puangbe/common/util/ResponseCode.java @@ -11,13 +11,15 @@ public enum ResponseCode { // 400 Bad Request BAD_REQUEST(HttpStatus.BAD_REQUEST, false, "잘못된 요청입니다."), + USER_NO_AUTH_HEADER(HttpStatus.BAD_REQUEST, false, "Authorization 헤더가 존재하지 않습니다."), + USER_INVALID_TOKEN(HttpStatus.BAD_REQUEST, false, "토큰이 형식이 올바르지 않습니다."), + USER_INVALID_KAKAO_CODE(HttpStatus.BAD_REQUEST, false, "인가 코드가 유효하지 않습니다."), // 401 Unauthorized UNAUTHORIZED(HttpStatus.UNAUTHORIZED, false, "인증되지 않은 사용자입니다."), // 403 Forbidden FORBIDDEN(HttpStatus.FORBIDDEN, false, "권한이 없습니다."), - REFRESH_TOKEN_EXPIRED(HttpStatus.FORBIDDEN, false, "리프레시 토큰이 만료되었습니다."), // 404 Not Found USER_NOT_FOUND(HttpStatus.NOT_FOUND, false, "사용자를 찾을 수 없습니다."), @@ -32,10 +34,14 @@ public enum ResponseCode { USER_ALREADY_EXISTS(HttpStatus.CONFLICT, false, "이미 존재하는 사용자입니다."), URL_ALREADY_UPLOADED(HttpStatus.CONFLICT, false, "이미 url이 업로드 되었습니다."), + // 429 Too Many Requests + RATE_LIMITER_TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, false, "호출 허용량 초과입니다. 잠시 후 다시 시도해 주세요."), + // 500 Internal Server Error INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, false, "서버에 오류가 발생하였습니다."), JSON_PARSE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, false, "JSON 파싱 오류가 발생하였습니다."), EMAIL_SEND_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, false, "이메일 발송에 오류가 발생하였습니다."), + RATE_LIMITER_POLICY_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, false, "정의되지 않은 정책입니다."), // 200 OK USER_LOGIN_SUCCESS(HttpStatus.OK, true, "사용자 로그인 성공"), diff --git a/src/test/java/gdsc/cau/puangbe/photo/controller/PhotoControllerTest.java b/src/test/java/gdsc/cau/puangbe/photo/controller/PhotoControllerTest.java index 903db90..671e0a5 100644 --- a/src/test/java/gdsc/cau/puangbe/photo/controller/PhotoControllerTest.java +++ b/src/test/java/gdsc/cau/puangbe/photo/controller/PhotoControllerTest.java @@ -4,10 +4,12 @@ import gdsc.cau.puangbe.auth.external.JwtProvider; import gdsc.cau.puangbe.common.config.resolver.PuangUserArgumentResolver; import gdsc.cau.puangbe.common.exception.BaseException; +import gdsc.cau.puangbe.common.interceptor.RateLimiterInterceptor; import gdsc.cau.puangbe.common.util.APIResponse; import gdsc.cau.puangbe.common.util.ResponseCode; import gdsc.cau.puangbe.photo.dto.request.UploadImageDto; import gdsc.cau.puangbe.photo.service.PhotoService; +import io.github.bucket4j.redis.lettuce.cas.LettuceBasedProxyManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -26,31 +28,37 @@ import static org.mockito.Mockito.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; - @WebMvcTest(controllers = PhotoController.class) class PhotoControllerTest { @Autowired - private MockMvc mockMvc; + MockMvc mockMvc; + + @MockBean + PhotoService photoService; + + @MockBean + JpaMetamodelMappingContext jpaMetamodelMappingContext; @MockBean - private PhotoService photoService; + JwtProvider jwtProvider; @MockBean - private JpaMetamodelMappingContext jpaMetamodelMappingContext; + PuangUserArgumentResolver puangUserArgumentResolver; @MockBean - private JwtProvider jwtProvider; + RateLimiterInterceptor rateLimiterInterceptor; @MockBean - private PuangUserArgumentResolver puangUserArgumentResolver; + LettuceBasedProxyManager lettuceBasedProxyManager; ObjectMapper mapper = new ObjectMapper(); String baseUrl = "/api/photo"; String kakaoId = "kakaoId"; @BeforeEach - void setUp() { + void setUp() throws Exception { + when(rateLimiterInterceptor.preHandle(any(), any(), any())).thenReturn(true); when(jwtProvider.getKakaoIdFromToken(anyString())).thenReturn(kakaoId); } diff --git a/src/test/java/gdsc/cau/puangbe/photorequest/controller/PhotoRequestControllerTest.java b/src/test/java/gdsc/cau/puangbe/photorequest/controller/PhotoRequestControllerTest.java index 6021b26..039395b 100644 --- a/src/test/java/gdsc/cau/puangbe/photorequest/controller/PhotoRequestControllerTest.java +++ b/src/test/java/gdsc/cau/puangbe/photorequest/controller/PhotoRequestControllerTest.java @@ -1,14 +1,16 @@ package gdsc.cau.puangbe.photorequest.controller; import com.fasterxml.jackson.databind.ObjectMapper; -import gdsc.cau.puangbe.auth.external.JwtProvider; import gdsc.cau.puangbe.common.config.resolver.PuangUserArgumentResolver; import gdsc.cau.puangbe.common.enums.RequestStatus; import gdsc.cau.puangbe.common.exception.BaseException; +import gdsc.cau.puangbe.common.interceptor.RateLimiterInterceptor; import gdsc.cau.puangbe.common.util.APIResponse; import gdsc.cau.puangbe.common.util.ResponseCode; import gdsc.cau.puangbe.photorequest.dto.CreateImageDto; import gdsc.cau.puangbe.photorequest.service.PhotoRequestService; +import io.github.bucket4j.redis.lettuce.cas.LettuceBasedProxyManager; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -32,25 +34,33 @@ class PhotoRequestControllerTest { @Autowired - private MockMvc mockMvc; + MockMvc mockMvc; @MockBean - private PhotoRequestService photoRequestService; + PhotoRequestService photoRequestService; @MockBean - private JpaMetamodelMappingContext jpaMetamodelMappingContext; + JpaMetamodelMappingContext jpaMetamodelMappingContext; @MockBean - private JwtProvider jwtProvider; + PuangUserArgumentResolver puangUserArgumentResolver; @MockBean - private PuangUserArgumentResolver puangUserArgumentResolver; + RateLimiterInterceptor rateLimiterInterceptor; + + @MockBean + LettuceBasedProxyManager lettuceBasedProxyManager; ObjectMapper mapper = new ObjectMapper(); String baseUrl = "/api/photo-request"; CreateImageDto createImageDto = new CreateImageDto( List.of("url1", "url2", "url3", "url4", "url5", "url6"), 0, "abc@naver.com"); + @BeforeEach + void setUp() throws Exception { + when(rateLimiterInterceptor.preHandle(any(), any(), any())).thenReturn(true); + } + @DisplayName("createImage: 이미지 처리 요청을 처리하며, 성공 객체를 반환한다.") @Test void createImageTest() throws Exception {