[#20] 도메인별 예외 분리 #21

Merged
pricelees merged 37 commits from refactor/#20 into main 2025-07-24 02:48:53 +00:00
70 changed files with 1084 additions and 886 deletions

View File

@ -1,12 +1,11 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins { plugins {
val springBootVersion = "3.5.3" val springBootVersion = "3.5.3"
val kotlinVersion = "2.2.0" val kotlinVersion = "2.2.0"
java
id("org.springframework.boot") version springBootVersion id("org.springframework.boot") version springBootVersion
id("io.spring.dependency-management") version "1.1.7" id("io.spring.dependency-management") version "1.1.7"
//kotlin plugins
kotlin("jvm") version kotlinVersion kotlin("jvm") version kotlinVersion
kotlin("plugin.spring") version kotlinVersion kotlin("plugin.spring") version kotlinVersion
kotlin("plugin.jpa") version kotlinVersion kotlin("plugin.jpa") version kotlinVersion
@ -22,61 +21,59 @@ java {
} }
} }
kapt {
keepJavacAnnotationProcessors = true
}
repositories { repositories {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
// spring // Spring
implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf") implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-validation") 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") runtimeOnly("com.h2database:h2")
// jwt // Jwt
implementation("io.jsonwebtoken:jjwt:0.9.1") implementation("io.jsonwebtoken:jjwt:0.12.6")
implementation("javax.xml.bind:jaxb-api:2.3.1")
// kotlin // Kotlin
implementation("org.jetbrains.kotlin:kotlin-reflect") implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin") implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("io.github.oshai:kotlin-logging-jvm:7.0.3") 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("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:kotest-runner-junit5:5.9.1")
testImplementation("io.kotest.extensions:kotest-extensions-spring:1.3.0") 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") // RestAssured
testRuntimeOnly("org.junit.platform:junit-platform-launcher") testImplementation("io.rest-assured:rest-assured:5.5.5")
testImplementation("io.rest-assured:rest-assured:5.3.1")
testImplementation("io.rest-assured:kotlin-extensions:5.5.5") testImplementation("io.rest-assured:kotlin-extensions:5.5.5")
} }
kapt { tasks.withType<Test> {
keepJavacAnnotationProcessors = true
}
tasks.withType<Test>().configureEach {
useJUnitPlatform() useJUnitPlatform()
} }
tasks { tasks.withType<KotlinCompile> {
compileKotlin { compilerOptions {
compilerOptions { freeCompilerArgs.addAll(
freeCompilerArgs.add("-Xjsr305=strict") "-Xjsr305=strict",
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) "-Xannotation-default-target=param-property"
freeCompilerArgs.set(listOf("-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"))
}
}
}

View File

@ -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", "회원 정보를 찾을 수 없어요."),
}

View File

@ -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)

View File

@ -1,50 +1,56 @@
package roomescape.auth.infrastructure.jwt 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.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import roomescape.common.exception.ErrorType import roomescape.auth.exception.AuthErrorCode
import roomescape.common.exception.RoomescapeException import roomescape.auth.exception.AuthException
import java.util.* import java.util.*
import javax.crypto.SecretKey
@Component @Component
class JwtHandler( class JwtHandler(
@Value("\${security.jwt.token.secret-key}") @Value("\${security.jwt.token.secret-key}")
private val secretKey: String, private val secretKeyString: String,
@Value("\${security.jwt.token.access.expire-length}") @Value("\${security.jwt.token.ttl-seconds}")
private val accessTokenExpireTime: Long private val tokenTtlSeconds: Long
) { ) {
private val secretKey: SecretKey = Keys.hmacShaKeyFor(secretKeyString.toByteArray())
fun createToken(memberId: Long): String { fun createToken(memberId: Long): String {
val date = Date() val date = Date()
val accessTokenExpiredAt = Date(date.time + accessTokenExpireTime) val accessTokenExpiredAt = Date(date.time + tokenTtlSeconds)
return Jwts.builder() return Jwts.builder()
.claim("memberId", memberId) .claim(MEMBER_ID_CLAIM_KEY, memberId)
.setIssuedAt(date) .issuedAt(date)
.setExpiration(accessTokenExpiredAt) .expiration(accessTokenExpiredAt)
.signWith(SignatureAlgorithm.HS256, secretKey.toByteArray()) .signWith(secretKey)
.compact() .compact()
} }
fun getMemberIdFromToken(token: String?): Long { fun getMemberIdFromToken(token: String?): Long {
try { try {
return Jwts.parser() return Jwts.parser()
.setSigningKey(secretKey.toByteArray()) .verifyWith(secretKey)
.parseClaimsJws(token) .build()
.getBody() .parseSignedClaims(token)
.get("memberId", Number::class.java) .payload
.get(MEMBER_ID_CLAIM_KEY, Number::class.java)
.toLong() .toLong()
} catch (e: Exception) { } catch (_: IllegalArgumentException) {
when (e) { throw AuthException(AuthErrorCode.TOKEN_NOT_FOUND)
is ExpiredJwtException -> throw RoomescapeException(ErrorType.EXPIRED_TOKEN, HttpStatus.UNAUTHORIZED) } catch (_: ExpiredJwtException) {
is UnsupportedJwtException -> throw RoomescapeException(ErrorType.UNSUPPORTED_TOKEN, HttpStatus.UNAUTHORIZED) throw AuthException(AuthErrorCode.EXPIRED_TOKEN)
is MalformedJwtException -> throw RoomescapeException(ErrorType.MALFORMED_TOKEN, HttpStatus.UNAUTHORIZED) } catch (_: Exception) {
is SignatureException -> throw RoomescapeException(ErrorType.INVALID_SIGNATURE_TOKEN, HttpStatus.UNAUTHORIZED) throw AuthException(AuthErrorCode.INVALID_TOKEN)
is IllegalArgumentException -> throw RoomescapeException(ErrorType.INVALID_TOKEN, HttpStatus.UNAUTHORIZED)
else -> throw RoomescapeException(ErrorType.UNEXPECTED_ERROR, HttpStatus.INTERNAL_SERVER_ERROR)
}
} }
} }
companion object {
private const val MEMBER_ID_CLAIM_KEY = "memberId"
}
} }

View File

@ -1,6 +1,8 @@
package roomescape.auth.service package roomescape.auth.service
import org.springframework.stereotype.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.infrastructure.jwt.JwtHandler
import roomescape.auth.web.LoginCheckResponse import roomescape.auth.web.LoginCheckResponse
import roomescape.auth.web.LoginRequest import roomescape.auth.web.LoginRequest
@ -14,10 +16,9 @@ class AuthService(
private val jwtHandler: JwtHandler private val jwtHandler: JwtHandler
) { ) {
fun login(request: LoginRequest): LoginResponse { fun login(request: LoginRequest): LoginResponse {
val member: MemberEntity = memberService.findByEmailAndPassword( val member: MemberEntity = fetchMemberOrThrow(AuthErrorCode.LOGIN_FAILED) {
request.email, memberService.findByEmailAndPassword(request.email, request.password)
request.password }
)
val accessToken: String = jwtHandler.createToken(member.id!!) val accessToken: String = jwtHandler.createToken(member.id!!)
@ -25,8 +26,21 @@ class AuthService(
} }
fun checkLogin(memberId: Long): LoginCheckResponse { fun checkLogin(memberId: Long): LoginCheckResponse {
val member = memberService.findById(memberId) val member: MemberEntity = fetchMemberOrThrow(AuthErrorCode.UNIDENTIFIABLE_MEMBER) {
memberService.findById(memberId)
}
return LoginCheckResponse(member.name) return LoginCheckResponse(member.name)
} }
private fun fetchMemberOrThrow(
errorCode: AuthErrorCode,
block: () -> MemberEntity
): MemberEntity {
try {
return block()
} catch (_: Exception) {
throw AuthException(errorCode)
}
}
} }

View File

@ -9,7 +9,7 @@ data class LoginResponse(
) )
data class LoginCheckResponse( data class LoginCheckResponse(
@field:Schema(description = "로그인된 회원의 이름") @Schema(description = "로그인된 회원의 이름")
val name: String val name: String
) )

View File

@ -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
}
}
}

View File

@ -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<out Annotation>): 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
)
}
}
}

View File

@ -18,7 +18,6 @@ class MemberIdResolver(
return parameter.hasParameterAnnotation(MemberId::class.java) return parameter.hasParameterAnnotation(MemberId::class.java)
} }
@Throws(Exception::class)
override fun resolveArgument( override fun resolveArgument(
parameter: MethodParameter, parameter: MethodParameter,
mavContainer: ModelAndViewContainer?, mavContainer: ModelAndViewContainer?,

View File

@ -4,15 +4,13 @@ import org.springframework.context.annotation.Configuration
import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.servlet.config.annotation.InterceptorRegistry import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
import roomescape.auth.web.support.AdminInterceptor import roomescape.auth.web.support.AuthInterceptor
import roomescape.auth.web.support.LoginInterceptor
import roomescape.auth.web.support.MemberIdResolver import roomescape.auth.web.support.MemberIdResolver
@Configuration @Configuration
class WebMvcConfig( class WebMvcConfig(
private val memberIdResolver: MemberIdResolver, private val memberIdResolver: MemberIdResolver,
private val adminInterceptor: AdminInterceptor, private val authInterceptor: AuthInterceptor
private val loginInterceptor: LoginInterceptor
) : WebMvcConfigurer { ) : WebMvcConfigurer {
override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) { override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) {
@ -20,7 +18,6 @@ class WebMvcConfig(
} }
override fun addInterceptors(registry: InterceptorRegistry) { override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(adminInterceptor) registry.addInterceptor(authInterceptor)
registry.addInterceptor(loginInterceptor)
} }
} }

View File

@ -1,7 +1,7 @@
package roomescape.common.dto.response package roomescape.common.dto.response
import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonInclude
import roomescape.common.exception.ErrorType import roomescape.common.exception.ErrorCode
@JsonInclude(JsonInclude.Include.NON_NULL) @JsonInclude(JsonInclude.Include.NON_NULL)
data class CommonApiResponse<T>( data class CommonApiResponse<T>(
@ -9,6 +9,11 @@ data class CommonApiResponse<T>(
) )
data class CommonErrorResponse( data class CommonErrorResponse(
val errorType: ErrorType, val code: String,
val message: String? = errorType.description val message: String
) ) {
constructor(errorCode: ErrorCode, message: String = errorCode.message) : this(
code = errorCode.errorCode,
message = message
)
}

View File

@ -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 = "서버에 예상치 못한 오류가 발생했어요. 관리자에게 문의해주세요.",
),
}

View File

@ -0,0 +1,9 @@
package roomescape.common.exception
import org.springframework.http.HttpStatus
interface ErrorCode {
val httpStatus: HttpStatus
val errorCode: String
val message: String
}

View File

@ -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("결제 서버에서 에러가 발생하였습니다. 잠시 후 다시 시도해주세요.")
;
}

View File

@ -2,7 +2,6 @@ package roomescape.common.exception
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.http.converter.HttpMessageNotReadableException import org.springframework.http.converter.HttpMessageNotReadableException
import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.MethodArgumentNotValidException
@ -14,43 +13,46 @@ import roomescape.common.dto.response.CommonErrorResponse
class ExceptionControllerAdvice( class ExceptionControllerAdvice(
private val logger: KLogger = KotlinLogging.logger {} private val logger: KLogger = KotlinLogging.logger {}
) { ) {
@ExceptionHandler(value = [RoomescapeException::class]) @ExceptionHandler(value = [RoomescapeException::class])
fun handleRoomEscapeException(e: RoomescapeException): ResponseEntity<CommonErrorResponse> { fun handleRoomException(e: RoomescapeException): ResponseEntity<CommonErrorResponse> {
logger.error(e) { "message: ${e.message}, invalidValue: ${e.invalidValue}" } logger.error(e) { "message: ${e.message}" }
val errorCode: ErrorCode = e.errorCode
return ResponseEntity return ResponseEntity
.status(e.httpStatus) .status(errorCode.httpStatus)
.body(CommonErrorResponse(e.errorType)) .body(CommonErrorResponse(errorCode, e.message))
} }
@ExceptionHandler(value = [HttpMessageNotReadableException::class]) @ExceptionHandler(value = [HttpMessageNotReadableException::class])
fun handleHttpMessageNotReadableException(e: HttpMessageNotReadableException): ResponseEntity<CommonErrorResponse> { fun handleHttpMessageNotReadableException(e: HttpMessageNotReadableException): ResponseEntity<CommonErrorResponse> {
logger.error(e) { "message: ${e.message}" } logger.error(e) { "message: ${e.message}" }
val errorCode: ErrorCode = CommonErrorCode.INVALID_INPUT_VALUE
return ResponseEntity return ResponseEntity
.status(HttpStatus.BAD_REQUEST) .status(errorCode.httpStatus)
.body(CommonErrorResponse(ErrorType.INVALID_REQUEST_DATA_TYPE)) .body(CommonErrorResponse(errorCode))
} }
@ExceptionHandler(value = [MethodArgumentNotValidException::class]) @ExceptionHandler(value = [MethodArgumentNotValidException::class])
fun handleMethodArgumentNotValidException(e: MethodArgumentNotValidException): ResponseEntity<CommonErrorResponse> { fun handleMethodArgumentNotValidException(e: MethodArgumentNotValidException): ResponseEntity<CommonErrorResponse> {
val messages: String = e.bindingResult.allErrors val message: String = e.bindingResult.allErrors
.mapNotNull { it.defaultMessage } .mapNotNull { it.defaultMessage }
.joinToString(", ") .joinToString(", ")
logger.error(e) { "message: $messages" } logger.error(e) { "message: $message" }
val errorCode: ErrorCode = CommonErrorCode.INVALID_INPUT_VALUE
return ResponseEntity return ResponseEntity
.status(HttpStatus.BAD_REQUEST) .status(errorCode.httpStatus)
.body(CommonErrorResponse(ErrorType.INVALID_REQUEST_DATA, messages)) .body(CommonErrorResponse(errorCode))
} }
@ExceptionHandler(value = [Exception::class]) @ExceptionHandler(value = [Exception::class])
fun handleException(e: Exception): ResponseEntity<CommonErrorResponse> { fun handleException(e: Exception): ResponseEntity<CommonErrorResponse> {
logger.error(e) { "message: ${e.message}" } logger.error(e) { "message: ${e.message}" }
val errorCode: ErrorCode = CommonErrorCode.UNEXPECTED_SERVER_ERROR
return ResponseEntity return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR) .status(errorCode.httpStatus)
.body(CommonErrorResponse(ErrorType.UNEXPECTED_ERROR)) .body(CommonErrorResponse(errorCode))
} }
} }

