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

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

View File

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

View File

@ -9,7 +9,7 @@ data class LoginResponse(
)
data class LoginCheckResponse(
@field:Schema(description = "로그인된 회원의 이름")
@Schema(description = "로그인된 회원의 이름")
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)
}
@Throws(Exception::class)
override fun resolveArgument(
parameter: MethodParameter,
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.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
import roomescape.auth.web.support.AdminInterceptor
import roomescape.auth.web.support.LoginInterceptor
import roomescape.auth.web.support.AuthInterceptor
import roomescape.auth.web.support.MemberIdResolver
@Configuration
class WebMvcConfig(
private val memberIdResolver: MemberIdResolver,
private val adminInterceptor: AdminInterceptor,
private val loginInterceptor: LoginInterceptor
private val authInterceptor: AuthInterceptor
) : WebMvcConfigurer {
override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) {
@ -20,7 +18,6 @@ class WebMvcConfig(
}
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(adminInterceptor)
registry.addInterceptor(loginInterceptor)
registry.addInterceptor(authInterceptor)
}
}

View File

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

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

View File

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

View File

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

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(
@field:Schema(description = "회원 식별자")
@Schema(description = "회원 식별자")
val id: Long,
@field:Schema(description = "회원 이름")
@Schema(description = "회원 이름")
val name: String
)

View File

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

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

View File

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

