generated from pricelees/issue-pr-template
[#20] 도메인별 예외 분리 #21
@ -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 {
|
||||
tasks.withType<KotlinCompile> {
|
||||
compilerOptions {
|
||||
freeCompilerArgs.add("-Xjsr305=strict")
|
||||
freeCompilerArgs.addAll(
|
||||
"-Xjsr305=strict",
|
||||
"-Xannotation-default-target=param-property"
|
||||
)
|
||||
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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/main/kotlin/roomescape/auth/exception/AuthErrorCode.kt
Normal file
17
src/main/kotlin/roomescape/auth/exception/AuthErrorCode.kt
Normal 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", "회원 정보를 찾을 수 없어요."),
|
||||
}
|
||||
@ -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)
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,7 @@ data class LoginResponse(
|
||||
)
|
||||
|
||||
data class LoginCheckResponse(
|
||||
@field:Schema(description = "로그인된 회원의 이름")
|
||||
@Schema(description = "로그인된 회원의 이름")
|
||||
val name: String
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -18,7 +18,6 @@ class MemberIdResolver(
|
||||
return parameter.hasParameterAnnotation(MemberId::class.java)
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
override fun resolveArgument(
|
||||
parameter: MethodParameter,
|
||||
mavContainer: ModelAndViewContainer?,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 = "서버에 예상치 못한 오류가 발생했어요. 관리자에게 문의해주세요.",
|
||||
),
|
||||
}
|
||||
9
src/main/kotlin/roomescape/common/exception/ErrorCode.kt
Normal file
9
src/main/kotlin/roomescape/common/exception/ErrorCode.kt
Normal 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
|
||||
}
|
||||
@ -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("결제 서버에서 에러가 발생하였습니다. 잠시 후 다시 시도해주세요.")
|
||||
;
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 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 findById(memberId: Long): MemberEntity = fetchOrThrow {
|
||||
memberRepository.findByIdOrNull(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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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", "회원을 찾을 수 없어요.")
|
||||
}
|
||||
@ -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)
|
||||
@ -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
|
||||
)
|
||||
|
||||
|
||||
@ -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,
|
||||
): PaymentCreateResponse {
|
||||
val payment = PaymentEntity(
|
||||
orderId = approveResponse.orderId,
|
||||
paymentKey = approveResponse.paymentKey,
|
||||
totalAmount = approveResponse.totalAmount,
|
||||
reservation = reservation,
|
||||
approvedAt = paymentResponse.approvedAt
|
||||
).also {
|
||||
paymentRepository.save(it)
|
||||
}.toCreateResponse()
|
||||
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(
|
||||
): CanceledPaymentEntity {
|
||||
val canceledPayment = CanceledPaymentEntity(
|
||||
paymentKey = paymentKey,
|
||||
cancelReason = cancelInfo.cancelReason,
|
||||
cancelAmount = cancelInfo.cancelAmount,
|
||||
approvedAt = approvedAt,
|
||||
canceledAt = cancelInfo.canceledAt
|
||||
).also { canceledPaymentRepository.save(it) }
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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", "시스템에 일시적인 오류가 발생했어요. 잠시 후 다시 시도해주세요.")
|
||||
}
|
||||
@ -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)
|
||||
@ -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?
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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", "접근 권한이 없어요."),
|
||||
}
|
||||
@ -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)
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"])
|
||||
|
||||
14
src/main/kotlin/roomescape/theme/exception/ThemeErrorCode.kt
Normal file
14
src/main/kotlin/roomescape/theme/exception/ThemeErrorCode.kt
Normal 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", "예약된 테마라 삭제할 수 없어요.")
|
||||
}
|
||||
@ -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)
|
||||
@ -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))
|
||||
|
||||
@ -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() }
|
||||
)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
@ -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 = "예약 시간을 조회 / 추가 / 삭제할 때 사용합니다.")
|
||||
14
src/main/kotlin/roomescape/time/exception/TimeErrorCode.kt
Normal file
14
src/main/kotlin/roomescape/time/exception/TimeErrorCode.kt
Normal 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", "예약된 시간이라 삭제할 수 없어요.")
|
||||
}
|
||||
@ -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)
|
||||
@ -1,4 +1,4 @@
|
||||
package roomescape.reservation.infrastructure.persistence
|
||||
package roomescape.time.infrastructure.persistence
|
||||
|
||||
import jakarta.persistence.*
|
||||
import java.time.LocalTime
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -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,44 +61,36 @@ 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")
|
||||
|
||||
Then("400 에러를 응답한다") {
|
||||
listOf(
|
||||
userRequest.copy(email = "invalid"),
|
||||
userRequest.copy(password = " "),
|
||||
"{\"email\": \"null\", \"password\": \"null\"}"
|
||||
).forEach {
|
||||
runPostTest(
|
||||
mockMvc = mockMvc,
|
||||
endpoint = endpoint,
|
||||
body = invalidRequest,
|
||||
body = it,
|
||||
) {
|
||||
status { isBadRequest() }
|
||||
jsonPath("$.message", containsString("이메일 형식이 일치하지 않습니다."))
|
||||
status { isEqualTo(expectedErrorCode.httpStatus.value()) }
|
||||
jsonPath("$.code", equalTo(expectedErrorCode.errorCode))
|
||||
}
|
||||
}
|
||||
|
||||
Then("비밀번호가 공백인 경우") {
|
||||
val invalidRequest = userRequest.copy(password = " ")
|
||||
|
||||
runPostTest(
|
||||
mockMvc = mockMvc,
|
||||
endpoint = endpoint,
|
||||
body = invalidRequest,
|
||||
) {
|
||||
status { isBadRequest() }
|
||||
jsonPath("$.message", containsString("비밀번호는 공백일 수 없습니다."))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -125,13 +118,14 @@ class AuthControllerTest(
|
||||
every { jwtHandler.getMemberIdFromToken(any()) } returns invalidMemberId
|
||||
every { 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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을 업데이트한다.") {
|
||||
|
||||
@ -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,9 +56,10 @@ class TossPaymentClientTest(
|
||||
}
|
||||
}
|
||||
|
||||
test("400 에러 발생") {
|
||||
context("실패 응답") {
|
||||
fun runTest(httpStatus: HttpStatus, expectedError: PaymentErrorCode) {
|
||||
commonAction().andRespond {
|
||||
withStatus(HttpStatus.BAD_REQUEST)
|
||||
withStatus(httpStatus)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(SampleTossPaymentConst.tossPaymentErrorJson)
|
||||
.createResponse(it)
|
||||
@ -68,16 +69,19 @@ class TossPaymentClientTest(
|
||||
val paymentRequest = SampleTossPaymentConst.paymentRequest
|
||||
|
||||
// then
|
||||
val exception = shouldThrow<RoomescapeException> {
|
||||
val exception = shouldThrow<PaymentException> {
|
||||
client.confirm(paymentRequest)
|
||||
}
|
||||
|
||||
assertSoftly(exception) {
|
||||
this.errorType shouldBe ErrorType.PAYMENT_ERROR
|
||||
this.invalidValue shouldBe "[ErrorCode = ERROR_CODE, ErrorMessage = Error message]"
|
||||
this.httpStatus shouldBe HttpStatus.BAD_REQUEST
|
||||
exception.errorCode shouldBe expectedError
|
||||
}
|
||||
|
||||
test("결제 서버에서 4XX 응답 시") {
|
||||
runTest(HttpStatus.BAD_REQUEST, PaymentErrorCode.PAYMENT_CLIENT_ERROR)
|
||||
}
|
||||
|
||||
test("결제 서버에서 5XX 응답 시") {
|
||||
runTest(HttpStatus.INTERNAL_SERVER_ERROR, PaymentErrorCode.PAYMENT_PROVIDER_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -111,26 +115,29 @@ class TossPaymentClientTest(
|
||||
}
|
||||
}
|
||||
|
||||
test("500 에러 발생") {
|
||||
context("실패 응답") {
|
||||
fun runTest(httpStatus: HttpStatus, expectedError: PaymentErrorCode) {
|
||||
commonAction().andRespond {
|
||||
withStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
withStatus(httpStatus)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(SampleTossPaymentConst.tossPaymentErrorJson)
|
||||
.createResponse(it)
|
||||
}
|
||||
|
||||
// when
|
||||
val cancelRequest: PaymentCancelRequest = SampleTossPaymentConst.cancelRequest
|
||||
|
||||
// then
|
||||
val exception = shouldThrow<RoomescapeException> {
|
||||
val exception = shouldThrow<PaymentException> {
|
||||
client.cancel(cancelRequest)
|
||||
}
|
||||
exception.errorCode shouldBe expectedError
|
||||
}
|
||||
|
||||
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("결제 서버에서 4XX 응답 시") {
|
||||
runTest(HttpStatus.BAD_REQUEST, PaymentErrorCode.PAYMENT_CLIENT_ERROR)
|
||||
}
|
||||
|
||||
test("결제 서버에서 5XX 응답 시") {
|
||||
runTest(HttpStatus.INTERNAL_SERVER_ERROR, PaymentErrorCode.PAYMENT_PROVIDER_ERROR)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,7 +48,7 @@ class ReservationWithPaymentServiceTest : FunSpec({
|
||||
context("addReservationWithPayment") {
|
||||
test("예약 및 결제 정보를 저장한다.") {
|
||||
every {
|
||||
reservationService.addReservation(reservationCreateWithPaymentRequest, memberId)
|
||||
reservationService.createConfirmedReservation(reservationCreateWithPaymentRequest, memberId)
|
||||
} returns reservationEntity
|
||||
|
||||
every {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -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()) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
every {
|
||||
themeRepository.existsByName(name)
|
||||
} returns true
|
||||
|
||||
val exception = shouldThrow<RoomescapeException> {
|
||||
themeService.createTheme(ThemeRequest(
|
||||
name = name,
|
||||
val request = ThemeCreateRequest(
|
||||
name = "New Theme",
|
||||
description = "Description",
|
||||
thumbnail = "http://example.com/thumbnail.jpg"
|
||||
))
|
||||
)
|
||||
|
||||
test("저장 성공") {
|
||||
every {
|
||||
themeRepository.existsByName(request.name)
|
||||
} returns false
|
||||
|
||||
every {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
assertSoftly(exception) {
|
||||
this.errorType shouldBe ErrorType.THEME_DUPLICATED
|
||||
this.httpStatus shouldBe HttpStatus.CONFLICT
|
||||
test("테마 이름이 중복되면 409 예외를 던진다.") {
|
||||
every {
|
||||
themeRepository.existsByName(request.name)
|
||||
} returns true
|
||||
|
||||
val exception = shouldThrow<ThemeException> {
|
||||
themeService.createTheme(request)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
109
src/test/kotlin/roomescape/time/business/TimeServiceTest.kt
Normal file
109
src/test/kotlin/roomescape/time/business/TimeServiceTest.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -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
|
||||
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user