View File

@ -1,11 +1,6 @@
package roomescape.common.exception package roomescape.common.exception
import org.springframework.http.HttpStatusCode open class RoomescapeException(
open val errorCode: ErrorCode,
class RoomescapeException( override val message: String = errorCode.message
val errorType: ErrorType, ) : RuntimeException(message)
val invalidValue: String? = "",
val httpStatus: HttpStatusCode,
) : RuntimeException(errorType.description) {
constructor(errorType: ErrorType, httpStatus: HttpStatusCode) : this(errorType, null, httpStatus)
}

View File

@ -1,11 +1,10 @@
package roomescape.member.business package roomescape.member.business
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import roomescape.common.exception.ErrorType import roomescape.member.exception.MemberErrorCode
import roomescape.common.exception.RoomescapeException import roomescape.member.exception.MemberException
import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.MemberRepository import roomescape.member.infrastructure.persistence.MemberRepository
import roomescape.member.web.MemberRetrieveListResponse import roomescape.member.web.MemberRetrieveListResponse
@ -17,27 +16,18 @@ class MemberService(
private val memberRepository: MemberRepository private val memberRepository: MemberRepository
) { ) {
fun findMembers(): MemberRetrieveListResponse = MemberRetrieveListResponse( fun findMembers(): MemberRetrieveListResponse = MemberRetrieveListResponse(
memberRepository.findAll() members = memberRepository.findAll().map { it.toRetrieveResponse() }
.map { it.toRetrieveResponse() }
.toList()
) )
fun findById(memberId: Long): MemberEntity = memberRepository.findByIdOrNull(memberId) fun findById(memberId: Long): MemberEntity = fetchOrThrow {
?: throw RoomescapeException( memberRepository.findByIdOrNull(memberId)
ErrorType.MEMBER_NOT_FOUND, }
String.format("[memberId: %d]", memberId),
HttpStatus.BAD_REQUEST
)
fun findByEmailAndPassword(email: String, password: String): MemberEntity = fun findByEmailAndPassword(email: String, password: String): MemberEntity = fetchOrThrow {
memberRepository.findByEmailAndPassword(email, password) 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)
private fun fetchOrThrow(block: () -> MemberEntity?): MemberEntity {
return block() ?: throw MemberException(MemberErrorCode.MEMBER_NOT_FOUND)
}
} }

View File

@ -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", "회원을 찾을 수 없어요.")
}

View File

@ -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)

View File

@ -9,10 +9,10 @@ fun MemberEntity.toRetrieveResponse(): MemberRetrieveResponse = MemberRetrieveRe
) )
data class MemberRetrieveResponse( data class MemberRetrieveResponse(
@field:Schema(description = "회원 식별자") @Schema(description = "회원 식별자")
val id: Long, val id: Long,
@field:Schema(description = "회원 이름") @Schema(description = "회원 이름")
val name: String val name: String
) )

View File

@ -1,10 +1,9 @@
package roomescape.payment.business package roomescape.payment.business
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import roomescape.common.exception.ErrorType import roomescape.payment.exception.PaymentErrorCode
import roomescape.common.exception.RoomescapeException import roomescape.payment.exception.PaymentException
import roomescape.payment.infrastructure.client.PaymentApproveResponse import roomescape.payment.infrastructure.client.PaymentApproveResponse
import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity import roomescape.payment.infrastructure.persistence.CanceledPaymentEntity
import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository
@ -24,44 +23,45 @@ class PaymentService(
) { ) {
@Transactional @Transactional
fun createPayment( fun createPayment(
paymentResponse: PaymentApproveResponse, approveResponse: PaymentApproveResponse,
reservation: ReservationEntity reservation: ReservationEntity
): PaymentCreateResponse = PaymentEntity( ): PaymentCreateResponse {
orderId = paymentResponse.orderId, val payment = PaymentEntity(
paymentKey = paymentResponse.paymentKey, orderId = approveResponse.orderId,
totalAmount = paymentResponse.totalAmount, paymentKey = approveResponse.paymentKey,
reservation = reservation, totalAmount = approveResponse.totalAmount,
approvedAt = paymentResponse.approvedAt reservation = reservation,
).also { approvedAt = approveResponse.approvedAt
paymentRepository.save(it) )
}.toCreateResponse()
return paymentRepository.save(payment).toCreateResponse()
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun isReservationPaid( fun isReservationPaid(reservationId: Long): Boolean = paymentRepository.existsByReservationId(reservationId)
reservationId: Long
): Boolean = paymentRepository.existsByReservationId(reservationId)
@Transactional @Transactional
fun createCanceledPayment( fun createCanceledPayment(
cancelInfo: PaymentCancelResponse, cancelInfo: PaymentCancelResponse,
approvedAt: OffsetDateTime, approvedAt: OffsetDateTime,
paymentKey: String paymentKey: String
): CanceledPaymentEntity = CanceledPaymentEntity( ): CanceledPaymentEntity {
paymentKey = paymentKey, val canceledPayment = CanceledPaymentEntity(
cancelReason = cancelInfo.cancelReason, paymentKey = paymentKey,
cancelAmount = cancelInfo.cancelAmount, cancelReason = cancelInfo.cancelReason,
approvedAt = approvedAt, cancelAmount = cancelInfo.cancelAmount,
canceledAt = cancelInfo.canceledAt approvedAt = approvedAt,
).also { canceledPaymentRepository.save(it) } canceledAt = cancelInfo.canceledAt
)
return canceledPaymentRepository.save(canceledPayment)
}
@Transactional @Transactional
fun createCanceledPaymentByReservationId(reservationId: Long): PaymentCancelRequest { fun createCanceledPaymentByReservationId(reservationId: Long): PaymentCancelRequest {
val paymentKey: String = paymentRepository.findPaymentKeyByReservationId(reservationId) val paymentKey: String = paymentRepository.findPaymentKeyByReservationId(reservationId)
?: throw RoomescapeException( ?: throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
ErrorType.PAYMENT_NOT_FOUND,
"[reservationId: $reservationId]",
HttpStatus.NOT_FOUND
)
// 취소 시간은 현재 시간으로 일단 생성한 뒤, 결제 취소 완료 후 해당 시간으로 변경합니다. // 취소 시간은 현재 시간으로 일단 생성한 뒤, 결제 취소 완료 후 해당 시간으로 변경합니다.
val canceled: CanceledPaymentEntity = cancelPayment(paymentKey) val canceled: CanceledPaymentEntity = cancelPayment(paymentKey)
@ -73,23 +73,19 @@ class PaymentService(
cancelReason: String = "고객 요청", cancelReason: String = "고객 요청",
canceledAt: OffsetDateTime = OffsetDateTime.now() canceledAt: OffsetDateTime = OffsetDateTime.now()
): CanceledPaymentEntity { ): CanceledPaymentEntity {
val paymentEntity: PaymentEntity = paymentRepository.findByPaymentKey(paymentKey) val payment: PaymentEntity = paymentRepository.findByPaymentKey(paymentKey)
?.also { paymentRepository.delete(it) } ?.also { paymentRepository.delete(it) }
?: throw RoomescapeException( ?: throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
ErrorType.PAYMENT_NOT_FOUND,
"[paymentKey: $paymentKey]",
HttpStatus.NOT_FOUND
)
return CanceledPaymentEntity( val canceledPayment = CanceledPaymentEntity(
paymentKey = paymentKey, paymentKey = paymentKey,
cancelReason = cancelReason, cancelReason = cancelReason,
cancelAmount = paymentEntity.totalAmount, cancelAmount = payment.totalAmount,
approvedAt = paymentEntity.approvedAt, approvedAt = payment.approvedAt,
canceledAt = canceledAt canceledAt = canceledAt
).also { )
canceledPaymentRepository.save(it)
} return canceledPaymentRepository.save(canceledPayment)
} }
@Transactional @Transactional
@ -97,12 +93,8 @@ class PaymentService(
paymentKey: String, paymentKey: String,
canceledAt: OffsetDateTime canceledAt: OffsetDateTime
) { ) {
canceledPaymentRepository.findByPaymentKey(paymentKey)?.let { canceledPaymentRepository.findByPaymentKey(paymentKey)
it.canceledAt = canceledAt ?.apply { this.canceledAt = canceledAt }
} ?: throw RoomescapeException( ?: throw PaymentException(PaymentErrorCode.PAYMENT_NOT_FOUND)
ErrorType.PAYMENT_NOT_FOUND,
"[paymentKey: $paymentKey]",
HttpStatus.NOT_FOUND
)
} }
} }

View File

@ -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", "시스템에 일시적인 오류가 발생했어요. 잠시 후 다시 시도해주세요.")
}

View File

@ -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)

View File

@ -6,13 +6,11 @@ import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import roomescape.payment.web.PaymentCancelResponse import roomescape.payment.web.PaymentCancelResponse
import java.io.IOException
import java.time.OffsetDateTime import java.time.OffsetDateTime
class PaymentCancelResponseDeserializer( class PaymentCancelResponseDeserializer(
vc: Class<PaymentCancelResponse>? = null vc: Class<PaymentCancelResponse>? = null
) : StdDeserializer<PaymentCancelResponse>(vc) { ) : StdDeserializer<PaymentCancelResponse>(vc) {
@Throws(IOException::class)
override fun deserialize( override fun deserialize(
jsonParser: JsonParser, jsonParser: JsonParser,
deserializationContext: DeserializationContext? deserializationContext: DeserializationContext?

View File

@ -4,17 +4,15 @@ import com.fasterxml.jackson.databind.ObjectMapper
import io.github.oshai.kotlinlogging.KLogger import io.github.oshai.kotlinlogging.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.http.HttpRequest import org.springframework.http.HttpRequest
import org.springframework.http.HttpStatus
import org.springframework.http.HttpStatusCode import org.springframework.http.HttpStatusCode
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.http.client.ClientHttpResponse import org.springframework.http.client.ClientHttpResponse
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient import org.springframework.web.client.RestClient
import roomescape.common.exception.ErrorType import roomescape.payment.exception.PaymentErrorCode
import roomescape.common.exception.RoomescapeException import roomescape.payment.exception.PaymentException
import roomescape.payment.web.PaymentCancelRequest import roomescape.payment.web.PaymentCancelRequest
import roomescape.payment.web.PaymentCancelResponse import roomescape.payment.web.PaymentCancelResponse
import java.io.IOException
import java.util.Map import java.util.Map
@Component @Component
@ -43,7 +41,7 @@ class TossPaymentClient(
{ req: HttpRequest, res: ClientHttpResponse -> handlePaymentError(res) } { req: HttpRequest, res: ClientHttpResponse -> handlePaymentError(res) }
) )
.body(PaymentApproveResponse::class.java) .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 { fun cancel(cancelRequest: PaymentCancelRequest): PaymentCancelResponse {
@ -60,7 +58,7 @@ class TossPaymentClient(
{ req: HttpRequest, res: ClientHttpResponse -> handlePaymentError(res) } { req: HttpRequest, res: ClientHttpResponse -> handlePaymentError(res) }
) )
.body(PaymentCancelResponse::class.java) .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) { private fun logPaymentInfo(paymentRequest: PaymentApproveRequest) {
@ -77,37 +75,28 @@ class TossPaymentClient(
} }
} }
@Throws(IOException::class)
private fun handlePaymentError( private fun handlePaymentError(
res: ClientHttpResponse res: ClientHttpResponse
): Nothing { ): Nothing {
val statusCode = res.statusCode getErrorCodeByHttpStatus(res.statusCode).also {
val errorType = getErrorTypeByStatusCode(statusCode) logTossPaymentError(res)
val errorResponse = getErrorResponse(res) throw PaymentException(it)
}
throw RoomescapeException(
errorType,
"[ErrorCode = ${errorResponse.code}, ErrorMessage = ${errorResponse.message}]",
statusCode
)
} }
@Throws(IOException::class) private fun logTossPaymentError(res: ClientHttpResponse): TossPaymentErrorResponse {
private fun getErrorResponse(
res: ClientHttpResponse
): TossPaymentErrorResponse {
val body = res.body val body = res.body
val errorResponse = objectMapper.readValue(body, TossPaymentErrorResponse::class.java) val errorResponse = objectMapper.readValue(body, TossPaymentErrorResponse::class.java)
body.close() body.close()
log.error { "결제 실패. response: $errorResponse" }
return errorResponse return errorResponse
} }
private fun getErrorTypeByStatusCode( private fun getErrorCodeByHttpStatus(statusCode: HttpStatusCode): PaymentErrorCode {
statusCode: HttpStatusCode
): ErrorType {
if (statusCode.is4xxClientError) { if (statusCode.is4xxClientError) {
return ErrorType.PAYMENT_ERROR return PaymentErrorCode.PAYMENT_CLIENT_ERROR
} }
return ErrorType.PAYMENT_SERVER_ERROR return PaymentErrorCode.PAYMENT_PROVIDER_ERROR
} }
} }

