Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,10 @@ protected BcsdException() {
}

public abstract BcsdExceptionType getExceptionType();

/** 로그에 예외 메시지가 남도록 예외 타입의 메시지를 돌려준다. */
@Override
public String getMessage() {
return getExceptionType().getMessage();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.hibernate.exception.ConstraintViolationException;
import org.springframework.beans.TypeMismatchException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.core.PropertyReferenceException;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
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;
Expand Down Expand Up @@ -59,13 +63,59 @@ public ResponseEntity<ErrorResponse> handleNoResourceFoundException(HttpServletR
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("요청한 API를 찾을 수 없습니다."));
}

@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ErrorResponse> handleMissingServletRequestParameterException(
HttpServletRequest request,
MissingServletRequestParameterException e
) {
logRequest(request, e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse("필수 요청 파라미터 '%s'가 없습니다.".formatted(e.getParameterName())));
}

/** 경로·쿼리 값의 형식 오류(숫자가 아닌 id, 없는 enum 값, 잘못된 날짜 등). */
@ExceptionHandler(TypeMismatchException.class)
public ResponseEntity<ErrorResponse> handleTypeMismatchException(HttpServletRequest request,
TypeMismatchException e) {
logRequest(request, e);
String name = e.getPropertyName() != null ? e.getPropertyName() : "요청 값";
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse("'%s'의 형식이 올바르지 않습니다.".formatted(name)));
}

/** 정렬 파라미터에 없는 필드를 지정한 경우. */
@ExceptionHandler(PropertyReferenceException.class)
public ResponseEntity<ErrorResponse> handlePropertyReferenceException(HttpServletRequest request,
PropertyReferenceException e) {
logRequest(request, e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse("정렬할 수 없는 필드입니다: " + e.getPropertyName()));
}

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(HttpServletRequest request, Exception e) {
// 405·415·406, 헤더 누락 등 Spring MVC 예외는 스스로 4xx 상태를 갖고 있다. 이를 500으로 바꾸지 않는다.
if (e instanceof org.springframework.web.ErrorResponse springError
&& springError.getStatusCode().is4xxClientError()) {
logRequest(request, e);
HttpStatusCode status = springError.getStatusCode();
return ResponseEntity.status(status).body(new ErrorResponse(clientErrorMessage(status)));
}
log.error("[{}] 예상하지 못한 예외가 발생했습니다. uri: {} {}", request.getMethod(), request.getRequestURI(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("알 수 없는 오류가 발생했습니다."));
}

private static String clientErrorMessage(HttpStatusCode status) {
return switch (status.value()) {
case 404 -> "요청한 API를 찾을 수 없습니다.";
case 405 -> "지원하지 않는 HTTP 메서드입니다.";
case 406 -> "지원하지 않는 응답 형식입니다.";
case 415 -> "지원하지 않는 Content-Type입니다.";
default -> "잘못된 요청입니다.";
};
}

private void logRequest(HttpServletRequest request, Exception e) {
log.warn("잘못된 요청입니다. uri: {} {} exception={} message={}",
request.getMethod(), request.getRequestURI(), e.getClass().getSimpleName(), e.getMessage());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.bcsdlab.bcsdinternalapiv2.global;

import static org.assertj.core.api.Assertions.assertThat;

import com.bcsdlab.bcsdinternalapiv2.game.exception.GameException;
import com.bcsdlab.bcsdinternalapiv2.game.exception.GameExceptionType;
import org.junit.jupiter.api.Test;

class BcsdExceptionTest {

@Test
void 도메인_예외의_메시지는_예외_타입의_메시지다() {
GameException exception = new GameException(GameExceptionType.GAME_NOT_FOUND);

assertThat(exception.getMessage()).isEqualTo(GameExceptionType.GAME_NOT_FOUND.getMessage());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.bcsdlab.bcsdinternalapiv2.global;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
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;

import com.bcsdlab.bcsdinternalapiv2.IntegrationTestSupport;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;

/** 클라이언트가 잘못 보낸 요청은 500이 아니라 알맞은 4xx와 {message} 본문으로 응답한다. */
class ClientErrorStatusIntegrationTest extends IntegrationTestSupport {

@Test
void 지원하지_않는_HTTP_메서드는_405다() throws Exception {
mockMvc.perform(delete("/health"))
.andExpect(status().isMethodNotAllowed())
.andExpect(jsonPath("$.message").value("지원하지 않는 HTTP 메서드입니다."));
}

@Test
void 지원하지_않는_Content_Type은_415다() throws Exception {
mockMvc.perform(post("/v1/games/builds/1/webhook")
.contentType(MediaType.TEXT_PLAIN)
.content("status=ACTIVE"))
.andExpect(status().isUnsupportedMediaType())
.andExpect(jsonPath("$.message").value("지원하지 않는 Content-Type입니다."));
}

@Test
void 숫자가_아닌_경로_id는_400이다() throws Exception {
mockMvc.perform(post("/v1/games/builds/abc/webhook")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"ACTIVE\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("'buildId'의 형식이 올바르지 않습니다."));
}

@Test
void 필수_쿼리_파라미터가_없으면_400이다() throws Exception {
mockMvc.perform(get("/v1/activities"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("필수 요청 파라미터 'category'가 없습니다."));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -220,4 +220,32 @@ void setUp() throws Exception {
assertThat(imageAssetRepository.findAll()).singleElement()
.satisfies(image -> assertThat(image.getUploadedBy()).isNull());
}

@Test
@DisplayName("라이브러리 조회에 purpose가 없으면 500이 아니라 400이다")
void purpose_없는_라이브러리_조회는_400() throws Exception {
mockMvc.perform(get("/v1/admin/images")
.header("Authorization", "Bearer " + adminToken))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("필수 요청 파라미터 'purpose'가 없습니다."));
}

@Test
@DisplayName("라이브러리 조회에 없는 purpose 값을 주면 500이 아니라 400이다")
void 없는_purpose_값은_400() throws Exception {
mockMvc.perform(get("/v1/admin/images")
.header("Authorization", "Bearer " + adminToken)
.queryParam("purpose", "NOT_A_PURPOSE"))
.andExpect(status().isBadRequest());
}

@Test
@DisplayName("라이브러리 조회에 없는 필드로 정렬하면 500이 아니라 400이다")
void 없는_필드_정렬은_400() throws Exception {
mockMvc.perform(get("/v1/admin/images")
.header("Authorization", "Bearer " + adminToken)
.queryParam("purpose", "TRACK_HERO")
.queryParam("sort", "noSuchField,desc"))
.andExpect(status().isBadRequest());
}
}
Loading