diff --git a/.github/workflows/backend-dev-cd.yml b/.github/workflows/backend-dev-cd.yml index eec962f53..6a10d7c00 100644 --- a/.github/workflows/backend-dev-cd.yml +++ b/.github/workflows/backend-dev-cd.yml @@ -57,7 +57,7 @@ jobs: deploy: name: Deploy via self-hosted runner needs: build - runs-on: [self-hosted, dev] + runs-on: [self-hosted, dev, oracle] steps: - name: Checkout to secret repository diff --git a/.github/workflows/backend-prod-cd.yml b/.github/workflows/backend-prod-cd.yml index 41050eb22..f3958ba9e 100644 --- a/.github/workflows/backend-prod-cd.yml +++ b/.github/workflows/backend-prod-cd.yml @@ -52,10 +52,7 @@ jobs: deploy: name: Deploy via self-hosted runner needs: build - strategy: - matrix: - runner: [prod-a, prod-b] - runs-on: [ self-hosted, "${{ matrix.runner }}" ] + runs-on: [self-hosted, prod, oracle] steps: - name: Checkout to secret repository diff --git a/backend/build.gradle b/backend/build.gradle index cea87d9cf..4981440a7 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -29,12 +29,12 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-cache' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'io.micrometer:micrometer-registry-prometheus' implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-mysql' - implementation 'org.springframework.boot:spring-boot-starter-data-redis' runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/backend/src/main/java/reviewme/config/CacheManagerConfig.java b/backend/src/main/java/reviewme/config/CacheManagerConfig.java new file mode 100644 index 000000000..21151523f --- /dev/null +++ b/backend/src/main/java/reviewme/config/CacheManagerConfig.java @@ -0,0 +1,19 @@ +package reviewme.config; + +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@Configuration +@EnableCaching +public class CacheManagerConfig { + + @Profile({"local", "dev", "prod"}) + @Bean + public CacheManager cacheManager() { + return new ConcurrentMapCacheManager(); + } +} diff --git a/backend/src/main/java/reviewme/config/requestlimit/RequestLimitInterceptor.java b/backend/src/main/java/reviewme/config/requestlimit/RequestLimitInterceptor.java deleted file mode 100644 index ef25b711e..000000000 --- a/backend/src/main/java/reviewme/config/requestlimit/RequestLimitInterceptor.java +++ /dev/null @@ -1,48 +0,0 @@ -package reviewme.config.requestlimit; - -import static org.springframework.http.HttpHeaders.USER_AGENT; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.ValueOperations; -import org.springframework.http.HttpMethod; -import org.springframework.stereotype.Component; -import org.springframework.web.servlet.HandlerInterceptor; - -@Component -@EnableConfigurationProperties(RequestLimitProperties.class) -@RequiredArgsConstructor -public class RequestLimitInterceptor implements HandlerInterceptor { - - private final RedisTemplate redisTemplate; - private final RequestLimitProperties requestLimitProperties; - - @Override - public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { - if (!HttpMethod.POST.matches(request.getMethod())) { - return true; - } - - String key = generateRequestKey(request); - ValueOperations valueOperations = redisTemplate.opsForValue(); - valueOperations.setIfAbsent(key, 0L, requestLimitProperties.duration()); - redisTemplate.expire(key, requestLimitProperties.duration()); - - long requestCount = valueOperations.increment(key); - if (requestCount > requestLimitProperties.threshold()) { - throw new TooManyRequestException(key); - } - return true; - } - - private String generateRequestKey(HttpServletRequest request) { - String requestURI = request.getRequestURI(); - String remoteAddr = request.getRemoteAddr(); - String userAgent = request.getHeader(USER_AGENT); - - return String.format("RequestURI: %s, RemoteAddr: %s, UserAgent: %s", requestURI, remoteAddr, userAgent); - } -} diff --git a/backend/src/main/java/reviewme/config/requestlimit/RequestLimitProperties.java b/backend/src/main/java/reviewme/config/requestlimit/RequestLimitProperties.java deleted file mode 100644 index 558378094..000000000 --- a/backend/src/main/java/reviewme/config/requestlimit/RequestLimitProperties.java +++ /dev/null @@ -1,8 +0,0 @@ -package reviewme.config.requestlimit; - -import java.time.Duration; -import org.springframework.boot.context.properties.ConfigurationProperties; - -@ConfigurationProperties(prefix = "request-limit") -public record RequestLimitProperties(long threshold, Duration duration, String host, int port) { -} diff --git a/backend/src/main/java/reviewme/config/requestlimit/RequestLimitRedisConfig.java b/backend/src/main/java/reviewme/config/requestlimit/RequestLimitRedisConfig.java deleted file mode 100644 index d8bb458a9..000000000 --- a/backend/src/main/java/reviewme/config/requestlimit/RequestLimitRedisConfig.java +++ /dev/null @@ -1,34 +0,0 @@ -package reviewme.config.requestlimit; - -import lombok.RequiredArgsConstructor; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.serializer.GenericToStringSerializer; - -@Configuration -@EnableConfigurationProperties(RequestLimitProperties.class) -@RequiredArgsConstructor -public class RequestLimitRedisConfig { - - private final RequestLimitProperties requestLimitProperties; - - @Bean - public RedisConnectionFactory redisConnectionFactory() { - return new LettuceConnectionFactory( - requestLimitProperties.host(), requestLimitProperties.port() - ); - } - - @Bean - public RedisTemplate requestLimitRedisTemplate() { - RedisTemplate redisTemplate = new RedisTemplate<>(); - redisTemplate.setConnectionFactory(redisConnectionFactory()); - redisTemplate.setValueSerializer(new GenericToStringSerializer<>(Long.class)); - - return redisTemplate; - } -} diff --git a/backend/src/main/java/reviewme/config/requestlimit/RequestLimitWebConfig.java b/backend/src/main/java/reviewme/config/requestlimit/RequestLimitWebConfig.java deleted file mode 100644 index 19f3b2fe4..000000000 --- a/backend/src/main/java/reviewme/config/requestlimit/RequestLimitWebConfig.java +++ /dev/null @@ -1,20 +0,0 @@ -package reviewme.config.requestlimit; - -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.web.servlet.config.annotation.InterceptorRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -@Configuration -@RequiredArgsConstructor -public class RequestLimitWebConfig implements WebMvcConfigurer { - - private final RedisTemplate redisTemplate; - private final RequestLimitProperties requestLimitProperties; - - @Override - public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(new RequestLimitInterceptor(redisTemplate, requestLimitProperties)); - } -} diff --git a/backend/src/main/java/reviewme/config/requestlimit/TooManyRequestException.java b/backend/src/main/java/reviewme/config/requestlimit/TooManyRequestException.java deleted file mode 100644 index 544fb5885..000000000 --- a/backend/src/main/java/reviewme/config/requestlimit/TooManyRequestException.java +++ /dev/null @@ -1,13 +0,0 @@ -package reviewme.config.requestlimit; - -import lombok.extern.slf4j.Slf4j; -import reviewme.global.exception.ReviewMeException; - -@Slf4j -public class TooManyRequestException extends ReviewMeException { - - public TooManyRequestException(String requestKey) { - super("짧은 시간 안에 너무 많은 동일한 요청이 일어났어요. 잠시 후 다시 시도해주세요."); - log.warn("Too many request received - request: {}", requestKey); - } -} diff --git a/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java b/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java index 161e43172..7724dd90e 100644 --- a/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java +++ b/backend/src/main/java/reviewme/global/GlobalExceptionHandler.java @@ -22,7 +22,6 @@ import org.springframework.web.servlet.resource.NoResourceFoundException; import reviewme.global.exception.BadRequestException; import reviewme.global.exception.DataInconsistencyException; -import reviewme.config.requestlimit.TooManyRequestException; import reviewme.global.exception.FieldErrorResponse; import reviewme.global.exception.NotFoundException; import reviewme.global.exception.UnauthorizedException; @@ -51,11 +50,6 @@ public ProblemDetail handleDataConsistencyException(DataInconsistencyException e return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, ex.getErrorMessage()); } - @ExceptionHandler(TooManyRequestException.class) - public ProblemDetail handleDuplicateRequestException(TooManyRequestException ex) { - return ProblemDetail.forStatusAndDetail(HttpStatus.TOO_MANY_REQUESTS, ex.getErrorMessage()); - } - @ExceptionHandler(Exception.class) public ProblemDetail handleException(Exception ex) { log.error("Internal server error has occurred", ex); diff --git a/backend/src/main/java/reviewme/review/repository/AnswerRepository.java b/backend/src/main/java/reviewme/review/repository/AnswerRepository.java index ea793623a..5b18ab9f2 100644 --- a/backend/src/main/java/reviewme/review/repository/AnswerRepository.java +++ b/backend/src/main/java/reviewme/review/repository/AnswerRepository.java @@ -15,7 +15,7 @@ public interface AnswerRepository extends JpaRepository { SELECT a FROM Answer a JOIN Review r ON a.reviewId = r.id WHERE r.reviewGroupId = :reviewGroupId AND a.questionId IN :questionIds - ORDER BY r.createdAt DESC + ORDER BY r.createdAt DESC, r.id DESC LIMIT :limit """) List findReceivedAnswersByQuestionIds(long reviewGroupId, Collection questionIds, int limit); diff --git a/backend/src/main/java/reviewme/review/repository/ReviewRepository.java b/backend/src/main/java/reviewme/review/repository/ReviewRepository.java index 3a0600ad9..90119fa0b 100644 --- a/backend/src/main/java/reviewme/review/repository/ReviewRepository.java +++ b/backend/src/main/java/reviewme/review/repository/ReviewRepository.java @@ -12,7 +12,7 @@ public interface ReviewRepository extends JpaRepository { @Query(""" SELECT r FROM Review r WHERE r.reviewGroupId = :reviewGroupId - ORDER BY r.createdAt DESC + ORDER BY r.createdAt DESC, r.id DESC """) List findAllByGroupId(long reviewGroupId); diff --git a/backend/src/main/java/reviewme/review/service/dto/request/ReviewAnswerRequest.java b/backend/src/main/java/reviewme/review/service/dto/request/ReviewAnswerRequest.java index 60233be2d..85d89cd7e 100644 --- a/backend/src/main/java/reviewme/review/service/dto/request/ReviewAnswerRequest.java +++ b/backend/src/main/java/reviewme/review/service/dto/request/ReviewAnswerRequest.java @@ -15,11 +15,12 @@ public record ReviewAnswerRequest( @Nullable String text ) { - public boolean hasTextAnswer() { - return text != null && !text.isEmpty(); + + public boolean hasNoText() { + return text == null || text.isBlank(); } - public boolean hasCheckboxAnswer() { - return selectedOptionIds != null && !selectedOptionIds.isEmpty(); + public boolean hasNoSelectedOptions() { + return selectedOptionIds == null || selectedOptionIds.isEmpty(); } } diff --git a/backend/src/main/java/reviewme/review/service/mapper/AnswerMapper.java b/backend/src/main/java/reviewme/review/service/mapper/AnswerMapper.java index 1181808a5..87ee4c511 100644 --- a/backend/src/main/java/reviewme/review/service/mapper/AnswerMapper.java +++ b/backend/src/main/java/reviewme/review/service/mapper/AnswerMapper.java @@ -1,8 +1,8 @@ package reviewme.review.service.mapper; -import reviewme.template.domain.QuestionType; import reviewme.review.domain.Answer; import reviewme.review.service.dto.request.ReviewAnswerRequest; +import reviewme.template.domain.QuestionType; public interface AnswerMapper { diff --git a/backend/src/main/java/reviewme/review/service/mapper/CheckboxAnswerMapper.java b/backend/src/main/java/reviewme/review/service/mapper/CheckboxAnswerMapper.java index 7fb87b0dc..2829890cd 100644 --- a/backend/src/main/java/reviewme/review/service/mapper/CheckboxAnswerMapper.java +++ b/backend/src/main/java/reviewme/review/service/mapper/CheckboxAnswerMapper.java @@ -1,10 +1,9 @@ package reviewme.review.service.mapper; import org.springframework.stereotype.Component; -import reviewme.template.domain.QuestionType; import reviewme.review.domain.CheckboxAnswer; import reviewme.review.service.dto.request.ReviewAnswerRequest; -import reviewme.review.service.exception.CheckBoxAnswerIncludedTextException; +import reviewme.template.domain.QuestionType; @Component public class CheckboxAnswerMapper implements AnswerMapper { @@ -16,8 +15,8 @@ public boolean supports(QuestionType questionType) { @Override public CheckboxAnswer mapToAnswer(ReviewAnswerRequest answerRequest) { - if (answerRequest.text() != null) { - throw new CheckBoxAnswerIncludedTextException(answerRequest.questionId()); + if (answerRequest.hasNoSelectedOptions()) { + return null; } return new CheckboxAnswer(answerRequest.questionId(), answerRequest.selectedOptionIds()); } diff --git a/backend/src/main/java/reviewme/review/service/mapper/ReviewMapper.java b/backend/src/main/java/reviewme/review/service/mapper/ReviewMapper.java index 58d0c6a6f..499a5ea19 100644 --- a/backend/src/main/java/reviewme/review/service/mapper/ReviewMapper.java +++ b/backend/src/main/java/reviewme/review/service/mapper/ReviewMapper.java @@ -62,20 +62,10 @@ private List getAnswersByQuestionType(ReviewRegisterRequest request) { private Answer mapRequestToAnswer(Map questions, ReviewAnswerRequest answerRequest) { Question question = questions.get(answerRequest.questionId()); - if (question == null) { throw new SubmittedQuestionNotFoundException(answerRequest.questionId()); } - // TODO: 아래 코드를 삭제해야 한다 - if (question.isSelectable() && answerRequest.selectedOptionIds() != null && answerRequest.selectedOptionIds().isEmpty()) { - return null; - } - if (!question.isSelectable() && answerRequest.text() != null && answerRequest.text().isEmpty()) { - return null; - } - // END - AnswerMapper answerMapper = answerMapperFactory.getAnswerMapper(question.getQuestionType()); return answerMapper.mapToAnswer(answerRequest); } diff --git a/backend/src/main/java/reviewme/review/service/mapper/TextAnswerMapper.java b/backend/src/main/java/reviewme/review/service/mapper/TextAnswerMapper.java index 48bd55789..6f28faedd 100644 --- a/backend/src/main/java/reviewme/review/service/mapper/TextAnswerMapper.java +++ b/backend/src/main/java/reviewme/review/service/mapper/TextAnswerMapper.java @@ -1,10 +1,9 @@ package reviewme.review.service.mapper; import org.springframework.stereotype.Component; -import reviewme.template.domain.QuestionType; import reviewme.review.domain.TextAnswer; import reviewme.review.service.dto.request.ReviewAnswerRequest; -import reviewme.review.service.exception.TextAnswerIncludedOptionItemException; +import reviewme.template.domain.QuestionType; @Component public class TextAnswerMapper implements AnswerMapper { @@ -16,12 +15,9 @@ public boolean supports(QuestionType questionType) { @Override public TextAnswer mapToAnswer(ReviewAnswerRequest answerRequest) { - if (!answerRequest.hasTextAnswer()) { + if (answerRequest.hasNoText()) { return null; } - if (answerRequest.selectedOptionIds() != null) { - throw new TextAnswerIncludedOptionItemException(answerRequest.questionId()); - } return new TextAnswer(answerRequest.questionId(), answerRequest.text()); } } diff --git a/backend/src/main/java/reviewme/template/service/mapper/TemplateMapper.java b/backend/src/main/java/reviewme/template/service/mapper/TemplateMapper.java index 7151003d5..5225bd7e6 100644 --- a/backend/src/main/java/reviewme/template/service/mapper/TemplateMapper.java +++ b/backend/src/main/java/reviewme/template/service/mapper/TemplateMapper.java @@ -2,6 +2,7 @@ import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Component; import reviewme.template.domain.OptionGroup; import reviewme.template.domain.OptionItem; @@ -14,9 +15,6 @@ import reviewme.template.domain.SectionQuestion; import reviewme.template.domain.Template; import reviewme.template.domain.TemplateSection; -import reviewme.template.service.exception.MissingOptionItemsInOptionGroupException; -import reviewme.template.service.exception.SectionInTemplateNotFoundException; -import reviewme.template.service.exception.TemplateNotFoundByReviewGroupException; import reviewme.template.repository.SectionRepository; import reviewme.template.repository.TemplateRepository; import reviewme.template.service.dto.response.OptionGroupResponse; @@ -24,7 +22,10 @@ import reviewme.template.service.dto.response.QuestionResponse; import reviewme.template.service.dto.response.SectionResponse; import reviewme.template.service.dto.response.TemplateResponse; +import reviewme.template.service.exception.MissingOptionItemsInOptionGroupException; import reviewme.template.service.exception.QuestionInSectionNotFoundException; +import reviewme.template.service.exception.SectionInTemplateNotFoundException; +import reviewme.template.service.exception.TemplateNotFoundByReviewGroupException; @Component @RequiredArgsConstructor @@ -38,6 +39,7 @@ public class TemplateMapper { private final OptionGroupRepository optionGroupRepository; private final OptionItemRepository optionItemRepository; + @Cacheable(value = "template_response", key = "#reviewGroup.templateId") public TemplateResponse mapToTemplateResponse(ReviewGroup reviewGroup) { Template template = templateRepository.findById(reviewGroup.getTemplateId()) .orElseThrow(() -> new TemplateNotFoundByReviewGroupException( diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index aa0160b1f..45df6e2cb 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -37,9 +37,3 @@ cors: allowed-origins: - http://localhost - https://localhost - -request-limit: - threshold: 3 - duration: 1s - host: localhost - port: 6379 diff --git a/backend/src/main/resources/ports.yml b/backend/src/main/resources/ports.yml deleted file mode 100644 index 8b8093829..000000000 --- a/backend/src/main/resources/ports.yml +++ /dev/null @@ -1,6 +0,0 @@ -server: - port: ${SERVER_PORT} - -management: - server: - port: ${ACTUATOR_PORT} diff --git a/backend/src/test/java/reviewme/api/ApiTest.java b/backend/src/test/java/reviewme/api/ApiTest.java index 682a2ea18..20d57db83 100644 --- a/backend/src/test/java/reviewme/api/ApiTest.java +++ b/backend/src/test/java/reviewme/api/ApiTest.java @@ -1,7 +1,5 @@ package reviewme.api; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyHeaders; import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyUris; @@ -17,11 +15,8 @@ import org.apache.http.HttpHeaders; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.ValueOperations; import org.springframework.http.MediaType; import org.springframework.restdocs.RestDocumentationContextProvider; import org.springframework.restdocs.RestDocumentationExtension; @@ -34,8 +29,8 @@ import reviewme.highlight.controller.HighlightController; import reviewme.highlight.service.HighlightService; import reviewme.review.controller.ReviewController; -import reviewme.review.service.ReviewGatheredLookupService; import reviewme.review.service.ReviewDetailLookupService; +import reviewme.review.service.ReviewGatheredLookupService; import reviewme.review.service.ReviewListLookupService; import reviewme.review.service.ReviewRegisterService; import reviewme.review.service.ReviewSummaryService; @@ -78,12 +73,6 @@ public abstract class ApiTest { @MockBean protected ReviewGroupLookupService reviewGroupLookupService; - @MockBean - protected RedisTemplate redisTemplate; - - @Mock - protected ValueOperations valueOperations; - @MockBean protected ReviewSummaryService reviewSummaryService; @@ -111,12 +100,6 @@ public abstract class ApiTest { } }; - @BeforeEach - void setUpRedisConfig() { - given(redisTemplate.opsForValue()).willReturn(valueOperations); - given(valueOperations.increment(anyString())).willReturn(1L); - } - @BeforeEach void setUpRestDocs(WebApplicationContext context, RestDocumentationContextProvider provider) { UriModifyingOperationPreprocessor uriModifier = modifyUris() diff --git a/backend/src/test/java/reviewme/config/requestlimit/RequestLimitInterceptorTest.java b/backend/src/test/java/reviewme/config/requestlimit/RequestLimitInterceptorTest.java deleted file mode 100644 index 969040683..000000000 --- a/backend/src/test/java/reviewme/config/requestlimit/RequestLimitInterceptorTest.java +++ /dev/null @@ -1,75 +0,0 @@ -package reviewme.config.requestlimit; - -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.AssertionsForClassTypes.assertThat; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.springframework.http.HttpHeaders.USER_AGENT; - -import jakarta.servlet.http.HttpServletRequest; -import java.time.Duration; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.ValueOperations; - -class RequestLimitInterceptorTest { - - private final HttpServletRequest request = mock(HttpServletRequest.class); - private final RedisTemplate redisTemplate = mock(RedisTemplate.class); - private final ValueOperations valueOperations = mock(ValueOperations.class); - private final RequestLimitProperties requestLimitProperties = mock(RequestLimitProperties.class); - private final RequestLimitInterceptor interceptor = new RequestLimitInterceptor(redisTemplate, requestLimitProperties); - private final String requestKey = "RequestURI: /api/v2/reviews, RemoteAddr: localhost, UserAgent: Postman"; - - @BeforeEach - void setUp() { - given(request.getMethod()).willReturn("POST"); - given(request.getRequestURI()).willReturn("/api/v2/reviews"); - given(request.getRemoteAddr()).willReturn("localhost"); - given(request.getHeader(USER_AGENT)).willReturn("Postman"); - - given(redisTemplate.opsForValue()).willReturn(valueOperations); - given(requestLimitProperties.duration()).willReturn(Duration.ofSeconds(1)); - given(requestLimitProperties.threshold()).willReturn(3L); - } - - @Test - void POST_요청이_아니면_통과한다() { - // given - given(request.getMethod()).willReturn("GET"); - - // when - boolean result = interceptor.preHandle(request, null, null); - - // then - assertThat(result).isTrue(); - } - - @Test - void 특정_POST_요청이_처음이_아니며_최대_빈도보다_작을_경우_빈도를_1증가시킨다() { - // given - long requestCount = 1; - given(valueOperations.get(anyString())).willReturn(requestCount); - - // when - boolean result = interceptor.preHandle(request, null, null); - - // then - assertThat(result).isTrue(); - verify(valueOperations).increment(requestKey); - } - - @Test - void 특정_POST_요청이_처음이_아니며_최대_빈도보다_클_경우_예외를_발생시킨다() { - // given - long maxRequestCount = 3; - given(valueOperations.increment(anyString())).willReturn(maxRequestCount + 1); - - // when & then - assertThatThrownBy(() -> interceptor.preHandle(request, null, null)) - .isInstanceOf(TooManyRequestException.class); - } -} diff --git a/backend/src/test/java/reviewme/review/service/mapper/AnswerMapperFactoryTest.java b/backend/src/test/java/reviewme/review/service/mapper/AnswerMapperFactoryTest.java index bdf37e905..f18dc74f3 100644 --- a/backend/src/test/java/reviewme/review/service/mapper/AnswerMapperFactoryTest.java +++ b/backend/src/test/java/reviewme/review/service/mapper/AnswerMapperFactoryTest.java @@ -8,9 +8,9 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.boot.test.system.CapturedOutput; import org.springframework.boot.test.system.OutputCaptureExtension; -import reviewme.template.domain.QuestionType; import reviewme.review.domain.Answer; import reviewme.review.service.dto.request.ReviewAnswerRequest; +import reviewme.template.domain.QuestionType; @ExtendWith(OutputCaptureExtension.class) class AnswerMapperFactoryTest { @@ -18,13 +18,13 @@ class AnswerMapperFactoryTest { private final AnswerMapper answerMapper = new AnswerMapper() { @Override - public boolean supports(QuestionType questionType) { - return questionType == QuestionType.CHECKBOX; + public Answer mapToAnswer(ReviewAnswerRequest answerRequest) { + return null; } @Override - public Answer mapToAnswer(ReviewAnswerRequest answerRequest) { - return null; + public boolean supports(QuestionType questionType) { + return questionType == QuestionType.CHECKBOX; } }; diff --git a/backend/src/test/java/reviewme/review/service/mapper/CheckboxAnswerMapperTest.java b/backend/src/test/java/reviewme/review/service/mapper/CheckboxAnswerMapperTest.java index eb2d96f98..c05b4553f 100644 --- a/backend/src/test/java/reviewme/review/service/mapper/CheckboxAnswerMapperTest.java +++ b/backend/src/test/java/reviewme/review/service/mapper/CheckboxAnswerMapperTest.java @@ -1,14 +1,14 @@ package reviewme.review.service.mapper; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.List; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; import reviewme.review.domain.CheckboxAnswer; import reviewme.review.domain.CheckboxAnswerSelectedOption; import reviewme.review.service.dto.request.ReviewAnswerRequest; -import reviewme.review.service.exception.CheckBoxAnswerIncludedTextException; class CheckboxAnswerMapperTest { @@ -28,16 +28,17 @@ class CheckboxAnswerMapperTest { .containsExactly(1L, 2L, 3L); } - @Test - void 체크박스_답변_요청에_텍스트가_포함되어_있으면_예외를_발생시킨다() { + @ParameterizedTest + @NullAndEmptySource + void 체크박스_답변이_비어있는_경우_null로_매핑한다(List selectedOptionIds) { // given - ReviewAnswerRequest request = new ReviewAnswerRequest(1L, List.of(1L, 2L, 3L), "text"); + ReviewAnswerRequest request = new ReviewAnswerRequest(1L, selectedOptionIds, null); + CheckboxAnswerMapper mapper = new CheckboxAnswerMapper(); // when - CheckboxAnswerMapper mapper = new CheckboxAnswerMapper(); + CheckboxAnswer actual = mapper.mapToAnswer(request); // then - assertThatThrownBy(() -> mapper.mapToAnswer(request)) - .isInstanceOf(CheckBoxAnswerIncludedTextException.class); + assertThat(actual).isNull(); } } diff --git a/backend/src/test/java/reviewme/review/service/mapper/ReviewMapperTest.java b/backend/src/test/java/reviewme/review/service/mapper/ReviewMapperTest.java index 4065c63de..2a624ccf5 100644 --- a/backend/src/test/java/reviewme/review/service/mapper/ReviewMapperTest.java +++ b/backend/src/test/java/reviewme/review/service/mapper/ReviewMapperTest.java @@ -2,7 +2,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.assertAll; import static reviewme.fixture.OptionGroupFixture.선택지_그룹; import static reviewme.fixture.OptionItemFixture.선택지; import static reviewme.fixture.QuestionFixture.서술형_옵션_질문; @@ -16,12 +15,6 @@ import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import reviewme.template.domain.OptionGroup; -import reviewme.template.domain.OptionItem; -import reviewme.template.domain.Question; -import reviewme.template.repository.OptionGroupRepository; -import reviewme.template.repository.OptionItemRepository; -import reviewme.template.repository.QuestionRepository; import reviewme.review.domain.CheckboxAnswer; import reviewme.review.domain.Review; import reviewme.review.domain.TextAnswer; @@ -31,7 +24,13 @@ import reviewme.reviewgroup.domain.ReviewGroup; import reviewme.reviewgroup.repository.ReviewGroupRepository; import reviewme.support.ServiceTest; +import reviewme.template.domain.OptionGroup; +import reviewme.template.domain.OptionItem; +import reviewme.template.domain.Question; import reviewme.template.domain.Section; +import reviewme.template.repository.OptionGroupRepository; +import reviewme.template.repository.OptionItemRepository; +import reviewme.template.repository.QuestionRepository; import reviewme.template.repository.SectionRepository; import reviewme.template.repository.TemplateRepository; @@ -60,7 +59,7 @@ class ReviewMapperTest { private TemplateRepository templateRepository; @Test - void 텍스트가_포함된_리뷰를_생성한다() { + void 서술형_답변을_매핑한다() { // given ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); @@ -81,7 +80,7 @@ class ReviewMapperTest { } @Test - void 체크박스가_포함된_리뷰를_생성한다() { + void 선택형_답변을_매핑한다() { // given ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); @@ -106,57 +105,47 @@ class ReviewMapperTest { } @Test - void 필수가_아닌_질문에_답변이_없을_경우_답변을_생성하지_않는다() { + void 필수가_아닌_서술형_질문에_답변이_없으면_매핑하지_않는다() { // given ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + Question question = questionRepository.save(서술형_옵션_질문()); + Section section = sectionRepository.save(항상_보이는_섹션(List.of(question.getId()))); + templateRepository.save(템플릿(List.of(section.getId()))); - Question requiredTextQuestion = questionRepository.save(서술형_필수_질문()); - Question optionalTextQuestion = questionRepository.save(서술형_옵션_질문()); + ReviewAnswerRequest answerRequest = new ReviewAnswerRequest(question.getId(), null, ""); + ReviewRegisterRequest reviewRegisterRequest = new ReviewRegisterRequest( + reviewGroup.getReviewRequestCode(), List.of(answerRequest)); - Question requeiredCheckBoxQuestion = questionRepository.save(선택형_필수_질문()); - OptionGroup optionGroup1 = optionGroupRepository.save(선택지_그룹(requeiredCheckBoxQuestion.getId())); - OptionItem optionItem1 = optionItemRepository.save(선택지(optionGroup1.getId())); - OptionItem optionItem2 = optionItemRepository.save(선택지(optionGroup1.getId())); + // when + Review review = reviewMapper.mapToReview(reviewRegisterRequest); - Question optionalCheckBoxQuestion = questionRepository.save(선택형_옵션_질문()); - OptionGroup optionGroup2 = optionGroupRepository.save(선택지_그룹(optionalCheckBoxQuestion.getId())); - OptionItem optionItem3 = optionItemRepository.save(선택지(optionGroup2.getId())); - OptionItem optionItem4 = optionItemRepository.save(선택지(optionGroup2.getId())); + // then + assertThat(review.getAnswersByType(TextAnswer.class)) + .extracting(TextAnswer::getQuestionId) + .isEmpty(); + } - Section section = sectionRepository.save(항상_보이는_섹션( - List.of(requiredTextQuestion.getId(), optionalTextQuestion.getId(), - requeiredCheckBoxQuestion.getId(), optionalCheckBoxQuestion.getId()))); + @Test + void 필수가_아닌_선택형_질문에_답변이_없으면_매핑하지_않는다() { + // given + ReviewGroup reviewGroup = reviewGroupRepository.save(리뷰_그룹()); + Question question = questionRepository.save(선택형_옵션_질문()); + OptionGroup optionGroup = optionGroupRepository.save(선택지_그룹(question.getId())); + + Section section = sectionRepository.save(항상_보이는_섹션(List.of(question.getId()))); templateRepository.save(템플릿(List.of(section.getId()))); - String textAnswer = "답".repeat(20); - ReviewAnswerRequest requiredTextAnswerRequest = new ReviewAnswerRequest( - requiredTextQuestion.getId(), null, textAnswer - ); - ReviewAnswerRequest optionalTextAnswerRequest = new ReviewAnswerRequest( - optionalTextQuestion.getId(), null, "" - ); - ReviewAnswerRequest requiredCheckBoxAnswerRequest = new ReviewAnswerRequest( - requeiredCheckBoxQuestion.getId(), List.of(optionItem1.getId()), null - ); - ReviewAnswerRequest optionalCheckBoxAnswerRequest = new ReviewAnswerRequest( - optionalCheckBoxQuestion.getId(), List.of(), null - ); - ReviewRegisterRequest reviewRegisterRequest = new ReviewRegisterRequest(reviewGroup.getReviewRequestCode(), - List.of(requiredTextAnswerRequest, optionalTextAnswerRequest, - requiredCheckBoxAnswerRequest, optionalCheckBoxAnswerRequest)); + ReviewAnswerRequest answerRequest = new ReviewAnswerRequest(question.getId(), List.of(), null); + ReviewRegisterRequest reviewRegisterRequest = new ReviewRegisterRequest( + reviewGroup.getReviewRequestCode(), List.of(answerRequest)); // when Review review = reviewMapper.mapToReview(reviewRegisterRequest); // then - assertAll( - () -> assertThat(review.getAnswersByType(TextAnswer.class)) - .extracting(TextAnswer::getQuestionId) - .containsExactly(requiredTextQuestion.getId()), - () -> assertThat(review.getAnswersByType(CheckboxAnswer.class)) - .extracting(CheckboxAnswer::getQuestionId) - .containsExactly(requeiredCheckBoxQuestion.getId()) - ); + assertThat(review.getAnswersByType(CheckboxAnswer.class)) + .extracting(CheckboxAnswer::getQuestionId) + .isEmpty(); } @Test diff --git a/backend/src/test/java/reviewme/review/service/mapper/TextAnswerMapperTest.java b/backend/src/test/java/reviewme/review/service/mapper/TextAnswerMapperTest.java index 841e2d5a3..b7fc960cf 100644 --- a/backend/src/test/java/reviewme/review/service/mapper/TextAnswerMapperTest.java +++ b/backend/src/test/java/reviewme/review/service/mapper/TextAnswerMapperTest.java @@ -1,23 +1,16 @@ package reviewme.review.service.mapper; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import java.util.List; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; import reviewme.review.domain.TextAnswer; import reviewme.review.service.dto.request.ReviewAnswerRequest; -import reviewme.review.service.exception.TextAnswerIncludedOptionItemException; class TextAnswerMapperTest { - /* - TODO: Request를 추상화해야 할까요? - 떠오르는 방법은 아래와 같습니다. - 1: static factory method를 사용 -> 걷잡을 수 없어지지 않을까요? - 2: 다른 방식으로 추상화 ? - */ - @Test void 텍스트_답변을_요청으로부터_매핑한다() { // given @@ -31,16 +24,18 @@ class TextAnswerMapperTest { assertThat(actual.getContent()).isEqualTo("text"); } - @Test - void 텍스트_답변_요청에_옵션이_포함되어_있으면_예외를_발생시킨다() { + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " "}) + void 텍스트_답변이_비어있는_경우_null로_매핑한다(String text) { // given - ReviewAnswerRequest request = new ReviewAnswerRequest(1L, List.of(1L), "text"); + ReviewAnswerRequest request = new ReviewAnswerRequest(1L, null, text); // when TextAnswerMapper mapper = new TextAnswerMapper(); + TextAnswer actual = mapper.mapToAnswer(request); // then - assertThatThrownBy(() -> mapper.mapToAnswer(request)) - .isInstanceOf(TextAnswerIncludedOptionItemException.class); + assertThat(actual).isNull(); } } diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index f18542246..ccbe2e2ff 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -13,6 +13,8 @@ spring: ddl-auto: update flyway: enabled: false + cache: + type: none springdoc: swagger-ui: @@ -38,9 +40,3 @@ logging: cors: allowed-origins: - https://allowed-domain.com - -request-limit: - threshold: 3 - duration: 1s - host: localhost - port: 6379