View File

@ -2,15 +2,21 @@ package roomescape.reservation.business
import org.springframework.data.jpa.domain.Specification import org.springframework.data.jpa.domain.Specification
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import roomescape.common.exception.ErrorType
import roomescape.common.exception.RoomescapeException
import roomescape.member.business.MemberService 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.reservation.web.*
import roomescape.theme.business.ThemeService 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.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
@ -29,7 +35,6 @@ class ReservationService(
.confirmed() .confirmed()
.build() .build()
return ReservationRetrieveListResponse(findAllReservationByStatus(spec)) return ReservationRetrieveListResponse(findAllReservationByStatus(spec))
} }
@ -51,17 +56,18 @@ class ReservationService(
reservationRepository.deleteById(reservationId) reservationRepository.deleteById(reservationId)
} }
fun addReservation(request: ReservationCreateWithPaymentRequest, memberId: Long): ReservationEntity { fun createConfirmedReservation(
validateIsReservationExist(request.themeId, request.timeId, request.date) request: ReservationCreateWithPaymentRequest,
return getReservationForSave( memberId: Long
request.timeId, ): ReservationEntity {
request.themeId, val themeId = request.themeId
request.date, val timeId = request.timeId
memberId, val date: LocalDate = request.date
ReservationStatus.CONFIRMED validateIsReservationExist(themeId, timeId, date)
).also {
reservationRepository.save(it) val reservation: ReservationEntity = createEntity(timeId, themeId, date, memberId, ReservationStatus.CONFIRMED)
}
return reservationRepository.save(reservation)
} }
fun createReservationByAdmin(request: AdminReservationCreateRequest): ReservationRetrieveResponse { fun createReservationByAdmin(request: AdminReservationCreateRequest): ReservationRetrieveResponse {
@ -93,12 +99,12 @@ class ReservationService(
date: LocalDate, date: LocalDate,
memberId: Long, memberId: Long,
status: ReservationStatus status: ReservationStatus
): ReservationRetrieveResponse = getReservationForSave(timeId, themeId, date, memberId, status) ): ReservationRetrieveResponse = createEntity(timeId, themeId, date, memberId, status)
.also { .also {
reservationRepository.save(it) reservationRepository.save(it)
}.toRetrieveResponse() }.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<ReservationEntity> = ReservationSearchSpecification() val spec: Specification<ReservationEntity> = ReservationSearchSpecification()
.sameMemberId(memberId) .sameMemberId(memberId)
.sameThemeId(themeId) .sameThemeId(themeId)
@ -107,7 +113,7 @@ class ReservationService(
.build() .build()
if (reservationRepository.exists(spec)) { 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() .build()
if (reservationRepository.exists(spec)) { 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) val request = LocalDateTime.of(requestDate, requestTime.startAt)
if (request.isBefore(now)) { if (request.isBefore(now)) {
throw RoomescapeException( throw ReservationException(ReservationErrorCode.PAST_REQUEST_DATETIME)
ErrorType.RESERVATION_PERIOD_IN_PAST,
"[now: $now | request: $request]",
HttpStatus.BAD_REQUEST
)
} }
} }
private fun getReservationForSave( private fun createEntity(
timeId: Long, timeId: Long,
themeId: Long, themeId: Long,
date: LocalDate, date: LocalDate,
memberId: Long, memberId: Long,
status: ReservationStatus status: ReservationStatus
): ReservationEntity { ): ReservationEntity {
val time = timeService.findById(timeId) val time: TimeEntity = timeService.findById(timeId)
val theme = themeService.findById(themeId) val theme: ThemeEntity = themeService.findById(themeId)
val member = memberService.findById(memberId) val member: MemberEntity = memberService.findById(memberId)
validateDateAndTime(date, time) validateDateAndTime(date, time)
@ -186,58 +188,54 @@ class ReservationService(
return return
} }
if (startFrom.isAfter(endAt)) { if (startFrom.isAfter(endAt)) {
throw RoomescapeException( throw ReservationException(ReservationErrorCode.INVALID_SEARCH_DATE_RANGE)
ErrorType.INVALID_DATE_RANGE,
"[startFrom: $startFrom, endAt: $endAt", HttpStatus.BAD_REQUEST
)
} }
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findReservationsByMemberId(memberId: Long): MyReservationRetrieveListResponse { fun findReservationsByMemberId(memberId: Long): MyReservationRetrieveListResponse {
return MyReservationRetrieveListResponse(reservationRepository.findAllById(memberId)) return MyReservationRetrieveListResponse(reservationRepository.findAllByMemberId(memberId))
} }
fun confirmWaiting(reservationId: Long, memberId: Long) { fun confirmWaiting(reservationId: Long, memberId: Long) {
validateIsMemberAdmin(memberId) validateIsMemberAdmin(memberId)
if (reservationRepository.isExistConfirmedReservation(reservationId)) { 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) reservationRepository.updateStatusByReservationId(reservationId, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED)
} }
fun deleteWaiting(reservationId: Long, memberId: Long) { fun deleteWaiting(reservationId: Long, memberId: Long) {
reservationRepository.findByIdOrNull(reservationId)?.takeIf { val reservation: ReservationEntity = findReservationOrThrow(reservationId)
it.isWaiting() && it.isSameMember(memberId) if (!reservation.isWaiting()) {
}?.let { throw ReservationException(ReservationErrorCode.ALREADY_CONFIRMED)
reservationRepository.delete(it) }
} ?: throw throwReservationNotFound(reservationId) if (!reservation.isReservedBy(memberId)) {
throw ReservationException(ReservationErrorCode.NOT_RESERVATION_OWNER)
}
reservationRepository.delete(reservation)
} }
fun rejectWaiting(reservationId: Long, memberId: Long) { fun rejectWaiting(reservationId: Long, memberId: Long) {
validateIsMemberAdmin(memberId) validateIsMemberAdmin(memberId)
reservationRepository.findByIdOrNull(reservationId)?.takeIf { val reservation: ReservationEntity = findReservationOrThrow(reservationId)
it.isWaiting()
}?.let { if (!reservation.isWaiting()) {
reservationRepository.delete(it) throw ReservationException(ReservationErrorCode.ALREADY_CONFIRMED)
} ?: throw throwReservationNotFound(reservationId) }
reservationRepository.delete(reservation)
} }
private fun validateIsMemberAdmin(memberId: Long) { private fun validateIsMemberAdmin(memberId: Long) {
memberService.findById(memberId).takeIf { val member: MemberEntity = memberService.findById(memberId)
it.isAdmin() if (member.isAdmin()) {
} ?: throw RoomescapeException( return
ErrorType.PERMISSION_DOES_NOT_EXIST, }
"[memberId: $memberId]", throw ReservationException(ReservationErrorCode.NO_PERMISSION)
HttpStatus.FORBIDDEN
)
} }
private fun throwReservationNotFound(reservationId: Long?): RoomescapeException { private fun findReservationOrThrow(reservationId: Long): ReservationEntity {
return RoomescapeException( return reservationRepository.findByIdOrNull(reservationId)
ErrorType.RESERVATION_NOT_FOUND, ?: throw ReservationException(ReservationErrorCode.RESERVATION_NOT_FOUND)
"[reservationId: $reservationId]",
HttpStatus.NOT_FOUND
)
} }
} }

View File

@ -22,7 +22,7 @@ class ReservationWithPaymentService(
paymentInfo: PaymentApproveResponse, paymentInfo: PaymentApproveResponse,
memberId: Long memberId: Long
): ReservationRetrieveResponse { ): ReservationRetrieveResponse {
val reservation: ReservationEntity = reservationService.addReservation(request, memberId) val reservation: ReservationEntity = reservationService.createConfirmedReservation(request, memberId)
return paymentService.createPayment(paymentInfo, reservation) return paymentService.createPayment(paymentInfo, reservation)
.reservation .reservation

View File

@ -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", "접근 권한이 없어요."),
}

View File

@ -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)

View File

@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore
import jakarta.persistence.* import jakarta.persistence.*
import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalDate import java.time.LocalDate
@Entity @Entity
@ -34,7 +35,7 @@ class ReservationEntity(
fun isWaiting(): Boolean = reservationStatus == ReservationStatus.WAITING fun isWaiting(): Boolean = reservationStatus == ReservationStatus.WAITING
@JsonIgnore @JsonIgnore
fun isSameMember(memberId: Long): Boolean { fun isReservedBy(memberId: Long): Boolean {
return this.member.id == memberId return this.member.id == memberId
} }
} }

View File

@ -6,11 +6,12 @@ import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param import org.springframework.data.repository.query.Param
import roomescape.reservation.web.MyReservationRetrieveResponse import roomescape.reservation.web.MyReservationRetrieveResponse
import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalDate import java.time.LocalDate
interface ReservationRepository interface ReservationRepository
: JpaRepository<ReservationEntity, Long>, JpaSpecificationExecutor<ReservationEntity> { : JpaRepository<ReservationEntity, Long>, JpaSpecificationExecutor<ReservationEntity> {
fun findByTime(time: TimeEntity): List<ReservationEntity> fun findAllByTime(time: TimeEntity): List<ReservationEntity>
fun findByDateAndThemeId(date: LocalDate, themeId: Long): List<ReservationEntity> fun findByDateAndThemeId(date: LocalDate, themeId: Long): List<ReservationEntity>
@ -58,5 +59,5 @@ interface ReservationRepository
ON p.reservation = r ON p.reservation = r
WHERE r.member.id = :memberId WHERE r.member.id = :memberId
""") """)
fun findAllById(memberId: Long): List<MyReservationRetrieveResponse> fun findAllByMemberId(memberId: Long): List<MyReservationRetrieveResponse>
} }

View File

@ -3,6 +3,7 @@ package roomescape.reservation.infrastructure.persistence
import org.springframework.data.jpa.domain.Specification import org.springframework.data.jpa.domain.Specification
import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalDate import java.time.LocalDate
class ReservationSearchSpecification( class ReservationSearchSpecification(

View File

@ -6,7 +6,6 @@ import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
import roomescape.auth.web.support.MemberId import roomescape.auth.web.support.MemberId
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
import roomescape.common.exception.RoomescapeException
import roomescape.payment.infrastructure.client.PaymentApproveRequest import roomescape.payment.infrastructure.client.PaymentApproveRequest
import roomescape.payment.infrastructure.client.PaymentApproveResponse import roomescape.payment.infrastructure.client.PaymentApproveResponse
import roomescape.payment.infrastructure.client.TossPaymentClient import roomescape.payment.infrastructure.client.TossPaymentClient
@ -90,7 +89,7 @@ class ReservationController(
) )
return ResponseEntity.created(URI.create("/reservations/${reservationRetrieveResponse.id}")) return ResponseEntity.created(URI.create("/reservations/${reservationRetrieveResponse.id}"))
.body(CommonApiResponse(reservationRetrieveResponse)) .body(CommonApiResponse(reservationRetrieveResponse))
} catch (e: RoomescapeException) { } catch (e: Exception) {
val cancelRequest = PaymentCancelRequest(paymentRequest.paymentKey, val cancelRequest = PaymentCancelRequest(paymentRequest.paymentKey,
paymentRequest.amount, e.message!!) paymentRequest.amount, e.message!!)
val paymentCancelResponse = paymentClient.cancel(cancelRequest) val paymentCancelResponse = paymentClient.cancel(cancelRequest)

View File

@ -16,16 +16,16 @@ data class ReservationCreateWithPaymentRequest(
val timeId: Long, val timeId: Long,
val themeId: Long, val themeId: Long,
@field:Schema(description = "결제 위젯을 통해 받은 결제 키") @Schema(description = "결제 위젯을 통해 받은 결제 키")
val paymentKey: String, val paymentKey: String,
@field:Schema(description = "결제 위젯을 통해 받은 주문번호.") @Schema(description = "결제 위젯을 통해 받은 주문번호.")
val orderId: String, val orderId: String,
@field:Schema(description = "결제 위젯을 통해 받은 결제 금액") @Schema(description = "결제 위젯을 통해 받은 결제 금액")
val amount: Long, val amount: Long,
@field:Schema(description = "결제 타입", example = "NORMAL") @Schema(description = "결제 타입", example = "NORMAL")
val paymentType: String val paymentType: String
) )

View File

