diff --git a/src/main/java/com/humix/api/domain/auth/controller/AuthController.java b/src/main/java/com/humix/api/domain/auth/controller/AuthController.java index 72d8cf9..c41dfc7 100644 --- a/src/main/java/com/humix/api/domain/auth/controller/AuthController.java +++ b/src/main/java/com/humix/api/domain/auth/controller/AuthController.java @@ -8,6 +8,7 @@ import com.humix.api.global.apiPayload.exception.GeneralException; import com.humix.api.global.security.dto.TokenDto; import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseCookie; @@ -30,7 +31,7 @@ public class AuthController implements AuthControllerDocs { @PostMapping("/guest-login") @Override public ApiResponse guestLogin( - @RequestBody MemberDTO.MemberRequest request, + @Valid @RequestBody MemberDTO.MemberRequest request, HttpServletResponse response) { // HttpServletResponse 주입받기 // 토큰 발급 diff --git a/src/main/java/com/humix/api/domain/auth/controller/AuthControllerDocs.java b/src/main/java/com/humix/api/domain/auth/controller/AuthControllerDocs.java index 4644d6f..bbd7616 100644 --- a/src/main/java/com/humix/api/domain/auth/controller/AuthControllerDocs.java +++ b/src/main/java/com/humix/api/domain/auth/controller/AuthControllerDocs.java @@ -19,10 +19,10 @@ public interface AuthControllerDocs { @Operation(summary = "게스트 로그인 API", description = "최초 접속 시 발급한 Device ID로 로그인하여 토큰을 발급받습니다.") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "성공"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청", + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 값 검증 실패", content = @Content(schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(name = "파라미터 에러", - value = "{\"isSuccess\":false, \"code\":\"COMMON400\", \"message\":\"잘못된 요청입니다.\", \"result\":null}"))) + examples = @ExampleObject(name = "검증 실패", + value = "{\"isSuccess\":false, \"code\":\"COMMON400\", \"message\":\"잘못된 요청입니다.\", \"result\":{\"device_id\":\"device_id는 필수입니다.\"}}"))) }) @PostMapping("/guest-login") ApiResponse guestLogin(@RequestBody MemberDTO.MemberRequest request, @@ -34,7 +34,11 @@ ApiResponse guestLogin(@RequestBody MemberDTO.MemberRe @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 만료", content = @Content(schema = @Schema(implementation = ApiResponse.class), examples = @ExampleObject(name = "리프레시 토큰 만료", - value = "{\"isSuccess\":false, \"code\":\"AUTH401\", \"message\":\"리프레시 토큰이 만료되었습니다. 다시 로그인해주세요.\", \"result\":null}"))) + value = "{\"isSuccess\":false, \"code\":\"AUTH4001\", \"message\":\"리프레시 토큰이 만료되었습니다. 다시 로그인해주세요.\", \"result\":null}"))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "사용자 없음", + content = @Content(schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(name = "사용자 없음", + value = "{\"isSuccess\":false, \"code\":\"MEMBER4000\", \"message\":\"해당하는 사용자가 존재하지 않습니다.\", \"result\":null}"))) }) @PostMapping("/silent-refresh") ApiResponse silentRefresh( diff --git a/src/main/java/com/humix/api/domain/humming/controller/HummingController.java b/src/main/java/com/humix/api/domain/humming/controller/HummingController.java index 1457c8b..08cf194 100644 --- a/src/main/java/com/humix/api/domain/humming/controller/HummingController.java +++ b/src/main/java/com/humix/api/domain/humming/controller/HummingController.java @@ -5,6 +5,7 @@ import com.humix.api.global.apiPayload.ApiResponse; import com.humix.api.global.apiPayload.code.GeneralSuccessCode; import com.humix.api.global.security.userdetails.CustomUserDetails; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -31,7 +32,7 @@ public ApiResponse convertHummingToVector(@ public ApiResponse updateHummingVector( @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable("humming_id") Long hummingId, - @RequestBody MelodyScoreDTO.MelodyUpdateRequest request) { + @Valid @RequestBody MelodyScoreDTO.MelodyUpdateRequest request) { MelodyScoreDTO.MelodyVectorResponse result = hummingService.updateHummingVector(hummingId, request); diff --git a/src/main/java/com/humix/api/domain/humming/controller/HummingControllerDocs.java b/src/main/java/com/humix/api/domain/humming/controller/HummingControllerDocs.java index 0a9229a..8ecaefd 100644 --- a/src/main/java/com/humix/api/domain/humming/controller/HummingControllerDocs.java +++ b/src/main/java/com/humix/api/domain/humming/controller/HummingControllerDocs.java @@ -24,7 +24,7 @@ public interface HummingControllerDocs { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "오디오 데이터 없음", content = @Content(schema = @Schema(implementation = ApiResponse.class), examples = @ExampleObject(name = "허밍 없음", - value = "{\"isSuccess\":false, \"code\":\"HUMMING404\", \"message\":\"해당 허밍 데이터를 찾을 수 없습니다.\", \"result\":null}"))) + value = "{\"isSuccess\":false, \"code\":\"HUMMING4000\", \"message\":\"해당하는 허밍 기록이 존재하지 않습니다.\", \"result\":null}"))) }) @PostMapping("/{humming_id}/vectors") ApiResponse convertHummingToVector(@AuthenticationPrincipal CustomUserDetails userDetails, @@ -32,7 +32,15 @@ ApiResponse convertHummingToVector(@Authent @Operation(summary = "사용자 수정 멜로디 벡터 저장 API", description = "사용자가 웹 에디터에서 가공한 멜로디 노트 데이터를 업데이트합니다.") @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "성공") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 값 검증 실패", + content = @Content(schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(name = "검증 실패", + value = "{\"isSuccess\":false, \"code\":\"COMMON400\", \"message\":\"잘못된 요청입니다.\", \"result\":{\"notes\":\"notes는 필수입니다.\"}}"))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "멜로디 악보 없음", + content = @Content(schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(name = "악보 없음", + value = "{\"isSuccess\":false, \"code\":\"MELODY4000\", \"message\":\"해당 허밍의 멜로디 악보가 존재하지 않습니다.\", \"result\":null}"))) }) @PutMapping("/{humming_id}/vectors") ApiResponse updateHummingVector( diff --git a/src/main/java/com/humix/api/domain/humming/dto/HummingDTO.java b/src/main/java/com/humix/api/domain/humming/dto/HummingDTO.java index c319883..46d4bf1 100644 --- a/src/main/java/com/humix/api/domain/humming/dto/HummingDTO.java +++ b/src/main/java/com/humix/api/domain/humming/dto/HummingDTO.java @@ -3,11 +3,13 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.humix.api.domain.humming.entity.Humming; import com.humix.api.domain.member.entity.Member; +import jakarta.validation.constraints.NotBlank; import java.time.LocalDateTime; public class HummingDTO { //허밍 정보 저장 요청 Request public record HummingSaveRequest( + @NotBlank(message = "file_key는 필수입니다.") @JsonProperty("file_key") String fileKey, @JsonProperty("duration_seconds") int durationSeconds ) { diff --git a/src/main/java/com/humix/api/domain/humming/service/HummingService.java b/src/main/java/com/humix/api/domain/humming/service/HummingService.java index b47ef0a..77e81ae 100644 --- a/src/main/java/com/humix/api/domain/humming/service/HummingService.java +++ b/src/main/java/com/humix/api/domain/humming/service/HummingService.java @@ -7,6 +7,8 @@ import com.humix.api.domain.melodyScore.dto.MelodyScoreDTO; import com.humix.api.domain.melodyScore.entity.MelodyScore; import com.humix.api.domain.melodyScore.repository.MelodyScoreRepository; +import com.humix.api.global.apiPayload.code.GeneralErrorCode; +import com.humix.api.global.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import org.jspecify.annotations.NonNull; import org.springframework.beans.factory.annotation.Value; @@ -44,7 +46,7 @@ public class HummingService { public MelodyScoreDTO.MelodyVectorResponse convertHummingToVector(Long hummingId) { // 1. 데이터베이스로부터 허밍 엔티티 및 적재된 S3 오디오 주소를 로드합니다. Humming humming = hummingRepository.findById(hummingId) - .orElseThrow(() -> new IllegalArgumentException("해당 허밍 기록이 존재하지 않습니다. ID: " + hummingId)); + .orElseThrow(() -> new GeneralException(GeneralErrorCode.HUMMING_NOT_FOUND)); // 2. S3 URL을 가져와 비공개 버킷인 경우도 접근 가능하도록 임시 Presigned GET URL을 생성합니다. String presignedGetUrl = generatePresignedGetUrl(humming.getS3FileUrl()); @@ -148,11 +150,7 @@ public MelodyScoreDTO.MelodyVectorResponse updateHummingVector(Long hummingId, M // 1. 기존 데이터베이스에 존재하는 멜로디 악보 레코드를 식별 조회합니다. MelodyScore existingMelodyScore = melodyScoreRepository.findByHummingId(hummingId) - .orElseThrow(() -> new IllegalArgumentException("해당 허밍의 추출된 멜로디 벡터(MelodyScore)가 존재하지 않습니다. ID: " + hummingId)); - - if (request == null || request.notes() == null) { - throw new IllegalArgumentException("수정할 노트(notes) 데이터가 비어있습니다."); - } + .orElseThrow(() -> new GeneralException(GeneralErrorCode.MELODY_SCORE_NOT_FOUND)); try { // 2. 프론트에서 넘어온 수정 노트 리스트를 JSON 문자열로 가공합니다. diff --git a/src/main/java/com/humix/api/domain/melodyScore/dto/MelodyScoreDTO.java b/src/main/java/com/humix/api/domain/melodyScore/dto/MelodyScoreDTO.java index 834e641..b85e7b2 100644 --- a/src/main/java/com/humix/api/domain/melodyScore/dto/MelodyScoreDTO.java +++ b/src/main/java/com/humix/api/domain/melodyScore/dto/MelodyScoreDTO.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.humix.api.domain.humming.entity.Humming; import com.humix.api.domain.melodyScore.entity.MelodyScore; +import jakarta.validation.constraints.NotNull; import java.util.List; public class MelodyScoreDTO { @@ -21,6 +22,7 @@ public record NoteDto( //멜로디 벡터 수정 요청 Request Body public record MelodyUpdateRequest( @JsonProperty("humming_id") Long hummingId, + @NotNull(message = "notes는 필수입니다.") List notes ) { public MelodyScore from(Humming humming) { diff --git a/src/main/java/com/humix/api/domain/musicGeneration/controller/GenerationController.java b/src/main/java/com/humix/api/domain/musicGeneration/controller/GenerationController.java index b983a7c..b65b87b 100644 --- a/src/main/java/com/humix/api/domain/musicGeneration/controller/GenerationController.java +++ b/src/main/java/com/humix/api/domain/musicGeneration/controller/GenerationController.java @@ -5,6 +5,7 @@ import com.humix.api.global.apiPayload.ApiResponse; import com.humix.api.global.apiPayload.code.GeneralSuccessCode; import com.humix.api.global.security.userdetails.CustomUserDetails; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -22,7 +23,7 @@ public class GenerationController implements GenerationControllerDocs { @Override public ApiResponse generateSong( @AuthenticationPrincipal CustomUserDetails userDetails, - @RequestBody MusicGenerationDTO.SongCreateRequest request) { + @Valid @RequestBody MusicGenerationDTO.SongCreateRequest request) { MusicGenerationDTO.TaskAcceptedResponse result = musicGenerationService.generateSong(userDetails, request); return ApiResponse.onSuccess(GeneralSuccessCode.ACCEPTED, result); @@ -49,7 +50,7 @@ public ApiResponse cancelTask(@PathVariab public ApiResponse modifySongPrompt( @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable("song_id") Long songId, - @RequestBody MusicGenerationDTO.SongModificationRequest request) { + @Valid @RequestBody MusicGenerationDTO.SongModificationRequest request) { MusicGenerationDTO.TaskAcceptedResponse result = musicGenerationService.modifySongPrompt(userDetails, songId, request); return ApiResponse.onSuccess(GeneralSuccessCode.ACCEPTED, result); diff --git a/src/main/java/com/humix/api/domain/musicGeneration/controller/GenerationControllerDocs.java b/src/main/java/com/humix/api/domain/musicGeneration/controller/GenerationControllerDocs.java index 110519b..b054c0a 100644 --- a/src/main/java/com/humix/api/domain/musicGeneration/controller/GenerationControllerDocs.java +++ b/src/main/java/com/humix/api/domain/musicGeneration/controller/GenerationControllerDocs.java @@ -68,7 +68,15 @@ public interface GenerationControllerDocs { value = "{\"isSuccess\": false, \"code\": \"AUTH4000\", \"message\": \"로그인이 필요한 기능입니다.\", \"result\": null}" ) ) - ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 값 검증 실패", + content = @Content(schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(name = "검증 실패", + value = "{\"isSuccess\":false, \"code\":\"COMMON400\", \"message\":\"잘못된 요청입니다.\", \"result\":{\"humming_id\":\"humming_id는 필수입니다.\"}}"))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "멜로디 악보 없음", + content = @Content(schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(name = "악보 없음", + value = "{\"isSuccess\":false, \"code\":\"MELODY4000\", \"message\":\"해당 허밍의 멜로디 악보가 존재하지 않습니다.\", \"result\":null}"))) }) @PostMapping("") ApiResponse generateSong( @@ -86,7 +94,11 @@ ApiResponse generateSong( "**complete 이벤트 예시:** `{\"status\": \"COMPLETED\", \"result\": {\"task_id\": \"task_xxx\", \"song_id\": 42, \"audio_url\": \"https://...\", \"duration_seconds\": 30}}`" ) @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "스트림 연결 성공") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "스트림 연결 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "태스크 없음", + content = @Content(schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(name = "태스크 없음", + value = "{\"isSuccess\":false, \"code\":\"TASK4000\", \"message\":\"해당 태스크 ID의 작업 정보가 존재하지 않습니다.\", \"result\":null}"))) }) @GetMapping(value = "/tasks/{task_id}/stream", produces = "text/event-stream") SseEmitter subscribeTaskStream(@PathVariable("task_id") String taskId); @@ -114,7 +126,11 @@ ApiResponse generateSong( "}" ) ) - ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "태스크 없음", + content = @Content(schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(name = "태스크 없음", + value = "{\"isSuccess\":false, \"code\":\"TASK4000\", \"message\":\"해당 태스크 ID의 작업 정보가 존재하지 않습니다.\", \"result\":null}"))) }) @DeleteMapping("/tasks/{task_id}") ApiResponse cancelTask(@PathVariable("task_id") String taskId); @@ -166,7 +182,15 @@ ApiResponse generateSong( value = "{\"isSuccess\": false, \"code\": \"AUTH4000\", \"message\": \"로그인이 필요한 기능입니다.\", \"result\": null}" ) ) - ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 값 검증 실패", + content = @Content(schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(name = "검증 실패", + value = "{\"isSuccess\":false, \"code\":\"COMMON400\", \"message\":\"잘못된 요청입니다.\", \"result\":{\"prompt\":\"prompt는 필수입니다.\"}}"))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "원본 곡 없음", + content = @Content(schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(name = "원본 곡 없음", + value = "{\"isSuccess\":false, \"code\":\"GENERATION4000\", \"message\":\"해당하는 생성곡이 존재하지 않습니다.\", \"result\":null}"))) }) @PostMapping("/{song_id}/modifications") ApiResponse modifySongPrompt( diff --git a/src/main/java/com/humix/api/domain/musicGeneration/controller/InternalController.java b/src/main/java/com/humix/api/domain/musicGeneration/controller/InternalController.java index 9a5082a..eef94c5 100644 --- a/src/main/java/com/humix/api/domain/musicGeneration/controller/InternalController.java +++ b/src/main/java/com/humix/api/domain/musicGeneration/controller/InternalController.java @@ -5,6 +5,7 @@ import com.humix.api.global.apiPayload.ApiResponse; import com.humix.api.global.apiPayload.code.GeneralSuccessCode; import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @@ -19,7 +20,7 @@ public class InternalController { @PostMapping("/{task_id}/completion") public ApiResponse completeTask( @PathVariable("task_id") String taskId, - @RequestBody MusicGenerationDTO.AiTaskCompletionRequest request) { + @Valid @RequestBody MusicGenerationDTO.AiTaskCompletionRequest request) { musicGenerationService.completeTask(taskId, request); return ApiResponse.onSuccess(GeneralSuccessCode.OK, null); diff --git a/src/main/java/com/humix/api/domain/musicGeneration/dto/MusicGenerationDTO.java b/src/main/java/com/humix/api/domain/musicGeneration/dto/MusicGenerationDTO.java index e728fea..75513f2 100644 --- a/src/main/java/com/humix/api/domain/musicGeneration/dto/MusicGenerationDTO.java +++ b/src/main/java/com/humix/api/domain/musicGeneration/dto/MusicGenerationDTO.java @@ -5,6 +5,8 @@ import com.humix.api.domain.member.entity.Member; import com.humix.api.domain.musicGeneration.entity.MusicGeneration; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import java.time.LocalDateTime; import java.util.List; @@ -13,6 +15,7 @@ public class MusicGenerationDTO { //노래 생성 요청 Request Body public record SongCreateRequest( @Schema(description = "허밍 ID", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "humming_id는 필수입니다.") @JsonProperty("humming_id") Long hummingId, @Schema(description = "생성할 곡 제목", example = "나의 첫 번째 곡") @@ -56,6 +59,7 @@ public record TaskCancelResponse( public record SongModificationRequest( @Schema(description = "수정 방향 프롬프트 (예: 더 밝게, 템포를 빠르게 등)", example = "더 밝고 경쾌하게 수정해줘", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "prompt는 필수입니다.") String prompt ) { //프롬프트 수정곡(수정 버전) 요청 시 새로운 MusicGeneration 엔티티를 생성하는 매핑 메서드 @@ -197,12 +201,7 @@ public static SongListResponse of(long totalCount, int currentPage, //AI 서버 작업 완료 콜백 Request Body public record AiTaskCompletionRequest( + @NotBlank(message = "generated_audio_url은 필수입니다.") @JsonProperty("generated_audio_url") String generatedAudioUrl - ) { - public AiTaskCompletionRequest { - if (generatedAudioUrl == null || generatedAudioUrl.isBlank()) { - throw new IllegalArgumentException("생성된 오디오 URL(generated_audio_url)은 필수입니다."); - } - } - } + ) {} } diff --git a/src/main/java/com/humix/api/domain/musicGeneration/service/MusicGenerationService.java b/src/main/java/com/humix/api/domain/musicGeneration/service/MusicGenerationService.java index d04b6a7..66a145b 100644 --- a/src/main/java/com/humix/api/domain/musicGeneration/service/MusicGenerationService.java +++ b/src/main/java/com/humix/api/domain/musicGeneration/service/MusicGenerationService.java @@ -100,7 +100,7 @@ public MusicGenerationDTO.TaskAcceptedResponse generateSong(CustomUserDetails us // 1. MelodyScore 로드 MelodyScore melodyScore = melodyScoreRepository.findByHummingId(request.hummingId()) - .orElseThrow(() -> new IllegalArgumentException("해당 허밍의 멜로디 악보가 존재하지 않습니다. ID: " + request.hummingId())); + .orElseThrow(() -> new GeneralException(GeneralErrorCode.MELODY_SCORE_NOT_FOUND)); // 2. Task ID 생성 String taskId = "task_" + UUID.randomUUID().toString(); @@ -162,7 +162,7 @@ public MusicGenerationDTO.TaskAcceptedResponse modifySongPrompt(CustomUserDetail // 1. 기존 완성곡(Parent) 로드 MusicGeneration parentGeneration = musicGenerationRepository.findById(songId) - .orElseThrow(() -> new IllegalArgumentException("수정의 대상이 되는 원본 곡이 존재하지 않습니다. ID: " + songId)); + .orElseThrow(() -> new GeneralException(GeneralErrorCode.GENERATION_NOT_FOUND)); MelodyScore melodyScore = parentGeneration.getMelodyScore(); @@ -220,7 +220,7 @@ public SseEmitter subscribeTaskStream(String taskId) { // 1. DB에서 현재 태스크 상태 조회 MusicGeneration musicGeneration = musicGenerationRepository.findByTaskId(taskId) - .orElseThrow(() -> new IllegalArgumentException("해당 태스크 ID의 작업 정보가 존재하지 않습니다. ID: " + taskId)); + .orElseThrow(() -> new GeneralException(GeneralErrorCode.TASK_NOT_FOUND)); SseEmitter emitter = new SseEmitter(1000 * 60 * 5L); // 5분 타임아웃 @@ -304,7 +304,7 @@ public SseEmitter subscribeTaskStream(String taskId) { @Transactional public MusicGenerationDTO.TaskCancelResponse cancelTask(String taskId) { MusicGeneration musicGeneration = musicGenerationRepository.findByTaskId(taskId) - .orElseThrow(() -> new IllegalArgumentException("해당 태스크 ID의 작업 정보가 존재하지 않습니다. ID: " + taskId)); + .orElseThrow(() -> new GeneralException(GeneralErrorCode.TASK_NOT_FOUND)); musicGeneration.updateStatus(GenerationStatus.CANCELED, null); @@ -328,7 +328,7 @@ public MusicGenerationDTO.TaskCancelResponse cancelTask(String taskId) { public void completeTask(String taskId, MusicGenerationDTO.AiTaskCompletionRequest request) { log.info("[MusicGeneration] Received completion callback for taskId: {}, audioUrl: {}", taskId, request.generatedAudioUrl()); MusicGeneration musicGeneration = musicGenerationRepository.findByTaskId(taskId) - .orElseThrow(() -> new IllegalArgumentException("해당 태스크 ID의 작업 정보가 존재하지 않습니다. ID: " + taskId)); + .orElseThrow(() -> new GeneralException(GeneralErrorCode.TASK_NOT_FOUND)); String audioUrl = request.generatedAudioUrl(); boolean isFailed = "FAILED".equalsIgnoreCase(audioUrl); diff --git a/src/main/java/com/humix/api/domain/upload/controller/UploadController.java b/src/main/java/com/humix/api/domain/upload/controller/UploadController.java index 28fc552..5b30402 100644 --- a/src/main/java/com/humix/api/domain/upload/controller/UploadController.java +++ b/src/main/java/com/humix/api/domain/upload/controller/UploadController.java @@ -6,6 +6,7 @@ import com.humix.api.global.apiPayload.ApiResponse; import com.humix.api.global.apiPayload.code.GeneralSuccessCode; import com.humix.api.global.security.userdetails.CustomUserDetails; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -19,7 +20,7 @@ public class UploadController implements UploadControllerDocs { @PostMapping("/audio/presigned") @Override - public ApiResponse getPresignedUrl(@RequestBody UploadDTO.AudioPresignedRequest request) { + public ApiResponse getPresignedUrl(@Valid @RequestBody UploadDTO.AudioPresignedRequest request) { UploadDTO.AudioPresignedResponse result = uploadService.getPresignedUrl(request); @@ -29,7 +30,7 @@ public ApiResponse getPresignedUrl(@RequestBod @PostMapping("/humming") @Override public ApiResponse saveHummingInfo(@AuthenticationPrincipal CustomUserDetails userDetails, - @RequestBody HummingDTO.HummingSaveRequest request) { + @Valid @RequestBody HummingDTO.HummingSaveRequest request) { HummingDTO.HummingSaveResponse result = uploadService.saveHummingInfo(userDetails, request); diff --git a/src/main/java/com/humix/api/domain/upload/controller/UploadControllerDocs.java b/src/main/java/com/humix/api/domain/upload/controller/UploadControllerDocs.java index 046cc51..e54f93b 100644 --- a/src/main/java/com/humix/api/domain/upload/controller/UploadControllerDocs.java +++ b/src/main/java/com/humix/api/domain/upload/controller/UploadControllerDocs.java @@ -20,17 +20,21 @@ public interface UploadControllerDocs { @Operation(summary = "오디오 Presigned URL 발급 API", description = "S3에 오디오 파일을 업로드하기 위한 URL과 Key를 발급받습니다. (.mp3, .wav만 허용)") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "성공"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "지원하지 않는 확장자", + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 값 검증 실패", content = @Content(schema = @Schema(implementation = ApiResponse.class), - examples = @ExampleObject(name = "확장자 에러", - value = "{\"isSuccess\":false, \"code\":\"UPLOAD400\", \"message\":\"지원하지 않는 오디오 확장자입니다.\", \"result\":null}"))) + examples = @ExampleObject(name = "검증 실패", + value = "{\"isSuccess\":false, \"code\":\"COMMON400\", \"message\":\"잘못된 요청입니다.\", \"result\":{\"audio_name\":\"audio_name은 필수입니다.\"}}"))) }) @PostMapping("/audio/presigned") ApiResponse getPresignedUrl(@RequestBody UploadDTO.AudioPresignedRequest request); @Operation(summary = "허밍 오디오 메타데이터 저장 API", description = "S3 업로드 완료 후 허밍 파일의 메타데이터를 서버에 저장합니다.") @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "성공") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 값 검증 실패", + content = @Content(schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject(name = "검증 실패", + value = "{\"isSuccess\":false, \"code\":\"COMMON400\", \"message\":\"잘못된 요청입니다.\", \"result\":{\"file_key\":\"file_key는 필수입니다.\"}}"))) }) @PostMapping("/humming") ApiResponse saveHummingInfo(@AuthenticationPrincipal CustomUserDetails userDetails, diff --git a/src/main/java/com/humix/api/domain/upload/dto/UploadDTO.java b/src/main/java/com/humix/api/domain/upload/dto/UploadDTO.java index d9645d0..e97e8b4 100644 --- a/src/main/java/com/humix/api/domain/upload/dto/UploadDTO.java +++ b/src/main/java/com/humix/api/domain/upload/dto/UploadDTO.java @@ -1,11 +1,14 @@ package com.humix.api.domain.upload.dto; import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.constraints.NotBlank; public class UploadDTO { //Presigned URL 발급 요청 Request public record AudioPresignedRequest( + @NotBlank(message = "audio_name은 필수입니다.") @JsonProperty("audio_name") String audioName, + @NotBlank(message = "content_type은 필수입니다.") @JsonProperty("content_type") String contentType, String usage ) {} diff --git a/src/main/java/com/humix/api/global/apiPayload/code/GeneralErrorCode.java b/src/main/java/com/humix/api/global/apiPayload/code/GeneralErrorCode.java index b7f5629..1abe606 100644 --- a/src/main/java/com/humix/api/global/apiPayload/code/GeneralErrorCode.java +++ b/src/main/java/com/humix/api/global/apiPayload/code/GeneralErrorCode.java @@ -13,6 +13,7 @@ public enum GeneralErrorCode implements BaseErrorCode{ UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "인증이 필요합니다."), FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."), NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON404", "요청한 리소스를 찾을 수 없습니다. 해당 경로는 존재하지 않습니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COMMON405", "허용되지 않는 요청 방식입니다."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 에러, 잠시 후 다시 시도해주세요."), // AUTH 관련 에러 @@ -20,7 +21,22 @@ public enum GeneralErrorCode implements BaseErrorCode{ REFRESH_TOKEN_EXPIRES(HttpStatus.UNAUTHORIZED, "AUTH4001", "리프레시 토큰이 만료되었습니다. 다시 로그인해주세요."), // MEMBER 관련 에러 - MEMBER_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER4000", "해당하는 사용자가 존재하지 않습니다."); + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER4000", "해당하는 사용자가 존재하지 않습니다."), + + // HUMMING 관련 에러 + HUMMING_NOT_FOUND(HttpStatus.NOT_FOUND, "HUMMING4000", "해당하는 허밍 기록이 존재하지 않습니다."), + + // MELODY 관련 에러 + MELODY_SCORE_NOT_FOUND(HttpStatus.NOT_FOUND, "MELODY4000", "해당 허밍의 멜로디 악보가 존재하지 않습니다."), + + // GENERATION 관련 에러 + GENERATION_NOT_FOUND(HttpStatus.NOT_FOUND, "GENERATION4000", "해당하는 생성곡이 존재하지 않습니다."), + + // TASK 관련 에러 + TASK_NOT_FOUND(HttpStatus.NOT_FOUND, "TASK4000", "해당 태스크 ID의 작업 정보가 존재하지 않습니다."), + + // UPLOAD 관련 에러 + UNSUPPORTED_AUDIO_TYPE(HttpStatus.BAD_REQUEST, "UPLOAD4000", "지원하지 않는 오디오 형식입니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/humix/api/global/apiPayload/handler/GeneralExceptionAdvice.java b/src/main/java/com/humix/api/global/apiPayload/handler/GeneralExceptionAdvice.java index e8e9c84..b6ad273 100644 --- a/src/main/java/com/humix/api/global/apiPayload/handler/GeneralExceptionAdvice.java +++ b/src/main/java/com/humix/api/global/apiPayload/handler/GeneralExceptionAdvice.java @@ -6,16 +6,41 @@ import com.humix.api.global.apiPayload.exception.GeneralException; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.security.access.AccessDeniedException; +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.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.servlet.resource.NoResourceFoundException; +import java.util.LinkedHashMap; +import java.util.Map; + @Slf4j @RestControllerAdvice public class GeneralExceptionAdvice { + // 요청 JSON 필드명(snake_case)과 맞추기 위해 자바 필드명을 변환한다. + private static String toSnakeCase(String field) { + return field.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(); + } + + /** + * 에러 응답은 항상 JSON으로 내려준다. + * - SSE 엔드포인트(produces = text/event-stream)에서 난 예외도 Accept 협상 없이 JSON 본문을 쓸 수 있도록 Content-Type을 명시한다. + */ + private static ResponseEntity> failure(BaseErrorCode code, T result) { + return ResponseEntity + .status(code.getStatus()) + .contentType(MediaType.APPLICATION_JSON) + .body(ApiResponse.onFailure(code, result)); + } + /** * 1. 비즈니스 로직 에러 (GeneralException) * - 서비스에서 직접 발생시킨 커스텀 예외를 처리. @@ -23,9 +48,7 @@ public class GeneralExceptionAdvice { @ExceptionHandler(GeneralException.class) public ResponseEntity> handleGeneralException(GeneralException ex) { BaseErrorCode code = ex.getCode(); - return ResponseEntity - .status(code.getStatus()) - .body(ApiResponse.onFailure(code, null)); + return failure(code, null); } /** @@ -35,9 +58,7 @@ public ResponseEntity> handleGeneralException(GeneralException @ExceptionHandler(NoResourceFoundException.class) public ResponseEntity> handleNoResourceFoundException(NoResourceFoundException ex) { BaseErrorCode code = GeneralErrorCode.NOT_FOUND; - return ResponseEntity - .status(code.getStatus()) - .body(ApiResponse.onFailure(code, null)); + return failure(code, null); } /** @@ -47,24 +68,56 @@ public ResponseEntity> handleNoResourceFoundException(NoResour @ExceptionHandler(AccessDeniedException.class) public ResponseEntity> handleAccessDeniedException(AccessDeniedException ex) { BaseErrorCode code = GeneralErrorCode.LOGIN_REQUIRED; - return ResponseEntity - .status(code.getStatus()) - .body(ApiResponse.onFailure(code, null)); + return failure(code, null); } /** - * 4. 그 외 모든 서버 내부 에러 (Exception) + * 4. 요청 값 검증 실패 (@Valid) + * - result에 {필드명: 검증 메시지}를 담아 반환한다. + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity>> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { + Map errors = new LinkedHashMap<>(); + ex.getBindingResult().getFieldErrors() + .forEach(error -> errors.putIfAbsent(toSnakeCase(error.getField()), error.getDefaultMessage())); + + BaseErrorCode code = GeneralErrorCode.BAD_REQUEST; + return failure(code, errors); + } + + /** + * 5. 읽을 수 없는 요청 (깨진 JSON, 타입 불일치, 필수 파라미터 누락) + */ + @ExceptionHandler({ + HttpMessageNotReadableException.class, + MethodArgumentTypeMismatchException.class, + MissingServletRequestParameterException.class + }) + public ResponseEntity> handleBadRequestException(Exception ex) { + BaseErrorCode code = GeneralErrorCode.BAD_REQUEST; + return failure(code, null); + } + + /** + * 6. 허용되지 않는 HTTP 메서드 (405) + */ + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity> handleMethodNotSupportedException(HttpRequestMethodNotSupportedException ex) { + BaseErrorCode code = GeneralErrorCode.METHOD_NOT_ALLOWED; + return failure(code, null); + } + + /** + * 7. 그 외 모든 서버 내부 에러 (Exception) * - 예상치 못한 NullPointerException 등이 여기서 적발 - * - log.error()를 사용하여 서버 로그에 에러 스택을 남긴다 + * - log.error()를 사용하여 서버 로그에 에러 스택을 남기고, 응답에는 내부 메시지를 노출하지 않는다 */ @ExceptionHandler(Exception.class) - public ResponseEntity> handleException(Exception ex, HttpServletRequest request) { + public ResponseEntity> handleException(Exception ex, HttpServletRequest request) { // 에러 로그 남기기 (어떤 요청에서 에러가 났는지 확인용) log.error("❌ Unhandled Exception occurred at URL: {}", request.getRequestURI(), ex); BaseErrorCode code = GeneralErrorCode.INTERNAL_SERVER_ERROR; - return ResponseEntity - .status(code.getStatus()) - .body(ApiResponse.onFailure(code, ex.getMessage())); + return failure(code, null); } } \ No newline at end of file diff --git a/src/main/java/com/humix/api/global/apiPayload/type/AudioMimeType.java b/src/main/java/com/humix/api/global/apiPayload/type/AudioMimeType.java index e9616fb..ab7ecf4 100644 --- a/src/main/java/com/humix/api/global/apiPayload/type/AudioMimeType.java +++ b/src/main/java/com/humix/api/global/apiPayload/type/AudioMimeType.java @@ -1,5 +1,7 @@ package com.humix.api.global.apiPayload.type; +import com.humix.api.global.apiPayload.code.GeneralErrorCode; +import com.humix.api.global.apiPayload.exception.GeneralException; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -20,6 +22,6 @@ public static AudioMimeType from(String mimeType) { return type; } } - throw new IllegalArgumentException("지원하지 않는 오디오 형식입니다: " + mimeType); + throw new GeneralException(GeneralErrorCode.UNSUPPORTED_AUDIO_TYPE); } } \ No newline at end of file diff --git a/src/test/java/com/humix/api/global/ExceptionHandlingTest.java b/src/test/java/com/humix/api/global/ExceptionHandlingTest.java new file mode 100644 index 0000000..32e49fd --- /dev/null +++ b/src/test/java/com/humix/api/global/ExceptionHandlingTest.java @@ -0,0 +1,172 @@ +package com.humix.api.global; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class ExceptionHandlingTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + private String bearer; + + @BeforeEach + void issueToken() throws Exception { + String body = mockMvc.perform(post("/api/v1/auth/guest-login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"device_id\":\"exception-test-device\"}")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + bearer = "Bearer " + objectMapper.readTree(body).at("/result/access_token").asText(); + } + + private ResultActions postJson(String url, String json) throws Exception { + return mockMvc.perform(post(url) + .header("Authorization", bearer) + .contentType(MediaType.APPLICATION_JSON) + .content(json)); + } + + @Test + void guestLogin_deviceId_누락이면_400과_필드메시지() throws Exception { + mockMvc.perform(post("/api/v1/auth/guest-login") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("COMMON400")) + .andExpect(jsonPath("$.result.device_id").exists()); + } + + @Test + void 곡생성_없는_허밍이면_404() throws Exception { + postJson("/api/v1/generation/songs", + "{\"humming_id\":999,\"title\":\"t\",\"genre\":\"pop\",\"mood\":\"happy\"}") + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("MELODY4000")) + .andExpect(jsonPath("$.result").doesNotExist()); + } + + @Test + void 곡생성_humming_id_누락이면_400() throws Exception { + postJson("/api/v1/generation/songs", "{\"title\":\"t\"}") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON400")); + } + + @Test + void 벡터화_없는_허밍이면_404() throws Exception { + mockMvc.perform(post("/api/v1/hummings/999/vectors").header("Authorization", bearer)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("HUMMING4000")); + } + + @Test + void 벡터수정_없는_악보면_404() throws Exception { + mockMvc.perform(put("/api/v1/hummings/999/vectors") + .header("Authorization", bearer) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"notes\":[]}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("MELODY4000")); + } + + @Test + void 벡터수정_notes_누락이면_400() throws Exception { + mockMvc.perform(put("/api/v1/hummings/999/vectors") + .header("Authorization", bearer) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.result.notes").exists()); + } + + @Test + void 곡수정_없는_곡이면_404() throws Exception { + postJson("/api/v1/generation/songs/999/modifications", "{\"prompt\":\"더 밝게\"}") + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("GENERATION4000")); + } + + @Test + void 곡수정_prompt_누락이면_400() throws Exception { + postJson("/api/v1/generation/songs/999/modifications", "{}") + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.result.prompt").exists()); + } + + @Test + void 태스크_취소_없는_태스크면_404() throws Exception { + mockMvc.perform(delete("/api/v1/generation/songs/tasks/task_unknown")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("TASK4000")); + } + + @Test + void 완료콜백_없는_태스크면_404() throws Exception { + mockMvc.perform(post("/api/v1/internal/tasks/task_unknown/completion") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"generated_audio_url\":\"https://example.com/a.wav\"}")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("TASK4000")); + } + + @Test + void 완료콜백_generated_audio_url_누락이면_400() throws Exception { + mockMvc.perform(post("/api/v1/internal/tasks/task_unknown/completion") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON400")) + .andExpect(jsonPath("$.result.generated_audio_url").exists()); + } + + @Test + void 깨진_JSON이면_400() throws Exception { + mockMvc.perform(post("/api/v1/auth/guest-login") + .contentType(MediaType.APPLICATION_JSON) + .content("{not-json")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON400")); + } + + @Test + void 경로변수_타입_불일치면_400() throws Exception { + mockMvc.perform(post("/api/v1/hummings/abc/vectors").header("Authorization", bearer)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON400")); + } + + @Test + void 허용되지_않는_메서드면_405() throws Exception { + mockMvc.perform(put("/api/v1/auth/guest-login")) + .andExpect(status().isMethodNotAllowed()) + .andExpect(jsonPath("$.code").value("COMMON405")); + } + + @Test + void SSE_구독_없는_태스크면_404_JSON() throws Exception { + mockMvc.perform(get("/api/v1/generation/songs/tasks/task_unknown/stream") + .accept(MediaType.TEXT_EVENT_STREAM)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("TASK4000")); + } +} diff --git a/src/test/java/com/humix/api/global/InternalErrorResponseTest.java b/src/test/java/com/humix/api/global/InternalErrorResponseTest.java new file mode 100644 index 0000000..1230551 --- /dev/null +++ b/src/test/java/com/humix/api/global/InternalErrorResponseTest.java @@ -0,0 +1,54 @@ +package com.humix.api.global; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.humix.api.domain.humming.service.HummingService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.containsString; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class InternalErrorResponseTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private HummingService hummingService; + + @Test + void 예상못한_예외는_500이고_내부_메시지를_노출하지_않는다() throws Exception { + when(hummingService.convertHummingToVector(anyLong())) + .thenThrow(new IllegalStateException("secret internal detail")); + + String body = mockMvc.perform(post("/api/v1/auth/guest-login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"device_id\":\"internal-error-device\"}")) + .andReturn().getResponse().getContentAsString(); + String bearer = "Bearer " + objectMapper.readTree(body).at("/result/access_token").asText(); + + mockMvc.perform(post("/api/v1/hummings/1/vectors").header("Authorization", bearer)) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.code").value("COMMON500")) + .andExpect(jsonPath("$.result").doesNotExist()) + .andExpect(content().string(not(containsString("secret internal detail")))); + } +}