diff --git a/src/main/java/com/codereferee/codereferee_server/api/ErrorResponse.java b/src/main/java/com/codereferee/codereferee_server/api/ErrorResponse.java new file mode 100644 index 0000000..e00e6fc --- /dev/null +++ b/src/main/java/com/codereferee/codereferee_server/api/ErrorResponse.java @@ -0,0 +1,11 @@ +package com.codereferee.codereferee_server.api; + +public record ErrorResponse(ErrorDetail error) { + + public record ErrorDetail(String code, String message) + {} + + public static ErrorResponse of(String code, String message) { + return new ErrorResponse(new ErrorDetail(code, message)); + } +} diff --git a/src/main/java/com/codereferee/codereferee_server/api/GlobalExceptionHandler.java b/src/main/java/com/codereferee/codereferee_server/api/GlobalExceptionHandler.java new file mode 100644 index 0000000..9ad29de --- /dev/null +++ b/src/main/java/com/codereferee/codereferee_server/api/GlobalExceptionHandler.java @@ -0,0 +1,69 @@ +package com.codereferee.codereferee_server.api; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +import java.util.stream.Collectors; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + // @Valid 검증 실패 시 + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(err -> err.getField() + ": " + + err.getDefaultMessage()) + .collect(Collectors.joining(", ")); + + return ResponseEntity.badRequest() + .body(ErrorResponse.of("VALIDATION_FAILED", message)); + } + + // 깨진 JSON 등 본문을 파싱할 수 없을 때 + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleUnreadable(HttpMessageNotReadableException e) { + return ResponseEntity.badRequest() + .body(ErrorResponse.of("MALFORMED_REQUEST", "요청 본문을 읽을 수 없습니다.")); + } + + // 필수 쿼리 파라미터가 누럭되었을 때 + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingParam(MissingServletRequestParameterException e) { + return ResponseEntity.badRequest() + .body(ErrorResponse.of("MISSING_PARAMETER", "필수 파라미터가 없습니다.: " + + e.getParameterName())); + } + + // 지원하지 않는 HTTP 메서드 + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleMethodNotSupported(HttpRequestMethodNotSupportedException e) { + return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED) + .body(ErrorResponse.of("METHOD_NOT_ALLOWED", "지원하지 않는 메서드입니다.: " + + e.getMethod())); + } + + // 존재하지 않는 경로 호출 + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResource(NoResourceFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ErrorResponse.of("NOT_FOUND", "존재하지 않는 경로입니다.")); + } + + // 그 외 전부. 내부 정보는 로그에만 보이도록 하고 유저에게는 고정된 메시지를 출력한다. + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnexpected(Exception e) { + log.error("[API] 예상치 못한 에러", e); + return ResponseEntity.internalServerError() + .body(ErrorResponse.of("INTERNAL_ERROR", "서버 내부 오류가 발생했습니다.")); + } +} diff --git a/src/test/java/com/codereferee/codereferee_server/api/GlobalExceptionHandlerTest.java b/src/test/java/com/codereferee/codereferee_server/api/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..17b5f17 --- /dev/null +++ b/src/test/java/com/codereferee/codereferee_server/api/GlobalExceptionHandlerTest.java @@ -0,0 +1,66 @@ +package com.codereferee.codereferee_server.api; + +import com.codereferee.codereferee_server.application.RefereeService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(RefereeController.class) +class GlobalExceptionHandlerTest { + + @Autowired + MockMvc mockMvc; + + @MockitoBean + RefereeService refereeService; + + @Test + void missingRepositoryUrlReturns400WithErrorFormat() throws Exception { + mockMvc.perform(post("/api/validations/repository") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"branch\": \"main\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("VALIDATION_FAILED")) + .andExpect(jsonPath("$.error.message").exists()); + } + + @Test + void malformedJsonReturns400() throws Exception { + mockMvc.perform(post("/api/validations/repository") + .contentType(MediaType.APPLICATION_JSON) + .content("{broken json")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("MALFORMED_REQUEST")); + } + + @Test + void missingHistoryParamsReturns400() throws Exception { + mockMvc.perform(get("/api/validations/history")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error.code").value("MISSING_PARAMETER")); + } + + @Test + void unexpectedErrorReturns500WithoutInternalDetails() throws Exception { + given(refereeService.submit(any(), any(), any())) + .willThrow(new RuntimeException("connection to db failed at 10.0.3.7:5432")); + + mockMvc.perform(post("/api/validations/repository") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"repository_url\": \"https://github.com/phdcoco/QuickByte_Demo\"}")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.error.code").value("INTERNAL_ERROR")) + // 내부 정보(DB 주소 등)가 응답에 새지 않는지가 핵심 검증 + .andExpect(jsonPath("$.error.message").value("서버 내부 오류가 발생했습니다.")); + } +} \ No newline at end of file