@ -6,8 +6,10 @@ import roomescape.member.web.MemberRetrieveResponse
import roomescape.member.web.toRetrieveResponse import roomescape.member.web.toRetrieveResponse
import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.theme.web.ThemeResponse import roomescape.theme.web.ThemeRetrieveResponse
import roomescape.theme.web.toResponse import roomescape.theme.web.toResponse
import roomescape.time.web.TimeCreateResponse
import roomescape.time.web.toCreateResponse
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
@ -17,16 +19,16 @@ data class MyReservationRetrieveResponse(
val date: LocalDate, val date: LocalDate,
val time: LocalTime, val time: LocalTime,
val status: ReservationStatus, val status: ReservationStatus,
@field:Schema(description = "대기 순번. 확정된 예약은 0의 값을 가집니다.") @Schema(description = "대기 순번. 확정된 예약은 0의 값을 가집니다.")
val rank: Long, val rank: Long,
@field:Schema(description = "결제 키. 결제가 완료된 예약에만 값이 존재합니다.") @Schema(description = "결제 키. 결제가 완료된 예약에만 값이 존재합니다.")
val paymentKey: String?, val paymentKey: String?,
@field:Schema(description = "결제 금액. 결제가 완료된 예약에만 값이 존재합니다.") @Schema(description = "결제 금액. 결제가 완료된 예약에만 값이 존재합니다.")
val amount: Long? val amount: Long?
) )
data class MyReservationRetrieveListResponse( data class MyReservationRetrieveListResponse(
@field:Schema(description = "현재 로그인한 회원의 예약 및 대기 목록") @Schema(description = "현재 로그인한 회원의 예약 및 대기 목록")
val reservations: List<MyReservationRetrieveResponse> val reservations: List<MyReservationRetrieveResponse>
) )
@ -41,7 +43,7 @@ data class ReservationRetrieveResponse(
val time: TimeCreateResponse, val time: TimeCreateResponse,
@field:JsonProperty("theme") @field:JsonProperty("theme")
val theme: ThemeResponse, val theme: ThemeRetrieveResponse,
val status: ReservationStatus val status: ReservationStatus
) )

View File

@ -1,17 +1,13 @@
package roomescape.theme.business package roomescape.theme.business
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import roomescape.common.exception.ErrorType import roomescape.theme.exception.ThemeErrorCode
import roomescape.common.exception.RoomescapeException import roomescape.theme.exception.ThemeException
import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.theme.infrastructure.persistence.ThemeRepository import roomescape.theme.infrastructure.persistence.ThemeRepository
import roomescape.theme.web.ThemeRequest import roomescape.theme.web.*
import roomescape.theme.web.ThemeResponse
import roomescape.theme.web.ThemesResponse
import roomescape.theme.web.toResponse
import java.time.LocalDate import java.time.LocalDate
@Service @Service
@ -20,18 +16,14 @@ class ThemeService(
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findById(id: Long): ThemeEntity = themeRepository.findByIdOrNull(id) fun findById(id: Long): ThemeEntity = themeRepository.findByIdOrNull(id)
?: throw RoomescapeException( ?: throw ThemeException(ThemeErrorCode.THEME_NOT_FOUND)
ErrorType.THEME_NOT_FOUND,
"[themeId: $id]",
HttpStatus.BAD_REQUEST
)
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findThemes(): ThemesResponse = themeRepository.findAll() fun findThemes(): ThemeRetrieveListResponse = themeRepository.findAll()
.toResponse() .toResponse()
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findMostReservedThemes(count: Int): ThemesResponse { fun findMostReservedThemes(count: Int): ThemeRetrieveListResponse {
val today = LocalDate.now() val today = LocalDate.now()
val startDate = today.minusDays(7) val startDate = today.minusDays(7)
val endDate = today.minusDays(1) val endDate = today.minusDays(1)
@ -41,33 +33,21 @@ class ThemeService(
} }
@Transactional @Transactional
fun createTheme(request: ThemeRequest): ThemeResponse { fun createTheme(request: ThemeCreateRequest): ThemeRetrieveResponse {
if (themeRepository.existsByName(request.name)) { if (themeRepository.existsByName(request.name)) {
throw RoomescapeException( throw ThemeException(ThemeErrorCode.THEME_NAME_DUPLICATED)
ErrorType.THEME_DUPLICATED,
"[name: ${request.name}]",
HttpStatus.CONFLICT
)
} }
return ThemeEntity( val theme: ThemeEntity = request.toEntity()
name = request.name, return themeRepository.save(theme).toResponse()
description = request.description,
thumbnail = request.thumbnail
).also {
themeRepository.save(it)
}.toResponse()
} }
@Transactional @Transactional
fun deleteTheme(id: Long) { fun deleteTheme(id: Long) {
if (themeRepository.isReservedTheme(id)) { if (themeRepository.isReservedTheme(id)) {
throw RoomescapeException( throw ThemeException(ThemeErrorCode.THEME_ALREADY_RESERVED)
ErrorType.THEME_IS_USED_CONFLICT,
"[themeId: %d]",
HttpStatus.CONFLICT
)
} }
themeRepository.deleteById(id) themeRepository.deleteById(id)
} }
} }

View File

@ -13,9 +13,9 @@ import org.springframework.web.bind.annotation.RequestParam
import roomescape.auth.web.support.Admin import roomescape.auth.web.support.Admin
import roomescape.auth.web.support.LoginRequired import roomescape.auth.web.support.LoginRequired
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
import roomescape.theme.web.ThemeRequest import roomescape.theme.web.ThemeCreateRequest
import roomescape.theme.web.ThemeResponse import roomescape.theme.web.ThemeRetrieveListResponse
import roomescape.theme.web.ThemesResponse import roomescape.theme.web.ThemeRetrieveResponse
@Tag(name = "5. 테마 API", description = "테마를 조회 / 추가 / 삭제할 때 사용합니다.") @Tag(name = "5. 테마 API", description = "테마를 조회 / 추가 / 삭제할 때 사용합니다.")
interface ThemeAPI { interface ThemeAPI {
@ -23,13 +23,13 @@ interface ThemeAPI {
@LoginRequired @LoginRequired
@Operation(summary = "모든 테마 조회", description = "모든 테마를 조회합니다.", tags = ["로그인이 필요한 API"]) @Operation(summary = "모든 테마 조회", description = "모든 테마를 조회합니다.", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findThemes(): ResponseEntity<CommonApiResponse<ThemesResponse>> fun findThemes(): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponse>>
@Operation(summary = "가장 많이 예약된 테마 조회") @Operation(summary = "가장 많이 예약된 테마 조회")
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)) @ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findMostReservedThemes( fun findMostReservedThemes(
@RequestParam(defaultValue = "10") @Parameter(description = "최대로 조회할 테마 갯수") count: Int @RequestParam(defaultValue = "10") @Parameter(description = "최대로 조회할 테마 갯수") count: Int
): ResponseEntity<CommonApiResponse<ThemesResponse>> ): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponse>>
@Admin @Admin
@Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"])
@ -37,8 +37,8 @@ interface ThemeAPI {
ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true), ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true),
) )
fun createTheme( fun createTheme(
@Valid @RequestBody request: ThemeRequest, @Valid @RequestBody request: ThemeCreateRequest,
): ResponseEntity<CommonApiResponse<ThemeResponse>> ): ResponseEntity<CommonApiResponse<ThemeRetrieveResponse>>
@Admin @Admin
@Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"]) @Operation(summary = "테마 삭제", tags = ["관리자 로그인이 필요한 API"])

View File

@ -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", "예약된 테마라 삭제할 수 없어요.")
}

View File

@ -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)

View File

