From 49a808e06bb73ebcf7ba4bd814b73ad3b4e6ef62 Mon Sep 17 00:00:00 2001 From: pricelees Date: Thu, 24 Jul 2025 02:48:52 +0000 Subject: [PATCH] =?UTF-8?q?[#20]=20=EB=8F=84=EB=A9=94=EC=9D=B8=EB=B3=84=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EB=B6=84=EB=A6=AC=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 📝 관련 이슈 및 PR **PR과 관련된 이슈 번호** - #20 ## ✨ 작업 내용 - 기존에 공통으로 사용하던 커스텀 예외를 도메인별로 분리 - 새로 정의된 예외는 ErrorCode를 지정할 때 HttpStatusCode를 지정하도록 하여 잘못된 지정에서 오는 휴먼 에러 방지 - 커스텀 예외를 적용하는 과정에서 가독성이 낮다고 느껴지는 일부 함수형 코드는 선언형으로 수정 ## 🧪 테스트 - 테스트 중 일부 미비된 테스트 보완 후 전체 로직 테스트 완료 ## 📚 참고 자료 및 기타 커스텀 예외도 작업이 길어져 로깅은 이후에 진행할 예정 Reviewed-on: https://gitea.pricelees.me/pricelees/roomescape-refactored/pulls/21 Co-authored-by: pricelees Co-committed-by: pricelees --- build.gradle.kts | 67 ++++---- .../auth/exception/AuthErrorCode.kt | 17 ++ .../auth/exception/AuthException.kt | 8 + .../auth/infrastructure/jwt/JwtHandler.kt | 56 ++++--- .../roomescape/auth/service/AuthService.kt | 24 ++- .../kotlin/roomescape/auth/web/AuthDTO.kt | 2 +- .../auth/web/support/AuthInterceptor.kt | 52 ++++++ .../auth/web/support/AuthInterceptors.kt | 90 ----------- .../auth/web/support/MemberIdResolver.kt | 1 - .../roomescape/common/config/WebMvcConfig.kt | 9 +- .../common/dto/response/CommonApiResponse.kt | 13 +- .../common/exception/CommonErrorCode.kt | 20 +++ .../roomescape/common/exception/ErrorCode.kt | 9 ++ .../roomescape/common/exception/ErrorType.kt | 53 ------- .../exception/ExceptionControllerAdvice.kt | 30 ++-- .../common/exception/RoomescapeException.kt | 13 +- .../member/business/MemberService.kt | 34 ++-- .../member/exception/MemberErrorCode.kt | 12 ++ .../member/exception/MemberException.kt | 8 + .../kotlin/roomescape/member/web/MemberDTO.kt | 4 +- .../payment/business/PaymentService.kt | 86 +++++----- .../payment/exception/PaymentErrorCode.kt | 16 ++ .../payment/exception/PaymentException.kt | 8 + .../PaymentCancelResponseDeserializer.kt | 2 - .../client/TossPaymentClient.kt | 39 ++--- .../business/ReservationService.kt | 114 +++++++------- .../business/ReservationWithPaymentService.kt | 2 +- .../exception/ReservationErrorCode.kt | 20 +++ .../exception/ReservationException.kt | 9 ++ .../persistence/ReservationEntity.kt | 3 +- .../persistence/ReservationRepository.kt | 5 +- .../ReservationSearchSpecification.kt | 1 + .../reservation/web/ReservationController.kt | 3 +- .../reservation/web/ReservationRequest.kt | 8 +- .../reservation/web/ReservationResponse.kt | 14 +- .../roomescape/theme/business/ThemeService.kt | 44 ++---- .../kotlin/roomescape/theme/docs/ThemeAPI.kt | 14 +- .../theme/exception/ThemeErrorCode.kt | 14 ++ .../theme/exception/ThemeException.kt | 8 + .../roomescape/theme/web/ThemeController.kt | 14 +- .../kotlin/roomescape/theme/web/ThemeDTO.kt | 40 ++--- .../business/TimeService.kt | 53 +++---- .../{reservation => time}/docs/TimeAPI.kt | 12 +- .../time/exception/TimeErrorCode.kt | 14 ++ .../time/exception/TimeException.kt | 9 ++ .../infrastructure/persistence/TimeEntity.kt | 2 +- .../persistence/TimeRepository.kt | 2 +- .../web/TimeController.kt | 6 +- .../{reservation => time}/web/TimeDTO.kt | 28 ++-- src/main/resources/application.yaml | 3 +- .../auth/business/AuthServiceTest.kt | 12 +- .../auth/infrastructure/jwt/JwtHandlerTest.kt | 26 +-- .../roomescape/auth/web/AuthControllerTest.kt | 62 ++++---- .../payment/business/PaymentServiceTest.kt | 34 ++-- .../client/TossPaymentClientTest.kt | 77 ++++----- .../business/ReservationServiceTest.kt | 148 +++++++++++++++--- .../ReservationWithPaymentServiceTest.kt | 2 +- .../reservation/business/TimeServiceTest.kt | 87 ---------- .../persistence/ReservationRepositoryTest.kt | 6 +- .../ReservationSearchSpecificationTest.kt | 1 + .../web/ReservationControllerTest.kt | 125 +++++++++------ .../theme/business/ThemeServiceTest.kt | 66 +++++--- .../theme/util/TestThemeCreateUtil.kt | 2 +- .../theme/web/ThemeControllerTest.kt | 35 +++-- .../time/business/TimeServiceTest.kt | 109 +++++++++++++ .../persistence/TimeRepositoryTest.kt | 4 +- .../web/TimeControllerTest.kt | 35 +++-- src/test/kotlin/roomescape/util/Fixtures.kt | 6 +- .../roomescape/util/RoomescapeApiTest.kt | 15 +- src/test/resources/application.yaml | 3 +- 70 files changed, 1084 insertions(+), 886 deletions(-) create mode 100644 src/main/kotlin/roomescape/auth/exception/AuthErrorCode.kt create mode 100644 src/main/kotlin/roomescape/auth/exception/AuthException.kt create mode 100644 src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt delete mode 100644 src/main/kotlin/roomescape/auth/web/support/AuthInterceptors.kt create mode 100644 src/main/kotlin/roomescape/common/exception/CommonErrorCode.kt create mode 100644 src/main/kotlin/roomescape/common/exception/ErrorCode.kt delete mode 100644 src/main/kotlin/roomescape/common/exception/ErrorType.kt create mode 100644 src/main/kotlin/roomescape/member/exception/MemberErrorCode.kt create mode 100644 src/main/kotlin/roomescape/member/exception/MemberException.kt create mode 100644 src/main/kotlin/roomescape/payment/exception/PaymentErrorCode.kt create mode 100644 src/main/kotlin/roomescape/payment/exception/PaymentException.kt create mode 100644 src/main/kotlin/roomescape/reservation/exception/ReservationErrorCode.kt create mode 100644 src/main/kotlin/roomescape/reservation/exception/ReservationException.kt create mode 100644 src/main/kotlin/roomescape/theme/exception/ThemeErrorCode.kt create mode 100644 src/main/kotlin/roomescape/theme/exception/ThemeException.kt rename src/main/kotlin/roomescape/{reservation => time}/business/TimeService.kt (52%) rename src/main/kotlin/roomescape/{reservation => time}/docs/TimeAPI.kt (89%) create mode 100644 src/main/kotlin/roomescape/time/exception/TimeErrorCode.kt create mode 100644 src/main/kotlin/roomescape/time/exception/TimeException.kt rename src/main/kotlin/roomescape/{reservation => time}/infrastructure/persistence/TimeEntity.kt (80%) rename src/main/kotlin/roomescape/{reservation => time}/infrastructure/persistence/TimeRepository.kt (78%) rename src/main/kotlin/roomescape/{reservation => time}/web/TimeController.kt (92%) rename src/main/kotlin/roomescape/{reservation => time}/web/TimeDTO.kt (54%) delete mode 100644 src/test/kotlin/roomescape/reservation/business/TimeServiceTest.kt create mode 100644 src/test/kotlin/roomescape/time/business/TimeServiceTest.kt rename src/test/kotlin/roomescape/{reservation => time}/infrastructure/persistence/TimeRepositoryTest.kt (93%) rename src/test/kotlin/roomescape/{reservation => time}/web/TimeControllerTest.kt (88%) diff --git a/build.gradle.kts b/build.gradle.kts index 7598679a..5b4be923 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,12 +1,11 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { val springBootVersion = "3.5.3" val kotlinVersion = "2.2.0" - java id("org.springframework.boot") version springBootVersion id("io.spring.dependency-management") version "1.1.7" - - //kotlin plugins kotlin("jvm") version kotlinVersion kotlin("plugin.spring") version kotlinVersion kotlin("plugin.jpa") version kotlinVersion @@ -22,61 +21,59 @@ java { } } +kapt { + keepJavacAnnotationProcessors = true +} + repositories { mavenCentral() } dependencies { - // spring + // Spring implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-thymeleaf") implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-validation") - implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0") + + // API docs + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9") + + // DB runtimeOnly("com.h2database:h2") - // jwt - implementation("io.jsonwebtoken:jjwt:0.9.1") - implementation("javax.xml.bind:jaxb-api:2.3.1") + // Jwt + implementation("io.jsonwebtoken:jjwt:0.12.6") - // kotlin + // Kotlin implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("io.github.oshai:kotlin-logging-jvm:7.0.3") - // test + // Test + testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("io.mockk:mockk:1.14.4") + testImplementation("com.ninja-squad:springmockk:4.0.2") + + // Kotest testImplementation("io.kotest:kotest-runner-junit5:5.9.1") testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0") - testImplementation("com.ninja-squad:springmockk:4.0.2") - testImplementation("org.springframework.boot:spring-boot-starter-test") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") - testImplementation("io.rest-assured:rest-assured:5.3.1") + + // RestAssured + testImplementation("io.rest-assured:rest-assured:5.5.5") testImplementation("io.rest-assured:kotlin-extensions:5.5.5") } -kapt { - keepJavacAnnotationProcessors = true -} - -tasks.withType().configureEach { +tasks.withType { useJUnitPlatform() } -tasks { - compileKotlin { - compilerOptions { - freeCompilerArgs.add("-Xjsr305=strict") - jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) - freeCompilerArgs.set(listOf("-Xannotation-default-target=param-property")) - - } +tasks.withType { + compilerOptions { + freeCompilerArgs.addAll( + "-Xjsr305=strict", + "-Xannotation-default-target=param-property" + ) + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } - - compileTestKotlin { - compilerOptions { - jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) - freeCompilerArgs.set(listOf("-Xannotation-default-target=param-property")) - } - } -} \ No newline at end of file +} diff --git a/src/main/kotlin/roomescape/auth/exception/AuthErrorCode.kt b/src/main/kotlin/roomescape/auth/exception/AuthErrorCode.kt new file mode 100644 index 00000000..21318e51 --- /dev/null +++ b/src/main/kotlin/roomescape/auth/exception/AuthErrorCode.kt @@ -0,0 +1,17 @@ +package roomescape.auth.exception + +import org.springframework.http.HttpStatus +import roomescape.common.exception.ErrorCode + +enum class AuthErrorCode( + override val httpStatus: HttpStatus, + override val errorCode: String, + override val message: String, +) : ErrorCode { + TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "A001", "인증 토큰이 없어요."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "A002", "유효하지 않은 토큰이에요."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "A003", "토큰이 만료됐어요."), + ACCESS_DENIED(HttpStatus.FORBIDDEN, "A004", "접근 권한이 없어요."), + LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "A005", "이메일과 비밀번호를 확인해주세요."), + UNIDENTIFIABLE_MEMBER(HttpStatus.UNAUTHORIZED, "A006", "회원 정보를 찾을 수 없어요."), +} diff --git a/src/main/kotlin/roomescape/auth/exception/AuthException.kt b/src/main/kotlin/roomescape/auth/exception/AuthException.kt new file mode 100644 index 00000000..97fb8410 --- /dev/null +++ b/src/main/kotlin/roomescape/auth/exception/AuthException.kt @@ -0,0 +1,8 @@ +package roomescape.auth.exception + +import roomescape.common.exception.RoomescapeException + +class AuthException( + override val errorCode: AuthErrorCode, + override val message: String = errorCode.message +) : RoomescapeException(errorCode, message) diff --git a/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtHandler.kt b/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtHandler.kt index 649fe125..a3b89cf3 100644 --- a/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtHandler.kt +++ b/src/main/kotlin/roomescape/auth/infrastructure/jwt/JwtHandler.kt @@ -1,50 +1,56 @@ package roomescape.auth.infrastructure.jwt -import io.jsonwebtoken.* +import io.jsonwebtoken.ExpiredJwtException +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys import org.springframework.beans.factory.annotation.Value -import org.springframework.http.HttpStatus import org.springframework.stereotype.Component -import roomescape.common.exception.ErrorType -import roomescape.common.exception.RoomescapeException +import roomescape.auth.exception.AuthErrorCode +import roomescape.auth.exception.AuthException import java.util.* +import javax.crypto.SecretKey @Component class JwtHandler( @Value("\${security.jwt.token.secret-key}") - private val secretKey: String, + private val secretKeyString: String, - @Value("\${security.jwt.token.access.expire-length}") - private val accessTokenExpireTime: Long + @Value("\${security.jwt.token.ttl-seconds}") + private val tokenTtlSeconds: Long ) { + private val secretKey: SecretKey = Keys.hmacShaKeyFor(secretKeyString.toByteArray()) + fun createToken(memberId: Long): String { val date = Date() - val accessTokenExpiredAt = Date(date.time + accessTokenExpireTime) + val accessTokenExpiredAt = Date(date.time + tokenTtlSeconds) return Jwts.builder() - .claim("memberId", memberId) - .setIssuedAt(date) - .setExpiration(accessTokenExpiredAt) - .signWith(SignatureAlgorithm.HS256, secretKey.toByteArray()) + .claim(MEMBER_ID_CLAIM_KEY, memberId) + .issuedAt(date) + .expiration(accessTokenExpiredAt) + .signWith(secretKey) .compact() } fun getMemberIdFromToken(token: String?): Long { try { return Jwts.parser() - .setSigningKey(secretKey.toByteArray()) - .parseClaimsJws(token) - .getBody() - .get("memberId", Number::class.java) + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .payload + .get(MEMBER_ID_CLAIM_KEY, Number::class.java) .toLong() - } catch (e: Exception) { - when (e) { - is ExpiredJwtException -> throw RoomescapeException(ErrorType.EXPIRED_TOKEN, HttpStatus.UNAUTHORIZED) - is UnsupportedJwtException -> throw RoomescapeException(ErrorType.UNSUPPORTED_TOKEN, HttpStatus.UNAUTHORIZED) - is MalformedJwtException -> throw RoomescapeException(ErrorType.MALFORMED_TOKEN, HttpStatus.UNAUTHORIZED) - is SignatureException -> throw RoomescapeException(ErrorType.INVALID_SIGNATURE_TOKEN, HttpStatus.UNAUTHORIZED) - is IllegalArgumentException -> throw RoomescapeException(ErrorType.INVALID_TOKEN, HttpStatus.UNAUTHORIZED) - else -> throw RoomescapeException(ErrorType.UNEXPECTED_ERROR, HttpStatus.INTERNAL_SERVER_ERROR) - } + } catch (_: IllegalArgumentException) { + throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND) + } catch (_: ExpiredJwtException) { + throw AuthException(AuthErrorCode.EXPIRED_TOKEN) + } catch (_: Exception) { + throw AuthException(AuthErrorCode.INVALID_TOKEN) } } + + companion object { + private const val MEMBER_ID_CLAIM_KEY = "memberId" + } } diff --git a/src/main/kotlin/roomescape/auth/service/AuthService.kt b/src/main/kotlin/roomescape/auth/service/AuthService.kt index ce3d0e81..2c07146b 100644 --- a/src/main/kotlin/roomescape/auth/service/AuthService.kt +++ b/src/main/kotlin/roomescape/auth/service/AuthService.kt @@ -1,6 +1,8 @@ package roomescape.auth.service import org.springframework.stereotype.Service +import roomescape.auth.exception.AuthErrorCode +import roomescape.auth.exception.AuthException import roomescape.auth.infrastructure.jwt.JwtHandler import roomescape.auth.web.LoginCheckResponse import roomescape.auth.web.LoginRequest @@ -14,10 +16,9 @@ class AuthService( private val jwtHandler: JwtHandler ) { fun login(request: LoginRequest): LoginResponse { - val member: MemberEntity = memberService.findByEmailAndPassword( - request.email, - request.password - ) + val member: MemberEntity = fetchMemberOrThrow(AuthErrorCode.LOGIN_FAILED) { + memberService.findByEmailAndPassword(request.email, request.password) + } val accessToken: String = jwtHandler.createToken(member.id!!) @@ -25,8 +26,21 @@ class AuthService( } fun checkLogin(memberId: Long): LoginCheckResponse { - val member = memberService.findById(memberId) + val member: MemberEntity = fetchMemberOrThrow(AuthErrorCode.UNIDENTIFIABLE_MEMBER) { + memberService.findById(memberId) + } return LoginCheckResponse(member.name) } + + private fun fetchMemberOrThrow( + errorCode: AuthErrorCode, + block: () -> MemberEntity + ): MemberEntity { + try { + return block() + } catch (_: Exception) { + throw AuthException(errorCode) + } + } } diff --git a/src/main/kotlin/roomescape/auth/web/AuthDTO.kt b/src/main/kotlin/roomescape/auth/web/AuthDTO.kt index 8b910d01..6910fe44 100644 --- a/src/main/kotlin/roomescape/auth/web/AuthDTO.kt +++ b/src/main/kotlin/roomescape/auth/web/AuthDTO.kt @@ -9,7 +9,7 @@ data class LoginResponse( ) data class LoginCheckResponse( - @field:Schema(description = "로그인된 회원의 이름") + @Schema(description = "로그인된 회원의 이름") val name: String ) diff --git a/src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt b/src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt new file mode 100644 index 00000000..c7b92706 --- /dev/null +++ b/src/main/kotlin/roomescape/auth/web/support/AuthInterceptor.kt @@ -0,0 +1,52 @@ +package roomescape.auth.web.support + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.stereotype.Component +import org.springframework.web.method.HandlerMethod +import org.springframework.web.servlet.HandlerInterceptor +import roomescape.auth.exception.AuthErrorCode +import roomescape.auth.exception.AuthException +import roomescape.auth.infrastructure.jwt.JwtHandler +import roomescape.member.business.MemberService +import roomescape.member.infrastructure.persistence.MemberEntity + +@Component +class AuthInterceptor( + private val memberService: MemberService, + private val jwtHandler: JwtHandler +) : HandlerInterceptor { + override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean { + if (handler !is HandlerMethod) { + return true + } + + val loginRequired = handler.getMethodAnnotation(LoginRequired::class.java) + val admin = handler.getMethodAnnotation(Admin::class.java) + + if (loginRequired == null && admin == null) { + return true + } + + val member: MemberEntity = findMember(request, response) + + if (admin != null && !member.isAdmin()) { + response.sendRedirect("/login") + throw AuthException(AuthErrorCode.ACCESS_DENIED) + } + + return true + } + + private fun findMember(request: HttpServletRequest, response: HttpServletResponse): MemberEntity { + try { + val token: String? = request.accessTokenCookie().value + val memberId: Long = jwtHandler.getMemberIdFromToken(token) + + return memberService.findById(memberId) + } catch (e: Exception) { + response.sendRedirect("/login") + throw e + } + } +} diff --git a/src/main/kotlin/roomescape/auth/web/support/AuthInterceptors.kt b/src/main/kotlin/roomescape/auth/web/support/AuthInterceptors.kt deleted file mode 100644 index 42c6e958..00000000 --- a/src/main/kotlin/roomescape/auth/web/support/AuthInterceptors.kt +++ /dev/null @@ -1,90 +0,0 @@ -package roomescape.auth.web.support - -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse -import org.springframework.http.HttpStatus -import org.springframework.stereotype.Component -import org.springframework.web.method.HandlerMethod -import org.springframework.web.servlet.HandlerInterceptor -import roomescape.auth.infrastructure.jwt.JwtHandler -import roomescape.common.exception.ErrorType -import roomescape.common.exception.RoomescapeException -import roomescape.member.business.MemberService -import roomescape.member.infrastructure.persistence.MemberEntity - -private fun Any.isIrrelevantWith(annotationType: Class): Boolean { - if (this !is HandlerMethod) { - return true - } - return !this.hasMethodAnnotation(annotationType) -} - -@Component -class LoginInterceptor( - private val memberService: MemberService, - private val jwtHandler: JwtHandler -) : HandlerInterceptor { - - @Throws(Exception::class) - override fun preHandle( - request: HttpServletRequest, - response: HttpServletResponse, - handler: Any - ): Boolean { - if (handler.isIrrelevantWith(LoginRequired::class.java)) { - return true - } - - try { - val token: String? = request.accessTokenCookie().value - val memberId: Long = jwtHandler.getMemberIdFromToken(token) - - return memberService.existsById(memberId) - } catch (_: RoomescapeException) { - response.sendRedirect("/login") - throw RoomescapeException(ErrorType.LOGIN_REQUIRED, HttpStatus.FORBIDDEN) - } - } -} - -@Component -class AdminInterceptor( - private val memberService: MemberService, - private val jwtHandler: JwtHandler -) : HandlerInterceptor { - - @Throws(Exception::class) - override fun preHandle( - request: HttpServletRequest, - response: HttpServletResponse, - handler: Any - ): Boolean { - if (handler.isIrrelevantWith(Admin::class.java)) { - return true - } - - val member: MemberEntity? - - try { - val token: String? = request.accessTokenCookie().value - val memberId: Long = jwtHandler.getMemberIdFromToken(token) - member = memberService.findById(memberId) - } catch (_: RoomescapeException) { - response.sendRedirect("/login") - throw RoomescapeException(ErrorType.LOGIN_REQUIRED, HttpStatus.FORBIDDEN) - } - - with(member) { - if (this.isAdmin()) { - return true - } - - response.sendRedirect("/login") - throw RoomescapeException( - ErrorType.PERMISSION_DOES_NOT_EXIST, - String.format("[memberId: %d, Role: %s]", this.id, this.role), - HttpStatus.FORBIDDEN - ) - } - } -} diff --git a/src/main/kotlin/roomescape/auth/web/support/MemberIdResolver.kt b/src/main/kotlin/roomescape/auth/web/support/MemberIdResolver.kt index dfd07804..57fec5ef 100644 --- a/src/main/kotlin/roomescape/auth/web/support/MemberIdResolver.kt +++ b/src/main/kotlin/roomescape/auth/web/support/MemberIdResolver.kt @@ -18,7 +18,6 @@ class MemberIdResolver( return parameter.hasParameterAnnotation(MemberId::class.java) } - @Throws(Exception::class) override fun resolveArgument( parameter: MethodParameter, mavContainer: ModelAndViewContainer?, diff --git a/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt b/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt index 2e1c2286..54739590 100644 --- a/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt +++ b/src/main/kotlin/roomescape/common/config/WebMvcConfig.kt @@ -4,15 +4,13 @@ import org.springframework.context.annotation.Configuration import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.servlet.config.annotation.InterceptorRegistry import org.springframework.web.servlet.config.annotation.WebMvcConfigurer -import roomescape.auth.web.support.AdminInterceptor -import roomescape.auth.web.support.LoginInterceptor +import roomescape.auth.web.support.AuthInterceptor import roomescape.auth.web.support.MemberIdResolver @Configuration class WebMvcConfig( private val memberIdResolver: MemberIdResolver, - private val adminInterceptor: AdminInterceptor, - private val loginInterceptor: LoginInterceptor + private val authInterceptor: AuthInterceptor ) : WebMvcConfigurer { override fun addArgumentResolvers(resolvers: MutableList) { @@ -20,7 +18,6 @@ class WebMvcConfig( } override fun addInterceptors(registry: InterceptorRegistry) { - registry.addInterceptor(adminInterceptor) - registry.addInterceptor(loginInterceptor) + registry.addInterceptor(authInterceptor) } } diff --git a/src/main/kotlin/roomescape/common/dto/response/CommonApiResponse.kt b/src/main/kotlin/roomescape/common/dto/response/CommonApiResponse.kt index 6d589727..1c9e1f17 100644 --- a/src/main/kotlin/roomescape/common/dto/response/CommonApiResponse.kt +++ b/src/main/kotlin/roomescape/common/dto/response/CommonApiResponse.kt @@ -1,7 +1,7 @@ package roomescape.common.dto.response import com.fasterxml.jackson.annotation.JsonInclude -import roomescape.common.exception.ErrorType +import roomescape.common.exception.ErrorCode @JsonInclude(JsonInclude.Include.NON_NULL) data class CommonApiResponse( @@ -9,6 +9,11 @@ data class CommonApiResponse( ) data class CommonErrorResponse( - val errorType: ErrorType, - val message: String? = errorType.description -) + val code: String, + val message: String +) { + constructor(errorCode: ErrorCode, message: String = errorCode.message) : this( + code = errorCode.errorCode, + message = message + ) +} diff --git a/src/main/kotlin/roomescape/common/exception/CommonErrorCode.kt b/src/main/kotlin/roomescape/common/exception/CommonErrorCode.kt new file mode 100644 index 00000000..b69fc022 --- /dev/null +++ b/src/main/kotlin/roomescape/common/exception/CommonErrorCode.kt @@ -0,0 +1,20 @@ +package roomescape.common.exception + +import org.springframework.http.HttpStatus + +enum class CommonErrorCode( + override val httpStatus: HttpStatus, + override val errorCode: String, + override val message: String, +) : ErrorCode { + INVALID_INPUT_VALUE( + httpStatus = HttpStatus.BAD_REQUEST, + errorCode = "C001", + message = "요청 값이 잘못되었어요." + ), + UNEXPECTED_SERVER_ERROR( + httpStatus = HttpStatus.INTERNAL_SERVER_ERROR, + errorCode = "C999", + message = "서버에 예상치 못한 오류가 발생했어요. 관리자에게 문의해주세요.", + ), +} diff --git a/src/main/kotlin/roomescape/common/exception/ErrorCode.kt b/src/main/kotlin/roomescape/common/exception/ErrorCode.kt new file mode 100644 index 00000000..1f08d3b5 --- /dev/null +++ b/src/main/kotlin/roomescape/common/exception/ErrorCode.kt @@ -0,0 +1,9 @@ +package roomescape.common.exception + +import org.springframework.http.HttpStatus + +interface ErrorCode { + val httpStatus: HttpStatus + val errorCode: String + val message: String +} diff --git a/src/main/kotlin/roomescape/common/exception/ErrorType.kt b/src/main/kotlin/roomescape/common/exception/ErrorType.kt deleted file mode 100644 index d002f14d..00000000 --- a/src/main/kotlin/roomescape/common/exception/ErrorType.kt +++ /dev/null @@ -1,53 +0,0 @@ -package roomescape.common.exception - -enum class ErrorType( - val description: String -) { - // 400 Bad Request - REQUEST_DATA_BLANK("요청 데이터에 유효하지 않은 값(null OR 공백)이 포함되어있습니다."), - INVALID_REQUEST_DATA_TYPE("요청 데이터 형식이 올바르지 않습니다."), - INVALID_REQUEST_DATA("요청 데이터 값이 올바르지 않습니다."), - INVALID_DATE_RANGE("종료 날짜는 시작 날짜 이전일 수 없습니다."), - HAS_RESERVATION_OR_WAITING("같은 테마에 대한 예약(대기)는 한 번만 가능합니다."), - - // 401 Unauthorized - EXPIRED_TOKEN("토큰이 만료되었습니다. 다시 로그인 해주세요."), - UNSUPPORTED_TOKEN("지원하지 않는 JWT 토큰입니다."), - MALFORMED_TOKEN("형식이 맞지 않는 JWT 토큰입니다."), - INVALID_SIGNATURE_TOKEN("잘못된 JWT 토큰 Signature 입니다."), - ILLEGAL_TOKEN("JWT 토큰의 Claim 이 비어있습니다."), - INVALID_TOKEN("JWT 토큰이 존재하지 않거나 유효하지 않습니다."), - NOT_EXIST_COOKIE("쿠키가 존재하지 않습니다. 로그인이 필요한 서비스입니다."), - - // 403 Forbidden - LOGIN_REQUIRED("로그인이 필요한 서비스입니다."), - PERMISSION_DOES_NOT_EXIST("접근 권한이 존재하지 않습니다."), - - // 404 Not Found - MEMBER_NOT_FOUND("회원(Member) 정보가 존재하지 않습니다."), - RESERVATION_NOT_FOUND("예약(Reservation) 정보가 존재하지 않습니다."), - TIME_NOT_FOUND("예약 시간(Time) 정보가 존재하지 않습니다."), - THEME_NOT_FOUND("테마(Theme) 정보가 존재하지 않습니다."), - PAYMENT_NOT_FOUND("결제(Payment) 정보가 존재하지 않습니다."), - - // 405 Method Not Allowed - METHOD_NOT_ALLOWED("지원하지 않는 HTTP Method 입니다."), - - // 409 Conflict - TIME_IS_USED_CONFLICT("삭제할 수 없는 시간대입니다. 예약이 존재하는지 확인해주세요."), - THEME_IS_USED_CONFLICT("삭제할 수 없는 테마입니다. 예약이 존재하는지 확인해주세요."), - TIME_DUPLICATED("이미 해당 시간이 존재합니다."), - THEME_DUPLICATED("같은 이름의 테마가 존재합니다."), - RESERVATION_DUPLICATED("해당 시간에 이미 예약이 존재합니다."), - RESERVATION_PERIOD_IN_PAST("이미 지난 시간대는 예약할 수 없습니다."), - CANCELED_BEFORE_PAYMENT("취소 시간이 결제 시간 이전일 수 없습니다."), - - // 500 Internal Server Error, - INTERNAL_SERVER_ERROR("서버 내부에서 에러가 발생하였습니다."), - UNEXPECTED_ERROR("예상치 못한 에러가 발생하였습니다. 잠시 후 다시 시도해주세요."), - - // Payment Error - PAYMENT_ERROR("결제(취소)에 실패했습니다. 결제(취소) 정보를 확인해주세요."), - PAYMENT_SERVER_ERROR("결제 서버에서 에러가 발생하였습니다. 잠시 후 다시 시도해주세요.") - ; -} diff --git a/src/main/kotlin/roomescape/common/exception/ExceptionControllerAdvice.kt b/src/main/kotlin/roomescape/common/exception/ExceptionControllerAdvice.kt index 1f892236..2a877347 100644 --- a/src/main/kotlin/roomescape/common/exception/ExceptionControllerAdvice.kt +++ b/src/main/kotlin/roomescape/common/exception/ExceptionControllerAdvice.kt @@ -2,7 +2,6 @@ package roomescape.common.exception import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging -import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.http.converter.HttpMessageNotReadableException import org.springframework.web.bind.MethodArgumentNotValidException @@ -14,43 +13,46 @@ import roomescape.common.dto.response.CommonErrorResponse class ExceptionControllerAdvice( private val logger: KLogger = KotlinLogging.logger {} ) { - @ExceptionHandler(value = [RoomescapeException::class]) - fun handleRoomEscapeException(e: RoomescapeException): ResponseEntity { - logger.error(e) { "message: ${e.message}, invalidValue: ${e.invalidValue}" } + fun handleRoomException(e: RoomescapeException): ResponseEntity { + logger.error(e) { "message: ${e.message}" } + val errorCode: ErrorCode = e.errorCode return ResponseEntity - .status(e.httpStatus) - .body(CommonErrorResponse(e.errorType)) + .status(errorCode.httpStatus) + .body(CommonErrorResponse(errorCode, e.message)) } @ExceptionHandler(value = [HttpMessageNotReadableException::class]) fun handleHttpMessageNotReadableException(e: HttpMessageNotReadableException): ResponseEntity { logger.error(e) { "message: ${e.message}" } + val errorCode: ErrorCode = CommonErrorCode.INVALID_INPUT_VALUE return ResponseEntity - .status(HttpStatus.BAD_REQUEST) - .body(CommonErrorResponse(ErrorType.INVALID_REQUEST_DATA_TYPE)) + .status(errorCode.httpStatus) + .body(CommonErrorResponse(errorCode)) } @ExceptionHandler(value = [MethodArgumentNotValidException::class]) fun handleMethodArgumentNotValidException(e: MethodArgumentNotValidException): ResponseEntity { - val messages: String = e.bindingResult.allErrors + val message: String = e.bindingResult.allErrors .mapNotNull { it.defaultMessage } .joinToString(", ") - logger.error(e) { "message: $messages" } + logger.error(e) { "message: $message" } + val errorCode: ErrorCode = CommonErrorCode.INVALID_INPUT_VALUE return ResponseEntity - .status(HttpStatus.BAD_REQUEST) - .body(CommonErrorResponse(ErrorType.INVALID_REQUEST_DATA, messages)) + .status(errorCode.httpStatus) + .body(CommonErrorResponse(errorCode)) } @ExceptionHandler(value = [Exception::class]) fun handleException(e: Exception): ResponseEntity { logger.error(e) { "message: ${e.message}" } + val errorCode: ErrorCode = CommonErrorCode.UNEXPECTED_SERVER_ERROR return ResponseEntity - .status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(CommonErrorResponse(ErrorType.UNEXPECTED_ERROR)) + .status(errorCode.httpStatus) + .body(CommonErrorResponse(errorCode)) } } diff --git a/src/main/kotlin/roomescape/common/exception/RoomescapeException.kt b/src/main/kotlin/roomescape/common/exception/RoomescapeException.kt index d6a8cb07..dcd8353f 100644 --- a/src/main/kotlin/roomescape/common/exception/RoomescapeException.kt +++ b/src/main/kotlin/roomescape/common/exception/RoomescapeException.kt @@ -1,11 +1,6 @@ package roomescape.common.exception -import org.springframework.http.HttpStatusCode - -class RoomescapeException( - val errorType: ErrorType, - val invalidValue: String? = "", - val httpStatus: HttpStatusCode, -) : RuntimeException(errorType.description) { - constructor(errorType: ErrorType, httpStatus: HttpStatusCode) : this(errorType, null, httpStatus) -} +open class RoomescapeException( + open val errorCode: ErrorCode, + override val message: String = errorCode.message +) : RuntimeException(message) diff --git a/src/main/kotlin/roomescape/member/business/MemberService.kt b/src/main/kotlin/roomescape/member/business/MemberService.kt index 3be407ed..8ff4072e 100644 --- a/src/main/kotlin/roomescape/member/business/MemberService.kt +++ b/src/main/kotlin/roomescape/member/business/MemberService.kt @@ -1,11 +1,10 @@ package roomescape.member.business import org.springframework.data.repository.findByIdOrNull -import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import roomescape.common.exception.ErrorType -import roomescape.common.exception.RoomescapeException +import roomescape.member.exception.MemberErrorCode +import roomescape.member.exception.MemberException import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberRepository import roomescape.member.web.MemberRetrieveListResponse @@ -17,27 +16,18 @@ class MemberService( private val memberRepository: MemberRepository ) { fun findMembers(): MemberRetrieveListResponse = MemberRetrieveListResponse( - memberRepository.findAll() - .map { it.toRetrieveResponse() } - .toList() + members = memberRepository.findAll().map { it.toRetrieveResponse() } ) - fun findById(memberId: Long): MemberEntity = memberRepository.findByIdOrNull(memberId) - ?: throw RoomescapeException( - ErrorType.MEMBER_NOT_FOUND, - String.format("[memberId: %d]", memberId), - HttpStatus.BAD_REQUEST - ) + fun findById(memberId: Long): MemberEntity = fetchOrThrow { + memberRepository.findByIdOrNull(memberId) + } - fun findByEmailAndPassword(email: String, password: String): MemberEntity = - memberRepository.findByEmailAndPassword(email, password) - ?: throw RoomescapeException( - ErrorType.MEMBER_NOT_FOUND, - String.format("[email: %s, password: %s]", email, password), - HttpStatus.BAD_REQUEST - ) - - fun existsById(memberId: Long): Boolean = memberRepository.existsById(memberId) + fun findByEmailAndPassword(email: String, password: String): MemberEntity = fetchOrThrow { + memberRepository.findByEmailAndPassword(email, password) + } + private fun fetchOrThrow(block: () -> MemberEntity?): MemberEntity { + return block() ?: throw MemberException(MemberErrorCode.MEMBER_NOT_FOUND) + } } - diff --git a/src/main/kotlin/roomescape/member/exception/MemberErrorCode.kt b/src/main/kotlin/roomescape/member/exception/MemberErrorCode.kt new file mode 100644 index 00000000..3b365311 --- /dev/null +++ b/src/main/kotlin/roomescape/member/exception/MemberErrorCode.kt @@ -0,0 +1,12 @@ +package roomescape.member.exception + +import org.springframework.http.HttpStatus +import roomescape.common.exception.ErrorCode + +enum class MemberErrorCode( + override val httpStatus: HttpStatus, + override val errorCode: String, + override val message: String +) : ErrorCode { + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "M001", "회원을 찾을 수 없어요.") +} diff --git a/src/main/kotlin/roomescape/member/exception/MemberException.kt b/src/main/kotlin/roomescape/member/exception/MemberException.kt new file mode 100644 index 00000000..0102f394 --- /dev/null +++ b/src/main/kotlin/roomescape/member/exception/MemberException.kt @@ -0,0 +1,8 @@ +package roomescape.member.exception + +import roomescape.common.exception.RoomescapeException + +class MemberException( + override val errorCode: MemberErrorCode, + override val message: String = errorCode.message +) : RoomescapeException(errorCode, message) diff --git a/src/main/kotlin/roomescape/member/web/MemberDTO.kt b/src/main/kotlin/roomescape/member/web/MemberDTO.kt index f6d8a43d..00c00c8f 100644 --- a/src/main/kotlin/roomescape/member/web/MemberDTO.kt +++ b/src/main/kotlin/roomescape/member/web/MemberDTO.kt @@ -9,10 +9,10 @@ fun MemberEntity.toRetrieveResponse(): MemberRetrieveResponse = MemberRetrieveRe ) data class MemberRetrieveResponse( - @field:Schema(description = "회원 식별자") + @Schema(description = "회원 식별자") val id: Long, - @field:Schema(description = "회원 이름") + @Schema(description = "회원 이름") val name: String ) diff --git a/src/main/kotlin/roomescape/payment/business/PaymentService.kt b/src/main/kotlin/roomescape/payment/business/PaymentService.kt index a7343ef8..4f2d9c83 100644 --- a/src/main/kotlin/roomescape/payment/business/PaymentService.kt +++ b/src/main/kotlin/roomescape/payment/business/PaymentService.kt @@ -1,10 +1,9 @@ package roomescape.payment.business -import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import roomescape.common.exception.ErrorType -import roomescape.common.exception.RoomescapeException +import roomescape.payment.exception.PaymentErrorCode +import roomescape.payment.exception.PaymentException import roomescape.payment.infrastructure.client.PaymentApproveResponse import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository @@ -24,44 +23,45 @@ class PaymentService( ) { @Transactional fun createPayment( - paymentResponse: PaymentApproveResponse, + approveResponse: PaymentApproveResponse, reservation: ReservationEntity - ): PaymentCreateResponse = PaymentEntity( - orderId = paymentResponse.orderId, - paymentKey = paymentResponse.paymentKey, - totalAmount = paymentResponse.totalAmount, - reservation = reservation, - approvedAt = paymentResponse.approvedAt - ).also { - paymentRepository.save(it) - }.toCreateResponse() + ): PaymentCreateResponse { + val payment = PaymentEntity( + orderId = approveResponse.orderId, + paymentKey = approveResponse.paymentKey, + totalAmount = approveResponse.totalAmount, + reservation = reservation, + approvedAt = approveResponse.approvedAt + ) + + return paymentRepository.save(payment).toCreateResponse() + } @Transactional(readOnly = true) - fun isReservationPaid( - reservationId: Long - ): Boolean = paymentRepository.existsByReservationId(reservationId) + fun isReservationPaid(reservationId: Long): Boolean = paymentRepository.existsByReservationId(reservationId) @Transactional fun createCanceledPayment( cancelInfo: PaymentCancelResponse, approvedAt: OffsetDateTime, paymentKey: String - ): CanceledPaymentEntity = CanceledPaymentEntity( - paymentKey = paymentKey, - cancelReason = cancelInfo.cancelReason, - cancelAmount = cancelInfo.cancelAmount, - approvedAt = approvedAt, - canceledAt = cancelInfo.canceledAt - ).also { canceledPaymentRepository.save(it) } + ): CanceledPaymentEntity { + val canceledPayment = CanceledPaymentEntity( + paymentKey = paymentKey, + cancelReason = cancelInfo.cancelReason, + cancelAmount = cancelInfo.cancelAmount, + approvedAt = approvedAt, + canceledAt = cancelInfo.canceledAt + ) + + return canceledPaymentRepository.save(canceledPayment) + } @Transactional fun createCanceledPaymentByReservationId(reservationId: Long): PaymentCancelRequest { val paymentKey: String = paymentRepository.findPaymentKeyByReservationId(reservationId) - ?: throw RoomescapeException( - ErrorType.PAYMENT_NOT_FOUND, - "[reservationId: $reservationId]", - HttpStatus.NOT_FOUND - ) + ?: throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND) + // 취소 시간은 현재 시간으로 일단 생성한 뒤, 결제 취소 완료 후 해당 시간으로 변경합니다. val canceled: CanceledPaymentEntity = cancelPayment(paymentKey) @@ -73,23 +73,19 @@ class PaymentService( cancelReason: String = "고객 요청", canceledAt: OffsetDateTime = OffsetDateTime.now() ): CanceledPaymentEntity { - val paymentEntity: PaymentEntity = paymentRepository.findByPaymentKey(paymentKey) + val payment: PaymentEntity = paymentRepository.findByPaymentKey(paymentKey) ?.also { paymentRepository.delete(it) } - ?: throw RoomescapeException( - ErrorType.PAYMENT_NOT_FOUND, - "[paymentKey: $paymentKey]", - HttpStatus.NOT_FOUND - ) + ?: throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND) - return CanceledPaymentEntity( + val canceledPayment = CanceledPaymentEntity( paymentKey = paymentKey, cancelReason = cancelReason, - cancelAmount = paymentEntity.totalAmount, - approvedAt = paymentEntity.approvedAt, + cancelAmount = payment.totalAmount, + approvedAt = payment.approvedAt, canceledAt = canceledAt - ).also { - canceledPaymentRepository.save(it) - } + ) + + return canceledPaymentRepository.save(canceledPayment) } @Transactional @@ -97,12 +93,8 @@ class PaymentService( paymentKey: String, canceledAt: OffsetDateTime ) { - canceledPaymentRepository.findByPaymentKey(paymentKey)?.let { - it.canceledAt = canceledAt - } ?: throw RoomescapeException( - ErrorType.PAYMENT_NOT_FOUND, - "[paymentKey: $paymentKey]", - HttpStatus.NOT_FOUND - ) + canceledPaymentRepository.findByPaymentKey(paymentKey) + ?.apply { this.canceledAt = canceledAt } + ?: throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND) } } diff --git a/src/main/kotlin/roomescape/payment/exception/PaymentErrorCode.kt b/src/main/kotlin/roomescape/payment/exception/PaymentErrorCode.kt new file mode 100644 index 00000000..1cad1ba4 --- /dev/null +++ b/src/main/kotlin/roomescape/payment/exception/PaymentErrorCode.kt @@ -0,0 +1,16 @@ +package roomescape.payment.exception + +import org.springframework.http.HttpStatus +import roomescape.common.exception.ErrorCode + +enum class PaymentErrorCode( + override val httpStatus: HttpStatus, + override val errorCode: String, + override val message: String +) : ErrorCode { + PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "P001", "결제 정보를 찾을 수 없어요."), + CANCELED_PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "P002", "취소된 결제 정보를 찾을 수 없어요."), + PAYMENT_CLIENT_ERROR(HttpStatus.BAD_REQUEST, "P003", "결제에 실패했어요. 결제 수단을 확인한 후 다시 시도해주세요."), + + PAYMENT_PROVIDER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "P999", "시스템에 일시적인 오류가 발생했어요. 잠시 후 다시 시도해주세요.") +} diff --git a/src/main/kotlin/roomescape/payment/exception/PaymentException.kt b/src/main/kotlin/roomescape/payment/exception/PaymentException.kt new file mode 100644 index 00000000..33cac713 --- /dev/null +++ b/src/main/kotlin/roomescape/payment/exception/PaymentException.kt @@ -0,0 +1,8 @@ +package roomescape.payment.exception + +import roomescape.common.exception.RoomescapeException + +class PaymentException( + override val errorCode: PaymentErrorCode, + override val message: String = errorCode.message +) : RoomescapeException(errorCode, message) diff --git a/src/main/kotlin/roomescape/payment/infrastructure/client/PaymentCancelResponseDeserializer.kt b/src/main/kotlin/roomescape/payment/infrastructure/client/PaymentCancelResponseDeserializer.kt index 4ffb1e93..01f7930a 100644 --- a/src/main/kotlin/roomescape/payment/infrastructure/client/PaymentCancelResponseDeserializer.kt +++ b/src/main/kotlin/roomescape/payment/infrastructure/client/PaymentCancelResponseDeserializer.kt @@ -6,13 +6,11 @@ import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.deser.std.StdDeserializer import roomescape.payment.web.PaymentCancelResponse -import java.io.IOException import java.time.OffsetDateTime class PaymentCancelResponseDeserializer( vc: Class? = null ) : StdDeserializer(vc) { - @Throws(IOException::class) override fun deserialize( jsonParser: JsonParser, deserializationContext: DeserializationContext? diff --git a/src/main/kotlin/roomescape/payment/infrastructure/client/TossPaymentClient.kt b/src/main/kotlin/roomescape/payment/infrastructure/client/TossPaymentClient.kt index e8ff0abe..baa6594e 100644 --- a/src/main/kotlin/roomescape/payment/infrastructure/client/TossPaymentClient.kt +++ b/src/main/kotlin/roomescape/payment/infrastructure/client/TossPaymentClient.kt @@ -4,17 +4,15 @@ import com.fasterxml.jackson.databind.ObjectMapper import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.http.HttpRequest -import org.springframework.http.HttpStatus import org.springframework.http.HttpStatusCode import org.springframework.http.MediaType import org.springframework.http.client.ClientHttpResponse import org.springframework.stereotype.Component import org.springframework.web.client.RestClient -import roomescape.common.exception.ErrorType -import roomescape.common.exception.RoomescapeException +import roomescape.payment.exception.PaymentErrorCode +import roomescape.payment.exception.PaymentException import roomescape.payment.web.PaymentCancelRequest import roomescape.payment.web.PaymentCancelResponse -import java.io.IOException import java.util.Map @Component @@ -43,7 +41,7 @@ class TossPaymentClient( { req: HttpRequest, res: ClientHttpResponse -> handlePaymentError(res) } ) .body(PaymentApproveResponse::class.java) - ?: throw RoomescapeException(ErrorType.PAYMENT_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR) + ?: throw PaymentException(PaymentErrorCode.PAYMENT_PROVIDER_ERROR) } fun cancel(cancelRequest: PaymentCancelRequest): PaymentCancelResponse { @@ -60,7 +58,7 @@ class TossPaymentClient( { req: HttpRequest, res: ClientHttpResponse -> handlePaymentError(res) } ) .body(PaymentCancelResponse::class.java) - ?: throw RoomescapeException(ErrorType.PAYMENT_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR) + ?: throw PaymentException(PaymentErrorCode.PAYMENT_PROVIDER_ERROR) } private fun logPaymentInfo(paymentRequest: PaymentApproveRequest) { @@ -77,37 +75,28 @@ class TossPaymentClient( } } - @Throws(IOException::class) private fun handlePaymentError( res: ClientHttpResponse ): Nothing { - val statusCode = res.statusCode - val errorType = getErrorTypeByStatusCode(statusCode) - val errorResponse = getErrorResponse(res) - - throw RoomescapeException( - errorType, - "[ErrorCode = ${errorResponse.code}, ErrorMessage = ${errorResponse.message}]", - statusCode - ) + getErrorCodeByHttpStatus(res.statusCode).also { + logTossPaymentError(res) + throw PaymentException(it) + } } - @Throws(IOException::class) - private fun getErrorResponse( - res: ClientHttpResponse - ): TossPaymentErrorResponse { + private fun logTossPaymentError(res: ClientHttpResponse): TossPaymentErrorResponse { val body = res.body val errorResponse = objectMapper.readValue(body, TossPaymentErrorResponse::class.java) body.close() + + log.error { "결제 실패. response: $errorResponse" } return errorResponse } - private fun getErrorTypeByStatusCode( - statusCode: HttpStatusCode - ): ErrorType { + private fun getErrorCodeByHttpStatus(statusCode: HttpStatusCode): PaymentErrorCode { if (statusCode.is4xxClientError) { - return ErrorType.PAYMENT_ERROR + return PaymentErrorCode.PAYMENT_CLIENT_ERROR } - return ErrorType.PAYMENT_SERVER_ERROR + return PaymentErrorCode.PAYMENT_PROVIDER_ERROR } } diff --git a/src/main/kotlin/roomescape/reservation/business/ReservationService.kt b/src/main/kotlin/roomescape/reservation/business/ReservationService.kt index 95f6d8ca..b7a17aaa 100644 --- a/src/main/kotlin/roomescape/reservation/business/ReservationService.kt +++ b/src/main/kotlin/roomescape/reservation/business/ReservationService.kt @@ -2,15 +2,21 @@ package roomescape.reservation.business import org.springframework.data.jpa.domain.Specification import org.springframework.data.repository.findByIdOrNull -import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import roomescape.common.exception.ErrorType -import roomescape.common.exception.RoomescapeException import roomescape.member.business.MemberService -import roomescape.reservation.infrastructure.persistence.* +import roomescape.member.infrastructure.persistence.MemberEntity +import roomescape.reservation.exception.ReservationErrorCode +import roomescape.reservation.exception.ReservationException +import roomescape.reservation.infrastructure.persistence.ReservationEntity +import roomescape.reservation.infrastructure.persistence.ReservationRepository +import roomescape.reservation.infrastructure.persistence.ReservationSearchSpecification +import roomescape.reservation.infrastructure.persistence.ReservationStatus import roomescape.reservation.web.* import roomescape.theme.business.ThemeService +import roomescape.theme.infrastructure.persistence.ThemeEntity +import roomescape.time.business.TimeService +import roomescape.time.infrastructure.persistence.TimeEntity import java.time.LocalDate import java.time.LocalDateTime @@ -29,7 +35,6 @@ class ReservationService( .confirmed() .build() - return ReservationRetrieveListResponse(findAllReservationByStatus(spec)) } @@ -51,17 +56,18 @@ class ReservationService( reservationRepository.deleteById(reservationId) } - fun addReservation(request: ReservationCreateWithPaymentRequest, memberId: Long): ReservationEntity { - validateIsReservationExist(request.themeId, request.timeId, request.date) - return getReservationForSave( - request.timeId, - request.themeId, - request.date, - memberId, - ReservationStatus.CONFIRMED - ).also { - reservationRepository.save(it) - } + fun createConfirmedReservation( + request: ReservationCreateWithPaymentRequest, + memberId: Long + ): ReservationEntity { + val themeId = request.themeId + val timeId = request.timeId + val date: LocalDate = request.date + validateIsReservationExist(themeId, timeId, date) + + val reservation: ReservationEntity = createEntity(timeId, themeId, date, memberId, ReservationStatus.CONFIRMED) + + return reservationRepository.save(reservation) } fun createReservationByAdmin(request: AdminReservationCreateRequest): ReservationRetrieveResponse { @@ -93,12 +99,12 @@ class ReservationService( date: LocalDate, memberId: Long, status: ReservationStatus - ): ReservationRetrieveResponse = getReservationForSave(timeId, themeId, date, memberId, status) + ): ReservationRetrieveResponse = createEntity(timeId, themeId, date, memberId, status) .also { reservationRepository.save(it) }.toRetrieveResponse() - private fun validateMemberAlreadyReserve(themeId: Long?, timeId: Long?, date: LocalDate?, memberId: Long?) { + private fun validateMemberAlreadyReserve(themeId: Long, timeId: Long, date: LocalDate, memberId: Long) { val spec: Specification = ReservationSearchSpecification() .sameMemberId(memberId) .sameThemeId(themeId) @@ -107,7 +113,7 @@ class ReservationService( .build() if (reservationRepository.exists(spec)) { - throw RoomescapeException(ErrorType.HAS_RESERVATION_OR_WAITING, HttpStatus.BAD_REQUEST) + throw ReservationException(ReservationErrorCode.ALREADY_RESERVE) } } @@ -120,7 +126,7 @@ class ReservationService( .build() if (reservationRepository.exists(spec)) { - throw RoomescapeException(ErrorType.RESERVATION_DUPLICATED, HttpStatus.CONFLICT) + throw ReservationException(ReservationErrorCode.RESERVATION_DUPLICATED) } } @@ -132,24 +138,20 @@ class ReservationService( val request = LocalDateTime.of(requestDate, requestTime.startAt) if (request.isBefore(now)) { - throw RoomescapeException( - ErrorType.RESERVATION_PERIOD_IN_PAST, - "[now: $now | request: $request]", - HttpStatus.BAD_REQUEST - ) + throw ReservationException(ReservationErrorCode.PAST_REQUEST_DATETIME) } } - private fun getReservationForSave( + private fun createEntity( timeId: Long, themeId: Long, date: LocalDate, memberId: Long, status: ReservationStatus ): ReservationEntity { - val time = timeService.findById(timeId) - val theme = themeService.findById(themeId) - val member = memberService.findById(memberId) + val time: TimeEntity = timeService.findById(timeId) + val theme: ThemeEntity = themeService.findById(themeId) + val member: MemberEntity = memberService.findById(memberId) validateDateAndTime(date, time) @@ -186,58 +188,54 @@ class ReservationService( return } if (startFrom.isAfter(endAt)) { - throw RoomescapeException( - ErrorType.INVALID_DATE_RANGE, - "[startFrom: $startFrom, endAt: $endAt", HttpStatus.BAD_REQUEST - ) + throw ReservationException(ReservationErrorCode.INVALID_SEARCH_DATE_RANGE) } } @Transactional(readOnly = true) fun findReservationsByMemberId(memberId: Long): MyReservationRetrieveListResponse { - return MyReservationRetrieveListResponse(reservationRepository.findAllById(memberId)) + return MyReservationRetrieveListResponse(reservationRepository.findAllByMemberId(memberId)) } fun confirmWaiting(reservationId: Long, memberId: Long) { validateIsMemberAdmin(memberId) if (reservationRepository.isExistConfirmedReservation(reservationId)) { - throw RoomescapeException(ErrorType.RESERVATION_DUPLICATED, HttpStatus.CONFLICT) + throw ReservationException(ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS) } reservationRepository.updateStatusByReservationId(reservationId, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED) } fun deleteWaiting(reservationId: Long, memberId: Long) { - reservationRepository.findByIdOrNull(reservationId)?.takeIf { - it.isWaiting() && it.isSameMember(memberId) - }?.let { - reservationRepository.delete(it) - } ?: throw throwReservationNotFound(reservationId) + val reservation: ReservationEntity = findReservationOrThrow(reservationId) + if (!reservation.isWaiting()) { + throw ReservationException(ReservationErrorCode.ALREADY_CONFIRMED) + } + if (!reservation.isReservedBy(memberId)) { + throw ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER) + } + reservationRepository.delete(reservation) } fun rejectWaiting(reservationId: Long, memberId: Long) { validateIsMemberAdmin(memberId) - reservationRepository.findByIdOrNull(reservationId)?.takeIf { - it.isWaiting() - }?.let { - reservationRepository.delete(it) - } ?: throw throwReservationNotFound(reservationId) + val reservation: ReservationEntity = findReservationOrThrow(reservationId) + + if (!reservation.isWaiting()) { + throw ReservationException(ReservationErrorCode.ALREADY_CONFIRMED) + } + reservationRepository.delete(reservation) } private fun validateIsMemberAdmin(memberId: Long) { - memberService.findById(memberId).takeIf { - it.isAdmin() - } ?: throw RoomescapeException( - ErrorType.PERMISSION_DOES_NOT_EXIST, - "[memberId: $memberId]", - HttpStatus.FORBIDDEN - ) + val member: MemberEntity = memberService.findById(memberId) + if (member.isAdmin()) { + return + } + throw ReservationException(ReservationErrorCode.NO_PERMISSION) } - private fun throwReservationNotFound(reservationId: Long?): RoomescapeException { - return RoomescapeException( - ErrorType.RESERVATION_NOT_FOUND, - "[reservationId: $reservationId]", - HttpStatus.NOT_FOUND - ) + private fun findReservationOrThrow(reservationId: Long): ReservationEntity { + return reservationRepository.findByIdOrNull(reservationId) + ?: throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND) } } diff --git a/src/main/kotlin/roomescape/reservation/business/ReservationWithPaymentService.kt b/src/main/kotlin/roomescape/reservation/business/ReservationWithPaymentService.kt index 3d48e276..f8705f38 100644 --- a/src/main/kotlin/roomescape/reservation/business/ReservationWithPaymentService.kt +++ b/src/main/kotlin/roomescape/reservation/business/ReservationWithPaymentService.kt @@ -22,7 +22,7 @@ class ReservationWithPaymentService( paymentInfo: PaymentApproveResponse, memberId: Long ): ReservationRetrieveResponse { - val reservation: ReservationEntity = reservationService.addReservation(request, memberId) + val reservation: ReservationEntity = reservationService.createConfirmedReservation(request, memberId) return paymentService.createPayment(paymentInfo, reservation) .reservation diff --git a/src/main/kotlin/roomescape/reservation/exception/ReservationErrorCode.kt b/src/main/kotlin/roomescape/reservation/exception/ReservationErrorCode.kt new file mode 100644 index 00000000..fc61f9a3 --- /dev/null +++ b/src/main/kotlin/roomescape/reservation/exception/ReservationErrorCode.kt @@ -0,0 +1,20 @@ +package roomescape.reservation.exception + +import org.springframework.http.HttpStatus +import roomescape.common.exception.ErrorCode + +enum class ReservationErrorCode( + override val httpStatus: HttpStatus, + override val errorCode: String, + override val message: String +) : ErrorCode { + RESERVATION_NOT_FOUND(HttpStatus.NOT_FOUND, "R001", "예약을 찾을 수 없어요."), + RESERVATION_DUPLICATED(HttpStatus.BAD_REQUEST, "R002", "이미 같은 예약이 있어요."), + ALREADY_RESERVE(HttpStatus.BAD_REQUEST, "R003", "같은 날짜, 시간, 테마에 대한 예약(대기)는 한 번만 가능해요."), + ALREADY_CONFIRMED(HttpStatus.CONFLICT, "R004", "이미 확정된 예약이에요"), + CONFIRMED_RESERVATION_ALREADY_EXISTS(HttpStatus.CONFLICT, "R005", "이미 확정된 예약이 있어서 승인할 수 없어요."), + PAST_REQUEST_DATETIME(HttpStatus.BAD_REQUEST, "R005", "과거 시간으로 예약할 수 없어요."), + NOT_RESERVATION_OWNER(HttpStatus.FORBIDDEN, "R006", "타인의 예약은 취소할 수 없어요."), + INVALID_SEARCH_DATE_RANGE(HttpStatus.BAD_REQUEST, "R007", "종료 날짜는 시작 날짜 이후여야 해요."), + NO_PERMISSION(HttpStatus.FORBIDDEN, "R008", "접근 권한이 없어요."), +} diff --git a/src/main/kotlin/roomescape/reservation/exception/ReservationException.kt b/src/main/kotlin/roomescape/reservation/exception/ReservationException.kt new file mode 100644 index 00000000..9f251380 --- /dev/null +++ b/src/main/kotlin/roomescape/reservation/exception/ReservationException.kt @@ -0,0 +1,9 @@ +package roomescape.reservation.exception + +import roomescape.common.exception.ErrorCode +import roomescape.common.exception.RoomescapeException + +class ReservationException( + override val errorCode: ErrorCode, + override val message: String = errorCode.message +) : RoomescapeException(errorCode, message) diff --git a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt index b7912f5b..6153ef28 100644 --- a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt +++ b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationEntity.kt @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore import jakarta.persistence.* import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.theme.infrastructure.persistence.ThemeEntity +import roomescape.time.infrastructure.persistence.TimeEntity import java.time.LocalDate @Entity @@ -34,7 +35,7 @@ class ReservationEntity( fun isWaiting(): Boolean = reservationStatus == ReservationStatus.WAITING @JsonIgnore - fun isSameMember(memberId: Long): Boolean { + fun isReservedBy(memberId: Long): Boolean { return this.member.id == memberId } } diff --git a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt index 4c563eea..43e9cf42 100644 --- a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt +++ b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepository.kt @@ -6,11 +6,12 @@ import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param import roomescape.reservation.web.MyReservationRetrieveResponse +import roomescape.time.infrastructure.persistence.TimeEntity import java.time.LocalDate interface ReservationRepository : JpaRepository, JpaSpecificationExecutor { - fun findByTime(time: TimeEntity): List + fun findAllByTime(time: TimeEntity): List fun findByDateAndThemeId(date: LocalDate, themeId: Long): List @@ -58,5 +59,5 @@ interface ReservationRepository ON p.reservation = r WHERE r.member.id = :memberId """) - fun findAllById(memberId: Long): List + fun findAllByMemberId(memberId: Long): List } diff --git a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecification.kt b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecification.kt index f42892c3..0de81420 100644 --- a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecification.kt +++ b/src/main/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecification.kt @@ -3,6 +3,7 @@ package roomescape.reservation.infrastructure.persistence import org.springframework.data.jpa.domain.Specification import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.theme.infrastructure.persistence.ThemeEntity +import roomescape.time.infrastructure.persistence.TimeEntity import java.time.LocalDate class ReservationSearchSpecification( diff --git a/src/main/kotlin/roomescape/reservation/web/ReservationController.kt b/src/main/kotlin/roomescape/reservation/web/ReservationController.kt index 47b4a720..858f210f 100644 --- a/src/main/kotlin/roomescape/reservation/web/ReservationController.kt +++ b/src/main/kotlin/roomescape/reservation/web/ReservationController.kt @@ -6,7 +6,6 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* import roomescape.auth.web.support.MemberId import roomescape.common.dto.response.CommonApiResponse -import roomescape.common.exception.RoomescapeException import roomescape.payment.infrastructure.client.PaymentApproveRequest import roomescape.payment.infrastructure.client.PaymentApproveResponse import roomescape.payment.infrastructure.client.TossPaymentClient @@ -90,7 +89,7 @@ class ReservationController( ) return ResponseEntity.created(URI.create("/reservations/${reservationRetrieveResponse.id}")) .body(CommonApiResponse(reservationRetrieveResponse)) - } catch (e: RoomescapeException) { + } catch (e: Exception) { val cancelRequest = PaymentCancelRequest(paymentRequest.paymentKey, paymentRequest.amount, e.message!!) val paymentCancelResponse = paymentClient.cancel(cancelRequest) diff --git a/src/main/kotlin/roomescape/reservation/web/ReservationRequest.kt b/src/main/kotlin/roomescape/reservation/web/ReservationRequest.kt index bb69cb31..2c5075bb 100644 --- a/src/main/kotlin/roomescape/reservation/web/ReservationRequest.kt +++ b/src/main/kotlin/roomescape/reservation/web/ReservationRequest.kt @@ -16,16 +16,16 @@ data class ReservationCreateWithPaymentRequest( val timeId: Long, val themeId: Long, - @field:Schema(description = "결제 위젯을 통해 받은 결제 키") + @Schema(description = "결제 위젯을 통해 받은 결제 키") val paymentKey: String, - @field:Schema(description = "결제 위젯을 통해 받은 주문번호.") + @Schema(description = "결제 위젯을 통해 받은 주문번호.") val orderId: String, - @field:Schema(description = "결제 위젯을 통해 받은 결제 금액") + @Schema(description = "결제 위젯을 통해 받은 결제 금액") val amount: Long, - @field:Schema(description = "결제 타입", example = "NORMAL") + @Schema(description = "결제 타입", example = "NORMAL") val paymentType: String ) diff --git a/src/main/kotlin/roomescape/reservation/web/ReservationResponse.kt b/src/main/kotlin/roomescape/reservation/web/ReservationResponse.kt index 69778c31..3a6b7577 100644 --- a/src/main/kotlin/roomescape/reservation/web/ReservationResponse.kt +++ b/src/main/kotlin/roomescape/reservation/web/ReservationResponse.kt @@ -6,8 +6,10 @@ import roomescape.member.web.MemberRetrieveResponse import roomescape.member.web.toRetrieveResponse import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationStatus -import roomescape.theme.web.ThemeResponse +import roomescape.theme.web.ThemeRetrieveResponse import roomescape.theme.web.toResponse +import roomescape.time.web.TimeCreateResponse +import roomescape.time.web.toCreateResponse import java.time.LocalDate import java.time.LocalTime @@ -17,16 +19,16 @@ data class MyReservationRetrieveResponse( val date: LocalDate, val time: LocalTime, val status: ReservationStatus, - @field:Schema(description = "대기 순번. 확정된 예약은 0의 값을 가집니다.") + @Schema(description = "대기 순번. 확정된 예약은 0의 값을 가집니다.") val rank: Long, - @field:Schema(description = "결제 키. 결제가 완료된 예약에만 값이 존재합니다.") + @Schema(description = "결제 키. 결제가 완료된 예약에만 값이 존재합니다.") val paymentKey: String?, - @field:Schema(description = "결제 금액. 결제가 완료된 예약에만 값이 존재합니다.") + @Schema(description = "결제 금액. 결제가 완료된 예약에만 값이 존재합니다.") val amount: Long? ) data class MyReservationRetrieveListResponse( - @field:Schema(description = "현재 로그인한 회원의 예약 및 대기 목록") + @Schema(description = "현재 로그인한 회원의 예약 및 대기 목록") val reservations: List ) @@ -41,7 +43,7 @@ data class ReservationRetrieveResponse( val time: TimeCreateResponse, @field:JsonProperty("theme") - val theme: ThemeResponse, + val theme: ThemeRetrieveResponse, val status: ReservationStatus ) diff --git a/src/main/kotlin/roomescape/theme/business/ThemeService.kt b/src/main/kotlin/roomescape/theme/business/ThemeService.kt index 91708c13..0f968e65 100644 --- a/src/main/kotlin/roomescape/theme/business/ThemeService.kt +++ b/src/main/kotlin/roomescape/theme/business/ThemeService.kt @@ -1,17 +1,13 @@ package roomescape.theme.business import org.springframework.data.repository.findByIdOrNull -import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import roomescape.common.exception.ErrorType -import roomescape.common.exception.RoomescapeException +import roomescape.theme.exception.ThemeErrorCode +import roomescape.theme.exception.ThemeException import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeRepository -import roomescape.theme.web.ThemeRequest -import roomescape.theme.web.ThemeResponse -import roomescape.theme.web.ThemesResponse -import roomescape.theme.web.toResponse +import roomescape.theme.web.* import java.time.LocalDate @Service @@ -20,18 +16,14 @@ class ThemeService( ) { @Transactional(readOnly = true) fun findById(id: Long): ThemeEntity = themeRepository.findByIdOrNull(id) - ?: throw RoomescapeException( - ErrorType.THEME_NOT_FOUND, - "[themeId: $id]", - HttpStatus.BAD_REQUEST - ) + ?: throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND) @Transactional(readOnly = true) - fun findThemes(): ThemesResponse = themeRepository.findAll() + fun findThemes(): ThemeRetrieveListResponse = themeRepository.findAll() .toResponse() @Transactional(readOnly = true) - fun findMostReservedThemes(count: Int): ThemesResponse { + fun findMostReservedThemes(count: Int): ThemeRetrieveListResponse { val today = LocalDate.now() val startDate = today.minusDays(7) val endDate = today.minusDays(1) @@ -41,33 +33,21 @@ class ThemeService( } @Transactional - fun createTheme(request: ThemeRequest): ThemeResponse { + fun createTheme(request: ThemeCreateRequest): ThemeRetrieveResponse { if (themeRepository.existsByName(request.name)) { - throw RoomescapeException( - ErrorType.THEME_DUPLICATED, - "[name: ${request.name}]", - HttpStatus.CONFLICT - ) + throw ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED) } - return ThemeEntity( - name = request.name, - description = request.description, - thumbnail = request.thumbnail - ).also { - themeRepository.save(it) - }.toResponse() + val theme: ThemeEntity = request.toEntity() + return themeRepository.save(theme).toResponse() } @Transactional fun deleteTheme(id: Long) { if (themeRepository.isReservedTheme(id)) { - throw RoomescapeException( - ErrorType.THEME_IS_USED_CONFLICT, - "[themeId: %d]", - HttpStatus.CONFLICT - ) + throw ThemeException(ThemeErrorCode.THEME_ALREADY_RESERVED) } + themeRepository.deleteById(id) } } diff --git a/src/main/kotlin/roomescape/theme/docs/ThemeAPI.kt b/src/main/kotlin/roomescape/theme/docs/ThemeAPI.kt index d971884b..6195e3fa 100644 --- a/src/main/kotlin/roomescape/theme/docs/ThemeAPI.kt +++ b/src/main/kotlin/roomescape/theme/docs/ThemeAPI.kt @@ -13,9 +13,9 @@ import org.springframework.web.bind.annotation.RequestParam import roomescape.auth.web.support.Admin import roomescape.auth.web.support.LoginRequired import roomescape.common.dto.response.CommonApiResponse -import roomescape.theme.web.ThemeRequest -import roomescape.theme.web.ThemeResponse -import roomescape.theme.web.ThemesResponse +import roomescape.theme.web.ThemeCreateRequest +import roomescape.theme.web.ThemeRetrieveListResponse +import roomescape.theme.web.ThemeRetrieveResponse @Tag(name = "5. 테마 API", description = "테마를 조회 / 추가 / 삭제할 때 사용합니다.") interface ThemeAPI { @@ -23,13 +23,13 @@ interface ThemeAPI { @LoginRequired @Operation(summary = "모든 테마 조회", description = "모든 테마를 조회합니다.", tags = ["로그인이 필요한 API"]) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) - fun findThemes(): ResponseEntity> + fun findThemes(): ResponseEntity> @Operation(summary = "가장 많이 예약된 테마 조회") @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) fun findMostReservedThemes( @RequestParam(defaultValue = "10") @Parameter(description = "최대로 조회할 테마 갯수") count: Int - ): ResponseEntity> + ): ResponseEntity> @Admin @Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"]) @@ -37,8 +37,8 @@ interface ThemeAPI { ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true), ) fun createTheme( - @Valid @RequestBody request: ThemeRequest, - ): ResponseEntity> + @Valid @RequestBody request: ThemeCreateRequest, + ): ResponseEntity> @Admin @Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"]) diff --git a/src/main/kotlin/roomescape/theme/exception/ThemeErrorCode.kt b/src/main/kotlin/roomescape/theme/exception/ThemeErrorCode.kt new file mode 100644 index 00000000..7f71250e --- /dev/null +++ b/src/main/kotlin/roomescape/theme/exception/ThemeErrorCode.kt @@ -0,0 +1,14 @@ +package roomescape.theme.exception + +import org.springframework.http.HttpStatus +import roomescape.common.exception.ErrorCode + +enum class ThemeErrorCode( + override val httpStatus: HttpStatus, + override val errorCode: String, + override val message: String +) : ErrorCode { + THEME_NOT_FOUND(HttpStatus.NOT_FOUND, "TH001", "테마를 찾을 수 없어요."), + THEME_NAME_DUPLICATED(HttpStatus.BAD_REQUEST, "TH002", "이미 같은 이름의 테마가 있어요."), + THEME_ALREADY_RESERVED(HttpStatus.CONFLICT, "TH003", "예약된 테마라 삭제할 수 없어요.") +} diff --git a/src/main/kotlin/roomescape/theme/exception/ThemeException.kt b/src/main/kotlin/roomescape/theme/exception/ThemeException.kt new file mode 100644 index 00000000..d678e4ea --- /dev/null +++ b/src/main/kotlin/roomescape/theme/exception/ThemeException.kt @@ -0,0 +1,8 @@ +package roomescape.theme.exception + +import roomescape.common.exception.RoomescapeException + +class ThemeException( + override val errorCode: ThemeErrorCode, + override val message: String = errorCode.message +) : RoomescapeException(errorCode, message) diff --git a/src/main/kotlin/roomescape/theme/web/ThemeController.kt b/src/main/kotlin/roomescape/theme/web/ThemeController.kt index 05820cb2..c0be7fc1 100644 --- a/src/main/kotlin/roomescape/theme/web/ThemeController.kt +++ b/src/main/kotlin/roomescape/theme/web/ThemeController.kt @@ -15,8 +15,8 @@ class ThemeController( ) : ThemeAPI { @GetMapping("/themes") - override fun findThemes(): ResponseEntity> { - val response: ThemesResponse = themeService.findThemes() + override fun findThemes(): ResponseEntity> { + val response: ThemeRetrieveListResponse = themeService.findThemes() return ResponseEntity.ok(CommonApiResponse(response)) } @@ -24,17 +24,17 @@ class ThemeController( @GetMapping("/themes/most-reserved-last-week") override fun findMostReservedThemes( @RequestParam(defaultValue = "10") @Parameter(description = "최대로 조회할 테마 갯수") count: Int - ): ResponseEntity> { - val response: ThemesResponse = themeService.findMostReservedThemes(count) + ): ResponseEntity> { + val response: ThemeRetrieveListResponse = themeService.findMostReservedThemes(count) return ResponseEntity.ok(CommonApiResponse(response)) } @PostMapping("/themes") override fun createTheme( - @RequestBody @Valid request: ThemeRequest - ): ResponseEntity> { - val themeResponse: ThemeResponse = themeService.createTheme(request) + @RequestBody @Valid request: ThemeCreateRequest + ): ResponseEntity> { + val themeResponse: ThemeRetrieveResponse = themeService.createTheme(request) return ResponseEntity.created(URI.create("/themes/${themeResponse.id}")) .body(CommonApiResponse(themeResponse)) diff --git a/src/main/kotlin/roomescape/theme/web/ThemeDTO.kt b/src/main/kotlin/roomescape/theme/web/ThemeDTO.kt index 5fa35711..a273fa7e 100644 --- a/src/main/kotlin/roomescape/theme/web/ThemeDTO.kt +++ b/src/main/kotlin/roomescape/theme/web/ThemeDTO.kt @@ -6,52 +6,46 @@ import jakarta.validation.constraints.Size import org.hibernate.validator.constraints.URL import roomescape.theme.infrastructure.persistence.ThemeEntity -@Schema(name = "테마 저장 요청", description = "테마 정보를 저장할 때 사용합니다.") -data class ThemeRequest( - @field:Schema(description = "필수 값이며, 최대 20글자까지 입력 가능합니다.") +data class ThemeCreateRequest( @NotBlank - @Size(max = 20, message = "테마의 이름은 1~20글자 사이여야 합니다.") + @Size(max = 20) val name: String, - @field:Schema(description = "필수 값이며, 최대 100글자까지 입력 가능합니다.") @NotBlank - @Size(max = 100, message = "테마의 설명은 1~100글자 사이여야 합니다.") + @Size(max = 100) val description: String, - @field:Schema(description = "필수 값이며, 썸네일 이미지 URL 을 입력해주세요.") - @NotBlank @URL + @NotBlank + @Schema(description = "썸네일 이미지 주소(URL).") val thumbnail: String ) -@Schema(name = "테마 정보", description = "테마 추가 및 조회 응답에 사용됩니다.") -data class ThemeResponse( - @field:Schema(description = "테마 번호. 테마를 식별할 때 사용합니다.") +fun ThemeCreateRequest.toEntity(): ThemeEntity = ThemeEntity( + name = this.name, + description = this.description, + thumbnail = this.thumbnail +) + +data class ThemeRetrieveResponse( val id: Long, - - @field:Schema(description = "테마 이름. 중복을 허용하지 않습니다.") val name: String, - - @field:Schema(description = "테마 설명") val description: String, - - @field:Schema(description = "테마 썸네일 이미지 URL") + @Schema(description = "썸네일 이미지 주소(URL).") val thumbnail: String ) -fun ThemeEntity.toResponse(): ThemeResponse = ThemeResponse( +fun ThemeEntity.toResponse(): ThemeRetrieveResponse = ThemeRetrieveResponse( id = this.id!!, name = this.name, description = this.description, thumbnail = this.thumbnail ) -@Schema(name = "테마 목록 조회 응답", description = "모든 테마 목록 조회 응답시 사용됩니다.") -data class ThemesResponse( - @field:Schema(description = "모든 테마 목록") - val themes: List +data class ThemeRetrieveListResponse( + val themes: List ) -fun List.toResponse(): ThemesResponse = ThemesResponse( +fun List.toResponse(): ThemeRetrieveListResponse = ThemeRetrieveListResponse( themes = this.map { it.toResponse() } ) diff --git a/src/main/kotlin/roomescape/reservation/business/TimeService.kt b/src/main/kotlin/roomescape/time/business/TimeService.kt similarity index 52% rename from src/main/kotlin/roomescape/reservation/business/TimeService.kt rename to src/main/kotlin/roomescape/time/business/TimeService.kt index edde1923..799a363f 100644 --- a/src/main/kotlin/roomescape/reservation/business/TimeService.kt +++ b/src/main/kotlin/roomescape/time/business/TimeService.kt @@ -1,16 +1,15 @@ -package roomescape.reservation.business +package roomescape.time.business import org.springframework.data.repository.findByIdOrNull -import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import roomescape.common.exception.ErrorType -import roomescape.common.exception.RoomescapeException import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationRepository -import roomescape.reservation.infrastructure.persistence.TimeEntity -import roomescape.reservation.infrastructure.persistence.TimeRepository -import roomescape.reservation.web.* +import roomescape.time.exception.TimeErrorCode +import roomescape.time.exception.TimeException +import roomescape.time.infrastructure.persistence.TimeEntity +import roomescape.time.infrastructure.persistence.TimeRepository +import roomescape.time.web.* import java.time.LocalDate import java.time.LocalTime @@ -21,42 +20,33 @@ class TimeService( ) { @Transactional(readOnly = true) fun findById(id: Long): TimeEntity = timeRepository.findByIdOrNull(id) - ?: throw RoomescapeException( - ErrorType.TIME_NOT_FOUND, - "[timeId: $id]", - HttpStatus.BAD_REQUEST - ) + ?: throw TimeException(TimeErrorCode.TIME_NOT_FOUND) @Transactional(readOnly = true) - fun findTimes(): TimeRetrieveListResponse = timeRepository.findAll().toRetrieveListResponse() + fun findTimes(): TimeRetrieveListResponse = timeRepository.findAll() + .toResponse() @Transactional - fun createTime(timeCreateRequest: TimeCreateRequest): TimeCreateResponse { - val startAt: LocalTime = timeCreateRequest.startAt - + fun createTime(request: TimeCreateRequest): TimeCreateResponse { + val startAt: LocalTime = request.startAt if (timeRepository.existsByStartAt(startAt)) { - throw RoomescapeException( - ErrorType.TIME_DUPLICATED, "[startAt: $startAt]", HttpStatus.CONFLICT - ) + throw TimeException(TimeErrorCode.TIME_DUPLICATED) } - return TimeEntity(startAt = startAt) - .also { timeRepository.save(it) } - .toCreateResponse() + val time: TimeEntity = request.toEntity() + + return timeRepository.save(time).toCreateResponse() } @Transactional fun deleteTime(id: Long) { val time: TimeEntity = findById(id) - reservationRepository.findByTime(time) - .also { - if (it.isNotEmpty()) { - throw RoomescapeException( - ErrorType.TIME_IS_USED_CONFLICT, "[timeId: $id]", HttpStatus.CONFLICT - ) - } - timeRepository.deleteById(id) - } + val reservations: List = reservationRepository.findAllByTime(time) + + if (reservations.isNotEmpty()) { + throw TimeException(TimeErrorCode.TIME_ALREADY_RESERVED) + } + timeRepository.delete(time) } @Transactional(readOnly = true) @@ -66,7 +56,6 @@ class TimeService( return TimeWithAvailabilityListResponse(allTimes.map { time -> val isAvailable: Boolean = reservations.none { reservation -> reservation.time.id == time.id } - TimeWithAvailabilityResponse(time.id!!, time.startAt, isAvailable) }) } diff --git a/src/main/kotlin/roomescape/reservation/docs/TimeAPI.kt b/src/main/kotlin/roomescape/time/docs/TimeAPI.kt similarity index 89% rename from src/main/kotlin/roomescape/reservation/docs/TimeAPI.kt rename to src/main/kotlin/roomescape/time/docs/TimeAPI.kt index 067137f3..6e1280c2 100644 --- a/src/main/kotlin/roomescape/reservation/docs/TimeAPI.kt +++ b/src/main/kotlin/roomescape/time/docs/TimeAPI.kt @@ -1,4 +1,4 @@ -package roomescape.reservation.docs +package roomescape.time.docs import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.responses.ApiResponse @@ -12,10 +12,10 @@ import org.springframework.web.bind.annotation.RequestParam import roomescape.auth.web.support.Admin import roomescape.auth.web.support.LoginRequired import roomescape.common.dto.response.CommonApiResponse -import roomescape.reservation.web.TimeCreateRequest -import roomescape.reservation.web.TimeCreateResponse -import roomescape.reservation.web.TimeRetrieveListResponse -import roomescape.reservation.web.TimeWithAvailabilityListResponse +import roomescape.time.web.TimeCreateRequest +import roomescape.time.web.TimeCreateResponse +import roomescape.time.web.TimeRetrieveListResponse +import roomescape.time.web.TimeWithAvailabilityListResponse import java.time.LocalDate @Tag(name = "4. 예약 시간 API", description = "예약 시간을 조회 / 추가 / 삭제할 때 사용합니다.") @@ -47,4 +47,4 @@ interface TimeAPI { @RequestParam date: LocalDate, @RequestParam themeId: Long ): ResponseEntity> -} +} \ No newline at end of file diff --git a/src/main/kotlin/roomescape/time/exception/TimeErrorCode.kt b/src/main/kotlin/roomescape/time/exception/TimeErrorCode.kt new file mode 100644 index 00000000..c0c967f3 --- /dev/null +++ b/src/main/kotlin/roomescape/time/exception/TimeErrorCode.kt @@ -0,0 +1,14 @@ +package roomescape.time.exception + +import org.springframework.http.HttpStatus +import roomescape.common.exception.ErrorCode + +enum class TimeErrorCode( + override val httpStatus: HttpStatus, + override val errorCode: String, + override val message: String +) : ErrorCode { + TIME_NOT_FOUND(HttpStatus.NOT_FOUND, "TM001", "시간을 찾을 수 없어요."), + TIME_DUPLICATED(HttpStatus.BAD_REQUEST, "TM002", "이미 같은 시간이 있어요."), + TIME_ALREADY_RESERVED(HttpStatus.CONFLICT, "TM003", "예약된 시간이라 삭제할 수 없어요.") +} diff --git a/src/main/kotlin/roomescape/time/exception/TimeException.kt b/src/main/kotlin/roomescape/time/exception/TimeException.kt new file mode 100644 index 00000000..ac97905e --- /dev/null +++ b/src/main/kotlin/roomescape/time/exception/TimeException.kt @@ -0,0 +1,9 @@ +package roomescape.time.exception + +import roomescape.common.exception.ErrorCode +import roomescape.common.exception.RoomescapeException + +class TimeException( + override val errorCode: ErrorCode, + override val message: String = errorCode.message +) : RoomescapeException(errorCode, message) diff --git a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/TimeEntity.kt b/src/main/kotlin/roomescape/time/infrastructure/persistence/TimeEntity.kt similarity index 80% rename from src/main/kotlin/roomescape/reservation/infrastructure/persistence/TimeEntity.kt rename to src/main/kotlin/roomescape/time/infrastructure/persistence/TimeEntity.kt index 38f1f361..d0fc306d 100644 --- a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/TimeEntity.kt +++ b/src/main/kotlin/roomescape/time/infrastructure/persistence/TimeEntity.kt @@ -1,4 +1,4 @@ -package roomescape.reservation.infrastructure.persistence +package roomescape.time.infrastructure.persistence import jakarta.persistence.* import java.time.LocalTime diff --git a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/TimeRepository.kt b/src/main/kotlin/roomescape/time/infrastructure/persistence/TimeRepository.kt similarity index 78% rename from src/main/kotlin/roomescape/reservation/infrastructure/persistence/TimeRepository.kt rename to src/main/kotlin/roomescape/time/infrastructure/persistence/TimeRepository.kt index df798889..5e726669 100644 --- a/src/main/kotlin/roomescape/reservation/infrastructure/persistence/TimeRepository.kt +++ b/src/main/kotlin/roomescape/time/infrastructure/persistence/TimeRepository.kt @@ -1,4 +1,4 @@ -package roomescape.reservation.infrastructure.persistence +package roomescape.time.infrastructure.persistence import org.springframework.data.jpa.repository.JpaRepository import java.time.LocalTime diff --git a/src/main/kotlin/roomescape/reservation/web/TimeController.kt b/src/main/kotlin/roomescape/time/web/TimeController.kt similarity index 92% rename from src/main/kotlin/roomescape/reservation/web/TimeController.kt rename to src/main/kotlin/roomescape/time/web/TimeController.kt index d697bafa..67a6b494 100644 --- a/src/main/kotlin/roomescape/reservation/web/TimeController.kt +++ b/src/main/kotlin/roomescape/time/web/TimeController.kt @@ -1,11 +1,11 @@ -package roomescape.reservation.web +package roomescape.time.web import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* import roomescape.common.dto.response.CommonApiResponse -import roomescape.reservation.business.TimeService -import roomescape.reservation.docs.TimeAPI +import roomescape.time.business.TimeService +import roomescape.time.docs.TimeAPI import java.net.URI import java.time.LocalDate diff --git a/src/main/kotlin/roomescape/reservation/web/TimeDTO.kt b/src/main/kotlin/roomescape/time/web/TimeDTO.kt similarity index 54% rename from src/main/kotlin/roomescape/reservation/web/TimeDTO.kt rename to src/main/kotlin/roomescape/time/web/TimeDTO.kt index 847fd855..7c36ebed 100644 --- a/src/main/kotlin/roomescape/reservation/web/TimeDTO.kt +++ b/src/main/kotlin/roomescape/time/web/TimeDTO.kt @@ -1,52 +1,54 @@ -package roomescape.reservation.web +package roomescape.time.web import io.swagger.v3.oas.annotations.media.Schema -import roomescape.reservation.infrastructure.persistence.TimeEntity +import roomescape.time.infrastructure.persistence.TimeEntity import java.time.LocalTime @Schema(name = "예약 시간 저장 요청", description = "예약 시간 저장 요청시 사용됩니다.") data class TimeCreateRequest( - @field:Schema(description = "시간", type = "string", example = "09:00") + @Schema(description = "시간", type = "string", example = "09:00") val startAt: LocalTime ) +fun TimeCreateRequest.toEntity(): TimeEntity = TimeEntity(startAt = this.startAt) + @Schema(name = "예약 시간 정보", description = "예약 시간 추가 및 조회 응답시 사용됩니다.") data class TimeCreateResponse( - @field:Schema(description = "시간 식별자") + @Schema(description = "시간 식별자") val id: Long, - @field:Schema(description = "시간") + @Schema(description = "시간") val startAt: LocalTime ) fun TimeEntity.toCreateResponse(): TimeCreateResponse = TimeCreateResponse(this.id!!, this.startAt) data class TimeRetrieveResponse( - @field:Schema(description = "시간 식별자.") + @Schema(description = "시간 식별자.") val id: Long, - @field:Schema(description = "시간") + @Schema(description = "시간") val startAt: LocalTime ) -fun TimeEntity.toRetrieveResponse(): TimeRetrieveResponse = TimeRetrieveResponse(this.id!!, this.startAt) +fun TimeEntity.toResponse(): TimeRetrieveResponse = TimeRetrieveResponse(this.id!!, this.startAt) data class TimeRetrieveListResponse( val times: List ) -fun List.toRetrieveListResponse(): TimeRetrieveListResponse = TimeRetrieveListResponse( - this.map { it.toRetrieveResponse() } +fun List.toResponse(): TimeRetrieveListResponse = TimeRetrieveListResponse( + this.map { it.toResponse() } ) data class TimeWithAvailabilityResponse( - @field:Schema(description = "시간 식별자") + @Schema(description = "시간 식별자") val id: Long, - @field:Schema(description = "시간") + @Schema(description = "시간") val startAt: LocalTime, - @field:Schema(description = "예약 가능 여부") + @Schema(description = "예약 가능 여부") val isAvailable: Boolean ) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 82422963..378e93fd 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -21,8 +21,7 @@ security: jwt: token: secret-key: daijawligagaf@LIJ$@U)9nagnalkkgalijaddljfi - access: - expire-length: 1800000 # 30 분 + ttl-seconds: 1800000 payment: api-base-url: https://api.tosspayments.com diff --git a/src/test/kotlin/roomescape/auth/business/AuthServiceTest.kt b/src/test/kotlin/roomescape/auth/business/AuthServiceTest.kt index 5f37c79e..2b4c6a5b 100644 --- a/src/test/kotlin/roomescape/auth/business/AuthServiceTest.kt +++ b/src/test/kotlin/roomescape/auth/business/AuthServiceTest.kt @@ -7,10 +7,10 @@ import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk import org.springframework.data.repository.findByIdOrNull +import roomescape.auth.exception.AuthErrorCode +import roomescape.auth.exception.AuthException import roomescape.auth.infrastructure.jwt.JwtHandler import roomescape.auth.service.AuthService -import roomescape.common.exception.ErrorType -import roomescape.common.exception.RoomescapeException import roomescape.member.business.MemberService import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberRepository @@ -45,11 +45,11 @@ class AuthServiceTest : BehaviorSpec({ memberRepository.findByEmailAndPassword(request.email, request.password) } returns null - val exception = shouldThrow { + val exception = shouldThrow { authService.login(request) } - exception.errorType shouldBe ErrorType.MEMBER_NOT_FOUND + exception.errorCode shouldBe AuthErrorCode.LOGIN_FAILED } } } @@ -71,11 +71,11 @@ class AuthServiceTest : BehaviorSpec({ Then("회원이 없다면 예외를 던진다.") { every { memberRepository.findByIdOrNull(userId) } returns null - val exception = shouldThrow { + val exception = shouldThrow { authService.checkLogin(userId) } - exception.errorType shouldBe ErrorType.MEMBER_NOT_FOUND + exception.errorCode shouldBe AuthErrorCode.UNIDENTIFIABLE_MEMBER } } } diff --git a/src/test/kotlin/roomescape/auth/infrastructure/jwt/JwtHandlerTest.kt b/src/test/kotlin/roomescape/auth/infrastructure/jwt/JwtHandlerTest.kt index 53f12279..66af9c03 100644 --- a/src/test/kotlin/roomescape/auth/infrastructure/jwt/JwtHandlerTest.kt +++ b/src/test/kotlin/roomescape/auth/infrastructure/jwt/JwtHandlerTest.kt @@ -1,12 +1,12 @@ package roomescape.auth.infrastructure.jwt import io.jsonwebtoken.Jwts -import io.jsonwebtoken.SignatureAlgorithm +import io.jsonwebtoken.security.Keys import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe -import roomescape.common.exception.ErrorType -import roomescape.common.exception.RoomescapeException +import roomescape.auth.exception.AuthErrorCode +import roomescape.auth.exception.AuthException import roomescape.util.JwtFixture import java.util.* import kotlin.random.Random @@ -33,29 +33,29 @@ class JwtHandlerTest : FunSpec({ Thread.sleep(expirationTime) // 만료 시간 이후로 대기 // when & then - shouldThrow { + shouldThrow { shortExpirationTimeJwtHandler.getMemberIdFromToken(token) - }.errorType shouldBe ErrorType.EXPIRED_TOKEN + }.errorCode shouldBe AuthErrorCode.EXPIRED_TOKEN } test("토큰이 빈 값이면 예외를 던진다.") { - shouldThrow { + shouldThrow { jwtHandler.getMemberIdFromToken("") - }.errorType shouldBe ErrorType.INVALID_TOKEN + }.errorCode shouldBe AuthErrorCode.TOKEN_NOT_FOUND } test("시크릿 키가 잘못된 경우 예외를 던진다.") { - val now: Date = Date() + val now = Date() val invalidSignatureToken: String = Jwts.builder() .claim("memberId", memberId) - .setIssuedAt(now) - .setExpiration(Date(now.time + JwtFixture.EXPIRATION_TIME)) - .signWith(SignatureAlgorithm.HS256, JwtFixture.SECRET_KEY.substring(1).toByteArray()) + .issuedAt(now) + .expiration(Date(now.time + JwtFixture.EXPIRATION_TIME)) + .signWith(Keys.hmacShaKeyFor(JwtFixture.SECRET_KEY_STRING.substring(1).toByteArray())) .compact() - shouldThrow { + shouldThrow { jwtHandler.getMemberIdFromToken(invalidSignatureToken) - }.errorType shouldBe ErrorType.INVALID_SIGNATURE_TOKEN + }.errorCode shouldBe AuthErrorCode.INVALID_TOKEN } } }) diff --git a/src/test/kotlin/roomescape/auth/web/AuthControllerTest.kt b/src/test/kotlin/roomescape/auth/web/AuthControllerTest.kt index 8fa954f9..9fc7b3f3 100644 --- a/src/test/kotlin/roomescape/auth/web/AuthControllerTest.kt +++ b/src/test/kotlin/roomescape/auth/web/AuthControllerTest.kt @@ -4,18 +4,19 @@ import com.ninjasquad.springmockk.SpykBean import io.mockk.every import org.hamcrest.Matchers.containsString import org.hamcrest.Matchers.equalTo -import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.data.repository.findByIdOrNull import org.springframework.test.web.servlet.MockMvc +import roomescape.auth.exception.AuthErrorCode import roomescape.auth.service.AuthService -import roomescape.common.exception.ErrorType +import roomescape.common.exception.CommonErrorCode +import roomescape.common.exception.ErrorCode import roomescape.util.MemberFixture import roomescape.util.RoomescapeApiTest @WebMvcTest(controllers = [AuthController::class]) class AuthControllerTest( - @Autowired mockMvc: MockMvc + val mockMvc: MockMvc ) : RoomescapeApiTest() { @SpykBean @@ -60,43 +61,35 @@ class AuthControllerTest( memberRepository.findByEmailAndPassword(userRequest.email, userRequest.password) } returns null - Then("400 에러를 응답한다") { + Then("에러 응답") { + val expectedError = AuthErrorCode.LOGIN_FAILED runPostTest( mockMvc = mockMvc, endpoint = endpoint, body = userRequest, ) { - status { isBadRequest() } - jsonPath("$.errorType", equalTo(ErrorType.MEMBER_NOT_FOUND.name)) + status { isEqualTo(expectedError.httpStatus.value()) } + jsonPath("$.code", equalTo(expectedError.errorCode)) } } } + When("입력 값이 잘못되면") { + val expectedErrorCode: ErrorCode = CommonErrorCode.INVALID_INPUT_VALUE - When("잘못된 요청을 보내면 400 에러를 응답한다.") { - - Then("이메일 형식이 잘못된 경우") { - val invalidRequest: LoginRequest = userRequest.copy(email = "invalid") - - runPostTest( - mockMvc = mockMvc, - endpoint = endpoint, - body = invalidRequest, - ) { - status { isBadRequest() } - jsonPath("$.message", containsString("이메일 형식이 일치하지 않습니다.")) - } - } - - Then("비밀번호가 공백인 경우") { - val invalidRequest = userRequest.copy(password = " ") - - runPostTest( - mockMvc = mockMvc, - endpoint = endpoint, - body = invalidRequest, - ) { - status { isBadRequest() } - jsonPath("$.message", containsString("비밀번호는 공백일 수 없습니다.")) + Then("400 에러를 응답한다") { + listOf( + userRequest.copy(email = "invalid"), + userRequest.copy(password = " "), + "{\"email\": \"null\", \"password\": \"null\"}" + ).forEach { + runPostTest( + mockMvc = mockMvc, + endpoint = endpoint, + body = it, + ) { + status { isEqualTo(expectedErrorCode.httpStatus.value()) } + jsonPath("$.code", equalTo(expectedErrorCode.errorCode)) + } } } } @@ -125,13 +118,14 @@ class AuthControllerTest( every { jwtHandler.getMemberIdFromToken(any()) } returns invalidMemberId every { memberRepository.findByIdOrNull(invalidMemberId) } returns null - Then("400 에러를 응답한다.") { + Then("에러 응답.") { + val expectedError = AuthErrorCode.UNIDENTIFIABLE_MEMBER runGetTest( mockMvc = mockMvc, endpoint = endpoint, ) { - status { isBadRequest() } - jsonPath("$.errorType", equalTo(ErrorType.MEMBER_NOT_FOUND.name)) + status { isEqualTo(expectedError.httpStatus.value()) } + jsonPath("$.code", equalTo(expectedError.errorCode)) } } } diff --git a/src/test/kotlin/roomescape/payment/business/PaymentServiceTest.kt b/src/test/kotlin/roomescape/payment/business/PaymentServiceTest.kt index a6ceaab2..2136cdda 100644 --- a/src/test/kotlin/roomescape/payment/business/PaymentServiceTest.kt +++ b/src/test/kotlin/roomescape/payment/business/PaymentServiceTest.kt @@ -8,9 +8,8 @@ import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.runs -import org.springframework.http.HttpStatus -import roomescape.common.exception.ErrorType -import roomescape.common.exception.RoomescapeException +import roomescape.payment.exception.PaymentErrorCode +import roomescape.payment.exception.PaymentException import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository import roomescape.payment.infrastructure.persistence.PaymentRepository import roomescape.payment.web.PaymentCancelRequest @@ -23,19 +22,15 @@ class PaymentServiceTest : FunSpec({ val paymentService = PaymentService(paymentRepository, canceledPaymentRepository) - context("cancelPaymentByAdmin") { + context("createCanceledPaymentByReservationId") { val reservationId = 1L test("reservationId로 paymentKey를 찾을 수 없으면 예외를 던진다.") { every { paymentRepository.findPaymentKeyByReservationId(reservationId) } returns null - val exception = shouldThrow { + val exception = shouldThrow { paymentService.createCanceledPaymentByReservationId(reservationId) } - - assertSoftly(exception) { - this.errorType shouldBe ErrorType.PAYMENT_NOT_FOUND - this.httpStatus shouldBe HttpStatus.NOT_FOUND - } + exception.errorCode shouldBe PaymentErrorCode.PAYMENT_NOT_FOUND } context("reservationId로 paymentKey를 찾고난 후") { @@ -50,14 +45,10 @@ class PaymentServiceTest : FunSpec({ paymentRepository.findByPaymentKey(paymentKey) } returns null - val exception = shouldThrow { + val exception = shouldThrow { paymentService.createCanceledPaymentByReservationId(reservationId) } - - assertSoftly(exception) { - this.errorType shouldBe ErrorType.PAYMENT_NOT_FOUND - this.httpStatus shouldBe HttpStatus.NOT_FOUND - } + exception.errorCode shouldBe PaymentErrorCode.PAYMENT_NOT_FOUND } test("해당 paymentKey로 paymentEntity를 찾고, cancelPaymentEntity를 저장한다.") { @@ -76,6 +67,7 @@ class PaymentServiceTest : FunSpec({ } returns PaymentFixture.createCanceled( id = 1L, paymentKey = paymentKey, + cancelReason = "Test", cancelAmount = paymentEntity.totalAmount, ) @@ -84,7 +76,7 @@ class PaymentServiceTest : FunSpec({ assertSoftly(result) { this.paymentKey shouldBe paymentKey this.amount shouldBe paymentEntity.totalAmount - this.cancelReason shouldBe "고객 요청" + this.cancelReason shouldBe "Test" } } } @@ -99,14 +91,10 @@ class PaymentServiceTest : FunSpec({ canceledPaymentRepository.findByPaymentKey(paymentKey) } returns null - val exception = shouldThrow { + val exception = shouldThrow { paymentService.updateCanceledTime(paymentKey, canceledAt) } - - assertSoftly(exception) { - this.errorType shouldBe ErrorType.PAYMENT_NOT_FOUND - this.httpStatus shouldBe HttpStatus.NOT_FOUND - } + exception.errorCode shouldBe PaymentErrorCode.PAYMENT_NOT_FOUND } test("paymentKey로 canceledPaymentEntity를 찾고, canceledAt을 업데이트한다.") { diff --git a/src/test/kotlin/roomescape/payment/infrastructure/client/TossPaymentClientTest.kt b/src/test/kotlin/roomescape/payment/infrastructure/client/TossPaymentClientTest.kt index b4f6e5c4..8f6eae96 100644 --- a/src/test/kotlin/roomescape/payment/infrastructure/client/TossPaymentClientTest.kt +++ b/src/test/kotlin/roomescape/payment/infrastructure/client/TossPaymentClientTest.kt @@ -14,8 +14,8 @@ import org.springframework.test.web.client.ResponseActions import org.springframework.test.web.client.match.MockRestRequestMatchers.* import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess -import roomescape.common.exception.ErrorType -import roomescape.common.exception.RoomescapeException +import roomescape.payment.exception.PaymentErrorCode +import roomescape.payment.exception.PaymentException import roomescape.payment.web.PaymentCancelRequest import roomescape.payment.web.PaymentCancelResponse @@ -56,28 +56,32 @@ class TossPaymentClientTest( } } - test("400 에러 발생") { - commonAction().andRespond { - withStatus(HttpStatus.BAD_REQUEST) - .contentType(MediaType.APPLICATION_JSON) - .body(SampleTossPaymentConst.tossPaymentErrorJson) - .createResponse(it) + context("실패 응답") { + fun runTest(httpStatus: HttpStatus, expectedError: PaymentErrorCode) { + commonAction().andRespond { + withStatus(httpStatus) + .contentType(MediaType.APPLICATION_JSON) + .body(SampleTossPaymentConst.tossPaymentErrorJson) + .createResponse(it) + } + + // when + val paymentRequest = SampleTossPaymentConst.paymentRequest + + // then + val exception = shouldThrow { + client.confirm(paymentRequest) + } + exception.errorCode shouldBe expectedError } - // when - val paymentRequest = SampleTossPaymentConst.paymentRequest - - // then - val exception = shouldThrow { - client.confirm(paymentRequest) + test("결제 서버에서 4XX 응답 시") { + runTest(HttpStatus.BAD_REQUEST, PaymentErrorCode.PAYMENT_CLIENT_ERROR) } - assertSoftly(exception) { - this.errorType shouldBe ErrorType.PAYMENT_ERROR - this.invalidValue shouldBe "[ErrorCode = ERROR_CODE, ErrorMessage = Error message]" - this.httpStatus shouldBe HttpStatus.BAD_REQUEST + test("결제 서버에서 5XX 응답 시") { + runTest(HttpStatus.INTERNAL_SERVER_ERROR, PaymentErrorCode.PAYMENT_PROVIDER_ERROR) } - } } @@ -111,26 +115,29 @@ class TossPaymentClientTest( } } - test("500 에러 발생") { - commonAction().andRespond { - withStatus(HttpStatus.INTERNAL_SERVER_ERROR) - .contentType(MediaType.APPLICATION_JSON) - .body(SampleTossPaymentConst.tossPaymentErrorJson) - .createResponse(it) + context("실패 응답") { + fun runTest(httpStatus: HttpStatus, expectedError: PaymentErrorCode) { + commonAction().andRespond { + withStatus(httpStatus) + .contentType(MediaType.APPLICATION_JSON) + .body(SampleTossPaymentConst.tossPaymentErrorJson) + .createResponse(it) + } + + val cancelRequest: PaymentCancelRequest = SampleTossPaymentConst.cancelRequest + + val exception = shouldThrow { + client.cancel(cancelRequest) + } + exception.errorCode shouldBe expectedError } - // when - val cancelRequest: PaymentCancelRequest = SampleTossPaymentConst.cancelRequest - - // then - val exception = shouldThrow { - client.cancel(cancelRequest) + test("결제 서버에서 4XX 응답 시") { + runTest(HttpStatus.BAD_REQUEST, PaymentErrorCode.PAYMENT_CLIENT_ERROR) } - assertSoftly(exception) { - this.errorType shouldBe ErrorType.PAYMENT_SERVER_ERROR - this.invalidValue shouldBe "[ErrorCode = ERROR_CODE, ErrorMessage = Error message]" - this.httpStatus shouldBe HttpStatus.INTERNAL_SERVER_ERROR + test("결제 서버에서 5XX 응답 시") { + runTest(HttpStatus.INTERNAL_SERVER_ERROR, PaymentErrorCode.PAYMENT_PROVIDER_ERROR) } } } diff --git a/src/test/kotlin/roomescape/reservation/business/ReservationServiceTest.kt b/src/test/kotlin/roomescape/reservation/business/ReservationServiceTest.kt index 745cc89d..cef5672c 100644 --- a/src/test/kotlin/roomescape/reservation/business/ReservationServiceTest.kt +++ b/src/test/kotlin/roomescape/reservation/business/ReservationServiceTest.kt @@ -5,12 +5,15 @@ import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk -import roomescape.common.exception.ErrorType -import roomescape.common.exception.RoomescapeException +import org.springframework.data.repository.findByIdOrNull import roomescape.member.business.MemberService import roomescape.member.infrastructure.persistence.Role +import roomescape.reservation.exception.ReservationErrorCode +import roomescape.reservation.exception.ReservationException import roomescape.reservation.infrastructure.persistence.ReservationRepository +import roomescape.reservation.infrastructure.persistence.ReservationStatus import roomescape.theme.business.ThemeService +import roomescape.time.business.TimeService import roomescape.util.MemberFixture import roomescape.util.ReservationFixture import roomescape.util.TimeFixture @@ -38,10 +41,10 @@ class ReservationServiceTest : FunSpec({ val reservationRequest = ReservationFixture.createRequest() - shouldThrow { - reservationService.addReservation(reservationRequest, 1L) + shouldThrow { + reservationService.createConfirmedReservation(reservationRequest, 1L) }.also { - it.errorType shouldBe ErrorType.RESERVATION_DUPLICATED + it.errorCode shouldBe ReservationErrorCode.RESERVATION_DUPLICATED } } @@ -68,10 +71,10 @@ class ReservationServiceTest : FunSpec({ timeService.findById(any()) } returns TimeFixture.create() - shouldThrow { - reservationService.addReservation(reservationRequest, 1L) + shouldThrow { + reservationService.createConfirmedReservation(reservationRequest, 1L) }.also { - it.errorType shouldBe ErrorType.RESERVATION_PERIOD_IN_PAST + it.errorCode shouldBe ReservationErrorCode.PAST_REQUEST_DATETIME } } @@ -86,10 +89,10 @@ class ReservationServiceTest : FunSpec({ startAt = LocalTime.now().minusMinutes(1) ) - shouldThrow { - reservationService.addReservation(reservationRequest, 1L) + shouldThrow { + reservationService.createConfirmedReservation(reservationRequest, 1L) }.also { - it.errorType shouldBe ErrorType.RESERVATION_PERIOD_IN_PAST + it.errorCode shouldBe ReservationErrorCode.PAST_REQUEST_DATETIME } } } @@ -107,7 +110,7 @@ class ReservationServiceTest : FunSpec({ reservationRepository.exists(any()) } returns true - shouldThrow { + shouldThrow { val waitingRequest = ReservationFixture.createWaitingRequest( date = reservationRequest.date, themeId = reservationRequest.themeId, @@ -115,7 +118,57 @@ class ReservationServiceTest : FunSpec({ ) reservationService.createWaiting(waitingRequest, 1L) }.also { - it.errorType shouldBe ErrorType.HAS_RESERVATION_OR_WAITING + it.errorCode shouldBe ReservationErrorCode.ALREADY_RESERVE + } + } + } + + context("예약 대기를 취소할 때") { + val reservationId = 1L + val member = MemberFixture.create(id = 1L, role = Role.MEMBER) + test("예약을 찾을 수 없으면 예외를 던진다.") { + every { + reservationRepository.findByIdOrNull(reservationId) + } returns null + + shouldThrow { + reservationService.deleteWaiting(reservationId, member.id!!) + }.also { + it.errorCode shouldBe ReservationErrorCode.RESERVATION_NOT_FOUND + } + } + + test("대기중인 해당 예약이 이미 확정된 상태라면 예외를 던진다.") { + val alreadyConfirmed = ReservationFixture.create( + id = reservationId, + status = ReservationStatus.CONFIRMED + ) + every { + reservationRepository.findByIdOrNull(reservationId) + } returns alreadyConfirmed + + shouldThrow { + reservationService.deleteWaiting(reservationId, member.id!!) + }.also { + it.errorCode shouldBe ReservationErrorCode.ALREADY_CONFIRMED + } + } + + test("타인의 대기를 취소하려고 하면 예외를 던진다.") { + val otherMembersWaiting = ReservationFixture.create( + id = reservationId, + member = MemberFixture.create(id = member.id!! + 1L), + status = ReservationStatus.WAITING + ) + + every { + reservationRepository.findByIdOrNull(reservationId) + } returns otherMembersWaiting + + shouldThrow { + reservationService.deleteWaiting(reservationId, member.id!!) + }.also { + it.errorCode shouldBe ReservationErrorCode.NOT_RESERVATION_OWNER } } } @@ -125,7 +178,7 @@ class ReservationServiceTest : FunSpec({ val startFrom = LocalDate.now() val endAt = startFrom.minusDays(1) - shouldThrow { + shouldThrow { reservationService.searchReservations( null, null, @@ -133,7 +186,7 @@ class ReservationServiceTest : FunSpec({ endAt ) }.also { - it.errorType shouldBe ErrorType.INVALID_DATE_RANGE + it.errorCode shouldBe ReservationErrorCode.INVALID_SEARCH_DATE_RANGE } } } @@ -146,10 +199,10 @@ class ReservationServiceTest : FunSpec({ memberService.findById(any()) } returns member - shouldThrow { + shouldThrow { reservationService.confirmWaiting(1L, member.id!!) }.also { - it.errorType shouldBe ErrorType.PERMISSION_DOES_NOT_EXIST + it.errorCode shouldBe ReservationErrorCode.NO_PERMISSION } } @@ -165,10 +218,67 @@ class ReservationServiceTest : FunSpec({ reservationRepository.isExistConfirmedReservation(reservationId) } returns true - shouldThrow { + shouldThrow { reservationService.confirmWaiting(reservationId, member.id!!) }.also { - it.errorType shouldBe ErrorType.RESERVATION_DUPLICATED + it.errorCode shouldBe ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS + } + } + } + + context("대기중인 예약을 거절할 때") { + test("관리자가 아니면 예외를 던진다.") { + val member = MemberFixture.create(id = 1L, role = Role.MEMBER) + + every { + memberService.findById(any()) + } returns member + + shouldThrow { + reservationService.rejectWaiting(1L, member.id!!) + }.also { + it.errorCode shouldBe ReservationErrorCode.NO_PERMISSION + } + } + + test("예약을 찾을 수 없으면 예외를 던진다.") { + val member = MemberFixture.create(id = 1L, role = Role.ADMIN) + val reservationId = 1L + + every { + memberService.findById(member.id!!) + } returns member + + every { + reservationRepository.findByIdOrNull(reservationId) + } returns null + + shouldThrow { + reservationService.rejectWaiting(reservationId, member.id!!) + }.also { + it.errorCode shouldBe ReservationErrorCode.RESERVATION_NOT_FOUND + } + } + + test("이미 확정된 예약이면 예외를 던진다.") { + val member = MemberFixture.create(id = 1L, role = Role.ADMIN) + val reservation = ReservationFixture.create( + id = 1L, + status = ReservationStatus.CONFIRMED + ) + + every { + memberService.findById(member.id!!) + } returns member + + every { + reservationRepository.findByIdOrNull(reservation.id!!) + } returns reservation + + shouldThrow { + reservationService.rejectWaiting(reservation.id!!, member.id!!) + }.also { + it.errorCode shouldBe ReservationErrorCode.ALREADY_CONFIRMED } } } diff --git a/src/test/kotlin/roomescape/reservation/business/ReservationWithPaymentServiceTest.kt b/src/test/kotlin/roomescape/reservation/business/ReservationWithPaymentServiceTest.kt index dcbf069b..c9c5af68 100644 --- a/src/test/kotlin/roomescape/reservation/business/ReservationWithPaymentServiceTest.kt +++ b/src/test/kotlin/roomescape/reservation/business/ReservationWithPaymentServiceTest.kt @@ -48,7 +48,7 @@ class ReservationWithPaymentServiceTest : FunSpec({ context("addReservationWithPayment") { test("예약 및 결제 정보를 저장한다.") { every { - reservationService.addReservation(reservationCreateWithPaymentRequest, memberId) + reservationService.createConfirmedReservation(reservationCreateWithPaymentRequest, memberId) } returns reservationEntity every { diff --git a/src/test/kotlin/roomescape/reservation/business/TimeServiceTest.kt b/src/test/kotlin/roomescape/reservation/business/TimeServiceTest.kt deleted file mode 100644 index 83ed7417..00000000 --- a/src/test/kotlin/roomescape/reservation/business/TimeServiceTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -package roomescape.reservation.business - -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.core.spec.style.FunSpec -import io.kotest.matchers.shouldBe -import io.mockk.every -import io.mockk.mockk -import org.springframework.data.repository.findByIdOrNull -import org.springframework.http.HttpStatus -import roomescape.common.exception.ErrorType -import roomescape.common.exception.RoomescapeException -import roomescape.reservation.infrastructure.persistence.ReservationRepository -import roomescape.reservation.infrastructure.persistence.TimeRepository -import roomescape.reservation.web.TimeCreateRequest -import roomescape.util.TimeFixture -import java.time.LocalTime - -class TimeServiceTest : FunSpec({ - val timeRepository: TimeRepository = mockk() - val reservationRepository: ReservationRepository = mockk() - - val timeService = TimeService( - timeRepository = timeRepository, - reservationRepository = reservationRepository - ) - - context("findTimeById") { - test("시간을 찾을 수 없으면 400 에러를 던진다.") { - val id = 1L - - every { timeRepository.findByIdOrNull(id) } returns null - - shouldThrow { - timeService.findById(id) - }.apply { - errorType shouldBe ErrorType.TIME_NOT_FOUND - httpStatus shouldBe HttpStatus.BAD_REQUEST - } - } - } - - context("addTime") { - test("중복된 시간이 있으면 409 에러를 던진다.") { - val request = TimeCreateRequest(startAt = LocalTime.of(10, 0)) - - every { timeRepository.existsByStartAt(request.startAt) } returns true - - shouldThrow { - timeService.createTime(request) - }.apply { - errorType shouldBe ErrorType.TIME_DUPLICATED - httpStatus shouldBe HttpStatus.CONFLICT - } - } - } - - context("removeTimeById") { - test("시간을 찾을 수 없으면 400 에러를 던진다.") { - val id = 1L - - every { timeRepository.findByIdOrNull(id) } returns null - - shouldThrow { - timeService.deleteTime(id) - }.apply { - errorType shouldBe ErrorType.TIME_NOT_FOUND - httpStatus shouldBe HttpStatus.BAD_REQUEST - } - } - - test("예약이 있는 시간이면 409 에러를 던진다.") { - val id = 1L - val time = TimeFixture.create() - - every { timeRepository.findByIdOrNull(id) } returns time - - every { reservationRepository.findByTime(time) } returns listOf(mockk()) - - shouldThrow { - timeService.deleteTime(id) - }.apply { - errorType shouldBe ErrorType.TIME_IS_USED_CONFLICT - httpStatus shouldBe HttpStatus.CONFLICT - } - } - } -}) diff --git a/src/test/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepositoryTest.kt b/src/test/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepositoryTest.kt index f911261b..c41da6b3 100644 --- a/src/test/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepositoryTest.kt +++ b/src/test/kotlin/roomescape/reservation/infrastructure/persistence/ReservationRepositoryTest.kt @@ -39,7 +39,7 @@ class ReservationRepositoryTest( } test("입력된 시간과 일치하는 예약을 반환한다.") { - assertSoftly(reservationRepository.findByTime(time)) { + assertSoftly(reservationRepository.findAllByTime(time)) { it shouldHaveSize 1 assertSoftly(it.first().time.startAt) { result -> result.hour shouldBe time.startAt.hour @@ -168,7 +168,7 @@ class ReservationRepositoryTest( entityManager.clear() } - val result: List = reservationRepository.findAllById(reservation.member.id!!) + val result: List = reservationRepository.findAllByMemberId(reservation.member.id!!) result shouldHaveSize 1 assertSoftly(result.first()) { @@ -179,7 +179,7 @@ class ReservationRepositoryTest( } test("결제 정보가 없다면 paymentKey와 amount는 null로 반환한다.") { - val result: List = reservationRepository.findAllById(reservation.member.id!!) + val result: List = reservationRepository.findAllByMemberId(reservation.member.id!!) result shouldHaveSize 1 assertSoftly(result.first()) { diff --git a/src/test/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecificationTest.kt b/src/test/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecificationTest.kt index 6d764f42..d1e63d9d 100644 --- a/src/test/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecificationTest.kt +++ b/src/test/kotlin/roomescape/reservation/infrastructure/persistence/ReservationSearchSpecificationTest.kt @@ -8,6 +8,7 @@ import jakarta.persistence.EntityManager import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.theme.infrastructure.persistence.ThemeEntity +import roomescape.time.infrastructure.persistence.TimeEntity import roomescape.util.MemberFixture import roomescape.util.ReservationFixture import roomescape.util.ThemeFixture diff --git a/src/test/kotlin/roomescape/reservation/web/ReservationControllerTest.kt b/src/test/kotlin/roomescape/reservation/web/ReservationControllerTest.kt index cf9afdef..04e3fb2a 100644 --- a/src/test/kotlin/roomescape/reservation/web/ReservationControllerTest.kt +++ b/src/test/kotlin/roomescape/reservation/web/ReservationControllerTest.kt @@ -17,19 +17,21 @@ import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.transaction.support.TransactionTemplate -import roomescape.auth.web.support.AdminInterceptor -import roomescape.auth.web.support.LoginInterceptor +import roomescape.auth.infrastructure.jwt.JwtHandler import roomescape.auth.web.support.MemberIdResolver -import roomescape.common.exception.ErrorType -import roomescape.common.exception.RoomescapeException +import roomescape.member.business.MemberService import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.Role +import roomescape.payment.exception.PaymentErrorCode +import roomescape.payment.exception.PaymentException import roomescape.payment.infrastructure.client.TossPaymentClient import roomescape.payment.infrastructure.persistence.PaymentEntity +import roomescape.reservation.exception.ReservationErrorCode import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationStatus -import roomescape.reservation.infrastructure.persistence.TimeEntity +import roomescape.theme.exception.ThemeErrorCode import roomescape.theme.infrastructure.persistence.ThemeEntity +import roomescape.time.infrastructure.persistence.TimeEntity import roomescape.util.* import java.time.LocalDate import java.time.LocalTime @@ -45,15 +47,15 @@ class ReservationControllerTest( @MockkBean lateinit var paymentClient: TossPaymentClient - @SpykBean - lateinit var loginInterceptor: LoginInterceptor - - @SpykBean - lateinit var adminInterceptor: AdminInterceptor - @SpykBean lateinit var memberIdResolver: MemberIdResolver + @SpykBean + lateinit var memberService: MemberService + + @MockkBean + lateinit var jwtHandler: JwtHandler + init { context("POST /reservations") { lateinit var member: MemberEntity @@ -88,10 +90,7 @@ class ReservationControllerTest( test("결제 과정에서 발생하는 에러는 그대로 응답") { val reservationRequest = createRequest() - val paymentException = RoomescapeException( - ErrorType.PAYMENT_SERVER_ERROR, - HttpStatus.INTERNAL_SERVER_ERROR - ) + val paymentException = PaymentException(PaymentErrorCode.PAYMENT_PROVIDER_ERROR) every { paymentClient.confirm(any()) @@ -104,8 +103,8 @@ class ReservationControllerTest( }.When { post("/reservations") }.Then { - statusCode(paymentException.httpStatus.value()) - body("errorType", equalTo(paymentException.errorType.name)) + statusCode(paymentException.errorCode.httpStatus.value()) + body("code", equalTo(paymentException.errorCode.errorCode)) } } @@ -123,7 +122,7 @@ class ReservationControllerTest( // 예약 저장 과정에서 테마가 없는 예외 val invalidRequest = reservationRequest.copy(themeId = reservationRequest.themeId + 1) - val expectedException = RoomescapeException(ErrorType.THEME_NOT_FOUND, HttpStatus.BAD_REQUEST) + val expectedException = ThemeErrorCode.THEME_NOT_FOUND every { paymentClient.cancel(any()) @@ -142,7 +141,7 @@ class ReservationControllerTest( post("/reservations") }.Then { statusCode(expectedException.httpStatus.value()) - body("errorType", equalTo(expectedException.errorType.name)) + body("code", equalTo(expectedException.errorCode)) } val canceledPaymentSizeAfterApiCall: Long = entityManager.createQuery( @@ -234,6 +233,7 @@ class ReservationControllerTest( val startDate = LocalDate.now().plusDays(1) val endDate = LocalDate.now() + val expectedError = ReservationErrorCode.INVALID_SEARCH_DATE_RANGE Given { port(port) @@ -243,8 +243,8 @@ class ReservationControllerTest( }.When { get("/reservations/search") }.Then { - statusCode(HttpStatus.BAD_REQUEST.value()) - body("errorType", equalTo(ErrorType.INVALID_DATE_RANGE.name)) + statusCode(expectedError.httpStatus.value()) + body("code", equalTo(expectedError.errorCode)) } } @@ -500,6 +500,7 @@ class ReservationControllerTest( themeId = reservationRequest.themeId, timeId = reservationRequest.timeId ) + val expectedError = ReservationErrorCode.ALREADY_RESERVE Given { port(port) @@ -508,8 +509,8 @@ class ReservationControllerTest( }.When { post("/reservations/waiting") }.Then { - statusCode(HttpStatus.BAD_REQUEST.value()) - body("errorType", equalTo(ErrorType.HAS_RESERVATION_OR_WAITING.name)) + statusCode(expectedError.httpStatus.value()) + body("code", equalTo(expectedError.errorCode)) } } } @@ -543,20 +544,21 @@ class ReservationControllerTest( } } - test("이미 완료된 예약은 삭제할 수 없다.") { + test("이미 확정된 예약을 삭제하면 예외 응답") { val member = login(MemberFixture.create(role = Role.MEMBER)) val reservation: ReservationEntity = createSingleReservation( member = member, status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED ) + val expectedError = ReservationErrorCode.ALREADY_CONFIRMED Given { port(port) }.When { delete("/reservations/waiting/{id}", reservation.id) }.Then { - body("errorType", equalTo(ErrorType.RESERVATION_NOT_FOUND.name)) - statusCode(HttpStatus.NOT_FOUND.value()) + statusCode(expectedError.httpStatus.value()) + body("code", equalTo(expectedError.errorCode)) } } } @@ -599,6 +601,42 @@ class ReservationControllerTest( } ?: throw AssertionError("Reservation not found") } } + + test("다른 확정된 예약을 승인하면 예외 응답") { + val admin = login(MemberFixture.create(role = Role.ADMIN)) + val alreadyReserved = createSingleReservation( + member = admin, + status = ReservationStatus.CONFIRMED + ) + + val member = MemberFixture.create(account = "account", role = Role.MEMBER).also { it -> + transactionTemplate.executeWithoutResult { _ -> + entityManager.persist(it) + } + } + val waiting = ReservationFixture.create( + date = alreadyReserved.date, + time = alreadyReserved.time, + theme = alreadyReserved.theme, + member = member, + status = ReservationStatus.WAITING + ).also { + transactionTemplate.executeWithoutResult { _ -> + entityManager.persist(it) + } + } + + val expectedError = ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS + Given { + port(port) + }.When { + post("/reservations/waiting/${waiting.id!!}/confirm") + }.Then { + log().all() + statusCode(expectedError.httpStatus.value()) + body("code", equalTo(expectedError.errorCode)) + } + } } context("POST /reservations/waiting/{id}/reject") { @@ -737,31 +775,18 @@ class ReservationControllerTest( } } - if (member.isAdmin()) { - loginAsAdmin() - } else { - loginAsUser() - } - resolveMemberId(member.id!!) + every { + jwtHandler.getMemberIdFromToken(any()) + } returns member.id!! + + every { + memberService.findById(member.id!!) + } returns member + + every { + memberIdResolver.resolveArgument(any(), any(), any(), any()) + } returns member.id!! return member } - - private fun loginAsUser() { - every { - loginInterceptor.preHandle(any(), any(), any()) - } returns true - } - - private fun loginAsAdmin() { - every { - adminInterceptor.preHandle(any(), any(), any()) - } returns true - } - - private fun resolveMemberId(memberId: Long) { - every { - memberIdResolver.resolveArgument(any(), any(), any(), any()) - } returns memberId - } } diff --git a/src/test/kotlin/roomescape/theme/business/ThemeServiceTest.kt b/src/test/kotlin/roomescape/theme/business/ThemeServiceTest.kt index c823c351..8b56460b 100644 --- a/src/test/kotlin/roomescape/theme/business/ThemeServiceTest.kt +++ b/src/test/kotlin/roomescape/theme/business/ThemeServiceTest.kt @@ -7,12 +7,12 @@ import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk import org.springframework.data.repository.findByIdOrNull -import org.springframework.http.HttpStatus -import roomescape.common.exception.ErrorType -import roomescape.common.exception.RoomescapeException +import roomescape.theme.exception.ThemeErrorCode +import roomescape.theme.exception.ThemeException import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeRepository -import roomescape.theme.web.ThemeRequest +import roomescape.theme.web.ThemeCreateRequest +import roomescape.theme.web.ThemeRetrieveResponse import roomescape.util.ThemeFixture class ThemeServiceTest : FunSpec({ @@ -36,11 +36,11 @@ class ThemeServiceTest : FunSpec({ themeRepository.findByIdOrNull(themeId) } returns null - val exception = shouldThrow { + val exception = shouldThrow { themeService.findById(themeId) } - exception.errorType shouldBe ErrorType.THEME_NOT_FOUND + exception.errorCode shouldBe ThemeErrorCode.THEME_NOT_FOUND } } @@ -60,25 +60,46 @@ class ThemeServiceTest : FunSpec({ } context("save") { - test("테마 이름이 중복되면 409 예외를 던진다.") { - val name = "Duplicate Theme" + val request = ThemeCreateRequest( + name = "New Theme", + description = "Description", + thumbnail = "http://example.com/thumbnail.jpg" + ) + + test("저장 성공") { + every { + themeRepository.existsByName(request.name) + } returns false every { - themeRepository.existsByName(name) + themeRepository.save(any()) + } returns ThemeFixture.create( + id = 1L, + name = request.name, + description = request.description, + thumbnail = request.thumbnail + ) + + val response: ThemeRetrieveResponse = themeService.createTheme(request) + + assertSoftly(response) { + this.id shouldBe 1L + this.name shouldBe request.name + this.description shouldBe request.description + this.thumbnail shouldBe request.thumbnail + } + } + + test("테마 이름이 중복되면 409 예외를 던진다.") { + every { + themeRepository.existsByName(request.name) } returns true - val exception = shouldThrow { - themeService.createTheme(ThemeRequest( - name = name, - description = "Description", - thumbnail = "http://example.com/thumbnail.jpg" - )) + val exception = shouldThrow { + themeService.createTheme(request) } - assertSoftly(exception) { - this.errorType shouldBe ErrorType.THEME_DUPLICATED - this.httpStatus shouldBe HttpStatus.CONFLICT - } + exception.errorCode shouldBe ThemeErrorCode.THEME_NAME_DUPLICATED } } @@ -90,14 +111,11 @@ class ThemeServiceTest : FunSpec({ themeRepository.isReservedTheme(themeId) } returns true - val exception = shouldThrow { + val exception = shouldThrow { themeService.deleteTheme(themeId) } - assertSoftly(exception) { - this.errorType shouldBe ErrorType.THEME_IS_USED_CONFLICT - this.httpStatus shouldBe HttpStatus.CONFLICT - } + exception.errorCode shouldBe ThemeErrorCode.THEME_ALREADY_RESERVED } } }) diff --git a/src/test/kotlin/roomescape/theme/util/TestThemeCreateUtil.kt b/src/test/kotlin/roomescape/theme/util/TestThemeCreateUtil.kt index 0dacd49b..43c46762 100644 --- a/src/test/kotlin/roomescape/theme/util/TestThemeCreateUtil.kt +++ b/src/test/kotlin/roomescape/theme/util/TestThemeCreateUtil.kt @@ -3,8 +3,8 @@ package roomescape.theme.util import jakarta.persistence.EntityManager import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.reservation.infrastructure.persistence.ReservationStatus -import roomescape.reservation.infrastructure.persistence.TimeEntity import roomescape.theme.infrastructure.persistence.ThemeEntity +import roomescape.time.infrastructure.persistence.TimeEntity import roomescape.util.MemberFixture import roomescape.util.ReservationFixture import roomescape.util.ThemeFixture diff --git a/src/test/kotlin/roomescape/theme/web/ThemeControllerTest.kt b/src/test/kotlin/roomescape/theme/web/ThemeControllerTest.kt index 817f5077..eb959887 100644 --- a/src/test/kotlin/roomescape/theme/web/ThemeControllerTest.kt +++ b/src/test/kotlin/roomescape/theme/web/ThemeControllerTest.kt @@ -11,7 +11,9 @@ import io.mockk.runs import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc +import roomescape.auth.exception.AuthErrorCode import roomescape.theme.business.ThemeService +import roomescape.theme.exception.ThemeErrorCode import roomescape.theme.infrastructure.persistence.ThemeRepository import roomescape.util.RoomescapeApiTest import roomescape.util.ThemeFixture @@ -57,7 +59,7 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() { ThemeFixture.create(id = 3, name = "theme3") ) - val response: ThemesResponse = runGetTest( + val response: ThemeRetrieveListResponse = runGetTest( mockMvc = mockMvc, endpoint = endpoint, ) { @@ -65,7 +67,7 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() { content { contentType(MediaType.APPLICATION_JSON) } - }.andReturn().readValue(ThemesResponse::class.java) + }.andReturn().readValue(ThemeRetrieveListResponse::class.java) assertSoftly(response.themes) { it.size shouldBe 3 @@ -77,7 +79,7 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() { Given("테마를 추가할 때") { val endpoint = "/themes" - val request = ThemeRequest( + val request = ThemeCreateRequest( name = "theme1", description = "description1", thumbnail = "http://example.com/thumbnail1.jpg" @@ -108,7 +110,7 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() { body = request, ) { status { is3xxRedirection() } - jsonPath("$.errorType") { value("PERMISSION_DOES_NOT_EXIST") } + jsonPath("$.code") { value(AuthErrorCode.ACCESS_DENIED.errorCode) } } } } @@ -116,7 +118,9 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() { When("동일한 이름의 테마가 있으면") { loginAsAdmin() - Then("409 에러를 응답한다.") { + val expectedError = ThemeErrorCode.THEME_NAME_DUPLICATED + + Then("에러 응답.") { every { themeRepository.existsByName(request.name) } returns true @@ -126,8 +130,8 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() { endpoint = endpoint, body = request, ) { - status { isConflict() } - jsonPath("$.errorType") { value("THEME_DUPLICATED") } + status { isEqualTo(expectedError.httpStatus.value()) } + jsonPath("$.code") { value(expectedError.errorCode) } } } } @@ -137,13 +141,13 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() { loginAsAdmin() } - val request = ThemeRequest( + val request = ThemeCreateRequest( name = "theme1", description = "description1", thumbnail = "http://example.com/thumbnail1.jpg" ) - fun runTest(request: ThemeRequest) { + fun runTest(request: ThemeCreateRequest) { runPostTest( mockMvc = mockMvc, endpoint = endpoint, @@ -196,7 +200,7 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() { every { themeService.createTheme(request) - } returns ThemeResponse( + } returns ThemeRetrieveResponse( id = theme.id!!, name = theme.name, description = theme.description, @@ -249,15 +253,16 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() { endpoint = endpoint, ) { status { is3xxRedirection() } - jsonPath("$.errorType") { value("PERMISSION_DOES_NOT_EXIST") } + jsonPath("$.code") { value(AuthErrorCode.ACCESS_DENIED.errorCode) } } } } - When("입력된 ID에 해당하는 테마가 없으면") { + When("이미 예약된 테마이면") { loginAsAdmin() + val expectedError = ThemeErrorCode.THEME_ALREADY_RESERVED - Then("409 에러를 응답한다.") { + Then("에러 응답") { every { themeRepository.isReservedTheme(themeId) } returns true @@ -266,8 +271,8 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() { mockMvc = mockMvc, endpoint = endpoint, ) { - status { isConflict() } - jsonPath("$.errorType") { value("THEME_IS_USED_CONFLICT") } + status { isEqualTo(expectedError.httpStatus.value()) } + jsonPath("$.code") { value(expectedError.errorCode) } } } } diff --git a/src/test/kotlin/roomescape/time/business/TimeServiceTest.kt b/src/test/kotlin/roomescape/time/business/TimeServiceTest.kt new file mode 100644 index 00000000..a3d9801a --- /dev/null +++ b/src/test/kotlin/roomescape/time/business/TimeServiceTest.kt @@ -0,0 +1,109 @@ +package roomescape.time.business + +import io.kotest.assertions.throwables.shouldNotThrow +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import org.springframework.data.repository.findByIdOrNull +import roomescape.reservation.infrastructure.persistence.ReservationRepository +import roomescape.time.exception.TimeErrorCode +import roomescape.time.exception.TimeException +import roomescape.time.infrastructure.persistence.TimeRepository +import roomescape.time.web.TimeCreateRequest +import roomescape.util.TimeFixture +import java.time.LocalTime + +class TimeServiceTest : FunSpec({ + val timeRepository: TimeRepository = mockk() + val reservationRepository: ReservationRepository = mockk() + + val timeService = TimeService( + timeRepository = timeRepository, + reservationRepository = reservationRepository + ) + + context("findTimeById") { + test("시간을 찾을 수 없으면 예외 응답") { + val id = 1L + + every { timeRepository.findByIdOrNull(id) } returns null + + shouldThrow { + timeService.findById(id) + }.also { + it.errorCode shouldBe TimeErrorCode.TIME_NOT_FOUND + } + } + } + + context("createTime") { + val request = TimeCreateRequest(startAt = LocalTime.of(10, 0)) + + test("정상 저장") { + every { timeRepository.existsByStartAt(request.startAt) } returns false + every { timeRepository.save(any()) } returns TimeFixture.create( + id = 1L, + startAt = request.startAt + ) + + val response = timeService.createTime(request) + response.id shouldBe 1L + } + + test("중복된 시간이 있으면 예외 응답") { + every { timeRepository.existsByStartAt(request.startAt) } returns true + + shouldThrow { + timeService.createTime(request) + }.also { + it.errorCode shouldBe TimeErrorCode.TIME_DUPLICATED + } + } + } + + context("removeTimeById") { + test("정상 제거 및 응답") { + val id = 1L + val time = TimeFixture.create(id = id) + + every { timeRepository.findByIdOrNull(id) } returns time + every { reservationRepository.findAllByTime(time) } returns emptyList() + every { timeRepository.delete(time) } just Runs + + shouldNotThrow { + timeService.deleteTime(id) + } + } + + test("시간을 찾을 수 없으면 예외 응답") { + val id = 1L + + every { timeRepository.findByIdOrNull(id) } returns null + + shouldThrow { + timeService.deleteTime(id) + }.also { + it.errorCode shouldBe TimeErrorCode.TIME_NOT_FOUND + } + } + + test("예약이 있는 시간이면 예외 응답") { + val id = 1L + val time = TimeFixture.create() + + every { timeRepository.findByIdOrNull(id) } returns time + + every { reservationRepository.findAllByTime(time) } returns listOf(mockk()) + + shouldThrow { + timeService.deleteTime(id) + }.also { + it.errorCode shouldBe TimeErrorCode.TIME_ALREADY_RESERVED + } + } + } +}) diff --git a/src/test/kotlin/roomescape/reservation/infrastructure/persistence/TimeRepositoryTest.kt b/src/test/kotlin/roomescape/time/infrastructure/persistence/TimeRepositoryTest.kt similarity index 93% rename from src/test/kotlin/roomescape/reservation/infrastructure/persistence/TimeRepositoryTest.kt rename to src/test/kotlin/roomescape/time/infrastructure/persistence/TimeRepositoryTest.kt index d3ef18c5..34f1b421 100644 --- a/src/test/kotlin/roomescape/reservation/infrastructure/persistence/TimeRepositoryTest.kt +++ b/src/test/kotlin/roomescape/time/infrastructure/persistence/TimeRepositoryTest.kt @@ -1,4 +1,4 @@ -package roomescape.reservation.infrastructure.persistence +package roomescape.time.infrastructure.persistence import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe @@ -30,4 +30,4 @@ class TimeRepositoryTest( timeRepository.existsByStartAt(startAt.plusSeconds(1)) shouldBe false } } -}) +}) \ No newline at end of file diff --git a/src/test/kotlin/roomescape/reservation/web/TimeControllerTest.kt b/src/test/kotlin/roomescape/time/web/TimeControllerTest.kt similarity index 88% rename from src/test/kotlin/roomescape/reservation/web/TimeControllerTest.kt rename to src/test/kotlin/roomescape/time/web/TimeControllerTest.kt index 1161b705..19ae8465 100644 --- a/src/test/kotlin/roomescape/reservation/web/TimeControllerTest.kt +++ b/src/test/kotlin/roomescape/time/web/TimeControllerTest.kt @@ -1,4 +1,4 @@ -package roomescape.reservation.web +package roomescape.time.web import com.ninjasquad.springmockk.MockkBean import com.ninjasquad.springmockk.SpykBean @@ -12,11 +12,11 @@ import org.springframework.data.repository.findByIdOrNull import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import roomescape.common.config.JacksonConfig -import roomescape.common.exception.ErrorType -import roomescape.reservation.business.TimeService import roomescape.reservation.infrastructure.persistence.ReservationRepository -import roomescape.reservation.infrastructure.persistence.TimeEntity -import roomescape.reservation.infrastructure.persistence.TimeRepository +import roomescape.time.business.TimeService +import roomescape.time.exception.TimeErrorCode +import roomescape.time.infrastructure.persistence.TimeEntity +import roomescape.time.infrastructure.persistence.TimeRepository import roomescape.util.ReservationFixture import roomescape.util.RoomescapeApiTest import roomescape.util.ThemeFixture @@ -129,7 +129,8 @@ class TimeControllerTest( } } - Then("동일한 시간이 존재하면 409 응답") { + Then("동일한 시간이 존재하면 예외 응답") { + val expectedError = TimeErrorCode.TIME_DUPLICATED every { timeRepository.existsByStartAt(time) } returns true @@ -139,10 +140,10 @@ class TimeControllerTest( endpoint = endpoint, body = request, ) { - status { isConflict() } + status { isEqualTo(expectedError.httpStatus.value()) } content { contentType(MediaType.APPLICATION_JSON) - jsonPath("$.errorType") { value(ErrorType.TIME_DUPLICATED.name) } + jsonPath("$.code") { value(expectedError.errorCode) } } } } @@ -185,8 +186,9 @@ class TimeControllerTest( } } - Then("없는 시간을 조회하면 400 응답") { + Then("없는 시간을 조회하면 예외 응답") { val id = 1L + val expectedError = TimeErrorCode.TIME_NOT_FOUND every { timeRepository.findByIdOrNull(id) } returns null @@ -195,32 +197,33 @@ class TimeControllerTest( mockMvc = mockMvc, endpoint = "/times/$id", ) { - status { isBadRequest() } + status { isEqualTo(expectedError.httpStatus.value()) } content { contentType(MediaType.APPLICATION_JSON) - jsonPath("$.errorType") { value(ErrorType.TIME_NOT_FOUND.name) } + jsonPath("$.code") { value(expectedError.errorCode) } } } } - Then("예약이 있는 시간을 삭제하면 409 응답") { + Then("예약이 있는 시간을 삭제하면 예외 응답") { val id = 1L + val expectedError = TimeErrorCode.TIME_ALREADY_RESERVED every { timeRepository.findByIdOrNull(id) } returns TimeFixture.create(id = id) every { - reservationRepository.findByTime(any()) + reservationRepository.findAllByTime(any()) } returns listOf(ReservationFixture.create()) runDeleteTest( mockMvc = mockMvc, endpoint = "/times/$id", ) { - status { isConflict() } + status { isEqualTo(expectedError.httpStatus.value()) } content { contentType(MediaType.APPLICATION_JSON) - jsonPath("$.errorType") { value(ErrorType.TIME_IS_USED_CONFLICT.name) } + jsonPath("$.code") { value(expectedError.errorCode) } } } } @@ -292,4 +295,4 @@ class TimeControllerTest( } } } -} +} \ No newline at end of file diff --git a/src/test/kotlin/roomescape/util/Fixtures.kt b/src/test/kotlin/roomescape/util/Fixtures.kt index 21316345..8e372f41 100644 --- a/src/test/kotlin/roomescape/util/Fixtures.kt +++ b/src/test/kotlin/roomescape/util/Fixtures.kt @@ -12,10 +12,10 @@ import roomescape.payment.web.PaymentCancelRequest import roomescape.payment.web.PaymentCancelResponse import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationStatus -import roomescape.reservation.infrastructure.persistence.TimeEntity import roomescape.reservation.web.ReservationCreateWithPaymentRequest import roomescape.reservation.web.WaitingCreateRequest import roomescape.theme.infrastructure.persistence.ThemeEntity +import roomescape.time.infrastructure.persistence.TimeEntity import java.time.LocalDate import java.time.LocalTime import java.time.OffsetDateTime @@ -110,11 +110,11 @@ object ReservationFixture { } object JwtFixture { - const val SECRET_KEY: String = "daijawligagaf@LIJ$@U)9nagnalkkgalijaddljfi" + const val SECRET_KEY_STRING: String = "daijawligagaf@LIJ$@U)9nagnalkkgalijaddljfi" const val EXPIRATION_TIME: Long = 1000 * 60 * 60 fun create( - secretKey: String = SECRET_KEY, + secretKey: String = SECRET_KEY_STRING, expirationTime: Long = EXPIRATION_TIME ): JwtHandler = JwtHandler(secretKey, expirationTime) } diff --git a/src/test/kotlin/roomescape/util/RoomescapeApiTest.kt b/src/test/kotlin/roomescape/util/RoomescapeApiTest.kt index e8a0088d..e1874a72 100644 --- a/src/test/kotlin/roomescape/util/RoomescapeApiTest.kt +++ b/src/test/kotlin/roomescape/util/RoomescapeApiTest.kt @@ -7,16 +7,14 @@ import io.kotest.core.spec.style.BehaviorSpec import io.mockk.every import org.springframework.data.repository.findByIdOrNull import org.springframework.http.HttpHeaders -import org.springframework.http.HttpStatus import org.springframework.http.MediaType import org.springframework.test.web.servlet.* +import roomescape.auth.exception.AuthErrorCode +import roomescape.auth.exception.AuthException import roomescape.auth.infrastructure.jwt.JwtHandler -import roomescape.auth.web.support.AdminInterceptor -import roomescape.auth.web.support.LoginInterceptor +import roomescape.auth.web.support.AuthInterceptor import roomescape.auth.web.support.MemberIdResolver import roomescape.common.config.JacksonConfig -import roomescape.common.exception.ErrorType -import roomescape.common.exception.RoomescapeException import roomescape.member.business.MemberService import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberRepository @@ -25,10 +23,7 @@ import roomescape.util.MemberFixture.NOT_LOGGED_IN_USERID abstract class RoomescapeApiTest : BehaviorSpec() { @SpykBean - private lateinit var AdminInterceptor: AdminInterceptor - - @SpykBean - private lateinit var loginInterceptor: LoginInterceptor + private lateinit var authInterceptor: AuthInterceptor @SpykBean private lateinit var memberIdResolver: MemberIdResolver @@ -105,7 +100,7 @@ abstract class RoomescapeApiTest : BehaviorSpec() { fun doNotLogin() { every { jwtHandler.getMemberIdFromToken(any()) - } throws RoomescapeException(ErrorType.INVALID_TOKEN, HttpStatus.UNAUTHORIZED) + } throws AuthException(AuthErrorCode.INVALID_TOKEN) every { memberRepository.existsById(NOT_LOGGED_IN_USERID) } returns false every { memberRepository.findByIdOrNull(NOT_LOGGED_IN_USERID) } returns null diff --git a/src/test/resources/application.yaml b/src/test/resources/application.yaml index 9bc65da2..e2a6e144 100644 --- a/src/test/resources/application.yaml +++ b/src/test/resources/application.yaml @@ -14,8 +14,7 @@ security: jwt: token: secret-key: daijawligagaf@LIJ$@U)9nagnalkkgalijaddljfi - access: - expire-length: 1800000 # 30 분 + ttl-seconds: 1800000 payment: api-base-url: https://api.tosspayments.com