diff --git a/backend/src/main/java/codezap/voc/config/VocClientHttpRequestInterceptor.java b/backend/src/main/java/codezap/voc/config/VocClientHttpRequestInterceptor.java new file mode 100644 index 000000000..9a669cbb6 --- /dev/null +++ b/backend/src/main/java/codezap/voc/config/VocClientHttpRequestInterceptor.java @@ -0,0 +1,28 @@ +package codezap.voc.config; + +import java.io.IOException; +import java.net.SocketTimeoutException; + +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; + +import codezap.global.exception.CodeZapException; +import codezap.global.exception.ErrorCode; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class VocClientHttpRequestInterceptor implements ClientHttpRequestInterceptor { + + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) { + try { + return execution.execute(request, body); + } catch (SocketTimeoutException e) { + throw new CodeZapException(ErrorCode.INTERNAL_SERVER_ERROR, "스프레드시트 API 요청 시간이 초과되었습니다: " + e.getMessage()); + } catch (IOException e) { + throw new CodeZapException(ErrorCode.INTERNAL_SERVER_ERROR, "스프레드시트 API 요청에 실패했습니다: " + e.getMessage()); + } + } +} diff --git a/backend/src/main/java/codezap/voc/config/VocConfiguration.java b/backend/src/main/java/codezap/voc/config/VocConfiguration.java new file mode 100644 index 000000000..9c956fb56 --- /dev/null +++ b/backend/src/main/java/codezap/voc/config/VocConfiguration.java @@ -0,0 +1,39 @@ +package codezap.voc.config; + +import static org.springframework.http.HttpHeaders.CONTENT_TYPE; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.BufferingClientHttpRequestFactory; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableConfigurationProperties(VocProperties.class) +@RequiredArgsConstructor +public class VocConfiguration { + + private final VocProperties properties; + + @Bean + public RestClient.Builder vocRestClientBuilder() { + return RestClient.builder() + .baseUrl(properties.getBaseUrl()) + .defaultHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE) + .defaultStatusHandler(new VocResponseErrorHandler()) + .requestInterceptor(new VocClientHttpRequestInterceptor()) + .requestFactory(requestFactory()); + } + + private ClientHttpRequestFactory requestFactory() { + var httpRequestFactory = new SimpleClientHttpRequestFactory(); + httpRequestFactory.setConnectTimeout(properties.getConnectTimeout()); + httpRequestFactory.setReadTimeout(properties.getReadTimeout()); + return new BufferingClientHttpRequestFactory(httpRequestFactory); + } +} diff --git a/backend/src/main/java/codezap/voc/config/VocProperties.java b/backend/src/main/java/codezap/voc/config/VocProperties.java new file mode 100644 index 000000000..a225f6068 --- /dev/null +++ b/backend/src/main/java/codezap/voc/config/VocProperties.java @@ -0,0 +1,17 @@ +package codezap.voc.config; + +import java.time.Duration; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@ConfigurationProperties("voc.http.client") +@RequiredArgsConstructor +@Getter +public class VocProperties { + private final String baseUrl; + private final Duration connectTimeout; + private final Duration readTimeout; +} diff --git a/backend/src/main/java/codezap/voc/config/VocResponseErrorHandler.java b/backend/src/main/java/codezap/voc/config/VocResponseErrorHandler.java new file mode 100644 index 000000000..e32e9e7cc --- /dev/null +++ b/backend/src/main/java/codezap/voc/config/VocResponseErrorHandler.java @@ -0,0 +1,37 @@ +package codezap.voc.config; + +import java.io.IOException; + +import org.springframework.http.HttpStatusCode; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.web.client.ResponseErrorHandler; + +import codezap.global.exception.CodeZapException; +import codezap.global.exception.ErrorCode; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class VocResponseErrorHandler implements ResponseErrorHandler { + + @Override + public boolean hasError(ClientHttpResponse response) { + try { + HttpStatusCode statusCode = response.getStatusCode(); + return statusCode.isError(); + } catch (IOException e) { + log.error(e.getMessage(), e); + throw new CodeZapException(ErrorCode.INTERNAL_SERVER_ERROR, "스프레드시트 API 요청에 실패했습니다."); + } + } + + @Override + public void handleError(ClientHttpResponse response) { + try { + HttpStatusCode statusCode = response.getStatusCode(); + log.error("{}", statusCode); + } catch (IOException e) { + log.error(e.getMessage(), e); + } + throw new CodeZapException(ErrorCode.INTERNAL_SERVER_ERROR, "스프레드시트 API 요청에 실패했습니다."); + } +} diff --git a/backend/src/main/java/codezap/voc/controller/SpringDocVocController.java b/backend/src/main/java/codezap/voc/controller/SpringDocVocController.java new file mode 100644 index 000000000..60deb9c59 --- /dev/null +++ b/backend/src/main/java/codezap/voc/controller/SpringDocVocController.java @@ -0,0 +1,28 @@ +package codezap.voc.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import codezap.global.swagger.error.ApiErrorResponse; +import codezap.global.swagger.error.ErrorCase; +import codezap.voc.dto.VocRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "문의하기") +public interface SpringDocVocController { + + @Operation(summary = "문의하기") + @ApiResponse(responseCode = "201", description = "VOC 스프레드시트 데이터 추가 성공") + @ApiErrorResponse( + status = HttpStatus.BAD_REQUEST, + instance = "/contact", + errorCases = { + @ErrorCase(description = "문의 내용을 입력하지 않은 경우.", exampleMessage = "문의 내용을 입력해주세요."), + @ErrorCase(description = "문의 내용이 20자 미만인 경우.", exampleMessage = "문의 내용을 20자 이상 입력해주세요."), + @ErrorCase(description = "문의 내용이 10,000 글자를 초과한 경우.", exampleMessage = "문의 내용은 최대 10,000 글자까지 입력할 수 있습니다.") + } + ) + ResponseEntity create(VocRequest request); +} diff --git a/backend/src/main/java/codezap/voc/controller/VocController.java b/backend/src/main/java/codezap/voc/controller/VocController.java new file mode 100644 index 000000000..17b9b4a99 --- /dev/null +++ b/backend/src/main/java/codezap/voc/controller/VocController.java @@ -0,0 +1,25 @@ +package codezap.voc.controller; + +import jakarta.validation.Valid; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import codezap.voc.dto.VocRequest; +import codezap.voc.service.VocService; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class VocController implements SpringDocVocController { + + private final VocService vocService; + + @PostMapping("/contact") + public ResponseEntity create(@Valid @RequestBody VocRequest request) { + vocService.create(request); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/codezap/voc/dto/VocRequest.java b/backend/src/main/java/codezap/voc/dto/VocRequest.java new file mode 100644 index 000000000..1cee2096b --- /dev/null +++ b/backend/src/main/java/codezap/voc/dto/VocRequest.java @@ -0,0 +1,40 @@ +package codezap.voc.dto; + +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record VocRequest( + + @Schema(description = "문의 내용", example = "코드잽 정말 잘 사용하고 있어요. 우테코 6기 코드잽 화이팅!") + @NotEmpty(message = "문의 내용은 비어있을 수 없습니다.") + @Size(min = 20, max = 10_000, message = "문의 내용은 최소 20자, 최대 10,000 자 입력할 수 있습니다.") + String message, + + @Nullable + @Schema(description = "이메일(선택)", example = "codezap2024@gmail.com") + @Email(regexp = "[a-zA-Z0-9_!#$%&’*+/=?`{|}~^.-]+@[a-zA-Z0-9.-]+$", message = "올바른 형식의 이메일 주소여야 합니다.") // Regex from RFC 5322 + String email, + + @Nullable + @Schema(description = "사용자 db id(선택)", example = "1") + @Min(value = 1, message = "1 이상이어야 합니다.") + Long memberId, + + @Nullable + @Schema(description = "사용자 아이디(선택)", example = "만두") + @Size(max = 255, message = "아이디는 255자 이하로 입력해주세요.") + String name +) { + public VocRequest(String message) { + this(message, null); + } + + public VocRequest(String message, String email) { + this(message, email, null, null); + } +} diff --git a/backend/src/main/java/codezap/voc/service/VocService.java b/backend/src/main/java/codezap/voc/service/VocService.java new file mode 100644 index 000000000..a965ad9ba --- /dev/null +++ b/backend/src/main/java/codezap/voc/service/VocService.java @@ -0,0 +1,23 @@ +package codezap.voc.service; + +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import codezap.voc.dto.VocRequest; + +@Service +public class VocService { + + private final RestClient restClient; + + public VocService(RestClient.Builder builder) { + restClient = builder.build(); + } + + public void create(VocRequest request) { + restClient.post() + .body(request) + .retrieve() + .toBodilessEntity(); + } +} diff --git a/backend/src/main/resources/application-voc.yml b/backend/src/main/resources/application-voc.yml new file mode 100644 index 000000000..3bbc663d0 --- /dev/null +++ b/backend/src/main/resources/application-voc.yml @@ -0,0 +1,6 @@ +voc: + http: + client: + base-url: ${VOC_SPREADSHEET_API_BASE_URL} + connect-timeout: 5s + read-timeout: 5s diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index dc9059ec6..f3e68b349 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,6 +1,6 @@ spring: profiles: group: - default: local - dev: db, actuator, swagger, cors - prod: db, actuator, swagger, cors + default: local, voc + dev: db, actuator, swagger, cors, voc + prod: db, actuator, swagger, cors, voc diff --git a/backend/src/test/java/codezap/global/MockMvcTest.java b/backend/src/test/java/codezap/global/MockMvcTest.java index 017d132aa..59719db8c 100644 --- a/backend/src/test/java/codezap/global/MockMvcTest.java +++ b/backend/src/test/java/codezap/global/MockMvcTest.java @@ -24,6 +24,7 @@ import codezap.member.service.MemberService; import codezap.template.service.facade.TemplateApplicationService; import codezap.tag.service.TagService; +import codezap.voc.service.VocService; @WebMvcTest(SpringExtension.class) @EnableConfigurationProperties(CorsProperties.class) @@ -51,6 +52,9 @@ public abstract class MockMvcTest { @MockBean protected LikesService likesService; + @MockBean + private VocService vocService; + @MockBean protected TemplateApplicationService templateApplicationService; diff --git a/backend/src/test/java/codezap/voc/config/VocPropertiesTest.java b/backend/src/test/java/codezap/voc/config/VocPropertiesTest.java new file mode 100644 index 000000000..ff5dd4edf --- /dev/null +++ b/backend/src/test/java/codezap/voc/config/VocPropertiesTest.java @@ -0,0 +1,28 @@ +package codezap.voc.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.Duration; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(classes = VocConfiguration.class) +class VocPropertiesTest { + + @Autowired + private VocProperties sut; + + @Test + @DisplayName("yml 파일로부터 http client 관련 설정 값을 가져오는지 확인") + void getAllowedOrigins() { + assertAll( + () -> assertThat(sut.getBaseUrl()).isEqualTo("http://localhost:8080"), + () -> assertThat(sut.getConnectTimeout()).isEqualTo(Duration.ofSeconds(5L)), + () -> assertThat(sut.getReadTimeout()).isEqualTo(Duration.ofSeconds(5L)) + ); + } +} diff --git a/backend/src/test/java/codezap/voc/controller/VocControllerTest.java b/backend/src/test/java/codezap/voc/controller/VocControllerTest.java new file mode 100644 index 000000000..0cdaf89f3 --- /dev/null +++ b/backend/src/test/java/codezap/voc/controller/VocControllerTest.java @@ -0,0 +1,34 @@ +package codezap.voc.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; + +import codezap.global.MockMvcTest; +import codezap.voc.dto.VocRequest; + +@Import(VocController.class) +class VocControllerTest extends MockMvcTest { + + @Test + @DisplayName("문의하기 요청 성공") + void create() throws Exception { + var request = new VocRequest("lorem ipsum dolor sit amet consectetur adipiscing elit", null); + + mvc.perform(post("/contact") + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("문의하기 요청 실패 : 요청 본문 없음") + void create_error() throws Exception { + mvc.perform(post("/contact")) + .andExpect(status().isBadRequest()); + } +} diff --git a/backend/src/test/java/codezap/voc/dto/VocRequestTest.java b/backend/src/test/java/codezap/voc/dto/VocRequestTest.java new file mode 100644 index 000000000..a98d24645 --- /dev/null +++ b/backend/src/test/java/codezap/voc/dto/VocRequestTest.java @@ -0,0 +1,187 @@ +package codezap.voc.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.stream.Stream; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; + +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +class VocRequestTest { + + private static ValidatorFactory validatorFactory; + private static Validator validator; + + private static String message = "lorem ipsum dolor sit amet consectetur adipiscing elit fugiat cupiditat"; + private static String email = "codezap2024@gmail.com"; + private static Long memberId = 1L; + private static String name = "만두"; + + private VocRequest sut; + + @BeforeAll + static void setUp() { + validatorFactory = Validation.buildDefaultValidatorFactory(); + validator = validatorFactory.getValidator(); + } + + @AfterAll + static void tearDown() { + validatorFactory.close(); + } + + @ParameterizedTest + @MethodSource + @DisplayName("성공: email, memberId, name은 optional") + void success(String message, String email, Long memberId, String name) { + sut = new VocRequest(message, email, memberId, name); + + var constraintViolations = validator.validate(sut); + + assertThat(constraintViolations).isEmpty(); + } + + static Stream success() { + return Stream.of( + Arguments.of(message, email, memberId, name), + Arguments.of(message, email, memberId, null), + Arguments.of(message, email, null, null), + Arguments.of(message, null, null, null)); + } + + @Nested + @DisplayName("문의 내용 검증") + class MessageTest { + + @ParameterizedTest + @MethodSource + @DisplayName("성공: 문의 내용 길이 20글자부터 10,000글자") + void message_length_success(String message) { + sut = new VocRequest(message); + + var constraintViolations = validator.validate(sut); + + assertThat(constraintViolations).isEmpty(); + } + + static Stream message_length_success() { + var messageLength20 = RandomStringUtils.randomAlphanumeric(20); + var messageLength10_000 = RandomStringUtils.randomAlphanumeric(10_000); + return Stream.of(messageLength20, messageLength10_000); + } + + @ParameterizedTest + @MethodSource + @DisplayName("실패: 문의 내용 길이 19자 이하, 10,001글자 이상") + void message_length_fail(String message) { + sut = new VocRequest(message); + + var constraintViolations = validator.validate(sut); + + assertThat(constraintViolations).isNotEmpty() + .first() + .extracting(ConstraintViolation::getMessage) + .isEqualTo("문의 내용은 최소 20자, 최대 10,000 자 입력할 수 있습니다."); + } + + static Stream message_length_fail() { + var messageLength19 = RandomStringUtils.randomAlphanumeric(19); + var messageLength10_001 = RandomStringUtils.randomAlphanumeric(10_001); + return Stream.of(messageLength19, messageLength10_001); + } + + @Test + @DisplayName("실패: 문의 내용이 null인 경우") + void message_null_fail() { + sut = new VocRequest(null); + + var constraintViolations = validator.validate(sut); + + assertThat(constraintViolations).isNotEmpty() + .first() + .extracting(ConstraintViolation::getMessage) + .isEqualTo("문의 내용은 비어있을 수 없습니다."); + } + } + + @Nested + @DisplayName("이메일 검증") + class EmailTest { + + @ParameterizedTest + @ValueSource(strings = {"", "codezap", "@gmail.com", ".com"}) + @DisplayName("실패: 이메일 형식에 맞지 않는 경우") + void email_format_fail(String email) { + sut = new VocRequest(message, email); + + var constraintViolations = validator.validate(sut); + + assertThat(constraintViolations).isNotEmpty() + .first() + .extracting(ConstraintViolation::getMessage) + .isEqualTo("올바른 형식의 이메일 주소여야 합니다."); + } + } + + @Nested + @DisplayName("memberId 검증") + class MemberIdTest { + + @Test + @DisplayName("실패: memberId가 0인 경우 예외 발생") + void memberId_fail() { + memberId = 0L; + sut = new VocRequest(message, email, memberId, name); + + var constraintViolations = validator.validate(sut); + + assertThat(constraintViolations).isNotEmpty() + .first() + .extracting(ConstraintViolation::getMessage) + .isEqualTo("1 이상이어야 합니다."); + } + } + + @Nested + @DisplayName("로그인한 사용자 이름 검증") + class NameTest { + + @Test + @DisplayName("성공: 사용자 이름 길이 255글자") + void name_success() { + name = RandomStringUtils.randomAlphanumeric(255); + sut = new VocRequest(message, email, memberId, name); + + var constraintViolations = validator.validate(sut); + + assertThat(constraintViolations).isEmpty(); + } + + @Test + @DisplayName("실패: 사용자 이름 길이 256글자부터 예외 발생") + void name_fail() { + name = RandomStringUtils.randomAlphanumeric(256); + sut = new VocRequest(message, email, memberId, name); + + var constraintViolations = validator.validate(sut); + + assertThat(constraintViolations).isNotEmpty() + .first() + .extracting(ConstraintViolation::getMessage) + .isEqualTo("아이디는 255자 이하로 입력해주세요."); + } + } +} diff --git a/backend/src/test/java/codezap/voc/service/VocServiceTest.java b/backend/src/test/java/codezap/voc/service/VocServiceTest.java new file mode 100644 index 000000000..ccbbb521e --- /dev/null +++ b/backend/src/test/java/codezap/voc/service/VocServiceTest.java @@ -0,0 +1,175 @@ +package codezap.voc.service; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.content; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; + +import java.time.Duration; +import java.util.Arrays; +import java.util.StringJoiner; +import java.util.stream.Stream; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.mock.http.client.MockClientHttpResponse; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import codezap.global.exception.CodeZapException; +import codezap.voc.config.VocConfiguration; +import codezap.voc.config.VocProperties; +import codezap.voc.dto.VocRequest; + +class VocServiceTest { + + private VocService sut; + + private MockRestServiceServer mockServer; + + private RestClient.Builder builder; + + private VocConfiguration config; + + private VocProperties properties; + + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + properties = new VocProperties("http://localhost", Duration.ofNanos(1L), Duration.ofNanos(1L)); + config = new VocConfiguration(properties); + builder = config.vocRestClientBuilder(); + mockServer = MockRestServiceServer.bindTo(builder).build(); + sut = new VocService(builder); + } + + @AfterEach + void tearDown() { + mockServer.reset(); + } + + @ParameterizedTest + @MethodSource + @DisplayName("문의하기 성공") + void create_success(HttpStatusCode statusCode) throws JsonProcessingException { + // given + var message = "lorem ipsum dolor sit amet consectetur adipiscing elit"; + var email = "codezap@gmail.com"; + var requestBody = new VocRequest(message, email); + + mockServer.expect(requestTo(properties.getBaseUrl())) + .andExpect(method(HttpMethod.POST)) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().string(objectMapper.writeValueAsString(requestBody))) + .andRespond(withStatus(statusCode)); + + // when + sut.create(requestBody); + + // then + mockServer.verify(); + } + + static Stream create_success() { + return Arrays.stream(HttpStatus.values()).filter(status -> !status.isError()); + } + + @ParameterizedTest + @MethodSource + @DisplayName("외부 API에서 40x, 50x 상태 코드를 응답할 경우 예외 발생") + void create_status_code_exception(HttpStatusCode statusCode) throws JsonProcessingException { + // given + var message = "lorem ipsum dolor sit amet consectetur adipiscing elit"; + var email = "codezap@gmail.com"; + var requestBody = new VocRequest(message, email); + + mockServer.expect(requestTo(properties.getBaseUrl())) + .andExpect(method(HttpMethod.POST)) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(content().string(objectMapper.writeValueAsString(requestBody))) + .andRespond(withStatus(statusCode)); + + // when & then + assertThatCode(() -> sut.create(requestBody)) + .isInstanceOf(CodeZapException.class) + .hasMessage("스프레드시트 API 요청에 실패했습니다."); + + mockServer.verify(); + } + + static Stream create_status_code_exception() { + return Arrays.stream(HttpStatus.values()).filter(HttpStatus::isError); + } + + @Disabled("예상과 다르게 예외가 발생하지 않아 비활성화합니다.") + @Test + @DisplayName("시간 초과 예외 발생") + void create_timeout() { + // given + var message = "lorem ipsum dolor sit amet consectetur adipiscing elit"; + var email = "codezap@gmail.com"; + var requestBody = new VocRequest(message, email); + + mockServer.expect(requestTo(properties.getBaseUrl())) + .andExpect(method(HttpMethod.POST)) + .andRespond(request -> { + try { + Thread.sleep(1000L); + } catch (InterruptedException ignored) { + } + return new MockClientHttpResponse(); + }); + + // when & then + assertThatThrownBy(() -> sut.create(requestBody)) + .isInstanceOf(CodeZapException.class) + .hasMessageContaining("스프레드시트 API 요청 시간이 초과되었습니다"); + + mockServer.verify(); + } + + @Disabled + @Test + @DisplayName("실제 API URL을 입력하여 테스트") + void create_real_api() { + var baseUrl = "여기에 실제 url 입력. 커밋하지 않게 주의."; + var interceptor = loggingInterceptor(); + var restClientBuilder = RestClient.builder() + .baseUrl(baseUrl) + .requestInterceptor(interceptor); + var sut = new VocService(restClientBuilder); + + var message = "lorem ipsum dolor sit amet consectetur adipiscing elit"; + var email = "codezap@gmail.com"; + var requestBody = new VocRequest(message, email); + + sut.create(requestBody); + } + + private ClientHttpRequestInterceptor loggingInterceptor() { + return (request, body, execution) -> { + var response = execution.execute(request, body); + var message = new StringJoiner("\n"); + response.getHeaders().forEach((k, v) -> message.add(k + ": " + v.toString())); + System.out.println(message); + return response; + }; + } +}