@ -15,8 +15,8 @@ class ThemeController(
) : ThemeAPI { ) : ThemeAPI {
@GetMapping("/themes") @GetMapping("/themes")
override fun findThemes(): ResponseEntity<CommonApiResponse<ThemesResponse>> { override fun findThemes(): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponse>> {
val response: ThemesResponse = themeService.findThemes() val response: ThemeRetrieveListResponse = themeService.findThemes()
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }
@ -24,17 +24,17 @@ class ThemeController(
@GetMapping("/themes/most-reserved-last-week") @GetMapping("/themes/most-reserved-last-week")
override fun findMostReservedThemes( override fun findMostReservedThemes(
@RequestParam(defaultValue = "10") @Parameter(description = "최대로 조회할 테마 갯수") count: Int @RequestParam(defaultValue = "10") @Parameter(description = "최대로 조회할 테마 갯수") count: Int
): ResponseEntity<CommonApiResponse<ThemesResponse>> { ): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponse>> {
val response: ThemesResponse = themeService.findMostReservedThemes(count) val response: ThemeRetrieveListResponse = themeService.findMostReservedThemes(count)
return ResponseEntity.ok(CommonApiResponse(response)) return ResponseEntity.ok(CommonApiResponse(response))
} }
@PostMapping("/themes") @PostMapping("/themes")
override fun createTheme( override fun createTheme(
@RequestBody @Valid request: ThemeRequest @RequestBody @Valid request: ThemeCreateRequest
): ResponseEntity<CommonApiResponse<ThemeResponse>> { ): ResponseEntity<CommonApiResponse<ThemeRetrieveResponse>> {
val themeResponse: ThemeResponse = themeService.createTheme(request) val themeResponse: ThemeRetrieveResponse = themeService.createTheme(request)
return ResponseEntity.created(URI.create("/themes/${themeResponse.id}")) return ResponseEntity.created(URI.create("/themes/${themeResponse.id}"))
.body(CommonApiResponse(themeResponse)) .body(CommonApiResponse(themeResponse))

View File

@ -6,52 +6,46 @@ import jakarta.validation.constraints.Size
import org.hibernate.validator.constraints.URL import org.hibernate.validator.constraints.URL
import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeEntity
@Schema(name = "테마 저장 요청", description = "테마 정보를 저장할 때 사용합니다.") data class ThemeCreateRequest(
data class ThemeRequest(
@field:Schema(description = "필수 값이며, 최대 20글자까지 입력 가능합니다.")
@NotBlank @NotBlank
@Size(max = 20, message = "테마의 이름은 1~20글자 사이여야 합니다.") @Size(max = 20)
val name: String, val name: String,
@field:Schema(description = "필수 값이며, 최대 100글자까지 입력 가능합니다.")
@NotBlank @NotBlank
@Size(max = 100, message = "테마의 설명은 1~100글자 사이여야 합니다.") @Size(max = 100)
val description: String, val description: String,
@field:Schema(description = "필수 값이며, 썸네일 이미지 URL 을 입력해주세요.")
@NotBlank
@URL @URL
@NotBlank
@Schema(description = "썸네일 이미지 주소(URL).")
val thumbnail: String val thumbnail: String
) )
@Schema(name = "테마 정보", description = "테마 추가 및 조회 응답에 사용됩니다.") fun ThemeCreateRequest.toEntity(): ThemeEntity = ThemeEntity(
data class ThemeResponse( name = this.name,
@field:Schema(description = "테마 번호. 테마를 식별할 때 사용합니다.") description = this.description,
thumbnail = this.thumbnail
)
data class ThemeRetrieveResponse(
val id: Long, val id: Long,
@field:Schema(description = "테마 이름. 중복을 허용하지 않습니다.")
val name: String, val name: String,
@field:Schema(description = "테마 설명")
val description: String, val description: String,
@Schema(description = "썸네일 이미지 주소(URL).")
@field:Schema(description = "테마 썸네일 이미지 URL")
val thumbnail: String val thumbnail: String
) )
fun ThemeEntity.toResponse(): ThemeResponse = ThemeResponse( fun ThemeEntity.toResponse(): ThemeRetrieveResponse = ThemeRetrieveResponse(
id = this.id!!, id = this.id!!,
name = this.name, name = this.name,
description = this.description, description = this.description,
thumbnail = this.thumbnail thumbnail = this.thumbnail
) )
@Schema(name = "테마 목록 조회 응답", description = "모든 테마 목록 조회 응답시 사용됩니다.") data class ThemeRetrieveListResponse(
data class ThemesResponse( val themes: List<ThemeRetrieveResponse>
@field:Schema(description = "모든 테마 목록")
val themes: List<ThemeResponse>
) )
fun List<ThemeEntity>.toResponse(): ThemesResponse = ThemesResponse( fun List<ThemeEntity>.toResponse(): ThemeRetrieveListResponse = ThemeRetrieveListResponse(
themes = this.map { it.toResponse() } themes = this.map { it.toResponse() }
) )

View File

@ -1,16 +1,15 @@
package roomescape.reservation.business package roomescape.time.business
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional 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.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationRepository import roomescape.reservation.infrastructure.persistence.ReservationRepository
import roomescape.reservation.infrastructure.persistence.TimeEntity import roomescape.time.exception.TimeErrorCode
import roomescape.reservation.infrastructure.persistence.TimeRepository import roomescape.time.exception.TimeException
import roomescape.reservation.web.* import roomescape.time.infrastructure.persistence.TimeEntity
import roomescape.time.infrastructure.persistence.TimeRepository
import roomescape.time.web.*
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
@ -21,42 +20,33 @@ class TimeService(
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findById(id: Long): TimeEntity = timeRepository.findByIdOrNull(id) fun findById(id: Long): TimeEntity = timeRepository.findByIdOrNull(id)
?: throw RoomescapeException( ?: throw TimeException(TimeErrorCode.TIME_NOT_FOUND)
ErrorType.TIME_NOT_FOUND,
"[timeId: $id]",
HttpStatus.BAD_REQUEST
)
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun findTimes(): TimeRetrieveListResponse = timeRepository.findAll().toRetrieveListResponse() fun findTimes(): TimeRetrieveListResponse = timeRepository.findAll()
.toResponse()
@Transactional @Transactional
fun createTime(timeCreateRequest: TimeCreateRequest): TimeCreateResponse { fun createTime(request: TimeCreateRequest): TimeCreateResponse {
val startAt: LocalTime = timeCreateRequest.startAt val startAt: LocalTime = request.startAt
if (timeRepository.existsByStartAt(startAt)) { if (timeRepository.existsByStartAt(startAt)) {
throw RoomescapeException( throw TimeException(TimeErrorCode.TIME_DUPLICATED)
ErrorType.TIME_DUPLICATED, "[startAt: $startAt]", HttpStatus.CONFLICT
)
} }
return TimeEntity(startAt = startAt) val time: TimeEntity = request.toEntity()
.also { timeRepository.save(it) }
.toCreateResponse() return timeRepository.save(time).toCreateResponse()
} }
@Transactional @Transactional
fun deleteTime(id: Long) { fun deleteTime(id: Long) {
val time: TimeEntity = findById(id) val time: TimeEntity = findById(id)
reservationRepository.findByTime(time) val reservations: List<ReservationEntity> = reservationRepository.findAllByTime(time)
.also {
if (it.isNotEmpty()) { if (reservations.isNotEmpty()) {
throw RoomescapeException( throw TimeException(TimeErrorCode.TIME_ALREADY_RESERVED)
ErrorType.TIME_IS_USED_CONFLICT, "[timeId: $id]", HttpStatus.CONFLICT }
) timeRepository.delete(time)
}
timeRepository.deleteById(id)
}
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
@ -66,7 +56,6 @@ class TimeService(
return TimeWithAvailabilityListResponse(allTimes.map { time -> return TimeWithAvailabilityListResponse(allTimes.map { time ->
val isAvailable: Boolean = reservations.none { reservation -> reservation.time.id == time.id } val isAvailable: Boolean = reservations.none { reservation -> reservation.time.id == time.id }
TimeWithAvailabilityResponse(time.id!!, time.startAt, isAvailable) TimeWithAvailabilityResponse(time.id!!, time.startAt, isAvailable)
}) })
} }

View File

@ -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.Operation
import io.swagger.v3.oas.annotations.responses.ApiResponse 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.Admin
import roomescape.auth.web.support.LoginRequired import roomescape.auth.web.support.LoginRequired
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
import roomescape.reservation.web.TimeCreateRequest import roomescape.time.web.TimeCreateRequest
import roomescape.reservation.web.TimeCreateResponse import roomescape.time.web.TimeCreateResponse
import roomescape.reservation.web.TimeRetrieveListResponse import roomescape.time.web.TimeRetrieveListResponse
import roomescape.reservation.web.TimeWithAvailabilityListResponse import roomescape.time.web.TimeWithAvailabilityListResponse
import java.time.LocalDate import java.time.LocalDate
@Tag(name = "4. 예약 시간 API", description = "예약 시간을 조회 / 추가 / 삭제할 때 사용합니다.") @Tag(name = "4. 예약 시간 API", description = "예약 시간을 조회 / 추가 / 삭제할 때 사용합니다.")
@ -47,4 +47,4 @@ interface TimeAPI {
@RequestParam date: LocalDate, @RequestParam date: LocalDate,
@RequestParam themeId: Long @RequestParam themeId: Long
): ResponseEntity<CommonApiResponse<TimeWithAvailabilityListResponse>> ): ResponseEntity<CommonApiResponse<TimeWithAvailabilityListResponse>>
} }

View File

@ -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", "예약된 시간이라 삭제할 수 없어요.")
}

View File

@ -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)

View File

@ -1,4 +1,4 @@
package roomescape.reservation.infrastructure.persistence package roomescape.time.infrastructure.persistence
import jakarta.persistence.* import jakarta.persistence.*
import java.time.LocalTime import java.time.LocalTime

View File

@ -1,4 +1,4 @@
package roomescape.reservation.infrastructure.persistence package roomescape.time.infrastructure.persistence
import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.JpaRepository
import java.time.LocalTime import java.time.LocalTime

View File

@ -1,11 +1,11 @@
package roomescape.reservation.web package roomescape.time.web
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
import roomescape.common.dto.response.CommonApiResponse import roomescape.common.dto.response.CommonApiResponse
import roomescape.reservation.business.TimeService import roomescape.time.business.TimeService
import roomescape.reservation.docs.TimeAPI import roomescape.time.docs.TimeAPI
import java.net.URI import java.net.URI
import java.time.LocalDate import java.time.LocalDate

View File

@ -1,52 +1,54 @@
package roomescape.reservation.web package roomescape.time.web
import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.media.Schema
import roomescape.reservation.infrastructure.persistence.TimeEntity import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalTime import java.time.LocalTime
@Schema(name = "예약 시간 저장 요청", description = "예약 시간 저장 요청시 사용됩니다.") @Schema(name = "예약 시간 저장 요청", description = "예약 시간 저장 요청시 사용됩니다.")
data class TimeCreateRequest( data class TimeCreateRequest(
@field:Schema(description = "시간", type = "string", example = "09:00") @Schema(description = "시간", type = "string", example = "09:00")
val startAt: LocalTime val startAt: LocalTime
) )
fun TimeCreateRequest.toEntity(): TimeEntity = TimeEntity(startAt = this.startAt)
@Schema(name = "예약 시간 정보", description = "예약 시간 추가 및 조회 응답시 사용됩니다.") @Schema(name = "예약 시간 정보", description = "예약 시간 추가 및 조회 응답시 사용됩니다.")
data class TimeCreateResponse( data class TimeCreateResponse(
@field:Schema(description = "시간 식별자") @Schema(description = "시간 식별자")
val id: Long, val id: Long,
@field:Schema(description = "시간") @Schema(description = "시간")
val startAt: LocalTime val startAt: LocalTime
) )
fun TimeEntity.toCreateResponse(): TimeCreateResponse = TimeCreateResponse(this.id!!, this.startAt) fun TimeEntity.toCreateResponse(): TimeCreateResponse = TimeCreateResponse(this.id!!, this.startAt)
data class TimeRetrieveResponse( data class TimeRetrieveResponse(
@field:Schema(description = "시간 식별자.") @Schema(description = "시간 식별자.")
val id: Long, val id: Long,
@field:Schema(description = "시간") @Schema(description = "시간")
val startAt: LocalTime val startAt: LocalTime
) )
fun TimeEntity.toRetrieveResponse(): TimeRetrieveResponse = TimeRetrieveResponse(this.id!!, this.startAt) fun TimeEntity.toResponse(): TimeRetrieveResponse = TimeRetrieveResponse(this.id!!, this.startAt)
data class TimeRetrieveListResponse( data class TimeRetrieveListResponse(
val times: List<TimeRetrieveResponse> val times: List<TimeRetrieveResponse>
) )
fun List<TimeEntity>.toRetrieveListResponse(): TimeRetrieveListResponse = TimeRetrieveListResponse( fun List<TimeEntity>.toResponse(): TimeRetrieveListResponse = TimeRetrieveListResponse(
this.map { it.toRetrieveResponse() } this.map { it.toResponse() }
) )
data class TimeWithAvailabilityResponse( data class TimeWithAvailabilityResponse(
@field:Schema(description = "시간 식별자") @Schema(description = "시간 식별자")
val id: Long, val id: Long,
@field:Schema(description = "시간") @Schema(description = "시간")
val startAt: LocalTime, val startAt: LocalTime,
@field:Schema(description = "예약 가능 여부") @Schema(description = "예약 가능 여부")
val isAvailable: Boolean val isAvailable: Boolean
) )

View File

@ -21,8 +21,7 @@ security:
jwt: jwt:
token: token:
secret-key: daijawligagaf@LIJ$@U)9nagnalkkgalijaddljfi secret-key: daijawligagaf@LIJ$@U)9nagnalkkgalijaddljfi
access: ttl-seconds: 1800000
expire-length: 1800000 # 30 분
payment: payment:
api-base-url: https://api.tosspayments.com api-base-url: https://api.tosspayments.com

View File

@ -7,10 +7,10 @@ import io.kotest.matchers.shouldBe
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import org.springframework.data.repository.findByIdOrNull 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.infrastructure.jwt.JwtHandler
import roomescape.auth.service.AuthService import roomescape.auth.service.AuthService
import roomescape.common.exception.ErrorType
import roomescape.common.exception.RoomescapeException
import roomescape.member.business.MemberService import roomescape.member.business.MemberService
import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.MemberRepository import roomescape.member.infrastructure.persistence.MemberRepository
@ -45,11 +45,11 @@ class AuthServiceTest : BehaviorSpec({
memberRepository.findByEmailAndPassword(request.email, request.password) memberRepository.findByEmailAndPassword(request.email, request.password)
} returns null } returns null
val exception = shouldThrow<RoomescapeException> { val exception = shouldThrow<AuthException> {
authService.login(request) authService.login(request)
} }
exception.errorType shouldBe ErrorType.MEMBER_NOT_FOUND exception.errorCode shouldBe AuthErrorCode.LOGIN_FAILED
} }
} }
} }
@ -71,11 +71,11 @@ class AuthServiceTest : BehaviorSpec({
Then("회원이 없다면 예외를 던진다.") { Then("회원이 없다면 예외를 던진다.") {
every { memberRepository.findByIdOrNull(userId) } returns null every { memberRepository.findByIdOrNull(userId) } returns null
val exception = shouldThrow<RoomescapeException> { val exception = shouldThrow<AuthException> {
authService.checkLogin(userId) authService.checkLogin(userId)
} }
exception.errorType shouldBe ErrorType.MEMBER_NOT_FOUND exception.errorCode shouldBe AuthErrorCode.UNIDENTIFIABLE_MEMBER
} }
} }
} }

View File

@ -1,12 +1,12 @@
package roomescape.auth.infrastructure.jwt package roomescape.auth.infrastructure.jwt
import io.jsonwebtoken.Jwts import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm import io.jsonwebtoken.security.Keys
import io.kotest.assertions.throwables.shouldThrow import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import roomescape.common.exception.ErrorType import roomescape.auth.exception.AuthErrorCode
import roomescape.common.exception.RoomescapeException import roomescape.auth.exception.AuthException
import roomescape.util.JwtFixture import roomescape.util.JwtFixture
import java.util.* import java.util.*
import kotlin.random.Random import kotlin.random.Random
@ -33,29 +33,29 @@ class JwtHandlerTest : FunSpec({
Thread.sleep(expirationTime) // 만료 시간 이후로 대기 Thread.sleep(expirationTime) // 만료 시간 이후로 대기
// when & then // when & then
shouldThrow<RoomescapeException> { shouldThrow<AuthException> {
shortExpirationTimeJwtHandler.getMemberIdFromToken(token) shortExpirationTimeJwtHandler.getMemberIdFromToken(token)
}.errorType shouldBe ErrorType.EXPIRED_TOKEN }.errorCode shouldBe AuthErrorCode.EXPIRED_TOKEN
} }
test("토큰이 빈 값이면 예외를 던진다.") { test("토큰이 빈 값이면 예외를 던진다.") {
shouldThrow<RoomescapeException> { shouldThrow<AuthException> {
jwtHandler.getMemberIdFromToken("") jwtHandler.getMemberIdFromToken("")
}.errorType shouldBe ErrorType.INVALID_TOKEN }.errorCode shouldBe AuthErrorCode.TOKEN_NOT_FOUND
} }
test("시크릿 키가 잘못된 경우 예외를 던진다.") { test("시크릿 키가 잘못된 경우 예외를 던진다.") {
val now: Date = Date() val now = Date()
val invalidSignatureToken: String = Jwts.builder() val invalidSignatureToken: String = Jwts.builder()
.claim("memberId", memberId) .claim("memberId", memberId)
.setIssuedAt(now) .issuedAt(now)
.setExpiration(Date(now.time + JwtFixture.EXPIRATION_TIME)) .expiration(Date(now.time + JwtFixture.EXPIRATION_TIME))
.signWith(SignatureAlgorithm.HS256, JwtFixture.SECRET_KEY.substring(1).toByteArray()) .signWith(Keys.hmacShaKeyFor(JwtFixture.SECRET_KEY_STRING.substring(1).toByteArray()))
.compact() .compact()
shouldThrow<RoomescapeException> { shouldThrow<AuthException> {
jwtHandler.getMemberIdFromToken(invalidSignatureToken) jwtHandler.getMemberIdFromToken(invalidSignatureToken)
}.errorType shouldBe ErrorType.INVALID_SIGNATURE_TOKEN }.errorCode shouldBe AuthErrorCode.INVALID_TOKEN
} }
} }
}) })

View File

