diff --git a/.claude/agents/kotlin-migration-agent.md b/.claude/agents/kotlin-migration-agent.md index 34374d3f..56634f7c 100644 --- a/.claude/agents/kotlin-migration-agent.md +++ b/.claude/agents/kotlin-migration-agent.md @@ -14,11 +14,81 @@ Migrate Java to idiomatic Kotlin with Test-First methodology. ### 1. Pre-Migration Test - Analyze Java code behavior and dependencies -- Write tests FIRST in `src/test/kotlin/{domain}/` +- **Write ONLY essential tests** that verify critical business logic - Use Kotest + mockk - Run tests against Java code to confirm they pass +**Tests to Write (HIGH value):** +- Business logic with conditions/branching (예: 권한 검증, 상태 체크, 조건부 로직) +- Exception scenarios (예: 존재하지 않는 엔티티 조회 시 예외, 권한 없음 예외) +- Complex calculations or transformations +- Transaction boundaries and side effects +- Custom query methods with specific logic +- Domain validation rules + +**Tests to SKIP (LOW value):** +- JPA basic CRUD (findById, save, delete, findAll) +- Simple getter/setter or DTO field mapping +- Obvious pass-through methods (repository calls without logic) +- Framework-provided functionality +- Constructor assignments +- Simple delegation patterns + +**Example - Service to Test:** +```java +public Feed getFeed(Long feedId, Long userId) { + Feed feed = feedRepository.findById(feedId) + .orElseThrow(() -> new FeedNotFoundException()); + + if (feed.isBlocked(userId)) { // ← Test this + throw new FeedAccessDeniedException(); // ← Test this + } + + return feed; // ← Don't test simple return +} +``` + +**Write 2-3 focused tests:** +- "존재하지 않는 피드 조회 시 예외 발생" +- "차단된 사용자가 조회 시 예외 발생" +- (Optional) "정상 피드 조회 시 반환" only if complex setup is needed + +**Skip if code is trivial:** +```java +public void deleteFeed(Long feedId) { + feedRepository.deleteById(feedId); // ← Skip, JPA basic method +} +``` + ### 2. Migration + +#### File Move and Conversion Workflow +**Instead of deleting Java files and creating new ones, move files using `git mv` then modify the content.** + +**Step 1: Move file path** +```bash +git mv src/main/java/leets/leenk/domain/{domain}/{path}/{File}.java \ + src/main/kotlin/leets/leenk/domain/{domain}/{path}/{File}.kt +``` + +**Step 2: Convert to Kotlin** +- Read current file using Read tool +- Modify file content from Java → Kotlin syntax using Edit tool +- Keep business logic identical + +**Step 3: Run tests** +```bash +./gradlew test # Run pre-written tests +``` + +**Benefits:** +- `git log --stat` clearly shows file move + modifications +- `git show` can verify actual code changes +- `git blame` can track commit history of Java version + +--- + +#### Migration Guide - Convert to Kotlin preserving architecture: Controller → UseCase → Domain Service → Repository - Apply Kotlin idioms: data class for DTOs, val over var, nullable only when needed - Keep Single Responsibility: `{Domain}GetService`, `{Domain}SaveService`, etc. diff --git a/.claude/commands/code-review.md b/.claude/commands/code-review.md new file mode 100644 index 00000000..569084d9 --- /dev/null +++ b/.claude/commands/code-review.md @@ -0,0 +1,34 @@ +--- +description: "코드 리뷰 에이전트를 활용해 현재까지의 작업을 리뷰합니다." +--- + +# Code Review Command + +Invoke the code review agent defined in `.claude/agents/code-review-agent.md` to perform code review. + +## Determine Review Target + +1. Check staged changes with `git diff --staged` +2. If nothing staged, check current branch commit history with `git log` and review + +## Rules + +- If agent file (`.claude/agents/code-review-agent.md`) doesn't exist, notify user and stop +- Follow the checklist and output format defined in the agent exactly + + +## Changes + +| Item | Reason | +|------|--------| +| Specify agent path | Claude needs exact file location to find it | +| Specific git commands | Clear instructions on how to check | +| Add "follow agent format" | Prevent ignoring agent file's output format | + +## Folder Structure + +.claude/ +├── commands/ +│ └── code-review.md # This command file +└── agents/ + └── code-review-agent.md # Agent definition (checklist, output format, etc.) diff --git a/.claude/commands/kotlin-migrate.md b/.claude/commands/kotlin-migrate.md new file mode 100644 index 00000000..779032d6 --- /dev/null +++ b/.claude/commands/kotlin-migrate.md @@ -0,0 +1,42 @@ +--- +description: "kotlin-migration-agent를 사용해 Java 파일을 코틀린으로 마이그레이션하는 명령어입니다." +--- + +# Instructions + +You MUST use the Task tool to invoke the kotlin-migration-agent immediately. + +## Input Processing + +1. If user provides a file path: + - Use Read tool to verify the file exists and is a Java file + - Pass the absolute file path to the agent + +2. If user provides a directory path: + - Use Glob to find all `.java` files in that directory + - Pass the directory path to the agent + +3. If no path provided: + - Ask user to specify the Java file or directory to migrate + +## Agent Invocation + +Call the Task tool with: +- subagent_type: "kotlin-migration-agent" +- prompt: "Migrate [FILE_PATH or DIRECTORY_PATH] from Java to Kotlin following the Test-First methodology" +- description: "Migrate Java to Kotlin" + +Example: +```text +Task tool: + subagent_type: kotlin-migration-agent + prompt: Migrate src/main/java/leets/leenk/domain/feed/service/FeedGetService.java from Java to Kotlin following the Test-First methodology + description: Migrate Java to Kotlin +``` + +## Important Notes + +- NEVER perform migration yourself - ALWAYS delegate to kotlin-migration-agent +- Agent will handle: test writing, migration, refactoring, and ktlint verification +- Agent follows strict order: Test → Migrate → Refactor → Verify +- All agent output will be in Korean as per agent rules diff --git a/CLAUDE.md b/CLAUDE.md index 289b46a8..753f9b17 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,12 +88,17 @@ refactor: dto validation 수정 ## Testing - **Kotest** + **MockK** for unit tests +- **springmockk** for Spring bean mocking - use `@MockkBean` instead of `@MockBean` - **Testcontainers** for integration tests (MySQL, MongoDB) - Test both success and failure scenarios -- Use `@DisplayName` for clear test descriptions +- **Kotest Test Styles:** + - `StringSpec` - Simple tests with minimal boilerplate + - `BehaviorSpec` - BDD-style tests (Given/When/Then) + - `DescribeSpec` - Technical specs requiring detailed structure and readability +- Controller tests: Use `@WebMvcTest` with `@Import` for Security config testing ## Notes - Swagger UI: `/swagger-ui.html` - Profiles: `local` (default), `dev` -- Auth: OAuth2 Resource Server with JWT (Apple via Kakao) \ No newline at end of file +- Auth: OAuth2 Resource Server with JWT (Apple via Kakao) diff --git a/build.gradle.kts b/build.gradle.kts index 62dafac8..1496976f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -60,6 +60,7 @@ dependencies { testImplementation("org.testcontainers:mysql") testImplementation("org.testcontainers:mongodb") testImplementation("io.mockk:mockk:1.14.7") + testImplementation("com.ninja-squad:springmockk:4.0.2") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") // Kotest diff --git a/src/main/java/leets/leenk/global/common/controller/ExceptionDocController.java b/src/main/java/leets/leenk/global/common/controller/ExceptionDocController.java deleted file mode 100644 index 9d29616e..00000000 --- a/src/main/java/leets/leenk/global/common/controller/ExceptionDocController.java +++ /dev/null @@ -1,99 +0,0 @@ -package leets.leenk.global.common.controller; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import leets.leenk.domain.birthday.application.exception.BirthdayErrorCode; -import leets.leenk.domain.feed.application.exception.FeedErrorCode; -import leets.leenk.domain.leenk.application.exception.LeenkErrorCode; -import leets.leenk.domain.media.application.exception.MediaErrorCode; -import leets.leenk.domain.notification.application.exception.NotificationErrorCode; -import leets.leenk.domain.user.application.exception.UserErrorCode; -import leets.leenk.global.auth.application.exception.AuthErrorCode; -import leets.leenk.global.common.exception.ApiErrorCodeExample; -import leets.leenk.global.common.exception.CommonErrorCode; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -/** - * API 예외 코드 문서화를 위한 컨트롤러 - * 실제 비즈니스 로직을 수행하지 않고, Swagger 문서에 각 도메인별 예외 정보를 표시하기 위한 목적으로만 사용됩니다. - * 각 엔드포인트는 해당 도메인에서 발생할 수 있는 모든 예외 케이스를 Swagger UI에서 확인할 수 있도록 합니다. - */ -@RestController -@RequestMapping("/api/v1/docs/exceptions") -@Tag(name = "Exception Document", description = "API 에러 코드 문서") -public class ExceptionDocController { - - @GetMapping("/auth") - @Operation( - summary = "인증 관련 예외 목록", - description = "인증 및 권한 관련 예외 코드를 확인할 수 있습니다." - ) - @ApiErrorCodeExample(AuthErrorCode.class) - public void authErrorCodes() { - } - - @GetMapping("/user") - @Operation( - summary = "사용자 관련 예외 목록", - description = "사용자 조회, 수정, 차단 등 사용자 관련 예외 코드를 확인할 수 있습니다." - ) - @ApiErrorCodeExample(UserErrorCode.class) - public void userErrorCodes() { - } - - @GetMapping("/feed") - @Operation( - summary = "피드 및 댓글 관련 예외 목록", - description = "피드 조회, 작성, 수정, 삭제 및 댓글 관련 예외 코드를 확인할 수 있습니다." - ) - @ApiErrorCodeExample(FeedErrorCode.class) - public void feedErrorCodes() { - } - - @GetMapping("/notification") - @Operation( - summary = "알림 관련 예외 목록", - description = "알림 조회, 읽음 처리 등 알림 관련 예외 코드를 확인할 수 있습니다." - ) - @ApiErrorCodeExample(NotificationErrorCode.class) - public void notificationErrorCodes() { - } - - @GetMapping("/leenk") - @Operation( - summary = "링크 관련 예외 목록", - description = "링크 생성, 참여, 마감, 종료 등 링크 관련 예외 코드를 확인할 수 있습니다." - ) - @ApiErrorCodeExample(LeenkErrorCode.class) - public void leenkErrorCodes() { - } - - @GetMapping("/media") - @Operation( - summary = "미디어 관련 예외 목록", - description = "미디어 업로드, 조회 등 미디어 관련 예외 코드를 확인할 수 있습니다." - ) - @ApiErrorCodeExample(MediaErrorCode.class) - public void mediaErrorCodes() { - } - - @GetMapping("/birthday") - @Operation( - summary = "생일 관련 예외 목록", - description = "생일 조회, 축하 메시지 전송 등 생일 관련 예외 코드를 확인할 수 있습니다." - ) - @ApiErrorCodeExample(BirthdayErrorCode.class) - public void birthdayErrorCodes() { - } - - @GetMapping("/common") - @Operation( - summary = "공통 예외 목록", - description = "서버 에러, 클라이언트 요청 에러 등 공통 예외 코드를 확인할 수 있습니다." - ) - @ApiErrorCodeExample(CommonErrorCode.class) - public void commonErrorCodes() { - } -} diff --git a/src/main/java/leets/leenk/global/common/controller/StatusCheckController.java b/src/main/java/leets/leenk/global/common/controller/StatusCheckController.java deleted file mode 100644 index ba56667b..00000000 --- a/src/main/java/leets/leenk/global/common/controller/StatusCheckController.java +++ /dev/null @@ -1,17 +0,0 @@ -package leets.leenk.global.common.controller; - -import io.swagger.v3.oas.annotations.Hidden; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -@Hidden -@RestController -public class StatusCheckController { - - @GetMapping("/health-check") - public ResponseEntity checkHealthStatus() { - - return ResponseEntity.ok("OK"); - } -} diff --git a/src/main/java/leets/leenk/global/common/dto/CommonPageableResponse.java b/src/main/java/leets/leenk/global/common/dto/CommonPageableResponse.java deleted file mode 100644 index c0016675..00000000 --- a/src/main/java/leets/leenk/global/common/dto/CommonPageableResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -package leets.leenk.global.common.dto; - -import com.fasterxml.jackson.annotation.JsonInclude; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Builder; - -@Builder -@JsonInclude(JsonInclude.Include.NON_NULL) -public record CommonPageableResponse( - @Schema(description = "페이지 번호 (0부터 시작)", example = "0") - int pageNumber, - - @Schema(description = "페이지 크기", example = "10") - int pageSize, - - @Schema(description = "현재 페이지의 요소 개수", example = "10") - int numberOfElements, - - @Schema(description = "다음 페이지 존재 여부", example = "true") - boolean hasNext, - - @Schema(description = "현재 페이지의 요소가 비어 있는지의 여부", example = "false") - boolean empty -) { -} diff --git a/src/main/java/leets/leenk/global/common/dto/PageableMapperUtil.java b/src/main/java/leets/leenk/global/common/dto/PageableMapperUtil.java deleted file mode 100644 index 77eed4e2..00000000 --- a/src/main/java/leets/leenk/global/common/dto/PageableMapperUtil.java +++ /dev/null @@ -1,26 +0,0 @@ -package leets.leenk.global.common.dto; - -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Slice; - -public class PageableMapperUtil { - public static CommonPageableResponse from(Slice slice) { - return CommonPageableResponse.builder() - .pageNumber(slice.getNumber()) - .pageSize(slice.getSize()) - .numberOfElements(slice.getNumberOfElements()) - .hasNext(slice.hasNext()) - .empty(slice.isEmpty()) - .build(); - } - - public static CommonPageableResponse from(Page page) { - return CommonPageableResponse.builder() - .pageNumber(page.getNumber()) - .pageSize(page.getSize()) - .numberOfElements(page.getNumberOfElements()) - .hasNext(page.hasNext()) // or isLast(), etc. - .empty(page.isEmpty()) - .build(); - } -} diff --git a/src/main/java/leets/leenk/global/common/exception/ApiErrorCodeExample.java b/src/main/java/leets/leenk/global/common/exception/ApiErrorCodeExample.java deleted file mode 100644 index 237d969c..00000000 --- a/src/main/java/leets/leenk/global/common/exception/ApiErrorCodeExample.java +++ /dev/null @@ -1,12 +0,0 @@ -package leets.leenk.global.common.exception; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target({ElementType.METHOD, ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -public @interface ApiErrorCodeExample { - Class[] value(); -} diff --git a/src/main/java/leets/leenk/global/common/exception/BaseException.java b/src/main/java/leets/leenk/global/common/exception/BaseException.java deleted file mode 100644 index c8da6a5f..00000000 --- a/src/main/java/leets/leenk/global/common/exception/BaseException.java +++ /dev/null @@ -1,18 +0,0 @@ -package leets.leenk.global.common.exception; - -import lombok.Getter; - -@Getter -public abstract class BaseException extends RuntimeException { - private final ErrorCodeInterface errorCode; - - public BaseException(final ErrorCodeInterface errorCode) { - super(errorCode.getMessage()); - this.errorCode = errorCode; - } - - public BaseException(final ErrorCodeInterface errorCode, String message) { - super(message); - this.errorCode = errorCode; - } -} diff --git a/src/main/java/leets/leenk/global/common/exception/ErrorCodeInterface.java b/src/main/java/leets/leenk/global/common/exception/ErrorCodeInterface.java deleted file mode 100644 index 4dce7e59..00000000 --- a/src/main/java/leets/leenk/global/common/exception/ErrorCodeInterface.java +++ /dev/null @@ -1,19 +0,0 @@ -package leets.leenk.global.common.exception; - -import org.springframework.http.HttpStatus; - -import java.lang.reflect.Field; -import java.util.Objects; - -public interface ErrorCodeInterface { - int getCode(); - HttpStatus getStatus(); - String getMessage(); - - // ExplainError 어노테이션에 작성된 설명을 조회하는 메서드 - default String getExplainError() throws NoSuchFieldException { - Field field = this.getClass().getField(((Enum) this).name()); - ExplainError annotation = field.getAnnotation(ExplainError.class); - return Objects.nonNull(annotation) ? annotation.value() : getMessage(); - } -} diff --git a/src/main/java/leets/leenk/global/common/exception/ExampleHolder.java b/src/main/java/leets/leenk/global/common/exception/ExampleHolder.java deleted file mode 100644 index a57f1ce5..00000000 --- a/src/main/java/leets/leenk/global/common/exception/ExampleHolder.java +++ /dev/null @@ -1,13 +0,0 @@ -package leets.leenk.global.common.exception; - -import io.swagger.v3.oas.models.examples.Example; -import lombok.Builder; -import lombok.Getter; - -@Getter -@Builder -public class ExampleHolder { - private Example holder; - private String name; - private int code; -} diff --git a/src/main/java/leets/leenk/global/common/exception/ExplainError.java b/src/main/java/leets/leenk/global/common/exception/ExplainError.java deleted file mode 100644 index 300bb44a..00000000 --- a/src/main/java/leets/leenk/global/common/exception/ExplainError.java +++ /dev/null @@ -1,12 +0,0 @@ -package leets.leenk.global.common.exception; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target(ElementType.FIELD) -@Retention(RetentionPolicy.RUNTIME) -public @interface ExplainError { - String value() default ""; -} diff --git a/src/main/java/leets/leenk/global/common/exception/GlobalExceptionHandler.java b/src/main/java/leets/leenk/global/common/exception/GlobalExceptionHandler.java deleted file mode 100644 index 7a5cafc2..00000000 --- a/src/main/java/leets/leenk/global/common/exception/GlobalExceptionHandler.java +++ /dev/null @@ -1,112 +0,0 @@ -package leets.leenk.global.common.exception; - -import leets.leenk.global.common.exception.response.ValidErrorResponse; -import leets.leenk.global.common.response.CommonResponse; -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.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.servlet.resource.NoResourceFoundException; - -import java.util.List; - -@RestControllerAdvice -public class GlobalExceptionHandler { - - @ExceptionHandler(BaseException.class) - public ResponseEntity> handleException(BaseException e) { - ErrorCodeInterface errorCode = e.getErrorCode(); - String errorMessage = e.getMessage(); - CommonResponse body = CommonResponse.error(errorCode, errorMessage); - - return ResponseEntity - .status(errorCode.getStatus()) - .body(body); - } - - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity>> handleValidation(MethodArgumentNotValidException e) { - CommonErrorCode errorCode = CommonErrorCode.INVALID_ARGUMENT; - - List errors = e.getBindingResult() - .getFieldErrors().stream() - .map(fe -> ValidErrorResponse.of( - fe.getField(), - fe.getDefaultMessage(), - fe.getRejectedValue() - )) - .toList(); - CommonResponse> body = - CommonResponse.error(errorCode, errors); - - return ResponseEntity - .status(errorCode.getStatus()) - .body(body); - } - - @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity> handleIllegalArgument(IllegalArgumentException e) { - CommonErrorCode errorCode = CommonErrorCode.INVALID_ARGUMENT; - CommonResponse body = CommonResponse.error(errorCode); - - - return ResponseEntity - .status(errorCode.getStatus()) - .body(body); - } - - @ExceptionHandler(NoResourceFoundException.class) - public ResponseEntity> handleNoResourceFound() { - CommonErrorCode errorCode = CommonErrorCode.RESOURCE_NOT_FOUND; - CommonResponse body = CommonResponse.error(errorCode); - - return ResponseEntity - .status(errorCode.getStatus()) - .body(body); - } - - @ExceptionHandler(HttpRequestMethodNotSupportedException.class) - public ResponseEntity> handleMethodNotAllowed(HttpRequestMethodNotSupportedException e) { - CommonErrorCode errorCode = CommonErrorCode.METHOD_NOT_ALLOWED; - CommonResponse body = CommonResponse.error(errorCode); - - - return ResponseEntity - .status(e.getStatusCode().value()) - .body(body); - } - - @ExceptionHandler(HttpMessageNotReadableException.class) - public ResponseEntity> handleMessageNotReadable(HttpMessageNotReadableException ex) { - Throwable cause = ex.getMostSpecificCause(); - - if (cause instanceof BaseException be) { - ErrorCodeInterface errorCode = be.getErrorCode(); - CommonResponse body = CommonResponse.error(errorCode, ex.getMessage()); - - return ResponseEntity - .status(errorCode.getStatus()) - .body(body); - } - - CommonErrorCode errorCode = CommonErrorCode.JSON_PARSE_ERROR; - CommonResponse body = CommonResponse.error(errorCode, ex.getMessage()); - - return ResponseEntity - .status(errorCode.getStatus()) - .body(body); - } - - @ExceptionHandler(Exception.class) - public ResponseEntity> handleAll(Exception e) { - CommonErrorCode errorCode = CommonErrorCode.INTERNAL_SERVER_ERROR; - CommonResponse body = CommonResponse.error(errorCode, e.getMessage()); - - - return ResponseEntity - .status(errorCode.getStatus()) - .body(body); - } -} diff --git a/src/main/java/leets/leenk/global/common/exception/ResourceLockedException.java b/src/main/java/leets/leenk/global/common/exception/ResourceLockedException.java deleted file mode 100644 index 69a8b9ad..00000000 --- a/src/main/java/leets/leenk/global/common/exception/ResourceLockedException.java +++ /dev/null @@ -1,7 +0,0 @@ -package leets.leenk.global.common.exception; - -public class ResourceLockedException extends BaseException{ - public ResourceLockedException() { - super(CommonErrorCode.RESOURCE_LOCKED); - } -} diff --git a/src/main/java/leets/leenk/global/common/exception/response/ValidErrorResponse.java b/src/main/java/leets/leenk/global/common/exception/response/ValidErrorResponse.java deleted file mode 100644 index 38b2116e..00000000 --- a/src/main/java/leets/leenk/global/common/exception/response/ValidErrorResponse.java +++ /dev/null @@ -1,11 +0,0 @@ -package leets.leenk.global.common.exception.response; - -public record ValidErrorResponse( - String errorField, - String errorMessage, - Object inputValue -) { - public static ValidErrorResponse of(String field, String msg, Object value) { - return new ValidErrorResponse(field, msg, value); - } -} diff --git a/src/main/java/leets/leenk/global/common/response/CommonResponse.java b/src/main/java/leets/leenk/global/common/response/CommonResponse.java deleted file mode 100644 index 5fa67e1d..00000000 --- a/src/main/java/leets/leenk/global/common/response/CommonResponse.java +++ /dev/null @@ -1,46 +0,0 @@ -package leets.leenk.global.common.response; - -import leets.leenk.global.common.exception.ErrorCodeInterface; - -public record CommonResponse ( - int code, - String message, - T data -) -{ - public static CommonResponse success(ResponseCodeInterface responseCode) { - return new CommonResponse<>( - responseCode.getCode(), - responseCode.getMessage(), - null - ); - } - public static CommonResponse success(ResponseCodeInterface responseCode, T data) { - return new CommonResponse<>( - responseCode.getCode(), - responseCode.getMessage(), - data - ); - } - public static CommonResponse error(ErrorCodeInterface errorCode) { - return new CommonResponse<>( - errorCode.getCode(), - errorCode.getMessage(), - null - ); - } - public static CommonResponse error(ErrorCodeInterface errorCode, String message) { - return new CommonResponse<>( - errorCode.getCode(), - message, - null - ); - } - public static CommonResponse error(ErrorCodeInterface errorCode, T data) { - return new CommonResponse<>( - errorCode.getCode(), - errorCode.getMessage(), - data - ); - } -} diff --git a/src/main/java/leets/leenk/global/common/response/ResponseCodeInterface.java b/src/main/java/leets/leenk/global/common/response/ResponseCodeInterface.java deleted file mode 100644 index 573d2566..00000000 --- a/src/main/java/leets/leenk/global/common/response/ResponseCodeInterface.java +++ /dev/null @@ -1,9 +0,0 @@ -package leets.leenk.global.common.response; - -import org.springframework.http.HttpStatus; - -public interface ResponseCodeInterface { - int getCode(); - HttpStatus getStatus(); - String getMessage(); -} diff --git a/src/main/java/leets/leenk/global/config/swagger/SwaggerConfig.java b/src/main/java/leets/leenk/global/config/swagger/SwaggerConfig.java index 7ce696c0..0b7a1e20 100644 --- a/src/main/java/leets/leenk/global/config/swagger/SwaggerConfig.java +++ b/src/main/java/leets/leenk/global/config/swagger/SwaggerConfig.java @@ -16,13 +16,13 @@ import leets.leenk.global.common.exception.ExampleHolder; import leets.leenk.global.common.response.CommonResponse; import org.springdoc.core.customizers.OperationCustomizer; -import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.Objects; import static java.util.stream.Collectors.groupingBy; @@ -30,9 +30,6 @@ public class SwaggerConfig { private static final String JWT_SCHEME = "jwtAuth"; - public SwaggerConfig(ApplicationContext applicationContext) { - } - @Bean public OpenAPI openAPI() { return new OpenAPI() @@ -81,11 +78,11 @@ private void generateErrorCodeResponseExample(ApiResponses responses, Class) errorCode).name(); - return ExampleHolder.builder() - .holder(getSwaggerExample(errorCode.getExplainError(), errorCode)) - .code(errorCode.getStatus().value()) - .name("[" + enumName + "] " + errorCode.getMessage()) // 한글로된 드롭다운을 만들기 위해 예외 메시지를 이름으로 사용 - .build(); + return new ExampleHolder( + getSwaggerExample(errorCode.getExplainError(), errorCode), + "[" + enumName + "] " + errorCode.getMessage(), // 한글로된 드롭다운을 만들기 위해 예외 메시지를 이름으로 사용 + Objects.requireNonNull(errorCode.getStatus()).value() + ); } catch (NoSuchFieldException e) { throw new RuntimeException(e); } diff --git a/src/main/kotlin/leets/leenk/global/common/controller/ExceptionDocController.kt b/src/main/kotlin/leets/leenk/global/common/controller/ExceptionDocController.kt new file mode 100644 index 00000000..d6c510e4 --- /dev/null +++ b/src/main/kotlin/leets/leenk/global/common/controller/ExceptionDocController.kt @@ -0,0 +1,91 @@ +package leets.leenk.global.common.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import leets.leenk.domain.birthday.application.exception.BirthdayErrorCode +import leets.leenk.domain.feed.application.exception.FeedErrorCode +import leets.leenk.domain.leenk.application.exception.LeenkErrorCode +import leets.leenk.domain.media.application.exception.MediaErrorCode +import leets.leenk.domain.notification.application.exception.NotificationErrorCode +import leets.leenk.domain.user.application.exception.UserErrorCode +import leets.leenk.global.auth.application.exception.AuthErrorCode +import leets.leenk.global.common.exception.ApiErrorCodeExample +import leets.leenk.global.common.exception.CommonErrorCode +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +/** + * API 예외 코드 문서화를 위한 컨트롤러 + * + * 실제 비즈니스 로직을 수행하지 않고, Swagger 문서에 각 도메인별 예외 정보를 표시하기 위한 목적으로만 사용됩니다. + * 각 엔드포인트는 해당 도메인에서 발생할 수 있는 모든 예외 케이스를 Swagger UI에서 확인할 수 있도록 합니다. + */ +@RestController +@RequestMapping("/api/v1/docs/exceptions") +@Tag(name = "Exception Document", description = "API 에러 코드 문서") +class ExceptionDocController { + @GetMapping("/auth") + @Operation( + summary = "인증 관련 예외 목록", + description = "인증 및 권한 관련 예외 코드를 확인할 수 있습니다.", + ) + @ApiErrorCodeExample(AuthErrorCode::class) + fun authErrorCodes() = Unit + + @GetMapping("/user") + @Operation( + summary = "사용자 관련 예외 목록", + description = "사용자 조회, 수정, 차단 등 사용자 관련 예외 코드를 확인할 수 있습니다.", + ) + @ApiErrorCodeExample(UserErrorCode::class) + fun userErrorCodes() = Unit + + @GetMapping("/feed") + @Operation( + summary = "피드 및 댓글 관련 예외 목록", + description = "피드 조회, 작성, 수정, 삭제 및 댓글 관련 예외 코드를 확인할 수 있습니다.", + ) + @ApiErrorCodeExample(FeedErrorCode::class) + fun feedErrorCodes() = Unit + + @GetMapping("/notification") + @Operation( + summary = "알림 관련 예외 목록", + description = "알림 조회, 읽음 처리 등 알림 관련 예외 코드를 확인할 수 있습니다.", + ) + @ApiErrorCodeExample(NotificationErrorCode::class) + fun notificationErrorCodes() = Unit + + @GetMapping("/leenk") + @Operation( + summary = "링크 관련 예외 목록", + description = "링크 생성, 참여, 마감, 종료 등 링크 관련 예외 코드를 확인할 수 있습니다.", + ) + @ApiErrorCodeExample(LeenkErrorCode::class) + fun leenkErrorCodes() = Unit + + @GetMapping("/media") + @Operation( + summary = "미디어 관련 예외 목록", + description = "미디어 업로드, 조회 등 미디어 관련 예외 코드를 확인할 수 있습니다.", + ) + @ApiErrorCodeExample(MediaErrorCode::class) + fun mediaErrorCodes() = Unit + + @GetMapping("/birthday") + @Operation( + summary = "생일 관련 예외 목록", + description = "생일 조회, 축하 메시지 전송 등 생일 관련 예외 코드를 확인할 수 있습니다.", + ) + @ApiErrorCodeExample(BirthdayErrorCode::class) + fun birthdayErrorCodes() = Unit + + @GetMapping("/common") + @Operation( + summary = "공통 예외 목록", + description = "서버 에러, 클라이언트 요청 에러 등 공통 예외 코드를 확인할 수 있습니다.", + ) + @ApiErrorCodeExample(CommonErrorCode::class) + fun commonErrorCodes() = Unit +} diff --git a/src/main/kotlin/leets/leenk/global/common/controller/StatusCheckController.kt b/src/main/kotlin/leets/leenk/global/common/controller/StatusCheckController.kt new file mode 100644 index 00000000..2b553938 --- /dev/null +++ b/src/main/kotlin/leets/leenk/global/common/controller/StatusCheckController.kt @@ -0,0 +1,13 @@ +package leets.leenk.global.common.controller + +import io.swagger.v3.oas.annotations.Hidden +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@Hidden +@RestController +class StatusCheckController { + @GetMapping("/health-check") + fun checkHealthStatus(): ResponseEntity = ResponseEntity.ok("OK") +} diff --git a/src/main/kotlin/leets/leenk/global/common/dto/CommonPageableResponse.kt b/src/main/kotlin/leets/leenk/global/common/dto/CommonPageableResponse.kt new file mode 100644 index 00000000..1b4e215a --- /dev/null +++ b/src/main/kotlin/leets/leenk/global/common/dto/CommonPageableResponse.kt @@ -0,0 +1,18 @@ +package leets.leenk.global.common.dto + +import com.fasterxml.jackson.annotation.JsonInclude +import io.swagger.v3.oas.annotations.media.Schema + +@JsonInclude(JsonInclude.Include.NON_NULL) +data class CommonPageableResponse( + @field:Schema(description = "페이지 번호 (0부터 시작)", example = "0") + val pageNumber: Int, + @field:Schema(description = "페이지 크기", example = "10") + val pageSize: Int, + @field:Schema(description = "현재 페이지의 요소 개수", example = "10") + val numberOfElements: Int, + @field:Schema(description = "다음 페이지 존재 여부", example = "true") + val hasNext: Boolean, + @field:Schema(description = "현재 페이지의 요소가 비어 있는지의 여부", example = "false") + val empty: Boolean, +) diff --git a/src/main/kotlin/leets/leenk/global/common/dto/PageableMapperUtil.kt b/src/main/kotlin/leets/leenk/global/common/dto/PageableMapperUtil.kt new file mode 100644 index 00000000..78ef83bc --- /dev/null +++ b/src/main/kotlin/leets/leenk/global/common/dto/PageableMapperUtil.kt @@ -0,0 +1,26 @@ +package leets.leenk.global.common.dto + +import org.springframework.data.domain.Page +import org.springframework.data.domain.Slice + +object PageableMapperUtil { + @JvmStatic + fun from(slice: Slice<*>): CommonPageableResponse = + CommonPageableResponse( + pageNumber = slice.number, + pageSize = slice.size, + numberOfElements = slice.numberOfElements, + hasNext = slice.hasNext(), + empty = slice.isEmpty, + ) + + @JvmStatic + fun from(page: Page<*>): CommonPageableResponse = + CommonPageableResponse( + pageNumber = page.number, + pageSize = page.size, + numberOfElements = page.numberOfElements, + hasNext = page.hasNext(), + empty = page.isEmpty, + ) +} diff --git a/src/main/kotlin/leets/leenk/global/common/exception/ApiErrorCodeExample.kt b/src/main/kotlin/leets/leenk/global/common/exception/ApiErrorCodeExample.kt new file mode 100644 index 00000000..2ff9fb8a --- /dev/null +++ b/src/main/kotlin/leets/leenk/global/common/exception/ApiErrorCodeExample.kt @@ -0,0 +1,9 @@ +package leets.leenk.global.common.exception + +import kotlin.reflect.KClass + +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class ApiErrorCodeExample( + vararg val value: KClass, +) diff --git a/src/main/kotlin/leets/leenk/global/common/exception/BaseException.kt b/src/main/kotlin/leets/leenk/global/common/exception/BaseException.kt new file mode 100644 index 00000000..3dca0699 --- /dev/null +++ b/src/main/kotlin/leets/leenk/global/common/exception/BaseException.kt @@ -0,0 +1,8 @@ +package leets.leenk.global.common.exception + +abstract class BaseException + @JvmOverloads + constructor( + val errorCode: ErrorCodeInterface, + message: String? = null, + ) : RuntimeException(message ?: errorCode.message) diff --git a/src/main/java/leets/leenk/global/common/exception/CommonErrorCode.java b/src/main/kotlin/leets/leenk/global/common/exception/CommonErrorCode.kt similarity index 80% rename from src/main/java/leets/leenk/global/common/exception/CommonErrorCode.java rename to src/main/kotlin/leets/leenk/global/common/exception/CommonErrorCode.kt index 4e09a182..7ab6ed15 100644 --- a/src/main/java/leets/leenk/global/common/exception/CommonErrorCode.java +++ b/src/main/kotlin/leets/leenk/global/common/exception/CommonErrorCode.kt @@ -1,12 +1,12 @@ -package leets.leenk.global.common.exception; +package leets.leenk.global.common.exception -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatus -@Getter -@AllArgsConstructor -public enum CommonErrorCode implements ErrorCodeInterface { +enum class CommonErrorCode( + override val code: Int, + override val status: HttpStatus, + override val message: String, +) : ErrorCodeInterface { // 3000번대: 서버 에러 @ExplainError("예상하지 못한 서버 내부 오류가 발생했을 때 발생합니다.") INTERNAL_SERVER_ERROR(3001, HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류입니다."), @@ -28,9 +28,5 @@ public enum CommonErrorCode implements ErrorCodeInterface { RESOURCE_NOT_FOUND(4003, HttpStatus.NOT_FOUND, "요청하신 리소스를 찾을 수 없습니다."), @ExplainError("해당 엔드포인트에서 지원하지 않는 HTTP 메서드로 요청했을 때 발생합니다.") - METHOD_NOT_ALLOWED(4004, HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP 메서드입니다."); - - private final int code; - private final HttpStatus status; - private final String message; + METHOD_NOT_ALLOWED(4004, HttpStatus.METHOD_NOT_ALLOWED, "지원하지 않는 HTTP 메서드입니다."), } diff --git a/src/main/kotlin/leets/leenk/global/common/exception/ErrorCodeInterface.kt b/src/main/kotlin/leets/leenk/global/common/exception/ErrorCodeInterface.kt new file mode 100644 index 00000000..bd7621e2 --- /dev/null +++ b/src/main/kotlin/leets/leenk/global/common/exception/ErrorCodeInterface.kt @@ -0,0 +1,18 @@ +package leets.leenk.global.common.exception + +import org.springframework.http.HttpStatus +import java.util.* + +interface ErrorCodeInterface { + val code: Int + val status: HttpStatus + val message: String + + // ExplainError 어노테이션에 작성된 설명을 조회하는 메서드 + @Throws(NoSuchFieldException::class) + fun getExplainError(): String { + val field = this.javaClass.getField((this as Enum<*>).name) + val annotation = field.getAnnotation(ExplainError::class.java) + return if (Objects.nonNull(annotation)) annotation!!.value else message + } +} diff --git a/src/main/kotlin/leets/leenk/global/common/exception/ExampleHolder.kt b/src/main/kotlin/leets/leenk/global/common/exception/ExampleHolder.kt new file mode 100644 index 00000000..64369f57 --- /dev/null +++ b/src/main/kotlin/leets/leenk/global/common/exception/ExampleHolder.kt @@ -0,0 +1,9 @@ +package leets.leenk.global.common.exception + +import io.swagger.v3.oas.models.examples.Example + +data class ExampleHolder( + val holder: Example, + val name: String?, + val code: Int, +) diff --git a/src/main/kotlin/leets/leenk/global/common/exception/ExplainError.kt b/src/main/kotlin/leets/leenk/global/common/exception/ExplainError.kt new file mode 100644 index 00000000..95c7423f --- /dev/null +++ b/src/main/kotlin/leets/leenk/global/common/exception/ExplainError.kt @@ -0,0 +1,7 @@ +package leets.leenk.global.common.exception + +@Target(AnnotationTarget.FIELD) +@Retention(AnnotationRetention.RUNTIME) +annotation class ExplainError( + val value: String = "", +) diff --git a/src/main/kotlin/leets/leenk/global/common/exception/GlobalExceptionHandler.kt b/src/main/kotlin/leets/leenk/global/common/exception/GlobalExceptionHandler.kt new file mode 100644 index 00000000..836ec556 --- /dev/null +++ b/src/main/kotlin/leets/leenk/global/common/exception/GlobalExceptionHandler.kt @@ -0,0 +1,83 @@ +package leets.leenk.global.common.exception + +import leets.leenk.global.common.exception.response.ValidErrorResponse +import leets.leenk.global.common.response.CommonResponse +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.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.servlet.resource.NoResourceFoundException + +@RestControllerAdvice +class GlobalExceptionHandler { + @ExceptionHandler(BaseException::class) + fun handleException(e: BaseException): ResponseEntity> { + val errorCode = e.errorCode + val errorMessage = e.message ?: errorCode.message + val body = CommonResponse.error(errorCode, errorMessage) + + return ResponseEntity + .status(errorCode.status) + .body(body) + } + + @ExceptionHandler(MethodArgumentNotValidException::class) + fun handleValidation(e: MethodArgumentNotValidException): ResponseEntity>> { + val commonErrorCode = CommonErrorCode.INVALID_ARGUMENT + val errors = + e.bindingResult.fieldErrors.map { + ValidErrorResponse.of(it.field, it.defaultMessage ?: "Validation failed", it.rejectedValue) + } + + return ResponseEntity + .status(commonErrorCode.status) + .body(CommonResponse.error(commonErrorCode, errors)) + } + + @ExceptionHandler(IllegalArgumentException::class) + fun handleIllegalArgument(e: IllegalArgumentException): ResponseEntity> = + CommonErrorCode.INVALID_ARGUMENT.let { errorCode -> + ResponseEntity.status(errorCode.status).body(CommonResponse.error(errorCode)) + } + + @ExceptionHandler(NoResourceFoundException::class) + fun handleNoResourceFound(e: NoResourceFoundException): ResponseEntity> = + CommonErrorCode.RESOURCE_NOT_FOUND.let { errorCode -> + ResponseEntity.status(errorCode.status).body(CommonResponse.error(errorCode)) + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException::class) + fun handleMethodNotAllowed(e: HttpRequestMethodNotSupportedException): ResponseEntity> = + CommonErrorCode.METHOD_NOT_ALLOWED.let { errorCode -> + ResponseEntity.status(errorCode.status).body(CommonResponse.error(errorCode)) + } + + @ExceptionHandler(HttpMessageNotReadableException::class) + fun handleMessageNotReadable(ex: HttpMessageNotReadableException): ResponseEntity> = + when (val cause = ex.mostSpecificCause) { + is BaseException -> { + ResponseEntity + .status(cause.errorCode.status) + .body(CommonResponse.error(cause.errorCode, ex.message ?: cause.errorCode.message)) + } + + else -> { + val commonErrorCode = CommonErrorCode.JSON_PARSE_ERROR + ResponseEntity + .status(commonErrorCode.status) + .body(CommonResponse.error(commonErrorCode, ex.message ?: commonErrorCode.message)) + } + } + + @ExceptionHandler(Exception::class) + fun handleAll(e: Exception): ResponseEntity> { + val commonErrorCode = CommonErrorCode.INTERNAL_SERVER_ERROR + val body = CommonResponse.error(commonErrorCode, e.message ?: commonErrorCode.message) + + return ResponseEntity + .status(commonErrorCode.status) + .body(body) + } +} diff --git a/src/main/kotlin/leets/leenk/global/common/exception/ResourceLockedException.kt b/src/main/kotlin/leets/leenk/global/common/exception/ResourceLockedException.kt new file mode 100644 index 00000000..5e29ef65 --- /dev/null +++ b/src/main/kotlin/leets/leenk/global/common/exception/ResourceLockedException.kt @@ -0,0 +1,3 @@ +package leets.leenk.global.common.exception + +class ResourceLockedException : BaseException(CommonErrorCode.RESOURCE_LOCKED) diff --git a/src/main/kotlin/leets/leenk/global/common/exception/response/ValidErrorResponse.kt b/src/main/kotlin/leets/leenk/global/common/exception/response/ValidErrorResponse.kt new file mode 100644 index 00000000..dd2c87c7 --- /dev/null +++ b/src/main/kotlin/leets/leenk/global/common/exception/response/ValidErrorResponse.kt @@ -0,0 +1,16 @@ +package leets.leenk.global.common.exception.response + +data class ValidErrorResponse( + val errorField: String, + val errorMessage: String, + val inputValue: Any?, +) { + companion object { + @JvmStatic + fun of( + field: String, + msg: String, + value: Any?, + ): ValidErrorResponse = ValidErrorResponse(field, msg, value) + } +} diff --git a/src/main/kotlin/leets/leenk/global/common/response/CommonResponse.kt b/src/main/kotlin/leets/leenk/global/common/response/CommonResponse.kt new file mode 100644 index 00000000..87af66fa --- /dev/null +++ b/src/main/kotlin/leets/leenk/global/common/response/CommonResponse.kt @@ -0,0 +1,60 @@ +package leets.leenk.global.common.response + +import leets.leenk.global.common.exception.ErrorCodeInterface + +data class CommonResponse( + val code: Int, + val message: String, + val data: T?, +) { + companion object { + @JvmStatic + fun success(responseCode: ResponseCodeInterface): CommonResponse = + CommonResponse( + code = responseCode.code, + message = responseCode.message, + data = null, + ) + + @JvmStatic + fun success( + responseCode: ResponseCodeInterface, + data: T, + ): CommonResponse = + CommonResponse( + code = responseCode.code, + message = responseCode.message, + data = data, + ) + + @JvmStatic + fun error(errorCode: ErrorCodeInterface): CommonResponse = + CommonResponse( + code = errorCode.code, + message = errorCode.message, + data = null, + ) + + @JvmStatic + fun error( + errorCode: ErrorCodeInterface, + message: String, + ): CommonResponse = + CommonResponse( + code = errorCode.code, + message = message, + data = null, + ) + + @JvmStatic + fun error( + errorCode: ErrorCodeInterface, + data: T, + ): CommonResponse = + CommonResponse( + code = errorCode.code, + message = errorCode.message, + data = data, + ) + } +} diff --git a/src/main/kotlin/leets/leenk/global/common/response/ResponseCodeInterface.kt b/src/main/kotlin/leets/leenk/global/common/response/ResponseCodeInterface.kt new file mode 100644 index 00000000..5f3e85f0 --- /dev/null +++ b/src/main/kotlin/leets/leenk/global/common/response/ResponseCodeInterface.kt @@ -0,0 +1,9 @@ +package leets.leenk.global.common.response + +import org.springframework.http.HttpStatus + +interface ResponseCodeInterface { + val code: Int + val status: HttpStatus + val message: String +} diff --git a/src/test/kotlin/leets/leenk/global/common/controller/StatusCheckControllerTest.kt b/src/test/kotlin/leets/leenk/global/common/controller/StatusCheckControllerTest.kt new file mode 100644 index 00000000..d45a0a9f --- /dev/null +++ b/src/test/kotlin/leets/leenk/global/common/controller/StatusCheckControllerTest.kt @@ -0,0 +1,36 @@ +package leets.leenk.global.common.controller + +import com.ninjasquad.springmockk.MockkBean +import io.kotest.core.spec.style.StringSpec +import io.kotest.extensions.spring.SpringExtension +import leets.leenk.global.auth.application.property.OauthProperty +import leets.leenk.global.auth.domain.handler.CustomAccessDeniedHandler +import leets.leenk.global.auth.domain.handler.CustomAuthenticationEntryPoint +import leets.leenk.global.config.PermitUrlConfig +import leets.leenk.global.config.SecurityConfig +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.context.annotation.Import +import org.springframework.security.oauth2.jwt.JwtDecoder +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status + +@WebMvcTest(StatusCheckController::class) +@Import(SecurityConfig::class, PermitUrlConfig::class) +class StatusCheckControllerTest( + private val mockMvc: MockMvc, + @MockkBean private val jwtDecoder: JwtDecoder, + @MockkBean private val oauthProperty: OauthProperty, + @MockkBean private val customAuthenticationEntryPoint: CustomAuthenticationEntryPoint, + @MockkBean private val customAccessDeniedHandler: CustomAccessDeniedHandler, +) : StringSpec({ + extensions(SpringExtension) + + "헬스 체크 요청 시 Security 필터가 활성화된 상태에서 인증 없이 접근 가능해야 한다" { + mockMvc + .perform(get("/health-check")) + .andExpect(status().isOk) + .andExpect(content().string("OK")) + } + }) diff --git a/src/test/kotlin/leets/leenk/global/common/dto/PageableMapperUtilTest.kt b/src/test/kotlin/leets/leenk/global/common/dto/PageableMapperUtilTest.kt new file mode 100644 index 00000000..221ca2a9 --- /dev/null +++ b/src/test/kotlin/leets/leenk/global/common/dto/PageableMapperUtilTest.kt @@ -0,0 +1,55 @@ +package leets.leenk.global.common.dto + +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import org.springframework.data.domain.Page +import org.springframework.data.domain.Slice + +class PageableMapperUtilTest : + DescribeSpec({ + + describe("매퍼와 CommonPageableResponse 객체 간의 예외 케이스를 방지하기 위한 테스트") { + context("Slice 객체가 주어지면") { + val slice = + mockk> { + every { number } returns 2 + every { size } returns 10 + every { numberOfElements } returns 8 + every { hasNext() } returns true + every { isEmpty } returns false + } + it("필드가 올바르게 매핑된다") { + val result = PageableMapperUtil.from(slice) + + result.pageNumber shouldBe 2 + result.pageSize shouldBe 10 + result.numberOfElements shouldBe 8 + result.hasNext shouldBe true + result.empty shouldBe false + } + } + + context("Page 객체가 주어지면") { + val page = + mockk> { + every { number } returns 1 + every { size } returns 20 + every { numberOfElements } returns 15 + every { hasNext() } returns false + every { isEmpty } returns false + } + + it("필드가 올바르게 매핑된다") { + val result = PageableMapperUtil.from(page) + + result.pageNumber shouldBe 1 + result.pageSize shouldBe 20 + result.numberOfElements shouldBe 15 + result.hasNext shouldBe false + result.empty shouldBe false + } + } + } + }) diff --git a/src/test/kotlin/leets/leenk/global/common/exception/BaseExceptionTest.kt b/src/test/kotlin/leets/leenk/global/common/exception/BaseExceptionTest.kt new file mode 100644 index 00000000..f811d1f4 --- /dev/null +++ b/src/test/kotlin/leets/leenk/global/common/exception/BaseExceptionTest.kt @@ -0,0 +1,50 @@ +package leets.leenk.global.common.exception + +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import org.springframework.http.HttpStatus + +// TODO: 코틀린 스타일로 변경 후 이 주석을 제거합니다. +class BaseExceptionTest : + DescribeSpec({ + describe("BaseException") { + context("ErrorCodeInterface만으로 생성 시") { + it("ErrorCode의 메시지를 사용해야 한다") { + val exception = SimpleTestException(CommonErrorCode.INTERNAL_SERVER_ERROR) + + exception.message shouldBe CommonErrorCode.INTERNAL_SERVER_ERROR.message + exception.errorCode shouldBe CommonErrorCode.INTERNAL_SERVER_ERROR + } + } + + context("ErrorCodeInterface와 커스텀 메시지로 생성 시") { + it("커스텀 메시지를 사용해야 한다") { + val customMessage = "커스텀 에러 메시지" + val exception = SimpleTestExceptionWithMessage(CommonErrorCode.INTERNAL_SERVER_ERROR, customMessage) + + exception.message shouldBe customMessage + exception.errorCode shouldBe CommonErrorCode.INTERNAL_SERVER_ERROR + } + } + + context("ErrorCodeInterface의 속성 접근") { + it("ErrorCode의 모든 속성에 접근할 수 있어야 한다") { + val exception = SimpleTestException(CommonErrorCode.INVALID_ARGUMENT) + + exception.errorCode.code shouldBe 4001 + exception.errorCode.status shouldBe HttpStatus.BAD_REQUEST + exception.errorCode.message shouldBe "잘못된 인자입니다." + } + } + } + }) + +// 테스트용 구체 클래스 +private class SimpleTestException( + errorCode: ErrorCodeInterface, +) : BaseException(errorCode) + +private class SimpleTestExceptionWithMessage( + errorCode: ErrorCodeInterface, + message: String, +) : BaseException(errorCode, message) diff --git a/src/test/kotlin/leets/leenk/global/common/exception/GlobalExceptionHandlerTest.kt b/src/test/kotlin/leets/leenk/global/common/exception/GlobalExceptionHandlerTest.kt new file mode 100644 index 00000000..fb167b6f --- /dev/null +++ b/src/test/kotlin/leets/leenk/global/common/exception/GlobalExceptionHandlerTest.kt @@ -0,0 +1,189 @@ +package leets.leenk.global.common.exception + +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.springframework.http.HttpStatus +import org.springframework.http.converter.HttpMessageNotReadableException +import org.springframework.mock.http.MockHttpInputMessage +import org.springframework.validation.FieldError +import org.springframework.web.HttpRequestMethodNotSupportedException +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.servlet.resource.NoResourceFoundException + +class GlobalExceptionHandlerTest : + DescribeSpec({ + val handler = GlobalExceptionHandler() + + describe("GlobalExceptionHandler") { + context("BaseException 처리") { + it("BaseException을 처리하여 에러 응답을 반환해야 한다") { + val exception = HandlerTestException(CommonErrorCode.INTERNAL_SERVER_ERROR) + + val response = handler.handleException(exception) + + response.statusCode shouldBe HttpStatus.INTERNAL_SERVER_ERROR + response.body shouldNotBe null + response.body!!.code shouldBe 3001 + response.body!!.message shouldBe "서버 내부 오류입니다." + response.body!!.data shouldBe null + } + } + + context("BaseException with custom message 처리") { + it("커스텀 메시지를 포함한 에러 응답을 반환해야 한다") { + val customMessage = "커스텀 에러 메시지" + val exception = + HandlerTestExceptionWithMessage(CommonErrorCode.INTERNAL_SERVER_ERROR, customMessage) + + val response = handler.handleException(exception) + + response.statusCode shouldBe HttpStatus.INTERNAL_SERVER_ERROR + response.body shouldNotBe null + response.body!!.code shouldBe 3001 + response.body!!.message shouldBe customMessage + response.body!!.data shouldBe null + } + } + + context("ResourceLockedException 처리") { + it("ResourceLockedException을 처리하여 CONFLICT 응답을 반환해야 한다") { + val exception = ResourceLockedException() + + val response = handler.handleException(exception) + + response.statusCode shouldBe HttpStatus.CONFLICT + response.body shouldNotBe null + response.body!!.code shouldBe 3003 + response.body!!.message shouldBe "다른 사용자가 처리 중입니다. 잠시 후 다시 시도해주세요." + response.body!!.data shouldBe null + } + } + + context("MethodArgumentNotValidException 처리") { + it("validation 에러 목록을 반환해야 한다") { + val fieldError = FieldError("testObject", "name", "", false, null, null, "이름은 필수입니다") + val bindingResult = org.springframework.validation.BeanPropertyBindingResult(Any(), "testObject") + bindingResult.addError(fieldError) + + val exception = + MethodArgumentNotValidException( + org.springframework.core.MethodParameter.forExecutable( + GlobalExceptionHandlerTest::class.java.getDeclaredConstructor(), + -1, + ), + bindingResult, + ) + + val response = handler.handleValidation(exception) + + response.statusCode shouldBe HttpStatus.BAD_REQUEST + response.body shouldNotBe null + response.body!!.code shouldBe 4001 + response.body!!.message shouldBe "잘못된 인자입니다." + response.body!!.data shouldNotBe null + response.body!!.data!!.size shouldBe 1 + response.body!!.data!![0].errorField shouldBe "name" + response.body!!.data!![0].errorMessage shouldBe "이름은 필수입니다" + } + } + + context("IllegalArgumentException 처리") { + it("BAD_REQUEST 응답을 반환해야 한다") { + val exception = IllegalArgumentException("잘못된 인자입니다") + + val response = handler.handleIllegalArgument(exception) + + response.statusCode shouldBe HttpStatus.BAD_REQUEST + response.body shouldNotBe null + response.body!!.code shouldBe 4001 + response.body!!.message shouldBe "잘못된 인자입니다." + response.body!!.data shouldBe null + } + } + + context("NoResourceFoundException 처리") { + it("NOT_FOUND 응답을 반환해야 한다") { + val exception = NoResourceFoundException(org.springframework.http.HttpMethod.GET, "/api/test") + val response = handler.handleNoResourceFound(exception) + + response.statusCode shouldBe HttpStatus.NOT_FOUND + response.body shouldNotBe null + response.body!!.code shouldBe 4003 + response.body!!.message shouldBe "요청하신 리소스를 찾을 수 없습니다." + response.body!!.data shouldBe null + } + } + + context("HttpRequestMethodNotSupportedException 처리") { + it("METHOD_NOT_ALLOWED 응답을 반환해야 한다") { + val exception = HttpRequestMethodNotSupportedException("POST") + + val response = handler.handleMethodNotAllowed(exception) + + response.statusCode.value() shouldBe 405 + response.body shouldNotBe null + response.body!!.code shouldBe 4004 + response.body!!.message shouldBe "지원하지 않는 HTTP 메서드입니다." + } + } + + context("HttpMessageNotReadableException 처리") { + it("JSON_PARSE_ERROR 응답을 반환해야 한다") { + val exception = + HttpMessageNotReadableException( + "JSON parse error", + MockHttpInputMessage(ByteArray(0)), + ) + + val response = handler.handleMessageNotReadable(exception) + + response.statusCode shouldBe HttpStatus.BAD_REQUEST + response.body shouldNotBe null + response.body!!.code shouldBe 4002 + response.body!!.message shouldNotBe null + } + } + + context("HttpMessageNotReadableException with BaseException cause 처리") { + it("cause의 ErrorCode를 사용해야 한다") { + val cause = HandlerTestException(CommonErrorCode.INVALID_ARGUMENT) + val exception = + HttpMessageNotReadableException( + "JSON parse error", + cause, + MockHttpInputMessage(ByteArray(0)), + ) + + val response = handler.handleMessageNotReadable(exception) + + response.statusCode shouldBe HttpStatus.BAD_REQUEST + response.body shouldNotBe null + response.body!!.code shouldBe 4001 + } + } + + context("일반 Exception 처리") { + it("INTERNAL_SERVER_ERROR 응답을 반환해야 한다") { + val exception = RuntimeException("예상치 못한 에러") + + val response = handler.handleAll(exception) + + response.statusCode shouldBe HttpStatus.INTERNAL_SERVER_ERROR + response.body shouldNotBe null + response.body!!.code shouldBe 3001 + response.body!!.message shouldBe "예상치 못한 에러" + } + } + } + }) + +// 테스트용 예외 클래스 +internal class HandlerTestException( + errorCode: ErrorCodeInterface, +) : BaseException(errorCode) + +internal class HandlerTestExceptionWithMessage( + errorCode: ErrorCodeInterface, + message: String, +) : BaseException(errorCode, message) diff --git a/src/test/kotlin/leets/leenk/global/common/exception/ResourceLockedExceptionTest.kt b/src/test/kotlin/leets/leenk/global/common/exception/ResourceLockedExceptionTest.kt new file mode 100644 index 00000000..8f387a90 --- /dev/null +++ b/src/test/kotlin/leets/leenk/global/common/exception/ResourceLockedExceptionTest.kt @@ -0,0 +1,22 @@ +package leets.leenk.global.common.exception + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import org.springframework.http.HttpStatus + +class ResourceLockedExceptionTest : + StringSpec({ + "ResourceLockedException은 RESOURCE_LOCKED 에러 코드를 사용해야 한다" { + val exception = ResourceLockedException() + + exception.errorCode shouldBe CommonErrorCode.RESOURCE_LOCKED + exception.errorCode.code shouldBe 3003 + exception.errorCode.status shouldBe HttpStatus.CONFLICT + } + + "ResourceLockedException은 BaseException의 인스턴스이어야 한다" { + val exception = ResourceLockedException() + + assert(exception is BaseException) + } + }) diff --git a/src/test/kotlin/leets/leenk/global/common/exception/response/ValidErrorResponseTest.kt b/src/test/kotlin/leets/leenk/global/common/exception/response/ValidErrorResponseTest.kt new file mode 100644 index 00000000..e10bbccb --- /dev/null +++ b/src/test/kotlin/leets/leenk/global/common/exception/response/ValidErrorResponseTest.kt @@ -0,0 +1,64 @@ +package leets.leenk.global.common.exception.response + +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe + +// TODO: 코틀린 스타일로 변경 후 이 주석을 제거합니다. +class ValidErrorResponseTest : + DescribeSpec({ + describe("ValidErrorResponse") { + context("팩토리 메서드로 생성 시") { + it("필드 정보를 포함한 에러 응답을 생성해야 한다") { + val field = "email" + val message = "이메일 형식이 올바르지 않습니다" + val value = "invalid-email" + + val response = ValidErrorResponse.of(field, message, value) + + response.errorField shouldBe field + response.errorMessage shouldBe message + response.inputValue shouldBe value + } + } + + context("직접 생성 시") { + it("모든 필드가 올바르게 설정되어야 한다") { + val response = + ValidErrorResponse( + "password", + "비밀번호는 8자 이상이어야 합니다", + "123", + ) + + response.errorField shouldBe "password" + response.errorMessage shouldBe "비밀번호는 8자 이상이어야 합니다" + response.inputValue shouldBe "123" + } + } + + context("null 값 처리") { + it("inputValue가 null이어도 정상적으로 처리해야 한다") { + val response = ValidErrorResponse.of("username", "필수 입력 항목입니다", null) + + response.errorField shouldBe "username" + response.errorMessage shouldBe "필수 입력 항목입니다" + response.inputValue shouldBe null + } + } + + context("다양한 타입의 inputValue") { + it("정수 값을 inputValue로 가질 수 있어야 한다") { + val response = ValidErrorResponse.of("age", "나이는 18세 이상이어야 합니다", 15) + + response.inputValue shouldBe 15 + } + + it("리스트를 inputValue로 가질 수 있어야 한다") { + val list = listOf("a", "b") + val response = ValidErrorResponse.of("tags", "태그는 최대 5개까지 가능합니다", list) + + response.inputValue shouldBe list + } + } + } + })