View File

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

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 roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalDate
@Entity
@ -34,7 +35,7 @@ class ReservationEntity(
fun isWaiting(): Boolean = reservationStatus == ReservationStatus.WAITING
@JsonIgnore
fun isSameMember(memberId: Long): Boolean {
fun isReservedBy(memberId: Long): Boolean {
return this.member.id == memberId
}
}

View File

@ -6,11 +6,12 @@ import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import roomescape.reservation.web.MyReservationRetrieveResponse
import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalDate
interface ReservationRepository
: JpaRepository<ReservationEntity, Long>, JpaSpecificationExecutor<ReservationEntity> {
fun findByTime(time: TimeEntity): List<ReservationEntity>
fun findAllByTime(time: TimeEntity): List<ReservationEntity>
fun findByDateAndThemeId(date: LocalDate, themeId: Long): List<ReservationEntity>
@ -58,5 +59,5 @@ interface ReservationRepository
ON p.reservation = r
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 roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalDate
class ReservationSearchSpecification(

View File

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

View File

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

View File

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

View File

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

View File

@ -13,9 +13,9 @@ import org.springframework.web.bind.annotation.RequestParam
import roomescape.auth.web.support.Admin
import roomescape.auth.web.support.LoginRequired
import roomescape.common.dto.response.CommonApiResponse
import roomescape.theme.web.ThemeRequest
import roomescape.theme.web.ThemeResponse
import roomescape.theme.web.ThemesResponse
import roomescape.theme.web.ThemeCreateRequest
import roomescape.theme.web.ThemeRetrieveListResponse
import roomescape.theme.web.ThemeRetrieveResponse
@Tag(name = "5. 테마 API", description = "테마를 조회 / 추가 / 삭제할 때 사용합니다.")
interface ThemeAPI {
@ -23,13 +23,13 @@ interface ThemeAPI {
@LoginRequired
@Operation(summary = "모든 테마 조회", description = "모든 테마를 조회합니다.", tags = ["로그인이 필요한 API"])
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findThemes(): ResponseEntity<CommonApiResponse<ThemesResponse>>
fun findThemes(): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponse>>
@Operation(summary = "가장 많이 예약된 테마 조회")
@ApiResponses(ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true))
fun findMostReservedThemes(
@RequestParam(defaultValue = "10") @Parameter(description = "최대로 조회할 테마 갯수") count: Int
): ResponseEntity<CommonApiResponse<ThemesResponse>>
): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponse>>
@Admin
@Operation(summary = "테마 추가", tags = ["관리자 로그인이 필요한 API"])
@ -37,8 +37,8 @@ interface ThemeAPI {
ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true),
)
fun createTheme(
@Valid @RequestBody request: ThemeRequest,
): ResponseEntity<CommonApiResponse<ThemeResponse>>
@Valid @RequestBody request: ThemeCreateRequest,
): ResponseEntity<CommonApiResponse<ThemeRetrieveResponse>>
@Admin
@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 {
@GetMapping("/themes")
override fun findThemes(): ResponseEntity<CommonApiResponse<ThemesResponse>> {
val response: ThemesResponse = themeService.findThemes()
override fun findThemes(): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponse>> {
val response: ThemeRetrieveListResponse = themeService.findThemes()
return ResponseEntity.ok(CommonApiResponse(response))
}
@ -24,17 +24,17 @@ class ThemeController(
@GetMapping("/themes/most-reserved-last-week")
override fun findMostReservedThemes(
@RequestParam(defaultValue = "10") @Parameter(description = "최대로 조회할 테마 갯수") count: Int
): ResponseEntity<CommonApiResponse<ThemesResponse>> {
val response: ThemesResponse = themeService.findMostReservedThemes(count)
): ResponseEntity<CommonApiResponse<ThemeRetrieveListResponse>> {
val response: ThemeRetrieveListResponse = themeService.findMostReservedThemes(count)
return ResponseEntity.ok(CommonApiResponse(response))
}
@PostMapping("/themes")
override fun createTheme(
@RequestBody @Valid request: ThemeRequest
): ResponseEntity<CommonApiResponse<ThemeResponse>> {
val themeResponse: ThemeResponse = themeService.createTheme(request)
@RequestBody @Valid request: ThemeCreateRequest
): ResponseEntity<CommonApiResponse<ThemeRetrieveResponse>> {
val themeResponse: ThemeRetrieveResponse = themeService.createTheme(request)
return ResponseEntity.created(URI.create("/themes/${themeResponse.id}"))
.body(CommonApiResponse(themeResponse))

View File

@ -6,52 +6,46 @@ import jakarta.validation.constraints.Size
import org.hibernate.validator.constraints.URL
import roomescape.theme.infrastructure.persistence.ThemeEntity
@Schema(name = "테마 저장 요청", description = "테마 정보를 저장할 때 사용합니다.")
data class ThemeRequest(
@field:Schema(description = "필수 값이며, 최대 20글자까지 입력 가능합니다.")
data class ThemeCreateRequest(
@NotBlank
@Size(max = 20, message = "테마의 이름은 1~20글자 사이여야 합니다.")
@Size(max = 20)
val name: String,
@field:Schema(description = "필수 값이며, 최대 100글자까지 입력 가능합니다.")
@NotBlank
@Size(max = 100, message = "테마의 설명은 1~100글자 사이여야 합니다.")
@Size(max = 100)
val description: String,
@field:Schema(description = "필수 값이며, 썸네일 이미지 URL 을 입력해주세요.")
@NotBlank
@URL
@NotBlank
@Schema(description = "썸네일 이미지 주소(URL).")
val thumbnail: String
)
@Schema(name = "테마 정보", description = "테마 추가 및 조회 응답에 사용됩니다.")
data class ThemeResponse(
@field:Schema(description = "테마 번호. 테마를 식별할 때 사용합니다.")
fun ThemeCreateRequest.toEntity(): ThemeEntity = ThemeEntity(
name = this.name,
description = this.description,
thumbnail = this.thumbnail
)
data class ThemeRetrieveResponse(
val id: Long,
@field:Schema(description = "테마 이름. 중복을 허용하지 않습니다.")
val name: String,
@field:Schema(description = "테마 설명")
val description: String,
@field:Schema(description = "테마 썸네일 이미지 URL")
@Schema(description = "썸네일 이미지 주소(URL).")
val thumbnail: String
)
fun ThemeEntity.toResponse(): ThemeResponse = ThemeResponse(
fun ThemeEntity.toResponse(): ThemeRetrieveResponse = ThemeRetrieveResponse(
id = this.id!!,
name = this.name,
description = this.description,
thumbnail = this.thumbnail
)
@Schema(name = "테마 목록 조회 응답", description = "모든 테마 목록 조회 응답시 사용됩니다.")
data class ThemesResponse(
@field:Schema(description = "모든 테마 목록")
val themes: List<ThemeResponse>
data class ThemeRetrieveListResponse(
val themes: List<ThemeRetrieveResponse>
)
fun List<ThemeEntity>.toResponse(): ThemesResponse = ThemesResponse(
fun List<ThemeEntity>.toResponse(): ThemeRetrieveListResponse = ThemeRetrieveListResponse(
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.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import roomescape.common.exception.ErrorType
import roomescape.common.exception.RoomescapeException
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationRepository
import roomescape.reservation.infrastructure.persistence.TimeEntity
import roomescape.reservation.infrastructure.persistence.TimeRepository
import roomescape.reservation.web.*
import roomescape.time.exception.TimeErrorCode
import roomescape.time.exception.TimeException
import roomescape.time.infrastructure.persistence.TimeEntity
import roomescape.time.infrastructure.persistence.TimeRepository
import roomescape.time.web.*
import java.time.LocalDate
import java.time.LocalTime
@ -21,42 +20,33 @@ class TimeService(
) {
@Transactional(readOnly = true)
fun findById(id: Long): TimeEntity = timeRepository.findByIdOrNull(id)
?: throw RoomescapeException(
ErrorType.TIME_NOT_FOUND,
"[timeId: $id]",
HttpStatus.BAD_REQUEST
)
?: throw TimeException(TimeErrorCode.TIME_NOT_FOUND)
@Transactional(readOnly = true)
fun findTimes(): TimeRetrieveListResponse = timeRepository.findAll().toRetrieveListResponse()
fun findTimes(): TimeRetrieveListResponse = timeRepository.findAll()
.toResponse()
@Transactional
fun createTime(timeCreateRequest: TimeCreateRequest): TimeCreateResponse {
val startAt: LocalTime = timeCreateRequest.startAt
fun createTime(request: TimeCreateRequest): TimeCreateResponse {
val startAt: LocalTime = request.startAt
if (timeRepository.existsByStartAt(startAt)) {
throw RoomescapeException(
ErrorType.TIME_DUPLICATED, "[startAt: $startAt]", HttpStatus.CONFLICT
)
throw TimeException(TimeErrorCode.TIME_DUPLICATED)
}
return TimeEntity(startAt = startAt)
.also { timeRepository.save(it) }
.toCreateResponse()
val time: TimeEntity = request.toEntity()
return timeRepository.save(time).toCreateResponse()
}
@Transactional
fun deleteTime(id: Long) {
val time: TimeEntity = findById(id)
reservationRepository.findByTime(time)
.also {
if (it.isNotEmpty()) {
throw RoomescapeException(
ErrorType.TIME_IS_USED_CONFLICT, "[timeId: $id]", HttpStatus.CONFLICT
)
}
timeRepository.deleteById(id)
}
val reservations: List<ReservationEntity> = reservationRepository.findAllByTime(time)
if (reservations.isNotEmpty()) {
throw TimeException(TimeErrorCode.TIME_ALREADY_RESERVED)
}
timeRepository.delete(time)
}
@Transactional(readOnly = true)
@ -66,7 +56,6 @@ class TimeService(
return TimeWithAvailabilityListResponse(allTimes.map { time ->
val isAvailable: Boolean = reservations.none { reservation -> reservation.time.id == time.id }
TimeWithAvailabilityResponse(time.id!!, time.startAt, isAvailable)
})
}

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.responses.ApiResponse
@ -12,10 +12,10 @@ import org.springframework.web.bind.annotation.RequestParam
import roomescape.auth.web.support.Admin
import roomescape.auth.web.support.LoginRequired
import roomescape.common.dto.response.CommonApiResponse
import roomescape.reservation.web.TimeCreateRequest
import roomescape.reservation.web.TimeCreateResponse
import roomescape.reservation.web.TimeRetrieveListResponse
import roomescape.reservation.web.TimeWithAvailabilityListResponse
import roomescape.time.web.TimeCreateRequest
import roomescape.time.web.TimeCreateResponse
import roomescape.time.web.TimeRetrieveListResponse
import roomescape.time.web.TimeWithAvailabilityListResponse
import java.time.LocalDate
@Tag(name = "4. 예약 시간 API", description = "예약 시간을 조회 / 추가 / 삭제할 때 사용합니다.")

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 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 java.time.LocalTime

View File

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

View File

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

View File

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

View File

@ -7,10 +7,10 @@ import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import org.springframework.data.repository.findByIdOrNull
import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException
import roomescape.auth.infrastructure.jwt.JwtHandler
import roomescape.auth.service.AuthService
import roomescape.common.exception.ErrorType
import roomescape.common.exception.RoomescapeException
import roomescape.member.business.MemberService
import roomescape.member.infrastructure.persistence.MemberEntity
import roomescape.member.infrastructure.persistence.MemberRepository
@ -45,11 +45,11 @@ class AuthServiceTest : BehaviorSpec({
memberRepository.findByEmailAndPassword(request.email, request.password)
} returns null
val exception = shouldThrow<RoomescapeException> {
val exception = shouldThrow<AuthException> {
authService.login(request)
}
exception.errorType shouldBe ErrorType.MEMBER_NOT_FOUND
exception.errorCode shouldBe AuthErrorCode.LOGIN_FAILED
}
}
}
@ -71,11 +71,11 @@ class AuthServiceTest : BehaviorSpec({
Then("회원이 없다면 예외를 던진다.") {
every { memberRepository.findByIdOrNull(userId) } returns null
val exception = shouldThrow<RoomescapeException> {
val exception = shouldThrow<AuthException> {
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
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import io.jsonwebtoken.security.Keys
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import roomescape.common.exception.ErrorType
import roomescape.common.exception.RoomescapeException
import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.exception.AuthException
import roomescape.util.JwtFixture
import java.util.*
import kotlin.random.Random
@ -33,29 +33,29 @@ class JwtHandlerTest : FunSpec({
Thread.sleep(expirationTime) // 만료 시간 이후로 대기
// when & then
shouldThrow<RoomescapeException> {
shouldThrow<AuthException> {
shortExpirationTimeJwtHandler.getMemberIdFromToken(token)
}.errorType shouldBe ErrorType.EXPIRED_TOKEN
}.errorCode shouldBe AuthErrorCode.EXPIRED_TOKEN
}
test("토큰이 빈 값이면 예외를 던진다.") {
shouldThrow<RoomescapeException> {
shouldThrow<AuthException> {
jwtHandler.getMemberIdFromToken("")
}.errorType shouldBe ErrorType.INVALID_TOKEN
}.errorCode shouldBe AuthErrorCode.TOKEN_NOT_FOUND
}
test("시크릿 키가 잘못된 경우 예외를 던진다.") {
val now: Date = Date()
val now = Date()
val invalidSignatureToken: String = Jwts.builder()
.claim("memberId", memberId)
.setIssuedAt(now)
.setExpiration(Date(now.time + JwtFixture.EXPIRATION_TIME))
.signWith(SignatureAlgorithm.HS256, JwtFixture.SECRET_KEY.substring(1).toByteArray())
.issuedAt(now)
.expiration(Date(now.time + JwtFixture.EXPIRATION_TIME))
.signWith(Keys.hmacShaKeyFor(JwtFixture.SECRET_KEY_STRING.substring(1).toByteArray()))
.compact()
shouldThrow<RoomescapeException> {
shouldThrow<AuthException> {
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 org.hamcrest.Matchers.containsString
import org.hamcrest.Matchers.equalTo
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest
import org.springframework.data.repository.findByIdOrNull
import org.springframework.test.web.servlet.MockMvc
import roomescape.auth.exception.AuthErrorCode
import roomescape.auth.service.AuthService
import roomescape.common.exception.ErrorType
import roomescape.common.exception.CommonErrorCode
import roomescape.common.exception.ErrorCode
import roomescape.util.MemberFixture
import roomescape.util.RoomescapeApiTest
@WebMvcTest(controllers = [AuthController::class])
class AuthControllerTest(
@Autowired mockMvc: MockMvc
val mockMvc: MockMvc
) : RoomescapeApiTest() {
@SpykBean
@ -60,43 +61,35 @@ class AuthControllerTest(
memberRepository.findByEmailAndPassword(userRequest.email, userRequest.password)
} returns null
Then("400 에러를 응답한다") {
Then("에러 응답") {
val expectedError = AuthErrorCode.LOGIN_FAILED
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = userRequest,
) {
status { isBadRequest() }
jsonPath("$.errorType", equalTo(ErrorType.MEMBER_NOT_FOUND.name))
status { isEqualTo(expectedError.httpStatus.value()) }
jsonPath("$.code", equalTo(expectedError.errorCode))
}
}
}
When("입력 값이 잘못되면") {
val expectedErrorCode: ErrorCode = CommonErrorCode.INVALID_INPUT_VALUE
When("잘못된 요청을 보내면 400 에러를 응답한다.") {
Then("이메일 형식이 잘못된 경우") {
val invalidRequest: LoginRequest = userRequest.copy(email = "invalid")
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = invalidRequest,
) {
status { isBadRequest() }
jsonPath("$.message", containsString("이메일 형식이 일치하지 않습니다."))
}
}
Then("비밀번호가 공백인 경우") {
val invalidRequest = userRequest.copy(password = " ")
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = invalidRequest,
) {
status { isBadRequest() }
jsonPath("$.message", containsString("비밀번호는 공백일 수 없습니다."))
Then("400 에러를 응답한다") {
listOf(
userRequest.copy(email = "invalid"),
userRequest.copy(password = " "),
"{\"email\": \"null\", \"password\": \"null\"}"
).forEach {
runPostTest(
mockMvc = mockMvc,
endpoint = endpoint,
body = it,
) {
status { isEqualTo(expectedErrorCode.httpStatus.value()) }
jsonPath("$.code", equalTo(expectedErrorCode.errorCode))
}
}
}
}
@ -125,13 +118,14 @@ class AuthControllerTest(
every { jwtHandler.getMemberIdFromToken(any()) } returns invalidMemberId
every { memberRepository.findByIdOrNull(invalidMemberId) } returns null
Then("400 에러를 응답한다.") {
Then("에러 응답.") {
val expectedError = AuthErrorCode.UNIDENTIFIABLE_MEMBER
runGetTest(
mockMvc = mockMvc,
endpoint = endpoint,
) {
status { isBadRequest() }
jsonPath("$.errorType", equalTo(ErrorType.MEMBER_NOT_FOUND.name))
status { isEqualTo(expectedError.httpStatus.value()) }
jsonPath("$.code", equalTo(expectedError.errorCode))
}
}
}

View File

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

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

View File

@ -5,12 +5,15 @@ import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import roomescape.common.exception.ErrorType
import roomescape.common.exception.RoomescapeException
import org.springframework.data.repository.findByIdOrNull
import roomescape.member.business.MemberService
import roomescape.member.infrastructure.persistence.Role
import roomescape.reservation.exception.ReservationErrorCode
import roomescape.reservation.exception.ReservationException
import roomescape.reservation.infrastructure.persistence.ReservationRepository
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.theme.business.ThemeService
import roomescape.time.business.TimeService
import roomescape.util.MemberFixture
import roomescape.util.ReservationFixture
import roomescape.util.TimeFixture
@ -38,10 +41,10 @@ class ReservationServiceTest : FunSpec({
val reservationRequest = ReservationFixture.createRequest()
shouldThrow<RoomescapeException> {
reservationService.addReservation(reservationRequest, 1L)
shouldThrow<ReservationException> {
reservationService.createConfirmedReservation(reservationRequest, 1L)
}.also {
it.errorType shouldBe ErrorType.RESERVATION_DUPLICATED
it.errorCode shouldBe ReservationErrorCode.RESERVATION_DUPLICATED
}
}
@ -68,10 +71,10 @@ class ReservationServiceTest : FunSpec({
timeService.findById(any())
} returns TimeFixture.create()
shouldThrow<RoomescapeException> {
reservationService.addReservation(reservationRequest, 1L)
shouldThrow<ReservationException> {
reservationService.createConfirmedReservation(reservationRequest, 1L)
}.also {
it.errorType shouldBe ErrorType.RESERVATION_PERIOD_IN_PAST
it.errorCode shouldBe ReservationErrorCode.PAST_REQUEST_DATETIME
}
}
@ -86,10 +89,10 @@ class ReservationServiceTest : FunSpec({
startAt = LocalTime.now().minusMinutes(1)
)
shouldThrow<RoomescapeException> {
reservationService.addReservation(reservationRequest, 1L)
shouldThrow<ReservationException> {
reservationService.createConfirmedReservation(reservationRequest, 1L)
}.also {
it.errorType shouldBe ErrorType.RESERVATION_PERIOD_IN_PAST
it.errorCode shouldBe ReservationErrorCode.PAST_REQUEST_DATETIME
}
}
}
@ -107,7 +110,7 @@ class ReservationServiceTest : FunSpec({
reservationRepository.exists(any())
} returns true
shouldThrow<RoomescapeException> {
shouldThrow<ReservationException> {
val waitingRequest = ReservationFixture.createWaitingRequest(
date = reservationRequest.date,
themeId = reservationRequest.themeId,
@ -115,7 +118,57 @@ class ReservationServiceTest : FunSpec({
)
reservationService.createWaiting(waitingRequest, 1L)
}.also {
it.errorType shouldBe ErrorType.HAS_RESERVATION_OR_WAITING
it.errorCode shouldBe ReservationErrorCode.ALREADY_RESERVE
}
}
}
context("예약 대기를 취소할 때") {
val reservationId = 1L
val member = MemberFixture.create(id = 1L, role = Role.MEMBER)
test("예약을 찾을 수 없으면 예외를 던진다.") {
every {
reservationRepository.findByIdOrNull(reservationId)
} returns null
shouldThrow<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 endAt = startFrom.minusDays(1)
shouldThrow<RoomescapeException> {
shouldThrow<ReservationException> {
reservationService.searchReservations(
null,
null,
@ -133,7 +186,7 @@ class ReservationServiceTest : FunSpec({
endAt
)
}.also {
it.errorType shouldBe ErrorType.INVALID_DATE_RANGE
it.errorCode shouldBe ReservationErrorCode.INVALID_SEARCH_DATE_RANGE
}
}
}
@ -146,10 +199,10 @@ class ReservationServiceTest : FunSpec({
memberService.findById(any())
} returns member
shouldThrow<RoomescapeException> {
shouldThrow<ReservationException> {
reservationService.confirmWaiting(1L, member.id!!)
}.also {
it.errorType shouldBe ErrorType.PERMISSION_DOES_NOT_EXIST
it.errorCode shouldBe ReservationErrorCode.NO_PERMISSION
}
}
@ -165,10 +218,67 @@ class ReservationServiceTest : FunSpec({
reservationRepository.isExistConfirmedReservation(reservationId)
} returns true
shouldThrow<RoomescapeException> {
shouldThrow<ReservationException> {
reservationService.confirmWaiting(reservationId, member.id!!)
}.also {
it.errorType shouldBe ErrorType.RESERVATION_DUPLICATED
it.errorCode shouldBe ReservationErrorCode.CONFIRMED_RESERVATION_ALREADY_EXISTS
}
}
}
context("대기중인 예약을 거절할 때") {
test("관리자가 아니면 예외를 던진다.") {
val member = MemberFixture.create(id = 1L, role = Role.MEMBER)
every {
memberService.findById(any())
} returns member
shouldThrow<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") {
test("예약 및 결제 정보를 저장한다.") {
every {
reservationService.addReservation(reservationCreateWithPaymentRequest, memberId)
reservationService.createConfirmedReservation(reservationCreateWithPaymentRequest, memberId)
} returns reservationEntity
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("입력된 시간과 일치하는 예약을 반환한다.") {
assertSoftly(reservationRepository.findByTime(time)) {
assertSoftly(reservationRepository.findAllByTime(time)) {
it shouldHaveSize 1
assertSoftly(it.first().time.startAt) { result ->
result.hour shouldBe time.startAt.hour
@ -168,7 +168,7 @@ class ReservationRepositoryTest(
entityManager.clear()
}
val result: List<MyReservationRetrieveResponse> = reservationRepository.findAllById(reservation.member.id!!)
val result: List<MyReservationRetrieveResponse> = reservationRepository.findAllByMemberId(reservation.member.id!!)
result shouldHaveSize 1
assertSoftly(result.first()) {
@ -179,7 +179,7 @@ class ReservationRepositoryTest(
}
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
assertSoftly(result.first()) {

View File

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

View File

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

View File

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

View File

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

View File

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

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.matchers.shouldBe

View File

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

View File

@ -12,10 +12,10 @@ import roomescape.payment.web.PaymentCancelRequest
import roomescape.payment.web.PaymentCancelResponse
import roomescape.reservation.infrastructure.persistence.ReservationEntity
import roomescape.reservation.infrastructure.persistence.ReservationStatus
import roomescape.reservation.infrastructure.persistence.TimeEntity
import roomescape.reservation.web.ReservationCreateWithPaymentRequest
import roomescape.reservation.web.WaitingCreateRequest
import roomescape.theme.infrastructure.persistence.ThemeEntity
import roomescape.time.infrastructure.persistence.TimeEntity
import java.time.LocalDate
import java.time.LocalTime
import java.time.OffsetDateTime
@ -110,11 +110,11 @@ object ReservationFixture {
}
object JwtFixture {
const val SECRET_KEY: String = "daijawligagaf@LIJ$@U)9nagnalkkgalijaddljfi"
const val SECRET_KEY_STRING: String = "daijawligagaf@LIJ$@U)9nagnalkkgalijaddljfi"
const val EXPIRATION_TIME: Long = 1000 * 60 * 60
fun create(
secretKey: String = SECRET_KEY,
secretKey: String = SECRET_KEY_STRING,
expirationTime: Long = EXPIRATION_TIME
): JwtHandler = JwtHandler(secretKey, expirationTime)
}

View File

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

View File

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