@ -4,18 +4,19 @@ import com.ninjasquad.springmockk.SpykBean
import io.mockk.every import io.mockk.every
import org.hamcrest.Matchers.containsString import org.hamcrest.Matchers.containsString
import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.equalTo
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.service.AuthService 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.MemberFixture
import roomescape.util.RoomescapeApiTest import roomescape.util.RoomescapeApiTest
@WebMvcTest(controllers = [AuthController::class]) @WebMvcTest(controllers = [AuthController::class])
class AuthControllerTest( class AuthControllerTest(
@Autowired mockMvc: MockMvc val mockMvc: MockMvc
) : RoomescapeApiTest() { ) : RoomescapeApiTest() {
@SpykBean @SpykBean
@ -60,43 +61,35 @@ class AuthControllerTest(
memberRepository.findByEmailAndPassword(userRequest.email, userRequest.password) memberRepository.findByEmailAndPassword(userRequest.email, userRequest.password)
} returns null } returns null
Then("400 에러를 응답한다") { Then("에러 응답") {
val expectedError = AuthErrorCode.LOGIN_FAILED
runPostTest( runPostTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
body = userRequest, body = userRequest,
) { ) {
status { isBadRequest() } status { isEqualTo(expectedError.httpStatus.value()) }
jsonPath("$.errorType", equalTo(ErrorType.MEMBER_NOT_FOUND.name)) jsonPath("$.code", equalTo(expectedError.errorCode))
} }
} }
} }
When("입력 값이 잘못되면") {
val expectedErrorCode: ErrorCode = CommonErrorCode.INVALID_INPUT_VALUE
When("잘못된 요청을 보내면 400 에러를 응답한다.") { Then("400 에러를 응답한다") {
listOf(
Then("이메일 형식이 잘못된 경우") { userRequest.copy(email = "invalid"),
val invalidRequest: LoginRequest = userRequest.copy(email = "invalid") userRequest.copy(password = " "),
"{\"email\": \"null\", \"password\": \"null\"}"
runPostTest( ).forEach {
mockMvc = mockMvc, runPostTest(
endpoint = endpoint, mockMvc = mockMvc,
body = invalidRequest, endpoint = endpoint,
) { body = it,
status { isBadRequest() } ) {
jsonPath("$.message", containsString("이메일 형식이 일치하지 않습니다.")) status { isEqualTo(expectedErrorCode.httpStatus.value()) }
} jsonPath("$.code", equalTo(expectedErrorCode.errorCode))
} }
Then("비밀번호가 공백인 경우") {
val invalidRequest = userRequest.copy(password = " ")
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = invalidRequest,
) {
status { isBadRequest() }
jsonPath("$.message", containsString("비밀번호는 공백일 수 없습니다."))
} }
} }
} }
@ -125,13 +118,14 @@ class AuthControllerTest(
every { jwtHandler.getMemberIdFromToken(any()) } returns invalidMemberId every { jwtHandler.getMemberIdFromToken(any()) } returns invalidMemberId
every { memberRepository.findByIdOrNull(invalidMemberId) } returns null every { memberRepository.findByIdOrNull(invalidMemberId) } returns null
Then("400 에러를 응답한다.") { Then("에러 응답.") {
val expectedError = AuthErrorCode.UNIDENTIFIABLE_MEMBER
runGetTest( runGetTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
) { ) {
status { isBadRequest() } status { isEqualTo(expectedError.httpStatus.value()) }
jsonPath("$.errorType", equalTo(ErrorType.MEMBER_NOT_FOUND.name)) jsonPath("$.code", equalTo(expectedError.errorCode))
} }
} }
} }

View File

@ -8,9 +8,8 @@ import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.runs import io.mockk.runs
import org.springframework.http.HttpStatus import roomescape.payment.exception.PaymentErrorCode
import roomescape.common.exception.ErrorType import roomescape.payment.exception.PaymentException
import roomescape.common.exception.RoomescapeException
import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository import roomescape.payment.infrastructure.persistence.CanceledPaymentRepository
import roomescape.payment.infrastructure.persistence.PaymentRepository import roomescape.payment.infrastructure.persistence.PaymentRepository
import roomescape.payment.web.PaymentCancelRequest import roomescape.payment.web.PaymentCancelRequest
@ -23,19 +22,15 @@ class PaymentServiceTest : FunSpec({
val paymentService = PaymentService(paymentRepository, canceledPaymentRepository) val paymentService = PaymentService(paymentRepository, canceledPaymentRepository)
context("cancelPaymentByAdmin") { context("createCanceledPaymentByReservationId") {
val reservationId = 1L val reservationId = 1L
test("reservationId로 paymentKey를 찾을 수 없으면 예외를 던진다.") { test("reservationId로 paymentKey를 찾을 수 없으면 예외를 던진다.") {
every { paymentRepository.findPaymentKeyByReservationId(reservationId) } returns null every { paymentRepository.findPaymentKeyByReservationId(reservationId) } returns null
val exception = shouldThrow<RoomescapeException> { val exception = shouldThrow<PaymentException> {
paymentService.createCanceledPaymentByReservationId(reservationId) paymentService.createCanceledPaymentByReservationId(reservationId)
} }
exception.errorCode shouldBe PaymentErrorCode.PAYMENT_NOT_FOUND
assertSoftly(exception) {
this.errorType shouldBe ErrorType.PAYMENT_NOT_FOUND
this.httpStatus shouldBe HttpStatus.NOT_FOUND
}
} }
context("reservationId로 paymentKey를 찾고난 후") { context("reservationId로 paymentKey를 찾고난 후") {
@ -50,14 +45,10 @@ class PaymentServiceTest : FunSpec({
paymentRepository.findByPaymentKey(paymentKey) paymentRepository.findByPaymentKey(paymentKey)
} returns null } returns null
val exception = shouldThrow<RoomescapeException> { val exception = shouldThrow<PaymentException> {
paymentService.createCanceledPaymentByReservationId(reservationId) paymentService.createCanceledPaymentByReservationId(reservationId)
} }
exception.errorCode shouldBe PaymentErrorCode.PAYMENT_NOT_FOUND
assertSoftly(exception) {
this.errorType shouldBe ErrorType.PAYMENT_NOT_FOUND
this.httpStatus shouldBe HttpStatus.NOT_FOUND
}
} }
test("해당 paymentKey로 paymentEntity를 찾고, cancelPaymentEntity를 저장한다.") { test("해당 paymentKey로 paymentEntity를 찾고, cancelPaymentEntity를 저장한다.") {
@ -76,6 +67,7 @@ class PaymentServiceTest : FunSpec({
} returns PaymentFixture.createCanceled( } returns PaymentFixture.createCanceled(
id = 1L, id = 1L,
paymentKey = paymentKey, paymentKey = paymentKey,
cancelReason = "Test",
cancelAmount = paymentEntity.totalAmount, cancelAmount = paymentEntity.totalAmount,
) )
@ -84,7 +76,7 @@ class PaymentServiceTest : FunSpec({
assertSoftly(result) { assertSoftly(result) {
this.paymentKey shouldBe paymentKey this.paymentKey shouldBe paymentKey
this.amount shouldBe paymentEntity.totalAmount this.amount shouldBe paymentEntity.totalAmount
this.cancelReason shouldBe "고객 요청" this.cancelReason shouldBe "Test"
} }
} }
} }
@ -99,14 +91,10 @@ class PaymentServiceTest : FunSpec({
canceledPaymentRepository.findByPaymentKey(paymentKey) canceledPaymentRepository.findByPaymentKey(paymentKey)
} returns null } returns null
val exception = shouldThrow<RoomescapeException> { val exception = shouldThrow<PaymentException> {
paymentService.updateCanceledTime(paymentKey, canceledAt) paymentService.updateCanceledTime(paymentKey, canceledAt)
} }
exception.errorCode shouldBe PaymentErrorCode.PAYMENT_NOT_FOUND
assertSoftly(exception) {
this.errorType shouldBe ErrorType.PAYMENT_NOT_FOUND
this.httpStatus shouldBe HttpStatus.NOT_FOUND
}
} }
test("paymentKey로 canceledPaymentEntity를 찾고, canceledAt을 업데이트한다.") { test("paymentKey로 canceledPaymentEntity를 찾고, canceledAt을 업데이트한다.") {

View File

@ -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.match.MockRestRequestMatchers.*
import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus import org.springframework.test.web.client.response.MockRestResponseCreators.withStatus
import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess
import roomescape.common.exception.ErrorType import roomescape.payment.exception.PaymentErrorCode
import roomescape.common.exception.RoomescapeException import roomescape.payment.exception.PaymentException
import roomescape.payment.web.PaymentCancelRequest import roomescape.payment.web.PaymentCancelRequest
import roomescape.payment.web.PaymentCancelResponse import roomescape.payment.web.PaymentCancelResponse
@ -56,28 +56,32 @@ class TossPaymentClientTest(
} }
} }
test("400 에러 발생") { context("실패 응답") {
commonAction().andRespond { fun runTest(httpStatus: HttpStatus, expectedError: PaymentErrorCode) {
withStatus(HttpStatus.BAD_REQUEST) commonAction().andRespond {
.contentType(MediaType.APPLICATION_JSON) withStatus(httpStatus)
.body(SampleTossPaymentConst.tossPaymentErrorJson) .contentType(MediaType.APPLICATION_JSON)
.createResponse(it) .body(SampleTossPaymentConst.tossPaymentErrorJson)
.createResponse(it)
}
// when
val paymentRequest = SampleTossPaymentConst.paymentRequest
// then
val exception = shouldThrow<PaymentException> {
client.confirm(paymentRequest)
}
exception.errorCode shouldBe expectedError
} }
// when test("결제 서버에서 4XX 응답 시") {
val paymentRequest = SampleTossPaymentConst.paymentRequest runTest(HttpStatus.BAD_REQUEST, PaymentErrorCode.PAYMENT_CLIENT_ERROR)
// then
val exception = shouldThrow<RoomescapeException> {
client.confirm(paymentRequest)
} }
assertSoftly(exception) { test("결제 서버에서 5XX 응답 시") {
this.errorType shouldBe ErrorType.PAYMENT_ERROR runTest(HttpStatus.INTERNAL_SERVER_ERROR, PaymentErrorCode.PAYMENT_PROVIDER_ERROR)
this.invalidValue shouldBe "[ErrorCode = ERROR_CODE, ErrorMessage = Error message]"
this.httpStatus shouldBe HttpStatus.BAD_REQUEST
} }
} }
} }
@ -111,26 +115,29 @@ class TossPaymentClientTest(
} }
} }
test("500 에러 발생") { context("실패 응답") {
commonAction().andRespond { fun runTest(httpStatus: HttpStatus, expectedError: PaymentErrorCode) {
withStatus(HttpStatus.INTERNAL_SERVER_ERROR) commonAction().andRespond {
.contentType(MediaType.APPLICATION_JSON) withStatus(httpStatus)
.body(SampleTossPaymentConst.tossPaymentErrorJson) .contentType(MediaType.APPLICATION_JSON)
.createResponse(it) .body(SampleTossPaymentConst.tossPaymentErrorJson)
.createResponse(it)
}
val cancelRequest: PaymentCancelRequest = SampleTossPaymentConst.cancelRequest
val exception = shouldThrow<PaymentException> {
client.cancel(cancelRequest)
}
exception.errorCode shouldBe expectedError
} }
// when test("결제 서버에서 4XX 응답 시") {
val cancelRequest: PaymentCancelRequest = SampleTossPaymentConst.cancelRequest runTest(HttpStatus.BAD_REQUEST, PaymentErrorCode.PAYMENT_CLIENT_ERROR)
// then
val exception = shouldThrow<RoomescapeException> {
client.cancel(cancelRequest)
} }
assertSoftly(exception) { test("결제 서버에서 5XX 응답 시") {
this.errorType shouldBe ErrorType.PAYMENT_SERVER_ERROR runTest(HttpStatus.INTERNAL_SERVER_ERROR, PaymentErrorCode.PAYMENT_PROVIDER_ERROR)
this.invalidValue shouldBe "[ErrorCode = ERROR_CODE, ErrorMessage = Error message]"
this.httpStatus shouldBe HttpStatus.INTERNAL_SERVER_ERROR
} }
} }
} }

View File

