diff --git a/src/main/java/com/heddy/adapter/in/web/style/controller/SavedStyleController.java b/src/main/java/com/heddy/adapter/in/web/style/controller/SavedStyleController.java index 02eaf59..c782f15 100644 --- a/src/main/java/com/heddy/adapter/in/web/style/controller/SavedStyleController.java +++ b/src/main/java/com/heddy/adapter/in/web/style/controller/SavedStyleController.java @@ -2,12 +2,12 @@ import com.heddy.adapter.in.web.style.dto.SaveStyleRequest; import com.heddy.adapter.in.web.style.dto.SavedStyleResponse; +import com.heddy.adapter.in.web.style.dto.SavedStylesResponse; import com.heddy.adapter.in.web.style.dto.UpdateSavedStyleRequest; import com.heddy.domain.style.port.in.SavedStyleUseCase; import com.heddy.global.docs.ApiDocs; import com.heddy.global.filter.RequestIdFilter; import com.heddy.global.response.ApiResponse; -import com.heddy.global.response.PageResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -26,13 +26,12 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.bind.annotation.RequestParam; import java.util.UUID; @RestController @RequiredArgsConstructor -@RequestMapping("/saved-styles") +@RequestMapping("/me/saved-styles") @Tag(name = "저장한 후보 스타일", description = "AR 로 시연한 스타일을 보관하고 다시 꺼내 쓴다") @SecurityRequirement(name = "bearerAuth") public class SavedStyleController { @@ -43,19 +42,15 @@ public class SavedStyleController { @ApiDocs.Ok @ApiDocs.Authenticated @Operation(summary = "저장한 후보 스타일 목록", - description = "최신 저장순으로 돌려줍니다. 이미지 URL 은 저장값이 아니라 조회 시점에 " - + "짧은 만료의 Presigned GET 으로 발급합니다.") - public ApiResponse> list( + description = "최신 저장순으로 전부 돌려줍니다. 보관함은 최대 20개라 페이지를 " + + "나누지 않습니다. 이미지 URL 은 저장값이 아니라 조회 시점에 짧은 만료의 " + + "Presigned GET 으로 발급합니다.") + public ApiResponse list( @AuthenticationPrincipal UUID userId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, HttpServletRequest servletRequest ) { - SavedStyleUseCase.Page result = savedStyleUseCase.list(userId, page, size); return ApiResponse.success( - PageResponse.of( - result.items().stream().map(SavedStyleResponse::from).toList(), - page, size, result.totalElements()), + SavedStylesResponse.from(savedStyleUseCase.list(userId)), RequestIdFilter.get(servletRequest)); } diff --git a/src/main/java/com/heddy/application/style/service/SavedStyleService.java b/src/main/java/com/heddy/application/style/service/SavedStyleService.java index f0990c8..1d9e6d3 100644 --- a/src/main/java/com/heddy/application/style/service/SavedStyleService.java +++ b/src/main/java/com/heddy/application/style/service/SavedStyleService.java @@ -40,7 +40,6 @@ public class SavedStyleService implements SavedStyleUseCase { private static final int MAX_SAVED_STYLES = 20; - private static final int MAX_PAGE_SIZE = 100; private final SavedStyleRepositoryPort savedStyleRepositoryPort; private final HairColorRepositoryPort hairColorRepositoryPort; @@ -49,24 +48,21 @@ public class SavedStyleService implements SavedStyleUseCase { private final FileStoragePort fileStoragePort; private final ShareRepositoryPort shareRepositoryPort; + /** + * 보관함은 {@link #MAX_SAVED_STYLES} 개가 상한이라 한 번에 다 내려도 부담이 없다. + * 페이지를 나누면 화면이 얻는 것 없이 계약만 복잡해져 목록을 통째로 돌려준다. + */ @Override - public Page list(UUID requesterId, int page, int size) { - if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) { - throw new ApplicationException(ErrorCode.INVALID_REQUEST); - } - List all = savedStyleRepositoryPort.findAllByUserId(requesterId); - int fromIndex = (int) Math.min((long) page * size, all.size()); - int toIndex = Math.min(fromIndex + size, all.size()); - List savedStyles = all.subList(fromIndex, toIndex); + public List list(UUID requesterId) { + List savedStyles = savedStyleRepositoryPort.findAllByUserId(requesterId); Map colors = colorsOf(savedStyles); Map thumbnails = thumbnailFileIdsOf(savedStyles); - List items = savedStyles.stream() + return savedStyles.stream() .map(style -> new Item( style, style.colorId() == null ? null : colors.get(style.colorId()), imageUrl(style, thumbnails))) .toList(); - return new Page(items, all.size()); } @Override diff --git a/src/main/java/com/heddy/domain/style/port/in/SavedStyleUseCase.java b/src/main/java/com/heddy/domain/style/port/in/SavedStyleUseCase.java index 3e3977c..ac206cd 100644 --- a/src/main/java/com/heddy/domain/style/port/in/SavedStyleUseCase.java +++ b/src/main/java/com/heddy/domain/style/port/in/SavedStyleUseCase.java @@ -10,7 +10,7 @@ /** 저장한 후보 스타일 보관함. AR로 시연한 스타일을 담아 두고 다시 꺼내 쓰는 자리다. */ public interface SavedStyleUseCase { - Page list(UUID requesterId, int page, int size); + List list(UUID requesterId); Item save(SaveCommand command); @@ -21,9 +21,6 @@ public interface SavedStyleUseCase { record Item(SavedStyle savedStyle, HairColor color, URI imageUrl) { } - record Page(List items, long totalElements) { - } - record SaveCommand( UUID requesterId, UUID hairstyleId, diff --git a/src/main/java/com/heddy/global/error/ErrorCode.java b/src/main/java/com/heddy/global/error/ErrorCode.java index 94b97fc..929eea6 100644 --- a/src/main/java/com/heddy/global/error/ErrorCode.java +++ b/src/main/java/com/heddy/global/error/ErrorCode.java @@ -8,6 +8,7 @@ public enum ErrorCode { AUTHENTICATION_REQUIRED(HttpStatus.UNAUTHORIZED, "AUTHENTICATION_REQUIRED", "인증이 필요합니다."), FORBIDDEN_RESOURCE(HttpStatus.FORBIDDEN, "FORBIDDEN_RESOURCE", "접근 권한이 없습니다."), RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "RESOURCE_NOT_FOUND", "리소스를 찾을 수 없습니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "METHOD_NOT_ALLOWED", "지원하지 않는 요청 메서드입니다."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "서버 오류가 발생했습니다."), FILE_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "FILE_TOO_LARGE", "허용된 파일 크기를 초과했습니다."), FILE_CONTENT_TYPE_NOT_ALLOWED(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "FILE_CONTENT_TYPE_NOT_ALLOWED", "허용되지 않는 파일 형식입니다."), diff --git a/src/main/java/com/heddy/global/error/GlobalExceptionHandler.java b/src/main/java/com/heddy/global/error/GlobalExceptionHandler.java index bdc2409..175f5c0 100644 --- a/src/main/java/com/heddy/global/error/GlobalExceptionHandler.java +++ b/src/main/java/com/heddy/global/error/GlobalExceptionHandler.java @@ -6,12 +6,15 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; +import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.NoHandlerFoundException; +import org.springframework.web.servlet.resource.NoResourceFoundException; import java.util.List; @@ -69,6 +72,36 @@ ResponseEntity handleInvalidRequest(Exception exception, HttpS errorCode.code(), errorCode.message(), RequestIdFilter.get(request))); } + /** + * 매칭되는 핸들러가 없는 요청. 아래 {@code Exception} 핸들러가 함께 삼키면 경로 오타가 + * 500 으로 보고돼 클라이언트는 재시도할 상황인지 요청을 고칠 상황인지 구분할 수 없고, + * 오류 로그도 이 소음에 묻힌다. 서버가 아니라 요청의 문제이므로 로그를 남기지 않는다. + * + *

정적 리소스 핸들러가 있으면 {@code NoResourceFoundException} 이, + * {@code throw-exception-if-no-handler-found} 로 동작하면 + * {@code NoHandlerFoundException} 이 올라온다. 설정에 기대지 않도록 둘 다 받는다. + */ + @ExceptionHandler({NoResourceFoundException.class, NoHandlerFoundException.class}) + ResponseEntity handleNotFound(Exception exception, HttpServletRequest request) { + ErrorCode errorCode = ErrorCode.RESOURCE_NOT_FOUND; + return ResponseEntity.status(errorCode.status()) + .body(ApiErrorResponse.of( + errorCode.code(), errorCode.message(), RequestIdFilter.get(request))); + } + + /** 경로는 있으나 메서드가 없는 경우. 스프링이 계산해 둔 Allow 헤더를 그대로 실어 보낸다. */ + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + ResponseEntity handleMethodNotAllowed( + HttpRequestMethodNotSupportedException exception, + HttpServletRequest request + ) { + ErrorCode errorCode = ErrorCode.METHOD_NOT_ALLOWED; + return ResponseEntity.status(errorCode.status()) + .headers(exception.getHeaders()) + .body(ApiErrorResponse.of( + errorCode.code(), errorCode.message(), RequestIdFilter.get(request))); + } + @ExceptionHandler(Exception.class) ResponseEntity handleUnexpected(Exception exception, HttpServletRequest request) { log.error("Unexpected error", exception); diff --git a/src/test/java/com/heddy/adapter/in/web/style/SavedStyleApiIntegrationTest.java b/src/test/java/com/heddy/adapter/in/web/style/SavedStyleApiIntegrationTest.java index 20f0961..4c55920 100644 --- a/src/test/java/com/heddy/adapter/in/web/style/SavedStyleApiIntegrationTest.java +++ b/src/test/java/com/heddy/adapter/in/web/style/SavedStyleApiIntegrationTest.java @@ -72,7 +72,7 @@ void savesACandidateAndReadsItBackWithColorAndSignedCapture() throws Exception { UUID hairstyleId = insertHairstyle("남자 다운펌", null); UUID captureId = readyCapture(USER_ID); - String created = mockMvc.perform(post("/saved-styles") + String created = mockMvc.perform(post("/me/saved-styles") .with(authentication(userAuthentication(USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content(""" @@ -90,7 +90,7 @@ void savesACandidateAndReadsItBackWithColorAndSignedCapture() throws Exception { String savedStyleId = new ObjectMapper() .readTree(created).path("data").path("saved_style_id").asText(); - mockMvc.perform(get("/saved-styles") + mockMvc.perform(get("/me/saved-styles") .with(authentication(userAuthentication(USER_ID)))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.items.length()").value(1)) @@ -105,7 +105,7 @@ void fallsBackToTheCatalogThumbnailWhenThereIsNoCapture() throws Exception { UUID thumbnailFileId = readyCapture(null); UUID hairstyleId = insertHairstyle("레이어드 컷", thumbnailFileId); - mockMvc.perform(post("/saved-styles") + mockMvc.perform(post("/me/saved-styles") .with(authentication(userAuthentication(USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{\"hairstyle_id\":\"%s\"}".formatted(hairstyleId))) @@ -122,7 +122,7 @@ void refusesCatalogEntriesAndCapturesThatAreNotUsable() throws Exception { UUID retired = insertHairstyle("단종 스타일", null); jdbcTemplate.update("UPDATE hairstyle_assets SET active = FALSE WHERE hairstyle_id = ?", retired); - mockMvc.perform(post("/saved-styles") + mockMvc.perform(post("/me/saved-styles") .with(authentication(userAuthentication(USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{\"hairstyle_id\":\"%s\"}".formatted(retired))) @@ -130,7 +130,7 @@ void refusesCatalogEntriesAndCapturesThatAreNotUsable() throws Exception { .andExpect(jsonPath("$.error.code").value("RESOURCE_NOT_FOUND")); // 남의 캡처 - mockMvc.perform(post("/saved-styles") + mockMvc.perform(post("/me/saved-styles") .with(authentication(userAuthentication(USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{\"hairstyle_id\":\"%s\",\"capture_id\":\"%s\"}" @@ -139,7 +139,7 @@ void refusesCatalogEntriesAndCapturesThatAreNotUsable() throws Exception { .andExpect(jsonPath("$.error.code").value("FORBIDDEN_RESOURCE")); // 업로드가 끝나지 않은 캡처 - mockMvc.perform(post("/saved-styles") + mockMvc.perform(post("/me/saved-styles") .with(authentication(userAuthentication(USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{\"hairstyle_id\":\"%s\",\"capture_id\":\"%s\"}" @@ -152,7 +152,7 @@ void refusesCatalogEntriesAndCapturesThatAreNotUsable() throws Exception { void deletesOwnCandidateAndDropsItFromSharesWithoutBreakingTheLink() throws Exception { UUID hairstyleId = insertHairstyle("남자 다운펌", null); UUID captureId = readyCapture(USER_ID); - String created = mockMvc.perform(post("/saved-styles") + String created = mockMvc.perform(post("/me/saved-styles") .with(authentication(userAuthentication(USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{\"hairstyle_id\":\"%s\",\"capture_id\":\"%s\"}" @@ -163,7 +163,7 @@ void deletesOwnCandidateAndDropsItFromSharesWithoutBreakingTheLink() throws Exce .readTree(created).path("data").path("saved_style_id").asText()); UUID shareId = insertShareCarrying(savedStyleId); - mockMvc.perform(delete("/saved-styles/" + savedStyleId) + mockMvc.perform(delete("/me/saved-styles/" + savedStyleId) .with(authentication(userAuthentication(USER_ID)))) .andExpect(status().isNoContent()); @@ -183,7 +183,7 @@ void deletesOwnCandidateAndDropsItFromSharesWithoutBreakingTheLink() throws Exce @Test void hidesAnotherUsersCandidateBehindTheSameNotFound() throws Exception { UUID hairstyleId = insertHairstyle("남자 다운펌", null); - String created = mockMvc.perform(post("/saved-styles") + String created = mockMvc.perform(post("/me/saved-styles") .with(authentication(userAuthentication(OTHER_USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{\"hairstyle_id\":\"%s\"}".formatted(hairstyleId))) @@ -192,12 +192,12 @@ void hidesAnotherUsersCandidateBehindTheSameNotFound() throws Exception { String foreignId = new ObjectMapper() .readTree(created).path("data").path("saved_style_id").asText(); - mockMvc.perform(delete("/saved-styles/" + foreignId) + mockMvc.perform(delete("/me/saved-styles/" + foreignId) .with(authentication(userAuthentication(USER_ID)))) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.error.code").value("RESOURCE_NOT_FOUND")); - mockMvc.perform(get("/saved-styles") + mockMvc.perform(get("/me/saved-styles") .with(authentication(userAuthentication(USER_ID)))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.items.length()").value(0)); @@ -208,21 +208,21 @@ void updatesAndClearsMemoOnlyForTheOwner() throws Exception { UUID hairstyleId = insertHairstyle("레이어드 컷", null); UUID savedStyleId = saveCandidate(USER_ID, hairstyleId, null); - mockMvc.perform(patch("/saved-styles/" + savedStyleId) + mockMvc.perform(patch("/me/saved-styles/" + savedStyleId) .with(authentication(userAuthentication(USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{\"memo\":\"다음 상담 때 보여주기\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.memo").value("다음 상담 때 보여주기")); - mockMvc.perform(patch("/saved-styles/" + savedStyleId) + mockMvc.perform(patch("/me/saved-styles/" + savedStyleId) .with(authentication(userAuthentication(USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{\"memo\":null}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.memo").doesNotExist()); - mockMvc.perform(patch("/saved-styles/" + savedStyleId) + mockMvc.perform(patch("/me/saved-styles/" + savedStyleId) .with(authentication(userAuthentication(OTHER_USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{\"memo\":\"가져오기\"}")) @@ -235,7 +235,7 @@ void rejectsDuplicateCombinationAndTwentyFirstCandidate() throws Exception { UUID duplicatedHairstyleId = insertHairstyle("중복 후보", null); saveCandidate(USER_ID, duplicatedHairstyleId, NATURAL_BLACK); - mockMvc.perform(post("/saved-styles") + mockMvc.perform(post("/me/saved-styles") .with(authentication(userAuthentication(USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{\"hairstyle_id\":\"%s\",\"color_id\":\"%s\"}" @@ -246,7 +246,7 @@ void rejectsDuplicateCombinationAndTwentyFirstCandidate() throws Exception { for (int index = 1; index < 20; index++) { saveCandidate(USER_ID, insertHairstyle("후보 " + index, null), null); } - mockMvc.perform(post("/saved-styles") + mockMvc.perform(post("/me/saved-styles") .with(authentication(userAuthentication(USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{\"hairstyle_id\":\"%s\"}" @@ -256,22 +256,21 @@ void rejectsDuplicateCombinationAndTwentyFirstCandidate() throws Exception { } @Test - void returnsPagedCandidatesAndRejectsAnEmptyPatch() throws Exception { - saveCandidate(USER_ID, insertHairstyle("후보 하나", null), null); + void returnsEveryCandidateNewestFirstAndRejectsAnEmptyPatch() throws Exception { + UUID first = saveCandidate(USER_ID, insertHairstyle("후보 하나", null), null); UUID second = saveCandidate(USER_ID, insertHairstyle("후보 둘", null), null); - mockMvc.perform(get("/saved-styles") - .with(authentication(userAuthentication(USER_ID))) - .param("page", "0") - .param("size", "1")) + mockMvc.perform(get("/me/saved-styles") + .with(authentication(userAuthentication(USER_ID)))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.items.length()").value(2)) .andExpect(jsonPath("$.data.items[0].saved_style_id") .value(second.toString())) - .andExpect(jsonPath("$.data.page.total_elements").value(2)) - .andExpect(jsonPath("$.data.page.total_pages").value(2)); + .andExpect(jsonPath("$.data.items[1].saved_style_id") + .value(first.toString())) + .andExpect(jsonPath("$.data.page").doesNotExist()); - mockMvc.perform(patch("/saved-styles/" + second) + mockMvc.perform(patch("/me/saved-styles/" + second) .with(authentication(userAuthentication(USER_ID))) .contentType(MediaType.APPLICATION_JSON) .content("{}")) @@ -281,15 +280,15 @@ void returnsPagedCandidatesAndRejectsAnEmptyPatch() throws Exception { @Test void requiresAuthenticationForEveryEndpoint() throws Exception { - mockMvc.perform(get("/saved-styles")) + mockMvc.perform(get("/me/saved-styles")) .andExpect(status().isUnauthorized()); - mockMvc.perform(post("/saved-styles") + mockMvc.perform(post("/me/saved-styles") .contentType(MediaType.APPLICATION_JSON) .content("{\"hairstyle_id\":\"%s\"}".formatted(UUID.randomUUID()))) .andExpect(status().isUnauthorized()); - mockMvc.perform(delete("/saved-styles/" + UUID.randomUUID())) + mockMvc.perform(delete("/me/saved-styles/" + UUID.randomUUID())) .andExpect(status().isUnauthorized()); - mockMvc.perform(patch("/saved-styles/" + UUID.randomUUID()) + mockMvc.perform(patch("/me/saved-styles/" + UUID.randomUUID()) .contentType(MediaType.APPLICATION_JSON) .content("{\"memo\":null}")) .andExpect(status().isUnauthorized()); @@ -297,7 +296,7 @@ void requiresAuthenticationForEveryEndpoint() throws Exception { private UUID saveCandidate(UUID userId, UUID hairstyleId, UUID colorId) throws Exception { String colorField = colorId == null ? "" : ",\"color_id\":\"" + colorId + "\""; - String created = mockMvc.perform(post("/saved-styles") + String created = mockMvc.perform(post("/me/saved-styles") .with(authentication(userAuthentication(userId))) .contentType(MediaType.APPLICATION_JSON) .content("{\"hairstyle_id\":\"%s\"%s}" diff --git a/src/test/java/com/heddy/global/error/NotFoundHandlingIntegrationTest.java b/src/test/java/com/heddy/global/error/NotFoundHandlingIntegrationTest.java new file mode 100644 index 0000000..aa6ae14 --- /dev/null +++ b/src/test/java/com/heddy/global/error/NotFoundHandlingIntegrationTest.java @@ -0,0 +1,55 @@ +package com.heddy.global.error; + +import com.heddy.support.PostgresIntegrationTest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; +import java.util.UUID; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +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.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 잘못 부른 요청은 서버 장애가 아니다. 인증 필터가 먼저 401 을 돌려주는 탓에 토큰 없이 + * 호출할 때는 드러나지 않으므로, 인증된 요청으로 계약을 고정한다. + */ +@AutoConfigureMockMvc +class NotFoundHandlingIntegrationTest extends PostgresIntegrationTest { + + private static final UUID USER_ID = UUID.fromString( + "88000000-0000-4000-8000-0000000000ff"); + + @Autowired + private MockMvc mockMvc; + + @Test + void answersUnmappedPathWithNotFound() throws Exception { + mockMvc.perform(get("/me/does-not-exist") + .with(authentication(userAuthentication()))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error.code").value("RESOURCE_NOT_FOUND")) + .andExpect(jsonPath("$.request_id").isNotEmpty()); + } + + @Test + void answersUnsupportedMethodWithMethodNotAllowedAndAllowHeader() throws Exception { + // /me/consents 는 GET 만 받는다. 경로는 있고 메서드만 없는 상황이어야 405 다. + mockMvc.perform(post("/me/consents") + .with(authentication(userAuthentication()))) + .andExpect(status().isMethodNotAllowed()) + .andExpect(header().exists("Allow")) + .andExpect(jsonPath("$.error.code").value("METHOD_NOT_ALLOWED")); + } + + private UsernamePasswordAuthenticationToken userAuthentication() { + return new UsernamePasswordAuthenticationToken(USER_ID, null, List.of()); + } +}