@ -5,12 +5,15 @@ import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import roomescape.common.exception.ErrorType import org.springframework.data.repository.findByIdOrNull
import roomescape.common.exception.RoomescapeException
import roomescape.member.business.MemberService import roomescape.member.business.MemberService
import roomescape.member.infrastructure.persistence.Role 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.ReservationRepository
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.theme.business.ThemeService import roomescape.theme.business.ThemeService
import roomescape.time.business.TimeService
import roomescape.util.MemberFixture import roomescape.util.MemberFixture
import roomescape.util.ReservationFixture import roomescape.util.ReservationFixture
import roomescape.util.TimeFixture import roomescape.util.TimeFixture
@ -38,10 +41,10 @@ class ReservationServiceTest : FunSpec({
val reservationRequest = ReservationFixture.createRequest() val reservationRequest = ReservationFixture.createRequest()
shouldThrow<RoomescapeException> { shouldThrow<ReservationException> {
reservationService.addReservation(reservationRequest, 1L) reservationService.createConfirmedReservation(reservationRequest, 1L)
}.also { }.also {
it.errorType shouldBe ErrorType.RESERVATION_DUPLICATED it.errorCode shouldBe ReservationErrorCode.RESERVATION_DUPLICATED
} }
} }
@ -68,10 +71,10 @@ class ReservationServiceTest : FunSpec({
timeService.findById(any()) timeService.findById(any())
} returns TimeFixture.create() } returns TimeFixture.create()
shouldThrow<RoomescapeException> { shouldThrow<ReservationException> {
reservationService.addReservation(reservationRequest, 1L) reservationService.createConfirmedReservation(reservationRequest, 1L)
}.also { }.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) startAt = LocalTime.now().minusMinutes(1)
) )
shouldThrow<RoomescapeException> { shouldThrow<ReservationException> {
reservationService.addReservation(reservationRequest, 1L) reservationService.createConfirmedReservation(reservationRequest, 1L)
}.also { }.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()) reservationRepository.exists(any())
} returns true } returns true
shouldThrow<RoomescapeException> { shouldThrow<ReservationException> {
val waitingRequest = ReservationFixture.createWaitingRequest( val waitingRequest = ReservationFixture.createWaitingRequest(
date = reservationRequest.date, date = reservationRequest.date,
themeId = reservationRequest.themeId, themeId = reservationRequest.themeId,
@ -115,7 +118,57 @@ class ReservationServiceTest : FunSpec({
) )
reservationService.createWaiting(waitingRequest, 1L) reservationService.createWaiting(waitingRequest, 1L)
}.also { }.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<ReservationException> {
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<ReservationException> {
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<ReservationException> {
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 startFrom = LocalDate.now()
val endAt = startFrom.minusDays(1) val endAt = startFrom.minusDays(1)
shouldThrow<RoomescapeException> { shouldThrow<ReservationException> {
reservationService.searchReservations( reservationService.searchReservations(
null, null,
null, null,
@ -133,7 +186,7 @@ class ReservationServiceTest : FunSpec({
endAt endAt
) )
}.also { }.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()) memberService.findById(any())
} returns member } returns member
shouldThrow<RoomescapeException> { shouldThrow<ReservationException> {
reservationService.confirmWaiting(1L, member.id!!) reservationService.confirmWaiting(1L, member.id!!)
}.also { }.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) reservationRepository.isExistConfirmedReservation(reservationId)
} returns true } returns true
shouldThrow<RoomescapeException> { shouldThrow<ReservationException> {
reservationService.confirmWaiting(reservationId, member.id!!) reservationService.confirmWaiting(reservationId, member.id!!)
}.also { }.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<ReservationException> {
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<ReservationException> {
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<ReservationException> {
reservationService.rejectWaiting(reservation.id!!, member.id!!)
}.also {
it.errorCode shouldBe ReservationErrorCode.ALREADY_CONFIRMED
} }
} }
} }

View File

@ -48,7 +48,7 @@ class ReservationWithPaymentServiceTest : FunSpec({
context("addReservationWithPayment") { context("addReservationWithPayment") {
test("예약 및 결제 정보를 저장한다.") { test("예약 및 결제 정보를 저장한다.") {
every { every {
reservationService.addReservation(reservationCreateWithPaymentRequest, memberId) reservationService.createConfirmedReservation(reservationCreateWithPaymentRequest, memberId)
} returns reservationEntity } returns reservationEntity
every { every {

View File

@ -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<RoomescapeException> {
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<RoomescapeException> {
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<RoomescapeException> {
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<RoomescapeException> {
timeService.deleteTime(id)
}.apply {
errorType shouldBe ErrorType.TIME_IS_USED_CONFLICT
httpStatus shouldBe HttpStatus.CONFLICT
}
}
}
})

View File

@ -39,7 +39,7 @@ class ReservationRepositoryTest(
} }
test("입력된 시간과 일치하는 예약을 반환한다.") { test("입력된 시간과 일치하는 예약을 반환한다.") {
assertSoftly(reservationRepository.findByTime(time)) { assertSoftly(reservationRepository.findAllByTime(time)) {
it shouldHaveSize 1 it shouldHaveSize 1
assertSoftly(it.first().time.startAt) { result -> assertSoftly(it.first().time.startAt) { result ->
result.hour shouldBe time.startAt.hour result.hour shouldBe time.startAt.hour
@ -168,7 +168,7 @@ class ReservationRepositoryTest(
entityManager.clear() entityManager.clear()
} }
val result: List<MyReservationRetrieveResponse> = reservationRepository.findAllById(reservation.member.id!!) val result: List<MyReservationRetrieveResponse> = reservationRepository.findAllByMemberId(reservation.member.id!!)
result shouldHaveSize 1 result shouldHaveSize 1
assertSoftly(result.first()) { assertSoftly(result.first()) {
@ -179,7 +179,7 @@ class ReservationRepositoryTest(
} }
test("결제 정보가 없다면 paymentKey와 amount는 null로 반환한다.") { test("결제 정보가 없다면 paymentKey와 amount는 null로 반환한다.") {
val result: List<MyReservationRetrieveResponse> = reservationRepository.findAllById(reservation.member.id!!) val result: List<MyReservationRetrieveResponse> = reservationRepository.findAllByMemberId(reservation.member.id!!)
result shouldHaveSize 1 result shouldHaveSize 1
assertSoftly(result.first()) { assertSoftly(result.first()) {

View File

@ -8,6 +8,7 @@ import jakarta.persistence.EntityManager
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.time.infrastructure.persistence.TimeEntity
import roomescape.util.MemberFixture import roomescape.util.MemberFixture
import roomescape.util.ReservationFixture import roomescape.util.ReservationFixture
import roomescape.util.ThemeFixture import roomescape.util.ThemeFixture

View File

@ -17,19 +17,21 @@ import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.transaction.support.TransactionTemplate import org.springframework.transaction.support.TransactionTemplate
import roomescape.auth.web.support.AdminInterceptor import roomescape.auth.infrastructure.jwt.JwtHandler
import roomescape.auth.web.support.LoginInterceptor
import roomescape.auth.web.support.MemberIdResolver import roomescape.auth.web.support.MemberIdResolver
import roomescape.common.exception.ErrorType import roomescape.member.business.MemberService
import roomescape.common.exception.RoomescapeException
import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.Role 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.client.TossPaymentClient
import roomescape.payment.infrastructure.persistence.PaymentEntity import roomescape.payment.infrastructure.persistence.PaymentEntity
import roomescape.reservation.exception.ReservationErrorCode
import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus 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.theme.infrastructure.persistence.ThemeEntity
import roomescape.time.infrastructure.persistence.TimeEntity
import roomescape.util.* import roomescape.util.*
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
@ -45,15 +47,15 @@ class ReservationControllerTest(
@MockkBean @MockkBean
lateinit var paymentClient: TossPaymentClient lateinit var paymentClient: TossPaymentClient
@SpykBean
lateinit var loginInterceptor: LoginInterceptor
@SpykBean
lateinit var adminInterceptor: AdminInterceptor
@SpykBean @SpykBean
lateinit var memberIdResolver: MemberIdResolver lateinit var memberIdResolver: MemberIdResolver
@SpykBean
lateinit var memberService: MemberService
@MockkBean
lateinit var jwtHandler: JwtHandler
init { init {
context("POST /reservations") { context("POST /reservations") {
lateinit var member: MemberEntity lateinit var member: MemberEntity
@ -88,10 +90,7 @@ class ReservationControllerTest(
test("결제 과정에서 발생하는 에러는 그대로 응답") { test("결제 과정에서 발생하는 에러는 그대로 응답") {
val reservationRequest = createRequest() val reservationRequest = createRequest()
val paymentException = RoomescapeException( val paymentException = PaymentException(PaymentErrorCode.PAYMENT_PROVIDER_ERROR)
ErrorType.PAYMENT_SERVER_ERROR,
HttpStatus.INTERNAL_SERVER_ERROR
)
every { every {
paymentClient.confirm(any()) paymentClient.confirm(any())
@ -104,8 +103,8 @@ class ReservationControllerTest(
}.When { }.When {
post("/reservations") post("/reservations")
}.Then { }.Then {
statusCode(paymentException.httpStatus.value()) statusCode(paymentException.errorCode.httpStatus.value())
body("errorType", equalTo(paymentException.errorType.name)) body("code", equalTo(paymentException.errorCode.errorCode))
} }
} }
@ -123,7 +122,7 @@ class ReservationControllerTest(
// 예약 저장 과정에서 테마가 없는 예외 // 예약 저장 과정에서 테마가 없는 예외
val invalidRequest = reservationRequest.copy(themeId = reservationRequest.themeId + 1) 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 { every {
paymentClient.cancel(any()) paymentClient.cancel(any())
@ -142,7 +141,7 @@ class ReservationControllerTest(
post("/reservations") post("/reservations")
}.Then { }.Then {
statusCode(expectedException.httpStatus.value()) statusCode(expectedException.httpStatus.value())
body("errorType", equalTo(expectedException.errorType.name)) body("code", equalTo(expectedException.errorCode))
} }
val canceledPaymentSizeAfterApiCall: Long = entityManager.createQuery( val canceledPaymentSizeAfterApiCall: Long = entityManager.createQuery(
@ -234,6 +233,7 @@ class ReservationControllerTest(
val startDate = LocalDate.now().plusDays(1) val startDate = LocalDate.now().plusDays(1)
val endDate = LocalDate.now() val endDate = LocalDate.now()
val expectedError = ReservationErrorCode.INVALID_SEARCH_DATE_RANGE
Given { Given {
port(port) port(port)
@ -243,8 +243,8 @@ class ReservationControllerTest(
}.When { }.When {
get("/reservations/search") get("/reservations/search")
}.Then { }.Then {
statusCode(HttpStatus.BAD_REQUEST.value()) statusCode(expectedError.httpStatus.value())
body("errorType", equalTo(ErrorType.INVALID_DATE_RANGE.name)) body("code", equalTo(expectedError.errorCode))
} }
} }
@ -500,6 +500,7 @@ class ReservationControllerTest(
themeId = reservationRequest.themeId, themeId = reservationRequest.themeId,
timeId = reservationRequest.timeId timeId = reservationRequest.timeId
) )
val expectedError = ReservationErrorCode.ALREADY_RESERVE
Given { Given {
port(port) port(port)
@ -508,8 +509,8 @@ class ReservationControllerTest(
}.When { }.When {
post("/reservations/waiting") post("/reservations/waiting")
}.Then { }.Then {
statusCode(HttpStatus.BAD_REQUEST.value()) statusCode(expectedError.httpStatus.value())
body("errorType", equalTo(ErrorType.HAS_RESERVATION_OR_WAITING.name)) body("code", equalTo(expectedError.errorCode))
} }
} }
} }
@ -543,20 +544,21 @@ class ReservationControllerTest(
} }
} }
test("이미 완료된 예약은 삭제할 수 없다.") { test("이미 확정된 예약을 삭제하면 예외 응답") {
val member = login(MemberFixture.create(role = Role.MEMBER)) val member = login(MemberFixture.create(role = Role.MEMBER))
val reservation: ReservationEntity = createSingleReservation( val reservation: ReservationEntity = createSingleReservation(
member = member, member = member,
status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED status = ReservationStatus.CONFIRMED_PAYMENT_REQUIRED
) )
val expectedError = ReservationErrorCode.ALREADY_CONFIRMED
Given { Given {
port(port) port(port)
}.When { }.When {
delete("/reservations/waiting/{id}", reservation.id) delete("/reservations/waiting/{id}", reservation.id)
}.Then { }.Then {
body("errorType", equalTo(ErrorType.RESERVATION_NOT_FOUND.name)) statusCode(expectedError.httpStatus.value())
statusCode(HttpStatus.NOT_FOUND.value()) body("code", equalTo(expectedError.errorCode))
} }
} }
} }
@ -599,6 +601,42 @@ class ReservationControllerTest(
} ?: throw AssertionError("Reservation not found") } ?: 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") { context("POST /reservations/waiting/{id}/reject") {
@ -737,31 +775,18 @@ class ReservationControllerTest(
} }
} }
if (member.isAdmin()) { every {
loginAsAdmin() jwtHandler.getMemberIdFromToken(any())
} else { } returns member.id!!
loginAsUser()
} every {
resolveMemberId(member.id!!) memberService.findById(member.id!!)
} returns member
every {
memberIdResolver.resolveArgument(any(), any(), any(), any())
} returns member.id!!
return member 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
}
} }

View File

@ -7,12 +7,12 @@ import io.kotest.matchers.shouldBe
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpStatus import roomescape.theme.exception.ThemeErrorCode
import roomescape.common.exception.ErrorType import roomescape.theme.exception.ThemeException
import roomescape.common.exception.RoomescapeException
import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.theme.infrastructure.persistence.ThemeRepository 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 import roomescape.util.ThemeFixture
class ThemeServiceTest : FunSpec({ class ThemeServiceTest : FunSpec({
@ -36,11 +36,11 @@ class ThemeServiceTest : FunSpec({
themeRepository.findByIdOrNull(themeId) themeRepository.findByIdOrNull(themeId)
} returns null } returns null
val exception = shouldThrow<RoomescapeException> { val exception = shouldThrow<ThemeException> {
themeService.findById(themeId) 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") { context("save") {
test("테마 이름이 중복되면 409 예외를 던진다.") { val request = ThemeCreateRequest(
val name = "Duplicate Theme" name = "New Theme",
description = "Description",
thumbnail = "http://example.com/thumbnail.jpg"
)
test("저장 성공") {
every {
themeRepository.existsByName(request.name)
} returns false
every { 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 } returns true
val exception = shouldThrow<RoomescapeException> { val exception = shouldThrow<ThemeException> {
themeService.createTheme(ThemeRequest( themeService.createTheme(request)
name = name,
description = "Description",
thumbnail = "http://example.com/thumbnail.jpg"
))
} }
assertSoftly(exception) { exception.errorCode shouldBe ThemeErrorCode.THEME_NAME_DUPLICATED
this.errorType shouldBe ErrorType.THEME_DUPLICATED
this.httpStatus shouldBe HttpStatus.CONFLICT
}
} }
} }
@ -90,14 +111,11 @@ class ThemeServiceTest : FunSpec({
themeRepository.isReservedTheme(themeId) themeRepository.isReservedTheme(themeId)
} returns true } returns true
val exception = shouldThrow<RoomescapeException> { val exception = shouldThrow<ThemeException> {
themeService.deleteTheme(themeId) themeService.deleteTheme(themeId)
} }
assertSoftly(exception) { exception.errorCode shouldBe ThemeErrorCode.THEME_ALREADY_RESERVED
this.errorType shouldBe ErrorType.THEME_IS_USED_CONFLICT
this.httpStatus shouldBe HttpStatus.CONFLICT
}
} }
} }
}) })

View File

@ -3,8 +3,8 @@ package roomescape.theme.util
import jakarta.persistence.EntityManager import jakarta.persistence.EntityManager
import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.infrastructure.persistence.TimeEntity
import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.time.infrastructure.persistence.TimeEntity
import roomescape.util.MemberFixture import roomescape.util.MemberFixture
import roomescape.util.ReservationFixture import roomescape.util.ReservationFixture
import roomescape.util.ThemeFixture import roomescape.util.ThemeFixture

View File

@ -11,7 +11,9 @@ import io.mockk.runs
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import roomescape.auth.exception.AuthErrorCode
import roomescape.theme.business.ThemeService import roomescape.theme.business.ThemeService
import roomescape.theme.exception.ThemeErrorCode
import roomescape.theme.infrastructure.persistence.ThemeRepository import roomescape.theme.infrastructure.persistence.ThemeRepository
import roomescape.util.RoomescapeApiTest import roomescape.util.RoomescapeApiTest
import roomescape.util.ThemeFixture import roomescape.util.ThemeFixture
@ -57,7 +59,7 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
ThemeFixture.create(id = 3, name = "theme3") ThemeFixture.create(id = 3, name = "theme3")
) )
val response: ThemesResponse = runGetTest( val response: ThemeRetrieveListResponse = runGetTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
) { ) {
@ -65,7 +67,7 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
content { content {
contentType(MediaType.APPLICATION_JSON) contentType(MediaType.APPLICATION_JSON)
} }
}.andReturn().readValue(ThemesResponse::class.java) }.andReturn().readValue(ThemeRetrieveListResponse::class.java)
assertSoftly(response.themes) { assertSoftly(response.themes) {
it.size shouldBe 3 it.size shouldBe 3
@ -77,7 +79,7 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
Given("테마를 추가할 때") { Given("테마를 추가할 때") {
val endpoint = "/themes" val endpoint = "/themes"
val request = ThemeRequest( val request = ThemeCreateRequest(
name = "theme1", name = "theme1",
description = "description1", description = "description1",
thumbnail = "http://example.com/thumbnail1.jpg" thumbnail = "http://example.com/thumbnail1.jpg"
@ -108,7 +110,7 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
body = request, body = request,
) { ) {
status { is3xxRedirection() } 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("동일한 이름의 테마가 있으면") { When("동일한 이름의 테마가 있으면") {
loginAsAdmin() loginAsAdmin()
Then("409 에러를 응답한다.") { val expectedError = ThemeErrorCode.THEME_NAME_DUPLICATED
Then("에러 응답.") {
every { every {
themeRepository.existsByName(request.name) themeRepository.existsByName(request.name)
} returns true } returns true
@ -126,8 +130,8 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
endpoint = endpoint, endpoint = endpoint,
body = request, body = request,
) { ) {
status { isConflict() } status { isEqualTo(expectedError.httpStatus.value()) }
jsonPath("$.errorType") { value("THEME_DUPLICATED") } jsonPath("$.code") { value(expectedError.errorCode) }
} }
} }
} }
@ -137,13 +141,13 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
loginAsAdmin() loginAsAdmin()
} }
val request = ThemeRequest( val request = ThemeCreateRequest(
name = "theme1", name = "theme1",
description = "description1", description = "description1",
thumbnail = "http://example.com/thumbnail1.jpg" thumbnail = "http://example.com/thumbnail1.jpg"
) )
fun runTest(request: ThemeRequest) { fun runTest(request: ThemeCreateRequest) {
runPostTest( runPostTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
@ -196,7 +200,7 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
every { every {
themeService.createTheme(request) themeService.createTheme(request)
} returns ThemeResponse( } returns ThemeRetrieveResponse(
id = theme.id!!, id = theme.id!!,
name = theme.name, name = theme.name,
description = theme.description, description = theme.description,
@ -249,15 +253,16 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
endpoint = endpoint, endpoint = endpoint,
) { ) {
status { is3xxRedirection() } status { is3xxRedirection() }
jsonPath("$.errorType") { value("PERMISSION_DOES_NOT_EXIST") } jsonPath("$.code") { value(AuthErrorCode.ACCESS_DENIED.errorCode) }
} }
} }
} }
When("입력된 ID에 해당하는 테마가 없으") { When("이미 예약된 테마이") {
loginAsAdmin() loginAsAdmin()
val expectedError = ThemeErrorCode.THEME_ALREADY_RESERVED
Then("409 에러 응답한다.") { Then("에러 응답") {
every { every {
themeRepository.isReservedTheme(themeId) themeRepository.isReservedTheme(themeId)
} returns true } returns true
@ -266,8 +271,8 @@ class ThemeControllerTest(mockMvc: MockMvc) : RoomescapeApiTest() {
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = endpoint, endpoint = endpoint,
) { ) {
status { isConflict() } status { isEqualTo(expectedError.httpStatus.value()) }
jsonPath("$.errorType") { value("THEME_IS_USED_CONFLICT") } jsonPath("$.code") { value(expectedError.errorCode) }
} }
} }
} }

View File

@ -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<TimeException> {
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<TimeException> {
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<Exception> {
timeService.deleteTime(id)
}
}
test("시간을 찾을 수 없으면 예외 응답") {
val id = 1L
every { timeRepository.findByIdOrNull(id) } returns null
shouldThrow<TimeException> {
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<TimeException> {
timeService.deleteTime(id)
}.also {
it.errorCode shouldBe TimeErrorCode.TIME_ALREADY_RESERVED
}
}
}
})

View File

@ -1,4 +1,4 @@
package roomescape.reservation.infrastructure.persistence package roomescape.time.infrastructure.persistence
import io.kotest.core.spec.style.FunSpec import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
@ -30,4 +30,4 @@ class TimeRepositoryTest(
timeRepository.existsByStartAt(startAt.plusSeconds(1)) shouldBe false timeRepository.existsByStartAt(startAt.plusSeconds(1)) shouldBe false
} }
} }
}) })

View File

@ -1,4 +1,4 @@
package roomescape.reservation.web package roomescape.time.web
import com.ninjasquad.springmockk.MockkBean import com.ninjasquad.springmockk.MockkBean
import com.ninjasquad.springmockk.SpykBean import com.ninjasquad.springmockk.SpykBean
@ -12,11 +12,11 @@ import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.MockMvc
import roomescape.common.config.JacksonConfig 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.ReservationRepository
import roomescape.reservation.infrastructure.persistence.TimeEntity import roomescape.time.business.TimeService
import roomescape.reservation.infrastructure.persistence.TimeRepository import roomescape.time.exception.TimeErrorCode
import roomescape.time.infrastructure.persistence.TimeEntity
import roomescape.time.infrastructure.persistence.TimeRepository
import roomescape.util.ReservationFixture import roomescape.util.ReservationFixture
import roomescape.util.RoomescapeApiTest import roomescape.util.RoomescapeApiTest
import roomescape.util.ThemeFixture import roomescape.util.ThemeFixture
@ -129,7 +129,8 @@ class TimeControllerTest(
} }
} }
Then("동일한 시간이 존재하면 409 응답") { Then("동일한 시간이 존재하면 예외 응답") {
val expectedError = TimeErrorCode.TIME_DUPLICATED
every { every {
timeRepository.existsByStartAt(time) timeRepository.existsByStartAt(time)
} returns true } returns true
@ -139,10 +140,10 @@ class TimeControllerTest(
endpoint = endpoint, endpoint = endpoint,
body = request, body = request,
) { ) {
status { isConflict() } status { isEqualTo(expectedError.httpStatus.value()) }
content { content {
contentType(MediaType.APPLICATION_JSON) 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 id = 1L
val expectedError = TimeErrorCode.TIME_NOT_FOUND
every { every {
timeRepository.findByIdOrNull(id) timeRepository.findByIdOrNull(id)
} returns null } returns null
@ -195,32 +197,33 @@ class TimeControllerTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = "/times/$id", endpoint = "/times/$id",
) { ) {
status { isBadRequest() } status { isEqualTo(expectedError.httpStatus.value()) }
content { content {
contentType(MediaType.APPLICATION_JSON) contentType(MediaType.APPLICATION_JSON)
jsonPath("$.errorType") { value(ErrorType.TIME_NOT_FOUND.name) } jsonPath("$.code") { value(expectedError.errorCode) }
} }
} }
} }
Then("예약이 있는 시간을 삭제하면 409 응답") { Then("예약이 있는 시간을 삭제하면 예외 응답") {
val id = 1L val id = 1L
val expectedError = TimeErrorCode.TIME_ALREADY_RESERVED
every { every {
timeRepository.findByIdOrNull(id) timeRepository.findByIdOrNull(id)
} returns TimeFixture.create(id = id) } returns TimeFixture.create(id = id)
every { every {
reservationRepository.findByTime(any()) reservationRepository.findAllByTime(any())
} returns listOf(ReservationFixture.create()) } returns listOf(ReservationFixture.create())
runDeleteTest( runDeleteTest(
mockMvc = mockMvc, mockMvc = mockMvc,
endpoint = "/times/$id", endpoint = "/times/$id",
) { ) {
status { isConflict() } status { isEqualTo(expectedError.httpStatus.value()) }
content { content {
contentType(MediaType.APPLICATION_JSON) contentType(MediaType.APPLICATION_JSON)
jsonPath("$.errorType") { value(ErrorType.TIME_IS_USED_CONFLICT.name) } jsonPath("$.code") { value(expectedError.errorCode) }
} }
} }
} }
@ -292,4 +295,4 @@ class TimeControllerTest(
} }
} }
} }
} }

View File

@ -12,10 +12,10 @@ import roomescape.payment.web.PaymentCancelRequest
import roomescape.payment.web.PaymentCancelResponse import roomescape.payment.web.PaymentCancelResponse
import roomescape.reservation.infrastructure.persistence.ReservationEntity import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.infrastructure.persistence.TimeEntity
import roomescape.reservation.web.ReservationCreateWithPaymentRequest import roomescape.reservation.web.ReservationCreateWithPaymentRequest
import roomescape.reservation.web.WaitingCreateRequest import roomescape.reservation.web.WaitingCreateRequest
import roomescape.theme.infrastructure.persistence.ThemeEntity import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalTime import java.time.LocalTime
import java.time.OffsetDateTime import java.time.OffsetDateTime
@ -110,11 +110,11 @@ object ReservationFixture {
} }
object JwtFixture { 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 const val EXPIRATION_TIME: Long = 1000 * 60 * 60
fun create( fun create(
secretKey: String = SECRET_KEY, secretKey: String = SECRET_KEY_STRING,
expirationTime: Long = EXPIRATION_TIME expirationTime: Long = EXPIRATION_TIME
): JwtHandler = JwtHandler(secretKey, expirationTime) ): JwtHandler = JwtHandler(secretKey, expirationTime)
} }

View File

@ -7,16 +7,14 @@ import io.kotest.core.spec.style.BehaviorSpec
import io.mockk.every import io.mockk.every
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.http.HttpHeaders import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType import org.springframework.http.MediaType
import org.springframework.test.web.servlet.* 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.infrastructure.jwt.JwtHandler
import roomescape.auth.web.support.AdminInterceptor import roomescape.auth.web.support.AuthInterceptor
import roomescape.auth.web.support.LoginInterceptor
import roomescape.auth.web.support.MemberIdResolver import roomescape.auth.web.support.MemberIdResolver
import roomescape.common.config.JacksonConfig import roomescape.common.config.JacksonConfig
import roomescape.common.exception.ErrorType
import roomescape.common.exception.RoomescapeException
import roomescape.member.business.MemberService import roomescape.member.business.MemberService
import roomescape.member.infrastructure.persistence.MemberEntity import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.MemberRepository import roomescape.member.infrastructure.persistence.MemberRepository
@ -25,10 +23,7 @@ import roomescape.util.MemberFixture.NOT_LOGGED_IN_USERID
abstract class RoomescapeApiTest : BehaviorSpec() { abstract class RoomescapeApiTest : BehaviorSpec() {
@SpykBean @SpykBean
private lateinit var AdminInterceptor: AdminInterceptor private lateinit var authInterceptor: AuthInterceptor
@SpykBean
private lateinit var loginInterceptor: LoginInterceptor
@SpykBean @SpykBean
private lateinit var memberIdResolver: MemberIdResolver private lateinit var memberIdResolver: MemberIdResolver
@ -105,7 +100,7 @@ abstract class RoomescapeApiTest : BehaviorSpec() {
fun doNotLogin() { fun doNotLogin() {
every { every {
jwtHandler.getMemberIdFromToken(any()) 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.existsById(NOT_LOGGED_IN_USERID) } returns false
every { memberRepository.findByIdOrNull(NOT_LOGGED_IN_USERID) } returns null every { memberRepository.findByIdOrNull(NOT_LOGGED_IN_USERID) } returns null

View File

@ -14,8 +14,7 @@ security:
jwt: jwt:
token: token:
secret-key: daijawligagaf@LIJ$@U)9nagnalkkgalijaddljfi secret-key: daijawligagaf@LIJ$@U)9nagnalkkgalijaddljfi
access: ttl-seconds: 1800000
expire-length: 1800000 # 30 분
payment: payment:
api-base-url: https://api.tosspayments.com api-base-url: https://api.tosspayments.com